Merge feat/tenant-app: Complete FosterFlow tenant application

Full rescue management platform with public site, admin dashboard,
foster portal, setup wizard, CSV import, Docker deployment, and
config-driven theming with i18n-ready architecture.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 13:08:15 -05:00
co-authored by Claude Opus 4.6
243 changed files with 26517 additions and 26 deletions
+33
View File
@@ -0,0 +1,33 @@
# Database
DATABASE_URL=mysql://fosterflow:password@localhost:3306/fosterflow
# Auth
SESSION_SECRET=change-me-to-a-random-64-char-string
# Stripe (optional)
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# Email (optional)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@example.com
# File uploads
UPLOAD_DIR=./uploads
# Public URL
PUBLIC_SITE_URL=http://localhost:5173
# AI name generator (optional)
OLLAMA_URL=http://localhost:11434
WORKER_API_KEY=change-me
# Facebook integration (optional)
FACEBOOK_APP_ID=
FACEBOOK_APP_SECRET=
# Foster resources page password (optional)
FOSTER_RESOURCES_PASSWORD=
+27
View File
@@ -0,0 +1,27 @@
node_modules/
build/
.svelte-kit/
uploads/
# Output
.output
.vercel
.netlify
.wrangler
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Drizzle
drizzle/
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}
+3
View File
@@ -0,0 +1,3 @@
{$SITE_DOMAIN:localhost} {
reverse_proxy app:3000
}
+18
View File
@@ -0,0 +1,18 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/scripts ./scripts
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "build"]
+51 -26
View File
@@ -1,6 +1,6 @@
# 🐾 paw❤️print
# FosterFlow
**Open-source rescue management platform — self-host free or use PawPrint Cloud.**
**Open-source rescue management platform — self-host free or use FosterFlow Cloud.**
Modern, fast, mobile-first website and management system built for animal rescues. Everything a foster-based rescue needs, nothing it doesn't.
@@ -8,16 +8,16 @@ Modern, fast, mobile-first website and management system built for animal rescue
## What It Does
- 🐕 **Pet Management** — Profiles, photos, medical records, foster assignments, status tracking
- 📋 **Applications** — Adoption, foster, volunteer, surrender forms with configurable questions
- 🏠 **Foster Portal** — Dedicated dashboard for fosters to submit updates, photos, supply requests
- 💰 **Donations & Expenses** — Stripe payments, multi-source tracking, financial reports
- 📅 **Events** — Listings with optional Facebook auto-posting
- ✏️ **Content CMS** — Editable page sections, newsletter, happy tails / success stories
- 📊 **Analytics** — Human traffic, bot detection, honeypot, AI job tracking
- 🔐 **Role-Based Access** — 9 roles from sysadmin to foster with granular permissions
- 🤖 **Bot Protection** — Dynamic robots.txt, honeypot tarpit, managed blocklist/whitelist
- 🧠 **AI Features** — Pet name generator (Ollama), expandable job queue
- **Pet Management** — Profiles, photos, medical records, foster assignments, status tracking
- **Applications** — Adoption, foster, volunteer, surrender forms with configurable questions
- **Foster Portal** — Dedicated dashboard for fosters to submit updates, photos, supply requests
- **Donations & Expenses** — Stripe payments, multi-source tracking, financial reports
- **Events** — Listings with optional Facebook auto-posting
- **Content CMS** — Editable page sections, newsletter, happy tails / success stories
- **Analytics** — Human traffic, bot detection, honeypot, AI job tracking
- **Role-Based Access** — 9 roles from sysadmin to foster with granular permissions
- **Bot Protection** — Dynamic robots.txt, honeypot tarpit, managed blocklist/whitelist
- **AI Features** — Pet name generator (Ollama), expandable job queue
## Tech Stack
@@ -31,34 +31,59 @@ Modern, fast, mobile-first website and management system built for animal rescue
## SaaS Architecture
PawPrint supports multi-tenant deployment:
FosterFlow supports multi-tenant deployment:
- **Self-hosted** — One rescue, one server, free forever
- **PawPrint Cloud** — Automated onboarding, subdomain provisioning, managed hosting
- **FosterFlow Cloud** — Automated onboarding, subdomain provisioning, managed hosting
See [docs/SAAS.md](docs/SAAS.md) for the multi-tenant architecture.
See [docs/ONBOARDING.md](docs/ONBOARDING.md) for automated provisioning.
## Quick Start (Self-Hosted)
## Quick Start (Docker)
The fastest way to get running:
```bash
git clone https://github.com/yourusername/pawprint
cd pawprint
git clone https://github.com/yourusername/fosterflow
cd fosterflow
cp .env.example .env
# Edit .env with your database URL, SMTP, etc.
# Edit .env — set SESSION_SECRET and SITE_DOMAIN at minimum
docker compose up
```
On first run, migrate and seed the database:
```bash
docker compose exec app npx drizzle-kit push
docker compose exec app npx tsx scripts/seed.ts
```
Then open `http://localhost:3000` and log in with `admin@example.com` / `changeme`.
## Quick Start (Manual)
```bash
git clone https://github.com/yourusername/fosterflow
cd fosterflow
cp .env.example .env
# Edit .env with your database URL, SMTP settings, etc.
npm install
npx drizzle-kit push
npm run seed
npx tsx scripts/seed.ts
npm run dev
```
## PawPrint Cloud Pricing
## Self-Hosted vs FosterFlow Cloud
| Plan | Price | What You Get |
|------|-------|-------------|
| **Self-Hosted** | Free | Full source, you manage everything |
| **PawPrint Cloud** | $19/mo | Managed hosting, subdomain, SSL, backups, updates |
| **Setup Assist** | $50 one-time | We migrate your data from your old site |
| | Self-Hosted | FosterFlow Cloud |
|---|---|---|
| **Cost** | Free | $19/mo |
| **Hosting** | You manage | Fully managed |
| **SSL** | You configure | Automatic |
| **Backups** | You configure | Daily, included |
| **Updates** | Manual | Automatic |
| **Subdomain** | Your domain | yourrescue.fosterflow.app |
| **Setup assist** | Community support | $50 one-time migration |
## License
@@ -66,4 +91,4 @@ MIT — use it, fork it, save some dogs.
---
Built with ❤️ by [JRei](https://reiners.dev) — started as Almost Home Canine Rescue's website, now open for every rescue.
Built with love by [JRei](https://reiners.dev) — started as Almost Home Canine Rescue's website, now open for every rescue.
+52
View File
@@ -0,0 +1,52 @@
services:
db:
image: mariadb:11
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-fosterflow}
MYSQL_DATABASE: fosterflow
MYSQL_USER: fosterflow
MYSQL_PASSWORD: ${DB_PASSWORD:-fosterflow}
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
app:
build: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: mysql://fosterflow:${DB_PASSWORD:-fosterflow}@db:3306/fosterflow
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://localhost:3000}
UPLOAD_DIR: /app/uploads
PORT: 3000
env_file:
- .env
ports:
- "3000:3000"
volumes:
- uploads:/app/uploads
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
volumes:
db_data:
uploads:
caddy_data:
caddy_config:
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/lib/server/schema.ts',
out: './drizzle',
dialect: 'mysql',
dbCredentials: {
url: process.env.DATABASE_URL!
}
});
+4568
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "pawprint",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.2",
"@types/nodemailer": "^7.0.11",
"drizzle-kit": "^0.31.10",
"svelte": "^5.54.0",
"svelte-check": "^4.4.2",
"tailwindcss": "^4.2.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.1.2"
},
"dependencies": {
"@node-rs/argon2": "^2.0.2",
"drizzle-orm": "^0.45.1",
"mysql2": "^3.20.0",
"nodemailer": "^8.0.4",
"stripe": "^21.0.0"
}
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: 'tests/e2e',
timeout: 15000,
webServer: {
command: 'npm run dev',
port: 5173,
reuseExistingServer: true
},
use: {
baseURL: 'http://localhost:5173'
}
});
+139
View File
@@ -0,0 +1,139 @@
/**
* FosterFlow AI Worker — runs on a machine with local GPU/Ollama access.
* Polls the FosterFlow API for pending AI jobs, processes them through local Ollama,
* and posts results back.
*
* Usage:
* PUBLIC_SITE_URL=https://yourrescue.fosterflow.app WORKER_API_KEY=your-key npx tsx scripts/ai-worker.ts
*
* Environment variables:
* PUBLIC_SITE_URL — FosterFlow base URL (default: http://localhost:5173)
* WORKER_API_KEY — Shared secret for authenticating with the FosterFlow API
* OLLAMA_URL — Ollama API URL (default: http://localhost:11434)
* POLL_INTERVAL — Milliseconds between polls (default: 10000)
*
* NOTE: Old completed/failed jobs should be cleaned up periodically.
* Consider a cron job: DELETE FROM ai_jobs WHERE status IN ('completed','failed') AND completed_at < NOW() - INTERVAL 7 DAY;
*/
const API_URL = process.env.PUBLIC_SITE_URL || 'http://localhost:5173';
const WORKER_KEY = process.env.WORKER_API_KEY || '';
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
const POLL_INTERVAL = parseInt(process.env.POLL_INTERVAL || '10000', 10);
if (!WORKER_KEY) {
console.error('ERROR: WORKER_API_KEY environment variable is required');
process.exit(1);
}
async function fetchNextJob() {
const res = await fetch(`${API_URL}/api/worker/jobs`, {
headers: { Authorization: `Bearer ${WORKER_KEY}` }
});
if (!res.ok) return null;
return res.json();
}
async function reportResult(jobId: number, output: unknown) {
await fetch(`${API_URL}/api/worker/jobs/${jobId}`, {
method: 'POST',
headers: { Authorization: `Bearer ${WORKER_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'completed', output })
});
}
async function reportError(jobId: number, error: string) {
await fetch(`${API_URL}/api/worker/jobs/${jobId}`, {
method: 'POST',
headers: { Authorization: `Bearer ${WORKER_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'failed', error })
});
}
async function generateNames(input: { species: string; theme: string; count: number; existingNames?: string }) {
const themes: Record<string, string> = {
food: 'food and cooking themed names (like Biscuit, Waffles, Chimichurri, Mochi, Pancake)',
disney: 'Disney character themed names (like Simba, Nala, Elsa, Olaf, Stitch)',
celebrity: 'celebrity or pop culture themed names (like Bowie, Dolly, Elvis, Beyonce)',
nature: 'nature themed names (like Willow, Storm, River, Daisy, Bear)',
classic: 'classic pet names (like Max, Bella, Charlie, Luna, Cooper)',
funny: 'funny and punny names (like Sir Barks-a-Lot, Bark Twain, Chewbarka, Purrlock Holmes)',
random: 'creative and unique names'
};
const themePrompt = themes[input.theme] || themes.random;
const count = input.count || 10;
const prompt = `Generate exactly ${count} creative ${input.species || 'dog'} names. Theme: ${themePrompt}.
IMPORTANT: These names are ALREADY TAKEN and must NOT be reused: ${input.existingNames || 'none'}
Rules:
- Just the names, one per line
- No numbers, no explanations, no extra text
- Be creative and fun
- Mix single names and two-word names
- Do NOT repeat any name from the taken list above`;
const res = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gemma2:9b',
prompt,
stream: false,
options: {
temperature: 1.2,
top_p: 0.95,
num_predict: 200
}
})
});
if (!res.ok) throw new Error(`Ollama error: ${res.status}: ${await res.text()}`);
const data = await res.json();
const names = (data.response || '')
.split('\n')
.map((n: string) => n.replace(/^\d+[\.\)]\s*/, '').replace(/^[-*•]\s*/, '').trim())
.filter((name: string) => name && name.length > 1 && name.length < 40 && !name.includes(':'))
.filter((name: string, index: number, self: string[]) => self.indexOf(name) === index)
.slice(0, count);
return { names };
}
async function poll() {
console.log('FosterFlow AI Worker started');
console.log(` Polling: ${API_URL} every ${POLL_INTERVAL}ms`);
console.log(` Ollama: ${OLLAMA_URL}`);
// Run immediately on start, then on interval
const tick = async () => {
try {
const job = await fetchNextJob();
if (job && job.id) {
console.log(`Processing job ${job.id} (${job.type})`);
try {
if (job.type === 'pet-names') {
const result = await generateNames(job.input);
await reportResult(job.id, result);
console.log(`Job ${job.id} completed`);
} else {
await reportError(job.id, `Unknown job type: ${job.type}`);
}
} catch (err: any) {
await reportError(job.id, err.message);
console.error(`Job ${job.id} failed:`, err.message);
}
}
} catch (err) {
// Connection error — will retry on next interval
}
};
tick();
setInterval(tick, POLL_INTERVAL);
}
poll();
+52
View File
@@ -0,0 +1,52 @@
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import { hash } from '@node-rs/argon2';
import * as schema from '../src/lib/server/schema';
const args = process.argv.slice(2);
const flags: Record<string, string> = {};
for (let i = 0; i < args.length; i += 2) {
flags[args[i].replace('--', '')] = args[i + 1];
}
async function seedTenant() {
const { email, name, password, orgName } = flags;
if (!email || !name || !password || !orgName) {
console.error('Usage: npx tsx scripts/seed-tenant.ts --email X --name X --password X --orgName X');
process.exit(1);
}
const pool = mysql.createPool({ uri: process.env.DATABASE_URL });
const db = drizzle(pool, { schema, mode: 'default' });
const passwordHash = await hash(password);
await db.insert(schema.users).values({
email,
passwordHash,
name,
role: 'sysadmin',
active: true,
mustChangePassword: true
});
const orgConfigDefaults = [
{ key: 'branding', value: { primaryColor: '#0d9488', primaryColorLight: '#14b8a6', primaryColorDark: '#0f766e', accentColor: '#f59e0b', accentColorLight: '#fbbf24', accentColorDark: '#d97706', logoUrl: '' } },
{ key: 'org_info', value: { name: orgName, tagline: '', city: '', state: '', phone: '', email, facebookUrl: '', instagramUrl: '' } },
{ key: 'locale', value: { language: 'en', currency: 'USD', dateFormat: 'MM/DD/YYYY' } },
{ key: 'applications', value: { adoptionEnabled: true, fosterEnabled: true, volunteerEnabled: true, surrenderEnabled: true, adoptionFee: 250 } },
{ key: 'setup_complete', value: false }
];
for (const row of orgConfigDefaults) {
await db.insert(schema.orgConfig).values({ key: row.key, value: row.value });
}
const intakeSources = ['Owner Surrender', 'Stray', 'Transfer', 'Born in Care', 'Other'];
for (const sourceName of intakeSources) {
await db.insert(schema.intakeSources).values({ name: sourceName });
}
console.log(`Tenant seeded: ${email} / [password provided] for ${orgName}`);
await pool.end();
}
seedTenant().catch(console.error);
+63
View File
@@ -0,0 +1,63 @@
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import { hash } from '@node-rs/argon2';
import * as schema from '../src/lib/server/schema';
async function seed() {
const pool = mysql.createPool({ uri: process.env.DATABASE_URL });
const db = drizzle(pool, { schema, mode: 'default' });
console.log('Seeding FosterFlow database...');
// 1. Create admin user
const passwordHash = await hash('changeme');
await db.insert(schema.users).values({
email: 'admin@example.com',
passwordHash,
name: 'Admin',
role: 'sysadmin',
active: true,
mustChangePassword: true
});
console.log('Created admin user (admin@example.com / changeme)');
// 2. Default org config
const orgConfigDefaults = [
{ key: 'branding', value: { primaryColor: '#0d9488', primaryColorLight: '#14b8a6', primaryColorDark: '#0f766e', accentColor: '#f59e0b', accentColorLight: '#fbbf24', accentColorDark: '#d97706', logoUrl: '' } },
{ key: 'org_info', value: { name: 'My Rescue', tagline: '', city: '', state: '', phone: '', email: '', facebookUrl: '', instagramUrl: '' } },
{ key: 'locale', value: { language: 'en', currency: 'USD', dateFormat: 'MM/DD/YYYY' } },
{ key: 'applications', value: { adoptionEnabled: true, fosterEnabled: true, volunteerEnabled: true, surrenderEnabled: true, adoptionFee: 250 } },
{ key: 'setup_complete', value: false }
];
for (const row of orgConfigDefaults) {
await db.insert(schema.orgConfig).values({ key: row.key, value: row.value });
}
console.log('Created default org config');
// 3. Default intake sources
const intakeSources = ['Owner Surrender', 'Stray', 'Transfer', 'Born in Care', 'Other'];
for (const name of intakeSources) {
await db.insert(schema.intakeSources).values({ name });
}
console.log('Created default intake sources');
// 4. Default content blocks (placeholder)
const contentDefaults = [
{ page: 'home', sectionKey: 'hero', content: { title: 'Find Your Forever Friend', subtitle: 'We rescue, rehabilitate, and rehome animals in need.' } },
{ page: 'about', sectionKey: 'intro', content: { text: 'Welcome to our rescue! We are dedicated to saving animals in need.' } },
{ page: 'adoption-process', sectionKey: 'intro', content: { text: 'Our adoption process is designed to find the best match for both you and our animals.' } },
{ page: 'become-a-foster', sectionKey: 'intro', content: { text: 'Fostering saves lives! Learn how you can help by opening your home to an animal in need.' } },
{ page: 'how-to-help', sectionKey: 'intro', content: { text: 'There are many ways you can help our rescue mission.' } },
{ page: 'volunteer', sectionKey: 'intro', content: { text: 'Volunteers are the backbone of our organization.' } },
{ page: 'why-adopt', sectionKey: 'intro', content: { text: 'Adopting a pet changes two lives — yours and theirs.' } }
];
for (const block of contentDefaults) {
await db.insert(schema.contentBlocks).values(block);
}
console.log('Created default content blocks');
console.log('\nDone! Log in at /login with admin@example.com / changeme');
await pool.end();
}
seed().catch(console.error);
+20
View File
@@ -0,0 +1,20 @@
@import 'tailwindcss';
:root {
--color-primary: #0d9488;
--color-primary-light: #14b8a6;
--color-primary-dark: #0f766e;
--color-accent: #f59e0b;
--color-accent-light: #fbbf24;
--color-accent-dark: #d97706;
}
@theme {
--color-primary: var(--color-primary);
--color-primary-light: var(--color-primary-light);
--color-primary-dark: var(--color-primary-dark);
--color-accent: var(--color-accent);
--color-accent-light: var(--color-accent-light);
--color-accent-dark: var(--color-accent-dark);
--color-warm-bg: #faf8f5;
}
+20
View File
@@ -0,0 +1,20 @@
import type { OrgConfig } from '$lib/server/config';
declare global {
namespace App {
interface Locals {
user?: {
id: number;
email: string;
name: string;
role: string;
active: boolean;
mustChangePassword: boolean;
};
sessionToken?: string;
orgConfig: OrgConfig;
}
}
}
export {};
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<meta name="theme-color" content="#0d9488" />
<meta name="apple-mobile-web-app-capable" content="yes" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { validateSession } from '$lib/server/auth';
import { getOrgConfig } from '$lib/server/config';
import { trackPageView } from '$lib/server/track';
export const handle: Handle = async ({ event, resolve }) => {
// Load org config (cached)
event.locals.orgConfig = await getOrgConfig();
// Session validation
const sessionToken = event.cookies.get('session');
if (sessionToken) {
const result = await validateSession(sessionToken);
if (result) {
event.locals.user = result.user;
event.locals.sessionToken = sessionToken;
} else {
event.cookies.delete('session', { path: '/' });
}
}
// Force password change
if (event.locals.user?.mustChangePassword) {
const path = event.url.pathname;
if (path !== '/change-password' && path !== '/logout' && !path.startsWith('/api/')) {
throw redirect(302, '/change-password');
}
}
// Redirect to setup wizard if not complete
if (event.locals.user && !event.locals.orgConfig.setupComplete) {
const path = event.url.pathname;
if (
path.startsWith('/admin') &&
path !== '/admin' &&
!path.startsWith('/setup') &&
!path.startsWith('/api/') &&
path !== '/logout'
) {
throw redirect(302, '/setup');
}
}
// Auth guards
if (event.url.pathname.startsWith('/admin')) {
if (!event.locals.user) {
throw redirect(302, '/login');
}
}
if (event.url.pathname.startsWith('/foster')) {
if (!event.locals.user) {
throw redirect(302, '/login');
}
}
// Page view tracking (async, don't await)
const path = event.url.pathname;
if (!path.startsWith('/admin') && !path.startsWith('/api/') && !path.startsWith('/foster')) {
const ipAddress = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| event.getClientAddress();
trackPageView({
path,
ipAddress,
userAgent: event.request.headers.get('user-agent') || undefined,
referrer: event.request.headers.get('referer') || undefined
});
}
// Resolve with security headers
const response = await resolve(event);
response.headers.set('X-Frame-Options', 'SAMEORIGIN');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
return response;
};
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { headers, children }: {
headers: string[];
children: Snippet;
} = $props();
</script>
<div class="overflow-x-auto bg-white rounded-xl shadow">
<table class="w-full">
<thead>
<tr class="border-b bg-gray-50">
{#each headers as header}
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">{header}</th>
{/each}
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{@render children()}
</tbody>
</table>
</div>
@@ -0,0 +1,10 @@
<script lang="ts">
let { label = 'Donate Now', href = '/donate' }: {
label?: string;
href?: string;
} = $props();
</script>
<a {href} class="inline-block px-6 py-3 bg-accent text-white font-bold rounded-lg hover:bg-accent-dark transition shadow-sm">
{label}
</a>
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
import SponsorStrip from './SponsorStrip.svelte';
import NewsletterSignup from './NewsletterSignup.svelte';
let { orgName = 'FosterFlow', sponsors = [], facebookUrl = '', instagramUrl = '' }: {
orgName?: string;
sponsors?: Array<{ id: number; name: string; logoUrl: string | null; websiteUrl: string | null; tier: string }>;
facebookUrl?: string;
instagramUrl?: string;
} = $props();
</script>
<footer class="bg-gray-900 text-white">
{#if sponsors.length > 0}
<SponsorStrip {sponsors} />
{/if}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div>
<h3 class="font-bold text-lg mb-4">{orgName}</h3>
<p class="text-gray-400">Saving lives, one paw at a time.</p>
</div>
<div>
<h4 class="font-bold mb-4">Quick Links</h4>
<ul class="space-y-2 text-gray-400">
<li><a href="/pets" class="hover:text-white">Our Pets</a></li>
<li><a href="/about" class="hover:text-white">About</a></li>
<li><a href="/donate" class="hover:text-white">Donate</a></li>
<li><a href="/contact" class="hover:text-white">Contact</a></li>
<li><a href="/apply/adopt" class="hover:text-white">Apply to Adopt</a></li>
<li><a href="/apply/foster" class="hover:text-white">Apply to Foster</a></li>
</ul>
</div>
<div>
<h4 class="font-bold mb-4">Stay Connected</h4>
<NewsletterSignup />
<div class="flex gap-4 mt-4">
{#if facebookUrl}
<a href={facebookUrl} target="_blank" rel="noopener" class="text-gray-400 hover:text-white">Facebook</a>
{/if}
{#if instagramUrl}
<a href={instagramUrl} target="_blank" rel="noopener" class="text-gray-400 hover:text-white">Instagram</a>
{/if}
</div>
</div>
</div>
<div class="border-t border-gray-800 mt-8 pt-8 text-center text-gray-500 text-sm">
<p>&copy; {new Date().getFullYear()} {orgName}. All rights reserved.</p>
<p class="mt-1">Powered by <a href="https://fosterflow.app" class="text-primary hover:underline">FosterFlow</a></p>
</div>
</div>
</footer>
+72
View File
@@ -0,0 +1,72 @@
<script lang="ts">
let { orgName = 'FosterFlow', logoUrl = '' }: {
orgName?: string;
logoUrl?: string;
} = $props();
let mobileOpen = $state(false);
const navItems = [
{ href: '/pets', label: 'Our Pets' },
{ href: '/about', label: 'About' },
{ href: '/events', label: 'Events' },
{ href: '/happy-tails', label: 'Happy Tails' },
{ href: '/how-to-help', label: 'How to Help', children: [
{ href: '/become-a-foster', label: 'Become a Foster' },
{ href: '/volunteer', label: 'Volunteer' },
{ href: '/donate', label: 'Donate' },
{ href: '/sponsors', label: 'Sponsors' }
]},
{ href: '/contact', label: 'Contact' }
];
</script>
<header class="bg-white shadow-sm sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<a href="/" class="flex items-center gap-2">
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="h-10 w-auto" />
{:else}
<span class="text-xl font-bold text-primary">{orgName}</span>
{/if}
</a>
<nav class="hidden lg:flex items-center gap-6">
{#each navItems as item}
{#if item.children}
<div class="relative group">
<button class="text-gray-700 hover:text-primary transition">{item.label}</button>
<div class="absolute top-full left-0 hidden group-hover:block bg-white shadow-lg rounded-lg py-2 min-w-[180px]">
{#each item.children as child}
<a href={child.href} class="block px-4 py-2 text-gray-700 hover:bg-gray-50 hover:text-primary">{child.label}</a>
{/each}
</div>
</div>
{:else}
<a href={item.href} class="text-gray-700 hover:text-primary transition">{item.label}</a>
{/if}
{/each}
<a href="/donate" class="px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent-dark transition font-medium">Donate</a>
</nav>
<button class="lg:hidden p-2" onclick={() => mobileOpen = !mobileOpen}>
<span class="text-2xl">{mobileOpen ? '✕' : '☰'}</span>
</button>
</div>
{#if mobileOpen}
<nav class="lg:hidden pb-4 border-t">
{#each navItems as item}
<a href={item.href} class="block py-2 text-gray-700" onclick={() => mobileOpen = false}>{item.label}</a>
{#if item.children}
{#each item.children as child}
<a href={child.href} class="block py-2 pl-4 text-gray-500 text-sm" onclick={() => mobileOpen = false}>{child.label}</a>
{/each}
{/if}
{/each}
<a href="/donate" class="block mt-2 px-4 py-2 bg-accent text-white rounded-lg text-center font-medium">Donate</a>
</nav>
{/if}
</div>
</header>
@@ -0,0 +1,34 @@
<script lang="ts">
let email = $state('');
let submitted = $state(false);
async function subscribe(e: Event) {
e.preventDefault();
const res = await fetch('/api/newsletter', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
if (res.ok) {
submitted = true;
email = '';
}
}
</script>
{#if submitted}
<p class="text-green-400 text-sm">Thanks for subscribing!</p>
{:else}
<form onsubmit={subscribe} class="flex gap-2">
<input
type="email"
bind:value={email}
placeholder="Your email"
required
class="flex-1 px-3 py-2 rounded-lg text-gray-900 text-sm"
/>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm">
Subscribe
</button>
</form>
{/if}
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
let { pet }: {
pet: {
id: number;
name: string;
slug: string;
species: string;
breed: string | null;
age: string | null;
sex: string;
photoUrl: string | null;
};
} = $props();
</script>
<a href="/pets/{pet.slug}" class="group block bg-white rounded-xl shadow-sm overflow-hidden hover:shadow-md transition">
<div class="aspect-[4/3] bg-gray-200 overflow-hidden">
{#if pet.photoUrl}
<img src={pet.photoUrl} alt={pet.name} class="w-full h-full object-cover group-hover:scale-105 transition duration-300" />
{:else}
<div class="w-full h-full flex items-center justify-center text-gray-400 text-4xl">🐾</div>
{/if}
</div>
<div class="p-4">
<h3 class="font-bold text-lg text-gray-900">{pet.name}</h3>
<p class="text-sm text-gray-500">
{pet.breed || pet.species}
{#if pet.age} &middot; {pet.age}{/if}
&middot; {pet.sex}
</p>
</div>
</a>
@@ -0,0 +1,19 @@
<script lang="ts">
let { sponsors }: {
sponsors: Array<{ id: number; name: string; logoUrl: string | null; websiteUrl: string | null; tier: string }>;
} = $props();
</script>
<div class="bg-gray-800 py-6">
<div class="max-w-7xl mx-auto px-4 flex items-center justify-center gap-8 flex-wrap">
{#each sponsors as sponsor}
{#if sponsor.logoUrl}
<a href={sponsor.websiteUrl || '#'} target="_blank" rel="noopener" class="opacity-70 hover:opacity-100 transition">
<img src={sponsor.logoUrl} alt={sponsor.name} class="h-10 w-auto" />
</a>
{:else}
<span class="text-gray-400 text-sm">{sponsor.name}</span>
{/if}
{/each}
</div>
</div>
@@ -0,0 +1,14 @@
<script lang="ts">
let { stats }: {
stats: Array<{ label: string; value: number }>;
} = $props();
</script>
<div class="grid grid-cols-2 gap-6" style="grid-template-columns: repeat({Math.min(stats.length, 4)}, minmax(0, 1fr));">
{#each stats as stat}
<div class="text-center">
<div class="text-3xl md:text-4xl font-bold text-primary">{stat.value.toLocaleString()}</div>
<div class="text-sm text-gray-600 mt-1">{stat.label}</div>
</div>
{/each}
</div>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
let { open = $bindable(false), title = 'Confirm', message = 'Are you sure?', onconfirm, oncancel }: {
open?: boolean;
title?: string;
message?: string;
onconfirm?: () => void;
oncancel?: () => void;
} = $props();
</script>
{#if open}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="fixed inset-0 bg-black/50" onclick={() => { open = false; oncancel?.(); }} role="button" tabindex="-1" onkeydown={() => {}}></div>
<div class="relative bg-white rounded-xl shadow-xl p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-bold mb-2">{title}</h3>
<p class="text-gray-600 mb-6">{message}</p>
<div class="flex justify-end gap-3">
<button
onclick={() => { open = false; oncancel?.(); }}
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
>Cancel</button>
<button
onclick={() => { open = false; onconfirm?.(); }}
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>Confirm</button>
</div>
</div>
</div>
{/if}
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts">
let { accept = 'image/*', multiple = false, onfiles }: {
accept?: string;
multiple?: boolean;
onfiles?: (files: FileList) => void;
} = $props();
let dragging = $state(false);
let input: HTMLInputElement;
function handleDrop(e: DragEvent) {
e.preventDefault();
dragging = false;
if (e.dataTransfer?.files.length) {
onfiles?.(e.dataTransfer.files);
}
}
function handleChange(e: Event) {
const target = e.target as HTMLInputElement;
if (target.files?.length) {
onfiles?.(target.files);
}
}
</script>
<div
class="border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors
{dragging ? 'border-primary bg-primary/5' : 'border-gray-300 hover:border-primary'}"
ondragover={(e) => { e.preventDefault(); dragging = true; }}
ondragleave={() => dragging = false}
ondrop={handleDrop}
onclick={() => input.click()}
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') input.click(); }}
>
<input bind:this={input} type="file" {accept} {multiple} class="hidden" onchange={handleChange} />
<p class="text-gray-500">Drop files here or click to browse</p>
</div>
+27
View File
@@ -0,0 +1,27 @@
<script lang="ts">
let { page = 1, totalPages = 1, onpage }: {
page?: number;
totalPages?: number;
onpage?: (page: number) => void;
} = $props();
</script>
{#if totalPages > 1}
<nav class="flex items-center justify-center gap-2">
<button
onclick={() => onpage?.(page - 1)}
disabled={page <= 1}
class="px-3 py-1 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>&larr;</button>
<span class="px-3 py-1 text-sm text-gray-600">
Page {page} of {totalPages}
</span>
<button
onclick={() => onpage?.(page + 1)}
disabled={page >= totalPages}
class="px-3 py-1 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>&rarr;</button>
</nav>
{/if}
@@ -0,0 +1,48 @@
<script lang="ts">
let { value = $bindable<number | null>(null), placeholder = 'Search for a pet...' }: {
value?: number | null;
placeholder?: string;
} = $props();
let query = $state('');
let results = $state<Array<{ id: number; name: string }>>([]);
let open = $state(false);
async function search() {
if (query.length < 2) { results = []; return; }
const res = await fetch(`/api/pets?q=${encodeURIComponent(query)}&limit=10`);
if (res.ok) {
const data = await res.json();
results = data.pets || [];
}
}
function select(pet: { id: number; name: string }) {
value = pet.id;
query = pet.name;
open = false;
}
</script>
<div class="relative">
<input
type="text"
bind:value={query}
{placeholder}
oninput={search}
onfocus={() => open = true}
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary"
/>
{#if open && results.length > 0}
<ul class="absolute z-10 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto">
{#each results as pet}
<li>
<button
class="w-full px-4 py-2 text-left hover:bg-gray-50"
onclick={() => select(pet)}
>{pet.name}</button>
</li>
{/each}
</ul>
{/if}
</div>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
let { value = $bindable(''), placeholder = 'Search...', onsubmit }: {
value?: string;
placeholder?: string;
onsubmit?: (value: string) => void;
} = $props();
</script>
<form
class="relative"
onsubmit={(e) => { e.preventDefault(); onsubmit?.(value); }}
>
<input
type="text"
bind:value
{placeholder}
class="w-full px-4 py-2 pl-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<svg class="absolute left-3 top-2.5 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</form>
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
let { status }: { status: string } = $props();
const colorMap: Record<string, string> = {
available: 'bg-green-100 text-green-800',
adopted: 'bg-blue-100 text-blue-800',
foster: 'bg-purple-100 text-purple-800',
pending: 'bg-yellow-100 text-yellow-800',
medical_hold: 'bg-red-100 text-red-800',
behavioral_hold: 'bg-orange-100 text-orange-800',
trial: 'bg-teal-100 text-teal-800',
hospice: 'bg-pink-100 text-pink-800',
deceased: 'bg-gray-100 text-gray-800',
transferred: 'bg-gray-100 text-gray-800',
approved: 'bg-green-100 text-green-800',
denied: 'bg-red-100 text-red-800',
reviewing: 'bg-yellow-100 text-yellow-800',
active: 'bg-green-100 text-green-800',
suspended: 'bg-red-100 text-red-800'
};
const colors = $derived(colorMap[status] || 'bg-gray-100 text-gray-800');
const label = $derived(status.replace(/_/g, ' '));
</script>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize {colors}">
{label}
</span>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import { getToasts, removeToast } from '$lib/toast.svelte';
const toasts = $derived(getToasts());
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
info: 'bg-primary'
};
</script>
{#if toasts.length > 0}
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{#each toasts as toast (toast.id)}
<div class="{colors[toast.type]} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 max-w-sm">
<span class="flex-1">{toast.message}</span>
<button onclick={() => removeToast(toast.id)} class="text-white/80 hover:text-white">&times;</button>
</div>
{/each}
</div>
{/if}
+46
View File
@@ -0,0 +1,46 @@
type Translations = Record<string, string>;
export function createI18n(translations: Translations) {
function t(key: string, vars?: Record<string, string>): string {
let value = translations[key];
if (value === undefined) return key;
if (vars) {
for (const [varName, varValue] of Object.entries(vars)) {
value = value.replaceAll(`{${varName}}`, varValue);
}
}
return value;
}
return { t };
}
function flattenObject(obj: Record<string, unknown>, prefix = ''): Translations {
const result: Translations = {};
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
Object.assign(result, flattenObject(value as Record<string, unknown>, fullKey));
} else {
result[fullKey] = String(value);
}
}
return result;
}
let cachedI18n: ReturnType<typeof createI18n> | null = null;
export async function loadI18n(locale = 'en'): Promise<ReturnType<typeof createI18n>> {
if (cachedI18n) return cachedI18n;
const localeData = await import(`./locales/${locale}.json`);
const flat = flattenObject(localeData.default || localeData);
cachedI18n = createI18n(flat);
return cachedI18n;
}
export function resetI18nCache(): void {
cachedI18n = null;
}
+163
View File
@@ -0,0 +1,163 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"back": "Back",
"search": "Search",
"filter": "Filter",
"loading": "Loading...",
"noResults": "No results found",
"confirm": "Are you sure?",
"yes": "Yes",
"no": "No",
"submit": "Submit",
"required": "Required",
"optional": "Optional",
"actions": "Actions",
"status": "Status",
"date": "Date",
"name": "Name",
"email": "Email",
"phone": "Phone",
"notes": "Notes",
"description": "Description"
},
"auth": {
"login": "Log In",
"logout": "Log Out",
"register": "Register",
"email": "Email",
"password": "Password",
"currentPassword": "Current Password",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"changePassword": "Change Password",
"invalidCredentials": "Invalid email or password",
"accountInactive": "Account is not active",
"passwordMinLength": "Password must be at least 8 characters",
"passwordsDoNotMatch": "Passwords do not match"
},
"nav": {
"home": "Home",
"pets": "Our Pets",
"about": "About",
"events": "Events",
"donate": "Donate",
"contact": "Contact",
"apply": "Apply",
"adoptionProcess": "Adoption Process",
"becomeAFoster": "Become a Foster",
"howToHelp": "How to Help",
"volunteer": "Volunteer",
"whyAdopt": "Why Adopt",
"happyTails": "Happy Tails",
"sponsors": "Sponsors",
"fosterResources": "Foster Resources",
"nameGenerator": "Name Generator"
},
"pets": {
"title": "Our Pets",
"available": "Available for Adoption",
"featured": "Featured Pets",
"noFeatured": "No featured pets at the moment",
"noPets": "No pets available at the moment",
"adoptionFee": "Adoption Fee",
"learnMore": "Learn More",
"applyToAdopt": "Apply to Adopt",
"applyToFoster": "Apply to Foster",
"filterSpecies": "Species",
"filterAge": "Age",
"filterSex": "Sex",
"all": "All"
},
"applications": {
"adoption": "Adoption Application",
"foster": "Foster Application",
"volunteer": "Volunteer Application",
"surrender": "Surrender Application",
"thankYou": "Thank you for your application!",
"weWillReview": "We will review your application and get back to you soon."
},
"foster": {
"portal": "Foster Portal",
"myPets": "My Foster Pets",
"submitUpdate": "Submit Update",
"requestSupplies": "Request Supplies",
"noPets": "You don't have any foster pets assigned yet."
},
"admin": {
"dashboard": "Dashboard",
"pets": "Pets",
"applications": "Applications",
"sponsors": "Sponsors",
"donations": "Donations",
"expenses": "Expenses",
"vets": "Vets",
"events": "Events",
"content": "Content",
"newsletter": "Newsletter",
"happyTails": "Happy Tails",
"users": "Users",
"volunteers": "Volunteers",
"fosters": "Fosters",
"mentors": "Mentors",
"intake": "Intake",
"moderation": "Moderation",
"reports": "Reports",
"analytics": "Analytics",
"traffic": "Live Traffic",
"messages": "Messages",
"import": "Import",
"system": "System",
"fosterDashboard": "My Fosters"
},
"setup": {
"welcome": "Welcome to FosterFlow!",
"letsGetStarted": "Let's get your rescue set up.",
"step1Title": "Organization Info",
"step2Title": "Branding",
"step3Title": "Application Forms",
"step4Title": "Integrations",
"step5Title": "Import Data",
"next": "Next",
"previous": "Previous",
"finish": "Finish Setup"
},
"home": {
"hero": "Find Your Forever Friend",
"heroSubtitle": "We rescue, rehabilitate, and rehome animals in need.",
"viewPets": "View Our Pets",
"howToHelp": "How to Help",
"stats": {
"adopted": "Pets Adopted",
"inCare": "Currently in Care",
"fosters": "Active Fosters"
},
"upcomingEvents": "Upcoming Events",
"recentHappyTails": "Recent Happy Tails"
},
"contact": {
"title": "Contact Us",
"send": "Send Message",
"sent": "Message sent! We'll get back to you soon."
},
"newsletter": {
"subscribe": "Subscribe to Our Newsletter",
"emailPlaceholder": "Your email address",
"subscribed": "Thanks for subscribing!"
},
"donate": {
"title": "Donate",
"support": "Support Our Mission",
"oneTime": "One-Time Donation",
"monthly": "Monthly Donation"
},
"footer": {
"quickLinks": "Quick Links",
"connect": "Connect With Us",
"copyright": "All rights reserved."
}
}
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+56
View File
@@ -0,0 +1,56 @@
export type Role =
| 'sysadmin'
| 'director'
| 'foster_coordinator'
| 'volunteer_manager'
| 'vet_liaison'
| 'content_editor'
| 'applications_manager'
| 'viewer'
| 'foster';
export type Permission =
| 'dashboard'
| 'pets'
| 'pets_view'
| 'applications'
| 'sponsors'
| 'donations'
| 'expenses'
| 'vets'
| 'medical'
| 'events'
| 'content'
| 'volunteers'
| 'users'
| 'shop'
| 'import'
| 'system';
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
sysadmin: [
'dashboard', 'pets', 'pets_view', 'applications', 'sponsors',
'donations', 'expenses', 'vets', 'medical', 'events', 'content',
'volunteers', 'users', 'shop', 'import', 'system'
],
director: [
'dashboard', 'pets', 'pets_view', 'applications', 'sponsors',
'donations', 'expenses', 'vets', 'medical', 'events', 'content',
'volunteers', 'users', 'shop', 'import'
],
foster_coordinator: ['dashboard', 'pets', 'pets_view', 'applications'],
volunteer_manager: ['dashboard', 'volunteers', 'events'],
vet_liaison: ['dashboard', 'pets', 'pets_view', 'vets', 'medical', 'expenses'],
content_editor: ['dashboard', 'sponsors', 'content', 'events'],
applications_manager: ['dashboard', 'applications', 'pets_view'],
viewer: ['dashboard'],
foster: ['dashboard']
};
export function hasPermission(role: Role, permission: Permission): boolean {
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false;
}
export function canViewPets(role: Role): boolean {
return hasPermission(role, 'pets') || hasPermission(role, 'pets_view');
}
+32
View File
@@ -0,0 +1,32 @@
import { db } from './db';
import { aiJobs } from './schema';
import { eq } from 'drizzle-orm';
export async function queueNameGenerationJob(params: {
species: string;
theme?: string;
count?: number;
}): Promise<number> {
const result = await db.insert(aiJobs).values({
type: 'pet-names',
input: {
species: params.species,
theme: params.theme || 'classic',
count: params.count || 10
},
status: 'pending'
});
return Number(result[0].insertId);
}
export async function getJobStatus(jobId: number) {
const result = await db
.select()
.from(aiJobs)
.where(eq(aiJobs.id, jobId))
.limit(1);
if (result.length === 0) return null;
return result[0];
}
+20
View File
@@ -0,0 +1,20 @@
import { db } from './db';
import { auditLog } from './schema';
export async function logAudit(params: {
userId?: number;
action: string;
entity?: string;
entityId?: number;
diff?: Record<string, unknown>;
ipAddress?: string;
}): Promise<void> {
await db.insert(auditLog).values({
userId: params.userId ?? null,
action: params.action,
entity: params.entity ?? null,
entityId: params.entityId ?? null,
diff: params.diff ?? null,
ipAddress: params.ipAddress ?? null
});
}
+22
View File
@@ -0,0 +1,22 @@
import { hash, verify } from '@node-rs/argon2';
import crypto from 'crypto';
export async function hashPassword(password: string): Promise<string> {
return hash(password);
}
export async function verifyPassword(hashedPassword: string, password: string): Promise<boolean> {
try {
return await verify(hashedPassword, password);
} catch {
return false;
}
}
export function generateSessionToken(): string {
return crypto.randomBytes(32).toString('hex');
}
export function hashSessionToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
+71
View File
@@ -0,0 +1,71 @@
import { db } from './db';
import { users, sessions } from './schema';
import { eq, and, gt } from 'drizzle-orm';
import { generateSessionToken, hashSessionToken, verifyPassword } from './auth-utils';
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
export async function createSession(userId: number): Promise<string> {
const token = generateSessionToken();
const sessionId = hashSessionToken(token);
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
await db.insert(sessions).values({ sessionId, userId, expiresAt });
return token;
}
export async function validateSession(token: string) {
const sessionId = hashSessionToken(token);
const result = await db
.select({
session: sessions,
user: {
id: users.id,
email: users.email,
name: users.name,
role: users.role,
active: users.active,
mustChangePassword: users.mustChangePassword
}
})
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(
and(
eq(sessions.sessionId, sessionId),
gt(sessions.expiresAt, new Date())
)
)
.limit(1);
if (result.length === 0) return null;
const { session, user } = result[0];
if (!user.active) return null;
return { session, user };
}
export async function destroySession(token: string): Promise<void> {
const sessionId = hashSessionToken(token);
await db.delete(sessions).where(eq(sessions.sessionId, sessionId));
}
export async function authenticateUser(email: string, password: string) {
const result = await db
.select()
.from(users)
.where(eq(users.email, email.toLowerCase()))
.limit(1);
if (result.length === 0) return null;
const user = result[0];
if (!user.active) return null;
const valid = await verifyPassword(user.passwordHash, password);
if (!valid) return null;
return user;
}
+128
View File
@@ -0,0 +1,128 @@
import { db } from './db';
import { orgConfig } from './schema';
export interface OrgBranding {
primaryColor: string;
primaryColorLight: string;
primaryColorDark: string;
accentColor: string;
accentColorLight: string;
accentColorDark: string;
logoUrl: string;
}
export interface OrgInfo {
name: string;
tagline: string;
city: string;
state: string;
phone: string;
email: string;
facebookUrl: string;
instagramUrl: string;
}
export interface OrgLocale {
language: string;
currency: string;
dateFormat: string;
}
export interface OrgApplications {
adoptionEnabled: boolean;
fosterEnabled: boolean;
volunteerEnabled: boolean;
surrenderEnabled: boolean;
adoptionFee: number;
}
export interface OrgConfig {
branding: OrgBranding;
orgInfo: OrgInfo;
locale: OrgLocale;
applications: OrgApplications;
setupComplete: boolean;
}
const DEFAULTS: OrgConfig = {
branding: {
primaryColor: '#0d9488',
primaryColorLight: '#14b8a6',
primaryColorDark: '#0f766e',
accentColor: '#f59e0b',
accentColorLight: '#fbbf24',
accentColorDark: '#d97706',
logoUrl: ''
},
orgInfo: {
name: 'My Rescue',
tagline: '',
city: '',
state: '',
phone: '',
email: '',
facebookUrl: '',
instagramUrl: ''
},
locale: {
language: 'en',
currency: 'USD',
dateFormat: 'MM/DD/YYYY'
},
applications: {
adoptionEnabled: true,
fosterEnabled: true,
volunteerEnabled: true,
surrenderEnabled: true,
adoptionFee: 250
},
setupComplete: false
};
const KEY_MAP: Record<string, keyof OrgConfig> = {
branding: 'branding',
org_info: 'orgInfo',
locale: 'locale',
applications: 'applications',
setup_complete: 'setupComplete'
};
export function parseOrgConfig(rows: { key: string; value: unknown }[]): OrgConfig {
const config = structuredClone(DEFAULTS);
for (const row of rows) {
const configKey = KEY_MAP[row.key];
if (!configKey) continue;
if (configKey === 'setupComplete') {
config.setupComplete = Boolean(row.value);
} else {
config[configKey] = {
...config[configKey],
...(row.value as Record<string, unknown>)
} as any;
}
}
return config;
}
let cachedConfig: OrgConfig | null = null;
let cacheTime = 0;
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export async function getOrgConfig(): Promise<OrgConfig> {
if (cachedConfig && Date.now() - cacheTime < CACHE_TTL) {
return cachedConfig;
}
const rows = await db.select().from(orgConfig);
cachedConfig = parseOrgConfig(rows);
cacheTime = Date.now();
return cachedConfig;
}
export function invalidateConfigCache(): void {
cachedConfig = null;
cacheTime = 0;
}
+37
View File
@@ -0,0 +1,37 @@
import { db } from './db';
import { contentBlocks } from './schema';
import { eq, and } from 'drizzle-orm';
export async function getContentBlock(page: string, sectionKey: string): Promise<unknown | null> {
const result = await db
.select()
.from(contentBlocks)
.where(and(eq(contentBlocks.page, page), eq(contentBlocks.sectionKey, sectionKey)))
.limit(1);
if (result.length === 0) return null;
const content = result[0].content;
if (typeof content === 'string') {
try { return JSON.parse(content); } catch { return content; }
}
return content;
}
export async function getPageBlocks(page: string): Promise<Record<string, unknown>> {
const rows = await db
.select()
.from(contentBlocks)
.where(eq(contentBlocks.page, page));
const blocks: Record<string, unknown> = {};
for (const row of rows) {
const content = row.content;
if (typeof content === 'string') {
try { blocks[row.sectionKey] = JSON.parse(content); } catch { blocks[row.sectionKey] = content; }
} else {
blocks[row.sectionKey] = content;
}
}
return blocks;
}
+66
View File
@@ -0,0 +1,66 @@
export function generateAdoptionContract(params: {
orgName: string;
orgCity: string;
orgState: string;
petName: string;
species: string;
breed: string;
sex: string;
age: string;
adopterName: string;
adopterEmail: string;
adoptionFee: number;
agreementItems: string[];
date: string;
}): string {
const items = params.agreementItems
.map((item, i) => `<li>${i + 1}. ${item}</li>`)
.join('\n');
return `<!DOCTYPE html>
<html>
<head><title>Adoption Contract — ${params.petName}</title>
<style>
body { font-family: Georgia, serif; max-width: 800px; margin: 0 auto; padding: 40px; }
h1 { text-align: center; }
.info { margin: 20px 0; }
.info dt { font-weight: bold; display: inline; }
.info dd { display: inline; margin: 0 20px 0 5px; }
ol { line-height: 1.8; }
.signatures { margin-top: 60px; display: flex; justify-content: space-between; }
.sig-line { border-top: 1px solid #000; width: 200px; padding-top: 5px; }
</style>
</head>
<body>
<h1>${params.orgName}</h1>
<h2 style="text-align: center;">Adoption Contract</h2>
<dl class="info">
<dt>Pet Name:</dt><dd>${params.petName}</dd>
<dt>Species:</dt><dd>${params.species}</dd>
<dt>Breed:</dt><dd>${params.breed || 'Unknown'}</dd>
<dt>Sex:</dt><dd>${params.sex}</dd>
<dt>Age:</dt><dd>${params.age || 'Unknown'}</dd>
</dl>
<dl class="info">
<dt>Adopter:</dt><dd>${params.adopterName}</dd>
<dt>Email:</dt><dd>${params.adopterEmail}</dd>
<dt>Adoption Fee:</dt><dd>$${(params.adoptionFee / 100).toFixed(2)}</dd>
<dt>Date:</dt><dd>${params.date}</dd>
</dl>
<h3>Terms of Adoption</h3>
<ol>${items}</ol>
<div class="signatures">
<div>
<div class="sig-line">Adopter Signature</div>
</div>
<div>
<div class="sig-line">${params.orgName} Representative</div>
</div>
</div>
</body>
</html>`;
}
+11
View File
@@ -0,0 +1,11 @@
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import * as schema from './schema';
const pool = mysql.createPool({
uri: process.env.DATABASE_URL,
waitForConnections: true,
connectionLimit: 10
});
export const db = drizzle(pool, { schema, mode: 'default' });
+212
View File
@@ -0,0 +1,212 @@
import nodemailer from 'nodemailer';
function getTransporter() {
if (!process.env.SMTP_USER) return null;
return nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: parseInt(process.env.SMTP_PORT || '587'),
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
});
}
const from = process.env.SMTP_FROM || 'noreply@example.com';
async function sendEmail(to: string, subject: string, html: string): Promise<void> {
const transporter = getTransporter();
if (!transporter) return;
await transporter.sendMail({ from, to, subject, html });
}
export async function sendApplicationNotification(
orgName: string,
staffEmail: string,
applicantName: string,
type: string,
petName?: string
): Promise<void> {
const subject = `[${orgName}] New ${type} application from ${applicantName}`;
const html = `
<h2>New ${type} Application</h2>
<p><strong>Applicant:</strong> ${applicantName}</p>
${petName ? `<p><strong>Pet:</strong> ${petName}</p>` : ''}
<p>Log in to your admin dashboard to review this application.</p>
`;
await sendEmail(staffEmail, subject, html);
}
export async function sendApplicationStatusUpdate(
orgName: string,
to: string,
applicantName: string,
type: string,
status: string,
siteUrl: string
): Promise<void> {
const subject = `[${orgName}] Your ${type} application has been ${status}`;
const html = `
<h2>Application Update</h2>
<p>Hi ${applicantName},</p>
<p>Your ${type} application has been <strong>${status}</strong>.</p>
<p>Visit <a href="${siteUrl}">${siteUrl}</a> for more information.</p>
`;
await sendEmail(to, subject, html);
}
export async function sendFosterWelcomeEmail(
orgName: string,
to: string,
name: string,
tempPassword: string,
loginUrl: string
): Promise<void> {
const subject = `[${orgName}] Welcome to the foster team!`;
const html = `
<h2>Welcome, ${name}!</h2>
<p>Your foster account has been created.</p>
<p><strong>Login URL:</strong> <a href="${loginUrl}">${loginUrl}</a></p>
<p><strong>Email:</strong> ${to}</p>
<p><strong>Temporary Password:</strong> ${tempPassword}</p>
<p>You will be asked to change your password on first login.</p>
`;
await sendEmail(to, subject, html);
}
export async function sendRegistrationNotification(
orgName: string,
staffEmail: string,
name: string,
email: string,
reason: string
): Promise<void> {
const subject = `[${orgName}] New registration request from ${name}`;
const html = `
<h2>New Registration Request</h2>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Reason:</strong> ${reason}</p>
<p>Log in to your admin dashboard to approve or deny this request.</p>
`;
await sendEmail(staffEmail, subject, html);
}
export async function sendRegistrationApproved(
orgName: string,
to: string,
name: string,
loginUrl: string
): Promise<void> {
const subject = `[${orgName}] Your account has been approved!`;
const html = `
<h2>Account Approved</h2>
<p>Hi ${name}, your account at ${orgName} has been approved.</p>
<p>Log in at: <a href="${loginUrl}">${loginUrl}</a></p>
`;
await sendEmail(to, subject, html);
}
export async function sendRegistrationDenied(
orgName: string,
to: string,
name: string
): Promise<void> {
const subject = `[${orgName}] Registration update`;
const html = `
<h2>Registration Update</h2>
<p>Hi ${name}, unfortunately your registration request at ${orgName} was not approved at this time.</p>
<p>If you believe this is an error, please contact us.</p>
`;
await sendEmail(to, subject, html);
}
export async function sendFosterSubmissionNotification(
orgName: string,
staffEmail: string,
fosterName: string,
petName: string,
type: string
): Promise<void> {
const subject = `[${orgName}] New foster ${type} for ${petName}`;
const html = `
<h2>New Foster Submission</h2>
<p><strong>Foster:</strong> ${fosterName}</p>
<p><strong>Pet:</strong> ${petName}</p>
<p><strong>Type:</strong> ${type}</p>
<p>Log in to your admin dashboard to review.</p>
`;
await sendEmail(staffEmail, subject, html);
}
export async function sendFosterReviewNotification(
orgName: string,
to: string,
fosterName: string,
petName: string,
approved: boolean
): Promise<void> {
const status = approved ? 'approved' : 'not approved';
const subject = `[${orgName}] Your submission for ${petName} was ${status}`;
const html = `
<h2>Submission Review</h2>
<p>Hi ${fosterName}, your submission for ${petName} has been <strong>${status}</strong>.</p>
`;
await sendEmail(to, subject, html);
}
export async function sendSupplyRequestNotification(
orgName: string,
staffEmail: string,
fosterName: string,
items: string,
urgency: string
): Promise<void> {
const urgentTag = urgency === 'high' ? '[URGENT] ' : '';
const subject = `${urgentTag}[${orgName}] Supply request from ${fosterName}`;
const html = `
<h2>Supply Request</h2>
<p><strong>Foster:</strong> ${fosterName}</p>
<p><strong>Items:</strong> ${items}</p>
<p><strong>Urgency:</strong> ${urgency}</p>
`;
await sendEmail(staffEmail, subject, html);
}
export async function sendNewsletter(
orgName: string,
to: string,
subject: string,
body: string,
siteUrl: string
): Promise<void> {
const html = `
${body}
<hr />
<p style="font-size: 12px; color: #999;">
Sent by ${orgName}. <a href="${siteUrl}">Visit our website</a>.
</p>
`;
await sendEmail(to, `[${orgName}] ${subject}`, html);
}
export async function sendContactNotification(
orgName: string,
staffEmail: string,
senderName: string,
senderEmail: string,
messageSubject: string,
message: string
): Promise<void> {
const subject = `[${orgName}] Contact form: ${messageSubject || 'New message'}`;
const html = `
<h2>Contact Form Message</h2>
<p><strong>From:</strong> ${senderName} (${senderEmail})</p>
<p><strong>Subject:</strong> ${messageSubject || 'N/A'}</p>
<p>${message}</p>
`;
await sendEmail(staffEmail, subject, html);
}
+102
View File
@@ -0,0 +1,102 @@
import { db } from './db';
import { siteSettings } from './schema';
import { eq } from 'drizzle-orm';
function getFacebookConfig() {
const appId = process.env.FACEBOOK_APP_ID;
const appSecret = process.env.FACEBOOK_APP_SECRET;
if (!appId || !appSecret) return null;
return { appId, appSecret };
}
export function isFacebookConfigured(): boolean {
return getFacebookConfig() !== null;
}
export function getFacebookAuthUrl(redirectUri: string): string | null {
const config = getFacebookConfig();
if (!config) return null;
const params = new URLSearchParams({
client_id: config.appId,
redirect_uri: redirectUri,
scope: 'pages_manage_posts,pages_read_engagement'
});
return `https://www.facebook.com/v19.0/dialog/oauth?${params}`;
}
export async function exchangeCodeForToken(code: string, redirectUri: string): Promise<string | null> {
const config = getFacebookConfig();
if (!config) return null;
const params = new URLSearchParams({
client_id: config.appId,
client_secret: config.appSecret,
redirect_uri: redirectUri,
code
});
const res = await fetch(`https://graph.facebook.com/v19.0/oauth/access_token?${params}`);
if (!res.ok) return null;
const data = await res.json();
return data.access_token || null;
}
export async function getLongLivedToken(shortToken: string): Promise<string | null> {
const config = getFacebookConfig();
if (!config) return null;
const params = new URLSearchParams({
grant_type: 'fb_exchange_token',
client_id: config.appId,
client_secret: config.appSecret,
fb_exchange_token: shortToken
});
const res = await fetch(`https://graph.facebook.com/v19.0/oauth/access_token?${params}`);
if (!res.ok) return null;
const data = await res.json();
return data.access_token || null;
}
export async function getPages(userToken: string): Promise<Array<{ id: string; name: string; access_token: string }>> {
const res = await fetch(`https://graph.facebook.com/v19.0/me/accounts?access_token=${userToken}`);
if (!res.ok) return [];
const data = await res.json();
return data.data || [];
}
export async function postToPage(pageId: string, pageToken: string, message: string, link?: string): Promise<boolean> {
const body: Record<string, string> = { message, access_token: pageToken };
if (link) body.link = link;
const res = await fetch(`https://graph.facebook.com/v19.0/${pageId}/feed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
return res.ok;
}
export async function getFacebookSettings(): Promise<{ pageId: string; pageToken: string; pageName: string } | null> {
const result = await db
.select()
.from(siteSettings)
.where(eq(siteSettings.key, 'facebook'))
.limit(1);
if (result.length === 0) return null;
try {
const val = JSON.parse(result[0].value);
if (!val.pageId || !val.pageToken) return null;
return val;
} catch {
return null;
}
}
+33
View File
@@ -0,0 +1,33 @@
export class RateLimiter {
private windows = new Map<string, number[]>();
constructor(
private maxRequests: number,
private windowMs: number
) {}
check(key: string): boolean {
const now = Date.now();
const timestamps = this.windows.get(key) ?? [];
const valid = timestamps.filter((t) => now - t < this.windowMs);
if (valid.length >= this.maxRequests) {
this.windows.set(key, valid);
return false;
}
valid.push(now);
this.windows.set(key, valid);
return true;
}
reset(key: string): void {
this.windows.delete(key);
}
}
export const loginLimiter = new RateLimiter(5, 15 * 60 * 1000);
export const registrationLimiter = new RateLimiter(3, 60 * 60 * 1000);
export const apiLimiter = new RateLimiter(60, 60 * 1000);
export const nameGeneratorLimiter = new RateLimiter(10, 5 * 60 * 1000);
+423
View File
@@ -0,0 +1,423 @@
import {
mysqlTable, varchar, int, text, boolean, timestamp, json,
mysqlEnum, uniqueIndex, index, serial
} from 'drizzle-orm/mysql-core';
import { relations } from 'drizzle-orm';
// ── Auth ──────────────────────────────────────────
export const users = mysqlTable('users', {
id: serial().primaryKey(),
email: varchar({ length: 255 }).notNull().unique(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
name: varchar({ length: 255 }).notNull(),
role: mysqlEnum(['sysadmin', 'director', 'foster_coordinator', 'volunteer_manager', 'vet_liaison', 'content_editor', 'applications_manager', 'viewer', 'foster']).notNull().default('viewer'),
active: boolean().notNull().default(false),
mustChangePassword: boolean('must_change_password').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow()
});
export const sessions = mysqlTable('sessions', {
sessionId: varchar('session_id', { length: 255 }).primaryKey(),
userId: int('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at').notNull()
});
// ── Pets ──────────────────────────────────────────
export const intakeSources = mysqlTable('intake_sources', {
id: serial().primaryKey(),
name: varchar({ length: 255 }).notNull(),
active: boolean().notNull().default(true)
});
export const vets = mysqlTable('vets', {
id: serial().primaryKey(),
name: varchar({ length: 255 }).notNull(),
clinic: varchar({ length: 255 }),
phone: varchar({ length: 50 }),
email: varchar({ length: 255 }),
address: text(),
notes: text(),
active: boolean().notNull().default(true),
createdAt: timestamp('created_at').notNull().defaultNow()
});
export const pets = mysqlTable('pets', {
id: serial().primaryKey(),
name: varchar({ length: 255 }).notNull(),
slug: varchar({ length: 255 }).notNull().unique(),
species: mysqlEnum(['dog', 'cat', 'other']).notNull().default('dog'),
breed: varchar({ length: 255 }),
color: varchar({ length: 255 }),
markings: text(),
sex: mysqlEnum(['male', 'female', 'unknown']).notNull().default('unknown'),
weight: varchar({ length: 50 }),
age: varchar({ length: 100 }),
birthday: timestamp(),
status: mysqlEnum([
'available', 'adopted', 'foster', 'medical_hold', 'behavioral_hold',
'pending', 'trial', 'hospice', 'nursing', 'whelping', 'deceased', 'transferred'
]).notNull().default('available'),
description: text(),
medicalNotes: text('medical_notes'),
behavioralNotes: text('behavioral_notes'),
specialNeeds: text('special_needs'),
goodWithDogs: boolean('good_with_dogs'),
goodWithCats: boolean('good_with_cats'),
goodWithKids: boolean('good_with_kids'),
houseTrained: boolean('house_trained'),
spayedNeutered: boolean('spayed_neutered'),
microchipped: boolean(),
microchipId: varchar('microchip_id', { length: 100 }),
adoptionFee: int('adoption_fee'),
adoptionFeeCollected: boolean('adoption_fee_collected').default(false),
intakeDate: timestamp('intake_date'),
intakeSourceId: int('intake_source_id').references(() => intakeSources.id),
adoptedDate: timestamp('adopted_date'),
adopterName: varchar('adopter_name', { length: 255 }),
adopterEmail: varchar('adopter_email', { length: 255 }),
adopterPhone: varchar('adopter_phone', { length: 50 }),
vetId: int('vet_id').references(() => vets.id),
featured: boolean().notNull().default(false),
published: boolean().notNull().default(true),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow()
}, (table) => [
index('status_idx').on(table.status),
index('species_idx').on(table.species),
index('featured_idx').on(table.featured)
]);
export const petPhotos = mysqlTable('pet_photos', {
id: serial().primaryKey(),
petId: int('pet_id').notNull().references(() => pets.id, { onDelete: 'cascade' }),
url: varchar({ length: 500 }).notNull(),
alt: varchar({ length: 255 }),
isPrimary: boolean('is_primary').notNull().default(false),
sortOrder: int('sort_order').notNull().default(0),
createdAt: timestamp('created_at').notNull().defaultNow()
});
export const petMedical = mysqlTable('pet_medical', {
id: serial().primaryKey(),
petId: int('pet_id').notNull().references(() => pets.id, { onDelete: 'cascade' }),
type: mysqlEnum([
'vaccine', 'surgery', 'checkup', 'medication', 'deworming',
'flea_tick', 'heartworm_test', 'heartworm_treatment',
'dental', 'spay_neuter', 'microchip', 'other'
]).notNull(),
name: varchar({ length: 255 }).notNull(),
date: timestamp(),
vetId: int('vet_id').references(() => vets.id),
cost: int(),
notes: text(),
createdAt: timestamp('created_at').notNull().defaultNow()
});
export const fosterAssignments = mysqlTable('foster_assignments', {
id: serial().primaryKey(),
petId: int('pet_id').notNull().references(() => pets.id, { onDelete: 'cascade' }),
userId: int('user_id').notNull().references(() => users.id),
startDate: timestamp('start_date').notNull().defaultNow(),
endDate: timestamp('end_date'),
isCurrent: boolean('is_current').notNull().default(true),
notes: text(),
createdAt: timestamp('created_at').notNull().defaultNow()
});
export const mentors = mysqlTable('mentors', {
id: serial().primaryKey(),
userId: int('user_id').notNull().references(() => users.id).unique(),
bio: text(),
active: boolean().notNull().default(true),
createdAt: timestamp('created_at').notNull().defaultNow()
});
export const petMentors = mysqlTable('pet_mentors', {
id: serial().primaryKey(),
petId: int('pet_id').notNull().references(() => pets.id, { onDelete: 'cascade' }),
mentorId: int('mentor_id').notNull().references(() => mentors.id, { onDelete: 'cascade' }),
assignedAt: timestamp('assigned_at').notNull().defaultNow()
});
// ── Applications ──────────────────────────────────
export const applications = mysqlTable('applications', {
id: serial().primaryKey(),
type: mysqlEnum(['adoption', 'foster', 'volunteer', 'surrender']).notNull(),
status: mysqlEnum(['pending', 'reviewing', 'approved', 'denied', 'withdrawn']).notNull().default('pending'),
applicantName: varchar('applicant_name', { length: 255 }).notNull(),
applicantEmail: varchar('applicant_email', { length: 255 }).notNull(),
applicantPhone: varchar('applicant_phone', { length: 50 }),
petId: int('pet_id').references(() => pets.id),
formData: json('form_data').notNull(),
reviewedBy: int('reviewed_by').references(() => users.id),
reviewedAt: timestamp('reviewed_at'),
reviewNotes: text('review_notes'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow()
});
export const applicationHomevisitNotes = mysqlTable('application_homevisit_notes', {
id: serial().primaryKey(),
applicationId: int('application_id').notNull().references(() => applications.id, { onDelete: 'cascade' }),
userId: int('user_id').notNull().references(() => users.id),
notes: text().notNull(),
visitDate: timestamp('visit_date'),
createdAt: timestamp('created_at').notNull().defaultNow()
});
// ── Finance ───────────────────────────────────────
export const sponsors = mysqlTable('sponsors', {
id: serial().primaryKey(),
name: varchar({ length: 255 }).notNull(),
tier: mysqlEnum(['top_dog', 'wagging_tails', 'wet_noses']).notNull().default('wet_noses'),
logoUrl: varchar('logo_url', { length: 500 }),
websiteUrl: varchar('website_url', { length: 500 }),
description: text(),
active: boolean().notNull().default(true),
sortOrder: int('sort_order').notNull().default(0),
createdAt: timestamp('created_at').notNull().defaultNow()
});
export const donations = mysqlTable('donations', {
id: serial().primaryKey(),
donorName: varchar('donor_name', { length: 255 }),
donorEmail: varchar('donor_email', { length: 255 }),
amount: int().notNull(),
source: mysqlEnum([
'stripe', 'paypal', 'venmo', 'givebutter', 'cash',
'check', 'zelle', 'google_pay', 'square', 'other'
]).notNull().default('other'),
petId: int('pet_id').references(() => pets.id),
stripePaymentId: varchar('stripe_payment_id', { length: 255 }),
isRecurring: boolean('is_recurring').notNull().default(false),
purpose: varchar({ length: 255 }),
notes: text(),
date: timestamp().notNull().defaultNow(),
createdAt: timestamp('created_at').notNull().defaultNow()
});
export const expenses = mysqlTable('expenses', {
id: serial().primaryKey(),
description: varchar({ length: 255 }).notNull(),
amount: int().notNull(),
category: varchar({ length: 100 }),
petId: int('pet_id').references(() => pets.id),
vetId: int('vet_id').references(() => vets.id),
receiptUrl: varchar('receipt_url', { length: 500 }),
date: timestamp().notNull().defaultNow(),
notes: text(),
createdAt: timestamp('created_at').notNull().defaultNow()
});
// ── Content ───────────────────────────────────────
export const events = mysqlTable('events', {
id: serial().primaryKey(),
title: varchar({ length: 255 }).notNull(),
description: text(),
location: varchar({ length: 255 }),
startDate: timestamp('start_date').notNull(),
endDate: timestamp('end_date'),
imageUrl: varchar('image_url', { length: 500 }),
published: boolean().notNull().default(true),
facebookEventId: varchar('facebook_event_id', { length: 100 }),
createdAt: timestamp('created_at').notNull().defaultNow()
});
export const happyTails = mysqlTable('happy_tails', {
id: serial().primaryKey(),
petId: int('pet_id').references(() => pets.id),
title: varchar({ length: 255 }).notNull(),
story: text().notNull(),
imageUrl: varchar('image_url', { length: 500 }),
adopterName: varchar('adopter_name', { length: 255 }),
featured: boolean().notNull().default(false),
published: boolean().notNull().default(false),
submittedBy: varchar('submitted_by', { length: 255 }),
submittedEmail: varchar('submitted_email', { length: 255 }),
createdAt: timestamp('created_at').notNull().defaultNow()
});
export const contentBlocks = mysqlTable('content_blocks', {
id: serial().primaryKey(),
page: varchar({ length: 100 }).notNull(),
sectionKey: varchar('section_key', { length: 100 }).notNull(),
content: json().notNull(),
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow()
}, (table) => [
uniqueIndex('page_section_idx').on(table.page, table.sectionKey)
]);
export const newsletterSubscribers = mysqlTable('newsletter_subscribers', {
id: serial().primaryKey(),
email: varchar({ length: 255 }).notNull().unique(),
name: varchar({ length: 255 }),
subscribedAt: timestamp('subscribed_at').notNull().defaultNow(),
unsubscribedAt: timestamp('unsubscribed_at')
});
export const contactMessages = mysqlTable('contact_messages', {
id: serial().primaryKey(),
name: varchar({ length: 255 }).notNull(),
email: varchar({ length: 255 }).notNull(),
subject: varchar({ length: 255 }),
message: text().notNull(),
read: boolean().notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow()
});
// ── Foster Portal ─────────────────────────────────
export const fosterSubmissions = mysqlTable('foster_submissions', {
id: serial().primaryKey(),
userId: int('user_id').notNull().references(() => users.id),
petId: int('pet_id').notNull().references(() => pets.id, { onDelete: 'cascade' }),
type: mysqlEnum(['note', 'photo', 'update']).notNull().default('update'),
content: text(),
photoUrl: varchar('photo_url', { length: 500 }),
status: mysqlEnum(['pending', 'approved', 'rejected']).notNull().default('pending'),
reviewedBy: int('reviewed_by').references(() => users.id),
reviewedAt: timestamp('reviewed_at'),
createdAt: timestamp('created_at').notNull().defaultNow()
});
export const supplyRequests = mysqlTable('supply_requests', {
id: serial().primaryKey(),
userId: int('user_id').notNull().references(() => users.id),
petId: int('pet_id').references(() => pets.id),
items: text().notNull(),
urgency: mysqlEnum(['low', 'medium', 'high']).notNull().default('medium'),
status: mysqlEnum(['pending', 'approved', 'fulfilled', 'denied']).notNull().default('pending'),
notes: text(),
reviewedBy: int('reviewed_by').references(() => users.id),
reviewedAt: timestamp('reviewed_at'),
createdAt: timestamp('created_at').notNull().defaultNow()
});
// ── Config & System ───────────────────────────────
export const siteSettings = mysqlTable('site_settings', {
id: serial().primaryKey(),
key: varchar({ length: 100 }).notNull().unique(),
value: text().notNull(),
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow()
});
export const orgConfig = mysqlTable('org_config', {
id: serial().primaryKey(),
key: varchar({ length: 100 }).notNull().unique(),
value: json().notNull(),
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow()
});
export const auditLog = mysqlTable('audit_log', {
id: serial().primaryKey(),
userId: int('user_id').references(() => users.id),
action: varchar({ length: 100 }).notNull(),
entity: varchar({ length: 100 }),
entityId: int('entity_id'),
diff: json(),
ipAddress: varchar('ip_address', { length: 45 }),
createdAt: timestamp('created_at').notNull().defaultNow()
}, (table) => [
index('action_idx').on(table.action),
index('entity_idx').on(table.entity, table.entityId)
]);
export const pageViews = mysqlTable('page_views', {
id: serial().primaryKey(),
path: varchar({ length: 500 }).notNull(),
petId: int('pet_id').references(() => pets.id),
ipAddress: varchar('ip_address', { length: 45 }),
userAgent: text('user_agent'),
referrer: varchar({ length: 500 }),
isBot: boolean('is_bot').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow()
}, (table) => [
index('path_idx').on(table.path),
index('created_idx').on(table.createdAt)
]);
export const aiJobs = mysqlTable('ai_jobs', {
id: serial().primaryKey(),
type: varchar({ length: 50 }).notNull(),
input: json().notNull(),
status: mysqlEnum(['pending', 'processing', 'completed', 'failed']).notNull().default('pending'),
output: json(),
error: text(),
completedAt: timestamp('completed_at'),
createdAt: timestamp('created_at').notNull().defaultNow()
});
// ── Relations ─────────────────────────────────────
export const usersRelations = relations(users, ({ many }) => ({
sessions: many(sessions),
fosterAssignments: many(fosterAssignments),
fosterSubmissions: many(fosterSubmissions),
supplyRequests: many(supplyRequests)
}));
export const sessionsRelations = relations(sessions, ({ one }) => ({
user: one(users, { fields: [sessions.userId], references: [users.id] })
}));
export const petsRelations = relations(pets, ({ one, many }) => ({
photos: many(petPhotos),
medical: many(petMedical),
fosterAssignments: many(fosterAssignments),
applications: many(applications),
intakeSource: one(intakeSources, { fields: [pets.intakeSourceId], references: [intakeSources.id] }),
vet: one(vets, { fields: [pets.vetId], references: [vets.id] }),
donations: many(donations),
expenses: many(expenses)
}));
export const petPhotosRelations = relations(petPhotos, ({ one }) => ({
pet: one(pets, { fields: [petPhotos.petId], references: [pets.id] })
}));
export const petMedicalRelations = relations(petMedical, ({ one }) => ({
pet: one(pets, { fields: [petMedical.petId], references: [pets.id] }),
vet: one(vets, { fields: [petMedical.vetId], references: [vets.id] })
}));
export const fosterAssignmentsRelations = relations(fosterAssignments, ({ one }) => ({
pet: one(pets, { fields: [fosterAssignments.petId], references: [pets.id] }),
user: one(users, { fields: [fosterAssignments.userId], references: [users.id] })
}));
export const applicationsRelations = relations(applications, ({ one, many }) => ({
pet: one(pets, { fields: [applications.petId], references: [pets.id] }),
reviewer: one(users, { fields: [applications.reviewedBy], references: [users.id] }),
homevisitNotes: many(applicationHomevisitNotes)
}));
export const applicationHomevisitNotesRelations = relations(applicationHomevisitNotes, ({ one }) => ({
application: one(applications, { fields: [applicationHomevisitNotes.applicationId], references: [applications.id] }),
user: one(users, { fields: [applicationHomevisitNotes.userId], references: [users.id] })
}));
export const donationsRelations = relations(donations, ({ one }) => ({
pet: one(pets, { fields: [donations.petId], references: [pets.id] })
}));
export const expensesRelations = relations(expenses, ({ one }) => ({
pet: one(pets, { fields: [expenses.petId], references: [pets.id] }),
vet: one(vets, { fields: [expenses.vetId], references: [vets.id] })
}));
export const fosterSubmissionsRelations = relations(fosterSubmissions, ({ one }) => ({
user: one(users, { fields: [fosterSubmissions.userId], references: [users.id] }),
pet: one(pets, { fields: [fosterSubmissions.petId], references: [pets.id] })
}));
export const supplyRequestsRelations = relations(supplyRequests, ({ one }) => ({
user: one(users, { fields: [supplyRequests.userId], references: [users.id] }),
pet: one(pets, { fields: [supplyRequests.petId], references: [pets.id] })
}));
+94
View File
@@ -0,0 +1,94 @@
import Stripe from 'stripe';
function getStripe(): Stripe | null {
if (!process.env.STRIPE_SECRET_KEY) return null;
return new Stripe(process.env.STRIPE_SECRET_KEY);
}
export async function createDonationCheckout(params: {
amount: number;
donorEmail?: string;
petId?: number;
petName?: string;
successUrl: string;
cancelUrl: string;
}): Promise<string | null> {
const stripe = getStripe();
if (!stripe) return null;
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
mode: 'payment',
customer_email: params.donorEmail,
line_items: [{
price_data: {
currency: 'usd',
product_data: {
name: params.petName
? `Donation for ${params.petName}`
: 'Donation'
},
unit_amount: params.amount
},
quantity: 1
}],
metadata: {
type: 'donation',
petId: params.petId?.toString() || ''
},
success_url: params.successUrl,
cancel_url: params.cancelUrl
});
return session.url;
}
export async function createAdoptionFeeCheckout(params: {
amount: number;
petId: number;
petName: string;
adopterEmail: string;
successUrl: string;
cancelUrl: string;
}): Promise<string | null> {
const stripe = getStripe();
if (!stripe) return null;
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
mode: 'payment',
customer_email: params.adopterEmail,
line_items: [{
price_data: {
currency: 'usd',
product_data: {
name: `Adoption Fee — ${params.petName}`
},
unit_amount: params.amount
},
quantity: 1
}],
metadata: {
type: 'adoption_fee',
petId: params.petId.toString()
},
success_url: params.successUrl,
cancel_url: params.cancelUrl
});
return session.url;
}
export function constructWebhookEvent(
body: string,
signature: string
): Stripe.Event | null {
const stripe = getStripe();
if (!stripe || !process.env.STRIPE_WEBHOOK_SECRET) return null;
return stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
}
+36
View File
@@ -0,0 +1,36 @@
import { db } from './db';
import { pageViews } from './schema';
const BOT_PATTERNS = [
/bot/i, /crawl/i, /spider/i, /slurp/i, /mediapartners/i,
/googlebot/i, /bingbot/i, /yandex/i, /baidu/i, /duckduckbot/i,
/facebookexternalhit/i, /twitterbot/i, /linkedinbot/i,
/whatsapp/i, /telegrambot/i, /discordbot/i, /slack/i,
/semrush/i, /ahref/i, /mj12bot/i, /dotbot/i, /petalbot/i,
/bytespider/i, /gptbot/i, /claudebot/i, /anthropic/i,
/headlesschrome/i, /phantomjs/i, /selenium/i, /puppeteer/i,
/curl/i, /wget/i, /python-requests/i, /httpx/i, /axios/i, /node-fetch/i
];
function isBot(userAgent: string): boolean {
return BOT_PATTERNS.some((pattern) => pattern.test(userAgent));
}
export async function trackPageView(params: {
path: string;
petId?: number;
ipAddress?: string;
userAgent?: string;
referrer?: string;
}): Promise<void> {
const bot = params.userAgent ? isBot(params.userAgent) : false;
await db.insert(pageViews).values({
path: params.path,
petId: params.petId ?? null,
ipAddress: params.ipAddress ?? null,
userAgent: params.userAgent ?? null,
referrer: params.referrer ?? null,
isBot: bot
});
}
+66
View File
@@ -0,0 +1,66 @@
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads';
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
const MAGIC_BYTES: Record<string, number[]> = {
'image/jpeg': [0xff, 0xd8, 0xff],
'image/png': [0x89, 0x50, 0x4e, 0x47],
'image/gif': [0x47, 0x49, 0x46],
'image/webp': [0x52, 0x49, 0x46, 0x46]
};
function validateMagicBytes(buffer: Buffer, mimeType: string): boolean {
const expected = MAGIC_BYTES[mimeType];
if (!expected) return false;
for (let i = 0; i < expected.length; i++) {
if (buffer[i] !== expected[i]) return false;
}
return true;
}
export async function saveUpload(
file: File,
subdir = 'general'
): Promise<{ url: string; path: string }> {
if (file.size > MAX_SIZE) {
throw new Error('File too large (max 10MB)');
}
if (!ALLOWED_TYPES.includes(file.type)) {
throw new Error('File type not allowed');
}
const buffer = Buffer.from(await file.arrayBuffer());
if (!validateMagicBytes(buffer, file.type)) {
throw new Error('File content does not match declared type');
}
const ext = file.name.split('.').pop()?.toLowerCase() || 'jpg';
const filename = `${crypto.randomUUID()}.${ext}`;
const dir = path.join(UPLOAD_DIR, subdir);
await fs.mkdir(dir, { recursive: true });
const filePath = path.join(dir, filename);
await fs.writeFile(filePath, buffer);
return {
url: `/uploads/${subdir}/${filename}`,
path: filePath
};
}
export async function deleteUpload(url: string): Promise<void> {
if (!url.startsWith('/uploads/')) return;
const filePath = path.join(UPLOAD_DIR, url.replace('/uploads/', ''));
try {
await fs.unlink(filePath);
} catch {
// File already gone
}
}
+8
View File
@@ -0,0 +1,8 @@
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/[\s]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
+22
View File
@@ -0,0 +1,22 @@
interface Toast {
id: number;
message: string;
type: 'success' | 'error' | 'info';
}
let toasts = $state<Toast[]>([]);
let nextId = 0;
export function addToast(message: string, type: Toast['type'] = 'info'): void {
const id = nextId++;
toasts.push({ id, message, type });
setTimeout(() => removeToast(id), 5000);
}
export function removeToast(id: number): void {
toasts = toasts.filter((t) => t.id !== id);
}
export function getToasts(): Toast[] {
return toasts;
}
+16
View File
@@ -0,0 +1,16 @@
import { db } from '$lib/server/db';
import { sponsors } from '$lib/server/schema';
import { eq, asc } from 'drizzle-orm';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ parent }) => {
const parentData = await parent();
const allSponsors = await db
.select()
.from(sponsors)
.where(eq(sponsors.active, true))
.orderBy(asc(sponsors.sortOrder));
return { ...parentData, sponsors: allSponsors };
};
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import Header from '$lib/components/public/Header.svelte';
import Footer from '$lib/components/public/Footer.svelte';
let { data, children } = $props();
</script>
<svelte:head>
<meta name="description" content="{data.orgConfig.orgInfo.name} — {data.orgConfig.orgInfo.tagline || 'A foster-based animal rescue'}" />
<meta property="og:site_name" content={data.orgConfig.orgInfo.name} />
<meta property="og:type" content="website" />
{@html `<style>:root{--color-primary:${data.orgConfig.branding.primaryColor};--color-primary-light:${data.orgConfig.branding.primaryColorLight};--color-primary-dark:${data.orgConfig.branding.primaryColorDark};--color-accent:${data.orgConfig.branding.accentColor};--color-accent-light:${data.orgConfig.branding.accentColorLight};--color-accent-dark:${data.orgConfig.branding.accentColorDark}}</style>`}
</svelte:head>
<div class="min-h-screen flex flex-col bg-warm-bg">
<Header
orgName={data.orgConfig.orgInfo.name}
logoUrl={data.orgConfig.branding.logoUrl}
/>
<main class="flex-1">
{@render children()}
</main>
<Footer
orgName={data.orgConfig.orgInfo.name}
sponsors={data.sponsors}
facebookUrl={data.orgConfig.orgInfo.facebookUrl}
instagramUrl={data.orgConfig.orgInfo.instagramUrl}
/>
</div>
+87
View File
@@ -0,0 +1,87 @@
import { db } from '$lib/server/db';
import { pets, petPhotos, events, fosterAssignments } from '$lib/server/schema';
import { eq, and, gt, asc, or, isNotNull, desc, count } from 'drizzle-orm';
import { getPageBlocks } from '$lib/server/content';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
const parentData = await parent();
// Featured pets with primary photos
const rows = await db
.select({
id: pets.id,
name: pets.name,
slug: pets.slug,
breed: pets.breed,
age: pets.age,
sex: pets.sex,
species: pets.species,
photoUrl: petPhotos.url
})
.from(pets)
.leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true)))
.where(and(
eq(pets.status, 'available'),
eq(pets.published, true),
or(eq(pets.featured, true), isNotNull(petPhotos.url))
))
.orderBy(desc(pets.featured), pets.name)
.limit(8);
// Upcoming events (next 3)
const now = new Date();
const upcomingEvents = await db
.select()
.from(events)
.where(and(eq(events.published, true), gt(events.startDate, now)))
.orderBy(asc(events.startDate))
.limit(3);
const content = await getPageBlocks('home');
// Stats
const [{ totalRescued }] = await db
.select({ totalRescued: count() })
.from(pets);
const [{ totalAdopted }] = await db
.select({ totalAdopted: count() })
.from(pets)
.where(eq(pets.status, 'adopted'));
const [{ currentFosters }] = await db
.select({ currentFosters: count() })
.from(fosterAssignments)
.where(eq(fosterAssignments.isCurrent, true));
const [{ availableNow }] = await db
.select({ availableNow: count() })
.from(pets)
.where(and(eq(pets.status, 'available'), eq(pets.published, true)));
const foundedYear = (parentData.orgConfig as { foundedYear?: number }).foundedYear;
const yearsRescuing = new Date().getFullYear() - (foundedYear ?? new Date().getFullYear());
return {
...parentData,
featuredPets: rows.map((row) => ({
name: row.name,
slug: row.slug,
breed: row.breed,
age: row.age,
sex: row.sex,
species: row.species,
primaryPhoto: row.photoUrl ?? null
})),
upcomingEvents,
content,
stats: {
totalRescued: totalRescued ?? 0,
totalAdopted: totalAdopted ?? 0,
currentFosters: currentFosters ?? 0,
availableNow: availableNow ?? 0,
yearsRescuing
}
};
};
+298
View File
@@ -0,0 +1,298 @@
<script lang="ts">
import PetCard from '$lib/components/public/PetCard.svelte';
import DonateButton from '$lib/components/public/DonateButton.svelte';
import StatsCounter from '$lib/components/public/StatsCounter.svelte';
import NewsletterSignup from '$lib/components/public/NewsletterSignup.svelte';
let { data } = $props();
const orgName = $derived(data.orgConfig.orgInfo.name);
const tagline = $derived(data.orgConfig.orgInfo.tagline || 'Every Animal Deserves a Home');
const facebookUrl = $derived(data.orgConfig.orgInfo.facebookUrl);
</script>
<svelte:head>
<title>{orgName} — {tagline}</title>
<meta name="description" content="{orgName} is a foster-based rescue dedicated to saving animals and finding them loving forever homes." />
<meta property="og:type" content="website" />
<meta property="og:title" content="{orgName} — {tagline}" />
<meta property="og:description" content="{orgName} is a foster-based rescue dedicated to saving animals and finding them loving forever homes." />
<meta property="og:site_name" content={orgName} />
<meta name="twitter:card" content="summary_large_image" />
</svelte:head>
<!-- Hero Section -->
<section class="relative overflow-hidden bg-gradient-to-br from-teal-dark via-teal-brand to-teal-dark">
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 md:py-32">
<div class="max-w-3xl">
<h1 class="text-4xl sm:text-5xl md:text-6xl font-extrabold text-white leading-tight">
{(data.content as Record<string, Record<string, string>>)?.hero?.heading ?? tagline}
</h1>
<p class="mt-6 text-lg md:text-xl text-white/85 leading-relaxed max-w-2xl">
{(data.content as Record<string, Record<string, string>>)?.hero?.subtext ?? 'A foster-based rescue committed to giving every animal a second chance. Through compassion, community, and dedicated foster families, we help pets find the loving homes they deserve.'}
</p>
<div class="mt-10 flex flex-wrap gap-3">
<a
href="/pets?species=dog"
class="inline-flex items-center px-7 py-3.5 text-base font-semibold rounded-full bg-white text-teal-brand hover:bg-gray-soft transition-colors duration-200 shadow-lg hover:shadow-xl"
>
Meet Our Dogs
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 ml-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</a>
<a
href="/pets?species=cat"
class="inline-flex items-center px-7 py-3.5 text-base font-semibold rounded-full bg-white text-teal-brand hover:bg-gray-soft transition-colors duration-200 shadow-lg hover:shadow-xl"
>
Meet Our Cats
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 ml-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</a>
<DonateButton size="lg" variant="outline" />
</div>
</div>
</div>
<!-- Decorative wave -->
<div class="absolute bottom-0 left-0 right-0">
<svg viewBox="0 0 1440 60" fill="none" xmlns="http://www.w3.org/2000/svg" class="w-full">
<path d="M0 60V30C240 0 480 0 720 30C960 60 1200 60 1440 30V60H0Z" fill="var(--color-warm-bg)" />
</svg>
</div>
</section>
<!-- Stats Counter -->
<StatsCounter
totalRescued={data.stats.totalRescued}
totalAdopted={data.stats.totalAdopted}
currentFosters={data.stats.currentFosters}
availableNow={data.stats.availableNow}
yearsRescuing={data.stats.yearsRescuing}
/>
<!-- Featured Pets Section -->
<section class="py-16 md:py-24">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-gray-brand">Pets Looking for Homes</h2>
<p class="mt-3 text-gray-mid text-lg max-w-2xl mx-auto">
Meet some of our wonderful animals waiting for their forever families.
</p>
</div>
{#if data.featuredPets.length > 0}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{#each data.featuredPets as pet}
<PetCard {pet} />
{/each}
</div>
{:else}
<div class="text-center py-12 bg-white rounded-2xl">
<p class="text-gray-mid text-lg">No pets available at this time. Check back soon!</p>
</div>
{/if}
<div class="mt-12 text-center">
<a
href="/pets"
class="inline-flex items-center gap-2 px-8 py-3 text-base font-semibold text-teal-brand border-2 border-teal-brand rounded-full hover:bg-teal-brand hover:text-white transition-all duration-200"
>
View All Available Pets
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</a>
</div>
</div>
</section>
<!-- How You Can Help Section -->
<section class="py-16 md:py-24 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-gray-brand">How You Can Help</h2>
<p class="mt-3 text-gray-mid text-lg max-w-2xl mx-auto">
There are many ways to make a difference in the life of a rescue pet.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="bg-warm-bg rounded-2xl p-8 text-center hover:shadow-lg transition-shadow duration-300">
<div class="w-16 h-16 mx-auto mb-6 bg-teal-light rounded-2xl flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8 text-teal-brand">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 21v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21m0 0h4.5V3.545M12.75 21h7.5V10.75M2.25 21h1.5m18 0h-18M2.25 9l4.5-1.636M18.75 3l-1.5.545m0 6.205l3 1m1.5.5l-1.5-.5M6.75 7.364V3h-3v18m3-13.636l10.5-3.819" />
</svg>
</div>
<h3 class="text-xl font-bold text-gray-brand mb-3">Adopt</h3>
<p class="text-gray-mid text-sm leading-relaxed mb-6">
Open your heart and home to a rescue pet. Our adoption process is thorough, and we will help you find the perfect match for your family.
</p>
<a href="/pets" class="inline-flex items-center gap-1 text-teal-brand font-semibold hover:text-teal-dark transition-colors">
Browse Pets
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</a>
</div>
<div class="bg-warm-bg rounded-2xl p-8 text-center hover:shadow-lg transition-shadow duration-300">
<div class="w-16 h-16 mx-auto mb-6 bg-teal-light rounded-2xl flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8 text-teal-brand">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
</svg>
</div>
<h3 class="text-xl font-bold text-gray-brand mb-3">Foster</h3>
<p class="text-gray-mid text-sm leading-relaxed mb-6">
Provide a temporary safe haven for a pet in need. Fostering saves lives and helps pets prepare for their forever homes.
</p>
<a href="/become-a-foster" class="inline-flex items-center gap-1 text-teal-brand font-semibold hover:text-teal-dark transition-colors">
Learn More
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</a>
</div>
<div class="bg-warm-bg rounded-2xl p-8 text-center hover:shadow-lg transition-shadow duration-300">
<div class="w-16 h-16 mx-auto mb-6 bg-teal-light rounded-2xl flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8 text-teal-brand">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
</svg>
</div>
<h3 class="text-xl font-bold text-gray-brand mb-3">Donate</h3>
<p class="text-gray-mid text-sm leading-relaxed mb-6">
Your generous donations help cover veterinary care, food, and supplies for our rescue pets. Every dollar makes a difference.
</p>
<a href="/donate" class="inline-flex items-center gap-1 text-teal-brand font-semibold hover:text-teal-dark transition-colors">
Give Now
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</a>
</div>
</div>
</div>
</section>
<!-- Newsletter Signup Banner -->
<section class="py-12 md:py-16 bg-gradient-to-r from-teal-dark via-teal-brand to-teal-dark">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex flex-col md:flex-row items-center gap-6 md:gap-10">
<div class="flex-1 text-center md:text-left">
<h2 class="text-2xl md:text-3xl font-bold text-white">Stay in the Loop</h2>
<p class="mt-2 text-white/75 text-sm md:text-base">
Get rescue updates, adoption stories, and event news delivered to your inbox.
</p>
</div>
<div class="w-full md:w-auto md:min-w-[380px]">
<NewsletterSignup />
</div>
</div>
</div>
</section>
<!-- Upcoming Events Section -->
<section class="py-16 md:py-24">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-gray-brand">Upcoming Events</h2>
<p class="mt-3 text-gray-mid text-lg max-w-2xl mx-auto">
Join us at an upcoming event to meet our pets and support our mission.
</p>
</div>
{#if data.upcomingEvents.length > 0}
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
{#each data.upcomingEvents as event}
<div class="bg-white rounded-2xl overflow-hidden shadow-sm hover:shadow-lg transition-shadow duration-300">
{#if event.imageUrl}
<div class="aspect-[16/9] overflow-hidden">
<img src={event.imageUrl} alt={event.title} class="w-full h-full object-cover" />
</div>
{:else}
<div class="aspect-[16/9] bg-teal-light flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-12 h-12 text-teal-brand/30">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
</svg>
</div>
{/if}
<div class="p-6">
<div class="flex items-center gap-2 text-sm text-teal-brand font-medium mb-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
</svg>
{new Date(event.startDate).toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric'
})}
</div>
<h3 class="text-lg font-bold text-gray-brand mb-2">{event.title}</h3>
{#if event.location}
<div class="flex items-center gap-1.5 text-sm text-gray-mid mb-3">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0z" />
</svg>
{event.location}
</div>
{/if}
{#if event.description}
<p class="text-sm text-gray-mid line-clamp-3">{event.description}</p>
{/if}
</div>
</div>
{/each}
</div>
<div class="mt-12 text-center">
<a href="/events" class="inline-flex items-center gap-2 text-teal-brand font-semibold hover:text-teal-dark transition-colors">
View All Events
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</a>
</div>
{:else}
<div class="text-center py-12 bg-white rounded-2xl">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-12 h-12 mx-auto text-gray-soft mb-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
</svg>
<p class="text-gray-mid text-lg">No upcoming events at this time.</p>
{#if facebookUrl}
<p class="text-gray-mid/70 text-sm mt-1">Check back soon or follow us on Facebook for updates.</p>
{/if}
</div>
{/if}
</div>
</section>
<!-- Follow on Social -->
{#if facebookUrl}
<section class="py-16 md:py-24 bg-warm-bg">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-3xl md:text-4xl font-bold text-gray-brand">Follow Us</h2>
<p class="mt-3 text-gray-mid text-lg max-w-2xl mx-auto">
Stay up to date with our latest rescue stories, adoption news, and event announcements.
</p>
<div class="mt-8 flex justify-center gap-4">
{#if facebookUrl}
<a
href={facebookUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 text-teal-brand font-semibold hover:text-teal-dark transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
</svg>
Follow on Facebook
</a>
{/if}
</div>
</div>
</section>
{/if}
@@ -0,0 +1,8 @@
import { getPageBlocks } from '$lib/server/content';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
const parentData = await parent();
const blocks = await getPageBlocks('about');
return { ...parentData, blocks };
};
+144
View File
@@ -0,0 +1,144 @@
<script lang="ts">
let { data } = $props();
const orgName = $derived(data.orgConfig.orgInfo.name);
type Blocks = Record<string, Record<string, string>>;
const blocks = $derived(data.blocks as Blocks);
</script>
<svelte:head>
<title>About Us | {orgName}</title>
<meta name="description" content="Learn about {orgName} — a foster-based rescue dedicated to saving animals in need and finding them loving forever homes." />
</svelte:head>
<!-- Hero -->
<section class="bg-teal-brand py-16 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h1 class="text-4xl font-bold sm:text-5xl">{blocks?.intro?.heading ?? `About ${orgName}`}</h1>
<p class="mt-4 text-lg text-teal-light/90">
{blocks?.intro?.text ?? 'Every pet deserves a second chance at a happy life.'}
</p>
</div>
</section>
<!-- Mission -->
<section class="bg-warm-bg py-16">
<div class="mx-auto max-w-6xl px-4">
<div class="mx-auto max-w-4xl">
<h2 class="text-3xl font-bold text-gray-brand text-center">{blocks?.mission?.heading ?? 'Our Mission'}</h2>
<div class="mx-auto mt-3 h-1 w-16 rounded bg-teal-brand"></div>
<p class="mt-8 text-lg leading-relaxed text-gray-mid text-center">
{blocks?.mission?.text ?? `${orgName} is a foster-based rescue dedicated to saving animals in need and finding them loving forever homes. We believe every pet deserves safety, compassion, and a family to call their own.`}
</p>
</div>
</div>
</section>
<!-- How We Work -->
<section class="bg-white py-16">
<div class="mx-auto max-w-6xl px-4">
<h2 class="text-center text-3xl font-bold text-gray-brand">{blocks?.how_we_work?.heading ?? 'How We Work'}</h2>
<div class="mx-auto mt-3 h-1 w-16 rounded bg-teal-brand"></div>
<div class="mt-12 grid gap-8 md:grid-cols-3">
<div class="rounded-xl border border-gray-soft bg-warm-bg p-8 text-center">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-teal-light">
<svg class="h-8 w-8 text-teal-brand" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4" />
</svg>
</div>
<h3 class="mt-4 text-xl font-semibold text-gray-brand">Foster-Based Model</h3>
<p class="mt-3 text-gray-mid">
Every pet lives in a loving foster home where they receive personalized attention, socialization, and care while waiting for their forever family.
</p>
</div>
<div class="rounded-xl border border-gray-soft bg-warm-bg p-8 text-center">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-teal-light">
<svg class="h-8 w-8 text-teal-brand" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</div>
<h3 class="mt-4 text-xl font-semibold text-gray-brand">Complete Vetting</h3>
<p class="mt-3 text-gray-mid">
Every pet in our care receives a full veterinary workup, vaccinations, spay/neuter surgery, microchipping, and any additional medical treatment they need.
</p>
</div>
<div class="rounded-xl border border-gray-soft bg-warm-bg p-8 text-center">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-teal-light">
<svg class="h-8 w-8 text-teal-brand" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h3 class="mt-4 text-xl font-semibold text-gray-brand">Thoughtful Matching</h3>
<p class="mt-3 text-gray-mid">
We carefully match each pet with the right family through our application and screening process — for a fit that lasts a lifetime.
</p>
</div>
</div>
</div>
</section>
<!-- Our Story -->
<section class="bg-warm-bg py-16">
<div class="mx-auto max-w-6xl px-4">
<div class="mx-auto max-w-3xl">
<h2 class="text-center text-3xl font-bold text-gray-brand">Our Story</h2>
<div class="mx-auto mt-3 h-1 w-16 rounded bg-teal-brand"></div>
{#if blocks?.story?.text}
<div class="mt-8 prose prose-gray max-w-none text-gray-mid">{@html blocks.story.text}</div>
{:else}
<div class="mt-8 space-y-5 text-gray-mid leading-relaxed">
<p>
{orgName} was born from a simple belief: no pet should be without a safe place to call home. What started as a small group of passionate animal lovers has grown into a dedicated organization serving our community.
</p>
<p>
Through our network of incredible foster families, we've been able to rehabilitate, socialize, and prepare pets for life in their forever homes.
</p>
<p>
Every pet that comes through our program receives the medical care, training support, and love they need to thrive.
</p>
</div>
{/if}
</div>
</div>
</section>
<!-- Team & Volunteers -->
<section class="bg-white py-16">
<div class="mx-auto max-w-6xl px-4 text-center">
<h2 class="text-3xl font-bold text-gray-brand">Our Team &amp; Volunteers</h2>
<div class="mx-auto mt-3 h-1 w-16 rounded bg-teal-brand"></div>
<p class="mx-auto mt-6 max-w-2xl text-lg text-gray-mid">
{orgName} runs on the dedication of our volunteers — from foster families opening their homes to transport drivers, event coordinators, and behind-the-scenes helpers.
</p>
<div class="mt-10 grid gap-6 sm:grid-cols-3">
<div class="rounded-lg bg-teal-light/50 p-6">
<p class="text-4xl font-bold text-teal-brand">100%</p>
<p class="mt-2 font-medium text-gray-brand">Volunteer Run</p>
</div>
<div class="rounded-lg bg-teal-light/50 p-6">
<p class="text-4xl font-bold text-teal-brand">Foster</p>
<p class="mt-2 font-medium text-gray-brand">Based Network</p>
</div>
<div class="rounded-lg bg-teal-light/50 p-6">
<p class="text-4xl font-bold text-teal-brand">Hundreds</p>
<p class="mt-2 font-medium text-gray-brand">Of Pets Saved</p>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="bg-teal-brand py-16 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h2 class="text-3xl font-bold">Want to Help?</h2>
<p class="mt-4 text-lg text-teal-light/90">
See how you can make a difference in the lives of pets who need it most.
</p>
<a href="/how-to-help" class="mt-8 inline-block rounded-lg bg-white px-8 py-3 font-semibold text-teal-brand shadow-lg transition hover:bg-teal-light hover:shadow-xl">
How to Help
</a>
</div>
</section>
@@ -0,0 +1,8 @@
import { getPageBlocks } from '$lib/server/content';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
const parentData = await parent();
const blocks = await getPageBlocks('adoption-process');
return { ...parentData, blocks };
};
@@ -0,0 +1,138 @@
<script lang="ts">
let { data } = $props();
const orgName = $derived(data.orgConfig.orgInfo.name);
const orgEmail = $derived(data.orgConfig.orgInfo.email);
const adoptionFee = $derived(data.orgConfig.applications.adoptionFee);
</script>
<svelte:head>
<title>Adoption Process | {orgName}</title>
<meta name="description" content="Learn about the {orgName} adoption process — from application to home visit to meeting your new best friend." />
</svelte:head>
<!-- Hero -->
<section class="bg-teal-brand py-16 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h1 class="text-4xl font-bold sm:text-5xl">Adoption Process</h1>
<p class="mt-4 text-lg text-teal-light/90">Everything you need to know about adopting from us.</p>
</div>
</section>
<!-- Intro -->
<section class="bg-warm-bg py-16">
<div class="mx-auto max-w-3xl px-4">
<p class="text-lg leading-relaxed text-gray-mid">
Thank you for your interest in adopting a pet from {orgName}!
{#if orgEmail}
Please email us at
<a href="mailto:{orgEmail}" class="font-semibold text-teal-brand hover:text-teal-dark underline">{orgEmail}</a>
with any questions or concerns.
{/if}
</p>
<div class="mt-8 rounded-xl border-2 border-amber-400 bg-amber-50 p-6">
<p class="text-center text-lg font-bold text-amber-800">
Please read the following information before applying!
</p>
</div>
</div>
</section>
<!-- FAQ Accordion -->
<section class="bg-white py-16">
<div class="mx-auto max-w-3xl px-4">
<h2 class="text-center text-3xl font-bold text-gray-brand">Frequently Asked Questions</h2>
<div class="mx-auto mt-3 h-1 w-16 rounded bg-teal-brand"></div>
<div class="mt-12 space-y-4">
<details class="group rounded-xl border border-gray-soft bg-warm-bg shadow-sm" open>
<summary class="flex cursor-pointer items-center justify-between gap-4 px-6 py-5 text-lg font-semibold text-gray-brand transition hover:text-teal-brand [&::-webkit-details-marker]:hidden">
<span>What is the adoption process?</span>
<svg class="h-5 w-5 shrink-0 text-teal-brand transition-transform group-open:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="px-6 pb-6 text-gray-mid leading-relaxed">
<p class="mb-4">Submit an adoption application and complete each question thoroughly. Once submitted, the following steps will take place:</p>
<ul class="space-y-4">
<li><strong class="text-gray-brand">Reference Check</strong> — We may contact your vet, landlord, and personal references.</li>
<li><strong class="text-gray-brand">Pre-Adoption Home Visit</strong> — We will schedule a visit to ensure the home is safe for the pet.</li>
<li><strong class="text-gray-brand">Meet &amp; Greet</strong> — Once approved, we coordinate a time for you to meet the pet.</li>
<li><strong class="text-gray-brand">Adoption is Final</strong> — Adoption fees are non-refundable if the pet needs to be returned.</li>
</ul>
</div>
</details>
<details class="group rounded-xl border border-gray-soft bg-warm-bg shadow-sm">
<summary class="flex cursor-pointer items-center justify-between gap-4 px-6 py-5 text-lg font-semibold text-gray-brand transition hover:text-teal-brand [&::-webkit-details-marker]:hidden">
<span>What are the adoption fees?</span>
<svg class="h-5 w-5 shrink-0 text-teal-brand transition-transform group-open:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="px-6 pb-6 text-gray-mid leading-relaxed">
{#if adoptionFee}
<p>The standard adoption fee is <strong class="text-gray-brand">${adoptionFee}</strong>. Fees help cover veterinary care, vaccinations, spay/neuter, and microchipping.</p>
{:else}
<p>
Adoption fees vary. Contact us
{#if orgEmail}
at <a href="mailto:{orgEmail}" class="font-semibold text-teal-brand hover:text-teal-dark underline">{orgEmail}</a>
{/if}
for current fee information.
</p>
{/if}
</div>
</details>
<details class="group rounded-xl border border-gray-soft bg-warm-bg shadow-sm">
<summary class="flex cursor-pointer items-center justify-between gap-4 px-6 py-5 text-lg font-semibold text-gray-brand transition hover:text-teal-brand [&::-webkit-details-marker]:hidden">
<span>What happens after my application is approved?</span>
<svg class="h-5 w-5 shrink-0 text-teal-brand transition-transform group-open:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="px-6 pb-6 text-gray-mid leading-relaxed">
<p>Our adoption team will reach out to coordinate meeting the pet. They will work with both you and the foster family to find a time and place that works for everyone.</p>
</div>
</details>
<details class="group rounded-xl border border-gray-soft bg-warm-bg shadow-sm">
<summary class="flex cursor-pointer items-center justify-between gap-4 px-6 py-5 text-lg font-semibold text-gray-brand transition hover:text-teal-brand [&::-webkit-details-marker]:hidden">
<span>Have more questions?</span>
<svg class="h-5 w-5 shrink-0 text-teal-brand transition-transform group-open:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="px-6 pb-6 text-gray-mid leading-relaxed">
<p>
Contact us
{#if orgEmail}
at <a href="mailto:{orgEmail}" class="font-semibold text-teal-brand hover:text-teal-dark underline">{orgEmail}</a>
{/if}
and we will be happy to answer any questions.
</p>
</div>
</details>
</div>
</div>
</section>
<!-- CTA -->
{#if data.orgConfig.applications.adoptionEnabled}
<section class="bg-teal-brand py-16 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h2 class="text-3xl font-bold">Ready to Apply?</h2>
<p class="mt-4 text-lg text-teal-light/90">
Start the process today and take the first step toward welcoming a rescue pet into your family.
</p>
<div class="mt-8 flex flex-wrap items-center justify-center gap-4">
<a href="/pets" class="inline-block rounded-lg bg-white px-8 py-3 font-semibold text-teal-brand shadow-lg transition hover:bg-teal-light hover:shadow-xl">
Browse Available Pets
</a>
<a href="/apply/adopt" class="inline-block rounded-lg border-2 border-white px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-white hover:text-teal-brand">
Submit an Application
</a>
</div>
</div>
</section>
{/if}
@@ -0,0 +1,173 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { applications, pets } from '$lib/server/schema';
import { eq } from 'drizzle-orm';
import { sendApplicationNotification } from '$lib/server/email';
import { loginLimiter } from '$lib/server/rate-limit';
export const load: PageServerLoad = async ({ parent, url }) => {
const parentData = await parent();
if (!parentData.orgConfig.applications.adoptionEnabled) {
throw redirect(302, '/');
}
const petSlug = url.searchParams.get('pet');
let pet: { id: number; name: string; slug: string } | null = null;
if (petSlug) {
const result = await db
.select({ id: pets.id, name: pets.name, slug: pets.slug })
.from(pets)
.where(eq(pets.slug, petSlug))
.limit(1);
if (result.length > 0) {
pet = result[0];
}
}
return { ...parentData, pet };
};
export const actions: Actions = {
default: async ({ request, getClientAddress, locals }) => {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? getClientAddress();
if (!loginLimiter.check(`apply-adopt:${ip}`)) {
return fail(429, { error: 'Too many submissions. Please try again later.' });
}
const formData = await request.formData();
const firstName = formData.get('firstName')?.toString().trim() ?? '';
const lastName = formData.get('lastName')?.toString().trim() ?? '';
const applicantName = `${firstName} ${lastName}`.trim();
const email = formData.get('email')?.toString().trim() ?? '';
const phone = formData.get('phone')?.toString().trim() ?? '';
const petIdRaw = formData.get('petId')?.toString() ?? '';
const petId = petIdRaw ? parseInt(petIdRaw, 10) : null;
const data: Record<string, unknown> = {};
const fieldNames = [
'petOfInterest', 'firstName', 'lastName',
'address1', 'address2', 'city', 'state', 'zip',
'ownRent', 'landlordInfo',
'householdMembers', 'currentAnimals', 'otherPropertyPets', 'previousPets',
'petsVaccinated', 'yardFenced', 'fenceHeight',
'whereDuringDay', 'whereSleepNight', 'hoursAlone', 'activityLevel',
'whyAdopt', 'howHeard', 'agreeLeash',
'integratedBefore', 'twoWeekShutdown',
'felony', 'felonyDetails', 'animalNeglect',
'reference1Name', 'reference1Phone', 'reference1Relationship',
'reference2Name', 'reference2Phone', 'reference2Relationship',
'reference3Name', 'reference3Phone', 'reference3Relationship',
'vetName', 'vetClinic', 'vetPhone',
'landlordName', 'landlordPhone',
'acknowledge', 'signature'
];
for (const name of fieldNames) {
data[name] = formData.get(name)?.toString() ?? '';
}
data.dogType = formData.getAll('dogType').map((v) => v.toString());
data.traits = formData.getAll('traits').map((v) => v.toString());
for (let i = 1; i <= 16; i++) {
data[`agreement${i}`] = formData.get(`agreement${i}`)?.toString() ?? '';
}
const errors: string[] = [];
if (!firstName) errors.push('First name is required.');
if (!lastName) errors.push('Last name is required.');
if (!email) errors.push('Email is required.');
if (!phone) errors.push('Phone number is required.');
if (!data.address1) errors.push('Address is required.');
if (!data.city) errors.push('City is required.');
if (!data.state) errors.push('State is required.');
if (!data.zip) errors.push('ZIP code is required.');
if (!data.ownRent) errors.push('Own/rent is required.');
if (!data.householdMembers) errors.push('Household members is required.');
if (!data.currentAnimals) errors.push('Current animals is required.');
if (!data.previousPets) errors.push('Previous pets is required.');
if (!data.petsVaccinated) errors.push('Vaccination status is required.');
if (!data.yardFenced) errors.push('Yard fencing status is required.');
if (!data.whereDuringDay) errors.push('Where animal stays during the day is required.');
if (!data.whereSleepNight) errors.push('Where animal sleeps at night is required.');
if (!data.hoursAlone) errors.push('Hours alone is required.');
if (!data.activityLevel) errors.push('Activity level is required.');
if (!data.whyAdopt) errors.push('Why you want to adopt is required.');
if (!data.howHeard) errors.push('How you heard about us is required.');
if (!data.agreeLeash) errors.push('Leash agreement is required.');
if (!data.integratedBefore) errors.push('Integration experience is required.');
if (!data.twoWeekShutdown) errors.push('2-week shutdown response is required.');
if (!data.felony) errors.push('Felony question is required.');
if (!data.animalNeglect) errors.push('Animal neglect question is required.');
if (!data.reference1Name || !data.reference1Phone) errors.push('Reference 1 is required.');
if (!data.reference2Name || !data.reference2Phone) errors.push('Reference 2 is required.');
if (!data.reference3Name || !data.reference3Phone) errors.push('Reference 3 is required.');
if (!data.vetName || !data.vetClinic || !data.vetPhone) errors.push('Veterinarian information is required.');
if (!data.landlordName || !data.landlordPhone) errors.push('Landlord information is required (put N/A if you own).');
if (data.acknowledge !== 'yes') errors.push('You must acknowledge the adoption agreement.');
if (!data.signature) errors.push('Signature is required.');
for (let i = 1; i <= 16; i++) {
if (!data[`agreement${i}`]) {
errors.push(`Agreement item ${i} is required.`);
break;
}
}
if (errors.length > 0) {
return fail(400, {
error: errors.join(' '),
firstName,
lastName,
applicantName,
email,
phone,
...data
});
}
await db.insert(applications).values({
type: 'adoption',
petId: petId && !isNaN(petId) ? petId : null,
applicantName,
applicantEmail: email,
applicantPhone: phone || null,
formData: data
});
const orgConfig = locals.orgConfig;
if (orgConfig.orgInfo.email) {
let petName: string | undefined;
if (petId && !isNaN(petId)) {
try {
const [petRecord] = await db
.select({ name: pets.name })
.from(pets)
.where(eq(pets.id, petId))
.limit(1);
petName = petRecord?.name;
} catch {
// ignore
}
}
try {
sendApplicationNotification(
orgConfig.orgInfo.name,
orgConfig.orgInfo.email,
applicantName,
'adoption',
petName
);
} catch {
// Email failure should not break form submission
}
}
return { success: true };
}
};
@@ -0,0 +1,631 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form: formData }: { data: any; form: Record<string, any> | null } = $props();
const form = $derived(formData);
let ownRent = $state('');
let yardFenced = $state('');
let felony = $state('');
let animalNeglect = $state('');
$effect(() => {
if (form) {
ownRent = form.ownRent ?? '';
yardFenced = form.yardFenced ?? '';
felony = form.felony ?? '';
animalNeglect = form.animalNeglect ?? '';
}
});
// Signature pad
let canvas: HTMLCanvasElement;
let isDrawing = false;
let signatureData = $state('');
$effect(() => {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.strokeStyle = '#1a1a1a';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
});
function getCtx() {
return canvas?.getContext('2d');
}
function getPos(e: MouseEvent | TouchEvent) {
const rect = canvas.getBoundingClientRect();
if ('touches' in e) {
return { x: e.touches[0].clientX - rect.left, y: e.touches[0].clientY - rect.top };
}
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
function startDraw(e: MouseEvent | TouchEvent) {
e.preventDefault();
isDrawing = true;
const ctx = getCtx();
if (!ctx) return;
const pos = getPos(e);
ctx.beginPath();
ctx.moveTo(pos.x, pos.y);
}
function draw(e: MouseEvent | TouchEvent) {
if (!isDrawing) return;
e.preventDefault();
const ctx = getCtx();
if (!ctx) return;
const pos = getPos(e);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
}
function endDraw() {
if (!isDrawing) return;
isDrawing = false;
signatureData = canvas.toDataURL('image/png');
}
function clearSignature() {
const ctx = getCtx();
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
signatureData = '';
}
const inputClass = 'w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
const radioGroupClass = 'flex items-center gap-6 mt-1';
const radioClass = 'h-4 w-4 border-gray-300 text-teal-600 focus:ring-teal-500/30';
const checkboxClass = 'mt-0.5 h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500/30';
const dogTypeOptions = [
'Male', 'Female', 'Puppy', 'Young', 'Adult', 'Senior',
'Small Breed', 'Medium Breed', 'Large Breed', 'X-Large Breed'
];
const traitOptions = [
'Very Active (needing walks daily)',
'Mildly Active (yard to run in, walks throughout the week)',
'Lazy (couch-potato)',
'Laid Back',
'Kid Friendly',
'Dog Friendly',
'Cat Friendly',
'No Fence Required'
];
const agreements = [
'I understand that there is an adoption fee and that it helps cover veterinary care, spay/neuter, vaccinations, and other rescue expenses.',
'I understand that the rescue warrants that it has NO knowledge of any vicious or destructive tendencies of this pet. I agree to assume full responsibility and liability for the actions of this pet once the adoption is finalized.',
'I agree to provide proper veterinary care, including annual vaccinations, heartworm prevention, flea/tick prevention, and prompt medical attention when needed.',
'I understand that the pet must remain spayed/neutered. If the pet is not yet altered at the time of adoption, I agree to have the procedure completed as directed.',
'I agree to keep the pet as an indoor pet with outdoor access only under supervision or in a securely fenced area. I agree to keep pets leashed at all times when outside of a fenced area.',
'I agree to return the pet to the rescue if I am no longer able to care for it. I will NOT rehome, sell, give away, or surrender the pet to any other person, shelter, or rescue without prior approval.',
'I understand the rescue is not liable for any illness, disease, injury, or damage a person or pet may contract or sustain from the adopted pet.',
'Should any damages occur against my existing pet(s) from the adopted pet, I understand that I am responsible for any resulting veterinary costs.',
'I agree to provide the pet with a nutritious diet, fresh water, shelter, regular exercise, and socialization appropriate for the pet\'s needs.',
'I understand that the rescue reserves the right to conduct follow-up home visits and welfare checks after the adoption is finalized.',
'I agree to contact the rescue immediately if the pet is lost, stolen, or escapes.',
'I agree to comply with all local, state, and federal laws regarding pet ownership, including licensing and leash laws.',
'I understand that misrepresentation on this application may result in denial of the application or revocation of the adoption.',
'I understand that completing this application does not guarantee approval. The rescue reserves the right to deny any application.',
'A representative of the rescue has permission to inspect my home and property at a mutually convenient time prior to and/or after the adoption.',
'If it is determined by either party that the terms of this agreement cannot be fulfilled, I agree to return the pet immediately.'
];
</script>
<svelte:head>
<title>Adoption Application — {data.orgConfig?.orgInfo?.name ?? 'Pet Rescue'}</title>
<meta
name="description"
content="Apply to adopt a rescue pet. Complete our adoption application to start the process."
/>
</svelte:head>
<section class="py-12 md:py-20">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-10">
<h1 class="text-3xl md:text-4xl font-bold text-gray-900">Adoption Application</h1>
<p class="mt-3 text-gray-600 text-lg">
Thank you for considering adoption! Please fill out this application completely.
We will review it and get back to you as soon as possible.
</p>
</div>
{#if form?.success}
<div class="bg-white rounded-2xl shadow-sm p-8 md:p-12 text-center">
<div class="w-16 h-16 mx-auto mb-6 bg-teal-50 rounded-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-8 h-8 text-teal-600">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-3">Application Submitted!</h2>
<p class="text-gray-600 mb-6">
Thank you for your adoption application. Our team will review it and reach out to you
within a few business days. If you have any questions in the meantime, please don't
hesitate to contact us.
</p>
<a
href="/pets"
class="inline-flex items-center gap-2 px-6 py-3 bg-teal-600 text-white font-semibold rounded-full hover:bg-teal-700 transition-colors"
>
Browse More Pets
</a>
</div>
{:else}
<form method="POST" use:enhance class="bg-white rounded-2xl shadow-sm divide-y divide-gray-100">
{#if form?.error}
<div class="px-6 md:px-8 pt-6">
<div class="bg-red-50 border border-red-200 text-red-700 rounded-lg px-4 py-3 text-sm">
{form.error}
</div>
</div>
{/if}
<!-- Pet of Interest -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Pet of Interest</h2>
{#if data.pet}
<input type="hidden" name="petId" value={data.pet.id} />
<input type="hidden" name="petOfInterest" value={data.pet.name} />
<p class="text-gray-600">
Applying for: <span class="font-semibold text-gray-900">{data.pet.name}</span>
</p>
{:else}
<div>
<label for="petOfInterest" class={labelClass}>
Which pet are you interested in adopting?
</label>
<input
type="text"
id="petOfInterest"
name="petOfInterest"
value={form?.petOfInterest ?? ''}
placeholder="Enter the name of the pet you're interested in (if known)"
class={inputClass}
/>
</div>
{/if}
</div>
<!-- Contact Information -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Contact Information</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="firstName" class={labelClass}>
First Name <span class="text-red-500">*</span>
</label>
<input type="text" id="firstName" name="firstName" value={form?.firstName ?? ''} required class={inputClass} />
</div>
<div>
<label for="lastName" class={labelClass}>
Last Name <span class="text-red-500">*</span>
</label>
<input type="text" id="lastName" name="lastName" value={form?.lastName ?? ''} required class={inputClass} />
</div>
<div class="sm:col-span-2">
<label for="address1" class={labelClass}>
Address 1 <span class="text-red-500">*</span>
</label>
<input type="text" id="address1" name="address1" value={form?.address1 ?? ''} required class={inputClass} />
</div>
<div class="sm:col-span-2">
<label for="address2" class={labelClass}>Address 2</label>
<input type="text" id="address2" name="address2" value={form?.address2 ?? ''} class={inputClass} />
</div>
<div>
<label for="city" class={labelClass}>
City <span class="text-red-500">*</span>
</label>
<input type="text" id="city" name="city" value={form?.city ?? ''} required class={inputClass} />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="state" class={labelClass}>
State <span class="text-red-500">*</span>
</label>
<input type="text" id="state" name="state" value={form?.state ?? ''} required maxlength="2" placeholder="OH" class={inputClass} />
</div>
<div>
<label for="zip" class={labelClass}>
Zip Code <span class="text-red-500">*</span>
</label>
<input type="text" id="zip" name="zip" value={form?.zip ?? ''} required maxlength="10" class={inputClass} />
</div>
</div>
<div>
<label for="phone" class={labelClass}>
Phone <span class="text-red-500">*</span>
</label>
<input type="tel" id="phone" name="phone" value={form?.phone ?? ''} required class={inputClass} />
</div>
<div>
<label for="email" class={labelClass}>
Email Address <span class="text-red-500">*</span>
</label>
<input type="email" id="email" name="email" value={form?.email ?? ''} required class={inputClass} />
</div>
<div class="sm:col-span-2">
<span class={labelClass}>Do you own your home or rent? <span class="text-red-500">*</span></span>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="ownRent" value="own" required checked={ownRent === 'own'} onchange={() => ownRent = 'own'} class={radioClass} />
<span class="text-sm text-gray-700">Own</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="ownRent" value="rent" checked={ownRent === 'rent'} onchange={() => ownRent = 'rent'} class={radioClass} />
<span class="text-sm text-gray-700">Rent</span>
</label>
</div>
</div>
{#if ownRent === 'rent'}
<div class="sm:col-span-2">
<label for="landlordInfo" class={labelClass}>
If you rent, please provide your landlord's name and phone number.
</label>
<input type="text" id="landlordInfo" name="landlordInfo" value={form?.landlordInfo ?? ''} placeholder="Landlord name and phone number" class={inputClass} />
</div>
{/if}
</div>
</div>
<!-- Household & Home -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Your Family &amp; Home</h2>
<div class="space-y-4">
<div>
<label for="householdMembers" class={labelClass}>
Please list the names and ages of the people who live in your home. <span class="text-red-500">*</span>
</label>
<textarea id="householdMembers" name="householdMembers" rows="3" required class={inputClass}>{form?.householdMembers ?? ''}</textarea>
</div>
<div>
<label for="currentAnimals" class={labelClass}>
Please list the current animals you have in your home (breed, gender, age). <span class="text-red-500">*</span>
</label>
<textarea id="currentAnimals" name="currentAnimals" rows="3" required class={inputClass}>{form?.currentAnimals ?? ''}</textarea>
</div>
<div>
<label for="otherPropertyPets" class={labelClass}>
Any other pets on your property not listed above?
</label>
<textarea id="otherPropertyPets" name="otherPropertyPets" rows="2" class={inputClass}>{form?.otherPropertyPets ?? ''}</textarea>
</div>
<div>
<label for="previousPets" class={labelClass}>
Please list pets you had in the last 3 years not listed above and what happened to them. <span class="text-red-500">*</span>
</label>
<textarea id="previousPets" name="previousPets" rows="3" required class={inputClass}>{form?.previousPets ?? ''}</textarea>
</div>
<div>
<span class={labelClass}>Are your animals spayed/neutered and up to date on vaccines? <span class="text-red-500">*</span></span>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="petsVaccinated" value="yes" required checked={form?.petsVaccinated === 'yes'} class={radioClass} />
<span class="text-sm text-gray-700">Yes</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="petsVaccinated" value="no" checked={form?.petsVaccinated === 'no'} class={radioClass} />
<span class="text-sm text-gray-700">No</span>
</label>
</div>
</div>
<div>
<span class={labelClass}>Is your yard completely fenced in? <span class="text-red-500">*</span></span>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="yardFenced" value="yes" required checked={yardFenced === 'yes'} onchange={() => yardFenced = 'yes'} class={radioClass} />
<span class="text-sm text-gray-700">Yes</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="yardFenced" value="no" checked={yardFenced === 'no'} onchange={() => yardFenced = 'no'} class={radioClass} />
<span class="text-sm text-gray-700">No</span>
</label>
</div>
</div>
{#if yardFenced === 'yes'}
<div>
<label for="fenceHeight" class={labelClass}>What height of fence do you have?</label>
<input type="text" id="fenceHeight" name="fenceHeight" value={form?.fenceHeight ?? ''} placeholder="e.g. 4 feet, 6 feet" class={inputClass} />
</div>
{/if}
<div>
<label for="whereDuringDay" class={labelClass}>
Where would the animal stay during the day? <span class="text-red-500">*</span>
</label>
<textarea id="whereDuringDay" name="whereDuringDay" rows="3" required class={inputClass}>{form?.whereDuringDay ?? ''}</textarea>
</div>
<div>
<label for="whereSleepNight" class={labelClass}>
Where would the animal sleep at night? <span class="text-red-500">*</span>
</label>
<textarea id="whereSleepNight" name="whereSleepNight" rows="2" required class={inputClass}>{form?.whereSleepNight ?? ''}</textarea>
</div>
<div>
<label for="hoursAlone" class={labelClass}>
How many hours a day will the animal be home alone? <span class="text-red-500">*</span>
</label>
<input type="text" id="hoursAlone" name="hoursAlone" value={form?.hoursAlone ?? ''} required class={inputClass} />
</div>
<div>
<span class={labelClass}>How would you describe your level of activity at home? <span class="text-red-500">*</span></span>
<div class="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-6 mt-1">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="activityLevel" value="busy" required checked={form?.activityLevel === 'busy'} class={radioClass} />
<span class="text-sm text-gray-700">Busy/Noisy</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="activityLevel" value="moderate" checked={form?.activityLevel === 'moderate'} class={radioClass} />
<span class="text-sm text-gray-700">Moderate Comings/Goings</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="activityLevel" value="quiet" checked={form?.activityLevel === 'quiet'} class={radioClass} />
<span class="text-sm text-gray-700">Quiet with Occasional Guests</span>
</label>
</div>
</div>
</div>
</div>
<!-- Additional Information -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Additional Information</h2>
<div class="space-y-4">
<div>
<label for="whyAdopt" class={labelClass}>
Why do you want to adopt? <span class="text-red-500">*</span>
</label>
<textarea id="whyAdopt" name="whyAdopt" rows="3" required class={inputClass}>{form?.whyAdopt ?? ''}</textarea>
</div>
<div>
<label for="howHeard" class={labelClass}>
How did you hear about us and what prompted you to apply? <span class="text-red-500">*</span>
</label>
<textarea id="howHeard" name="howHeard" rows="3" required class={inputClass}>{form?.howHeard ?? ''}</textarea>
</div>
<div>
<span class={labelClass}>What type of pet are you interested in? <span class="text-red-500">*</span></span>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2">
{#each dogTypeOptions as option}
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="dogType" value={option} checked={form?.dogType?.includes?.(option)} class={checkboxClass} />
<span class="text-sm text-gray-700">{option}</span>
</label>
{/each}
</div>
</div>
<div>
<span class={labelClass}>What traits should your adopted pet have?</span>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
{#each traitOptions as option}
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="traits" value={option} checked={form?.traits?.includes?.(option)} class={checkboxClass} />
<span class="text-sm text-gray-700">{option}</span>
</label>
{/each}
</div>
</div>
<div>
<span class={labelClass}>Do you agree to keep pets leashed at all times when outside of a fenced area? <span class="text-red-500">*</span></span>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="agreeLeash" value="yes" required checked={form?.agreeLeash === 'yes'} class={radioClass} />
<span class="text-sm text-gray-700">Yes</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="agreeLeash" value="no" checked={form?.agreeLeash === 'no'} class={radioClass} />
<span class="text-sm text-gray-700">No</span>
</label>
</div>
</div>
<div>
<span class={labelClass}>Have you integrated a new pet into your home before? <span class="text-red-500">*</span></span>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="integratedBefore" value="yes" required checked={form?.integratedBefore === 'yes'} class={radioClass} />
<span class="text-sm text-gray-700">Yes</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="integratedBefore" value="no" checked={form?.integratedBefore === 'no'} class={radioClass} />
<span class="text-sm text-gray-700">No</span>
</label>
</div>
</div>
<div>
<span class={labelClass}>Are you comfortable doing a "2 week shut down" with your new pet? <span class="text-red-500">*</span></span>
<p class="text-xs text-gray-500 mb-1">A 2-week shut down is a decompression period where you limit the new pet's environment and stimulation to help them adjust.</p>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="twoWeekShutdown" value="yes" required checked={form?.twoWeekShutdown === 'yes'} class={radioClass} />
<span class="text-sm text-gray-700">Yes</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="twoWeekShutdown" value="no" checked={form?.twoWeekShutdown === 'no'} class={radioClass} />
<span class="text-sm text-gray-700">No</span>
</label>
</div>
</div>
<div>
<span class={labelClass}>Have you ever been charged with or convicted of a felony? <span class="text-red-500">*</span></span>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="felony" value="yes" required checked={felony === 'yes'} onchange={() => felony = 'yes'} class={radioClass} />
<span class="text-sm text-gray-700">Yes</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="felony" value="no" checked={felony === 'no'} onchange={() => felony = 'no'} class={radioClass} />
<span class="text-sm text-gray-700">No</span>
</label>
</div>
</div>
{#if felony === 'yes'}
<div>
<label for="felonyDetails" class={labelClass}>
Please provide information about the charge(s) and dates.
</label>
<textarea id="felonyDetails" name="felonyDetails" rows="3" class={inputClass}>{form?.felonyDetails ?? ''}</textarea>
</div>
{/if}
<div>
<span class={labelClass}>Have you ever been charged with neglect, abandonment, or mistreatment of an animal? <span class="text-red-500">*</span></span>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="animalNeglect" value="yes" required checked={animalNeglect === 'yes'} onchange={() => animalNeglect = 'yes'} class={radioClass} />
<span class="text-sm text-gray-700">Yes</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="animalNeglect" value="no" checked={animalNeglect === 'no'} onchange={() => animalNeglect = 'no'} class={radioClass} />
<span class="text-sm text-gray-700">No</span>
</label>
</div>
</div>
</div>
</div>
<!-- References -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">References</h2>
<p class="text-sm text-gray-600 mb-4">Please provide three personal references (non-relatives).</p>
<div class="space-y-6">
{#each [1, 2, 3] as num}
<div>
<h3 class="text-sm font-semibold text-gray-900 mb-3">Personal Reference {num} <span class="text-red-500">*</span></h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label for="reference{num}Name" class={labelClass}>Name</label>
<input type="text" id="reference{num}Name" name="reference{num}Name" value={form?.[`reference${num}Name`] ?? ''} required class={inputClass} />
</div>
<div>
<label for="reference{num}Phone" class={labelClass}>Phone</label>
<input type="tel" id="reference{num}Phone" name="reference{num}Phone" value={form?.[`reference${num}Phone`] ?? ''} required class={inputClass} />
</div>
<div>
<label for="reference{num}Relationship" class={labelClass}>Relationship</label>
<input type="text" id="reference{num}Relationship" name="reference{num}Relationship" value={form?.[`reference${num}Relationship`] ?? ''} class={inputClass} />
</div>
</div>
</div>
{/each}
<div>
<h3 class="text-sm font-semibold text-gray-900 mb-3">Veterinarian <span class="text-red-500">*</span></h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label for="vetName" class={labelClass}>Veterinarian Name</label>
<input type="text" id="vetName" name="vetName" value={form?.vetName ?? ''} required class={inputClass} />
</div>
<div>
<label for="vetClinic" class={labelClass}>Veterinarian Clinic</label>
<input type="text" id="vetClinic" name="vetClinic" value={form?.vetClinic ?? ''} required class={inputClass} />
</div>
<div>
<label for="vetPhone" class={labelClass}>Vet Phone</label>
<input type="tel" id="vetPhone" name="vetPhone" value={form?.vetPhone ?? ''} required class={inputClass} />
</div>
</div>
</div>
<div>
<h3 class="text-sm font-semibold text-gray-900 mb-3">Landlord</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="landlordName" class={labelClass}>Landlord Name (Put N/A if Own Home)</label>
<input type="text" id="landlordName" name="landlordName" value={form?.landlordName ?? ''} required class={inputClass} />
</div>
<div>
<label for="landlordPhone" class={labelClass}>Landlord Phone Number</label>
<input type="tel" id="landlordPhone" name="landlordPhone" value={form?.landlordPhone ?? ''} required class={inputClass} />
</div>
</div>
</div>
</div>
</div>
<!-- Adoption Agreement -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Adoption Agreement</h2>
<p class="text-sm text-gray-600 mb-4">Please read and respond to each item below.</p>
<div class="space-y-4">
{#each agreements as text, i}
<div class="border border-gray-200 rounded-lg p-4">
<p class="text-sm text-gray-700 mb-2">{text}</p>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="agreement{i + 1}" value="yes" required checked={form?.[`agreement${i + 1}`] === 'yes'} class={radioClass} />
<span class="text-sm text-gray-700">Yes</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="agreement{i + 1}" value="no" checked={form?.[`agreement${i + 1}`] === 'no'} class={radioClass} />
<span class="text-sm text-gray-700">No</span>
</label>
</div>
</div>
{/each}
<div class="border border-gray-200 rounded-lg p-4">
<p class="text-sm text-gray-700 mb-2">I acknowledge that my responses above indicate that I have read and understood each item in this adoption agreement.</p>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="acknowledge" value="yes" required checked={form?.acknowledge === 'yes'} class={radioClass} />
<span class="text-sm text-gray-700">Yes, I acknowledge</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="acknowledge" value="no" checked={form?.acknowledge === 'no'} class={radioClass} />
<span class="text-sm text-gray-700">No, I do not acknowledge</span>
</label>
</div>
</div>
</div>
</div>
<!-- Signature -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Signature</h2>
<p class="text-sm text-gray-600 mb-3">
My signature indicates that I have read and filled out this application truthfully and completely. Please sign below using your mouse or finger.
</p>
<input type="hidden" name="signature" value={signatureData} />
<div class="border-2 border-gray-200 rounded-lg overflow-hidden bg-white">
<canvas
bind:this={canvas}
width="600"
height="150"
class="w-full cursor-crosshair touch-none"
onmousedown={startDraw}
onmousemove={draw}
onmouseup={endDraw}
onmouseleave={endDraw}
ontouchstart={startDraw}
ontouchmove={draw}
ontouchend={endDraw}
></canvas>
</div>
<button
type="button"
onclick={clearSignature}
class="mt-2 text-sm text-teal-600 hover:text-teal-700 transition-colors"
>
Clear Signature
</button>
</div>
<!-- Submit -->
<div class="px-6 md:px-8 py-6">
<button
type="submit"
class="w-full sm:w-auto px-8 py-3 bg-teal-600 text-white font-semibold rounded-full hover:bg-teal-700 transition-colors focus:outline-none focus:ring-2 focus:ring-teal-500/50 focus:ring-offset-2"
>
Submit Adoption Application
</button>
</div>
</form>
{/if}
</div>
</section>
@@ -0,0 +1,141 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { applications } from '$lib/server/schema';
import { sendApplicationNotification } from '$lib/server/email';
import { loginLimiter } from '$lib/server/rate-limit';
export const load: PageServerLoad = async ({ parent }) => {
const parentData = await parent();
if (!parentData.orgConfig.applications.fosterEnabled) {
throw redirect(302, '/');
}
return parentData;
};
export const actions: Actions = {
default: async ({ request, getClientAddress, locals }) => {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? getClientAddress();
if (!loginLimiter.check(`apply-foster:${ip}`)) {
return fail(429, { error: 'Too many submissions. Please try again later.' });
}
const formData = await request.formData();
const firstName = formData.get('firstName')?.toString().trim() ?? '';
const lastName = formData.get('lastName')?.toString().trim() ?? '';
const applicantName = `${firstName} ${lastName}`.trim();
const email = formData.get('email')?.toString().trim() ?? '';
const phone = formData.get('phone')?.toString().trim() ?? '';
const data: Record<string, unknown> = {};
const fieldNames = [
'firstName', 'lastName',
'address1', 'address2', 'city', 'state', 'zip',
'ownRent', 'landlordInfo',
'householdMembers', 'currentAnimals', 'otherPropertyPets', 'previousPets',
'petsVaccinated', 'yardFenced', 'fenceHeight',
'whereDuringDay', 'whereSleepNight', 'hoursAlone', 'activityLevel',
'whyFoster', 'howHeard', 'agreeLeash',
'integratedBefore', 'twoWeekShutdown',
'felony', 'felonyDetails', 'animalNeglect',
'reference1Name', 'reference1Phone', 'reference1Relationship',
'reference2Name', 'reference2Phone', 'reference2Relationship',
'reference3Name', 'reference3Phone', 'reference3Relationship',
'vetName', 'vetClinic', 'vetPhone',
'landlordName', 'landlordPhone',
'acknowledge', 'signature'
];
for (const name of fieldNames) {
data[name] = formData.get(name)?.toString() ?? '';
}
data.petType = formData.getAll('petType').map((v) => v.toString());
data.traits = formData.getAll('traits').map((v) => v.toString());
for (let i = 1; i <= 20; i++) {
data[`agreement${i}`] = formData.get(`agreement${i}`)?.toString() ?? '';
}
const errors: string[] = [];
if (!firstName) errors.push('First name is required.');
if (!lastName) errors.push('Last name is required.');
if (!email) errors.push('Email is required.');
if (!phone) errors.push('Phone number is required.');
if (!data.address1) errors.push('Address is required.');
if (!data.city) errors.push('City is required.');
if (!data.state) errors.push('State is required.');
if (!data.zip) errors.push('ZIP code is required.');
if (!data.ownRent) errors.push('Own/rent is required.');
if (!data.householdMembers) errors.push('Household members is required.');
if (!data.currentAnimals) errors.push('Current animals is required.');
if (!data.previousPets) errors.push('Previous pets is required.');
if (!data.petsVaccinated) errors.push('Vaccination status is required.');
if (!data.yardFenced) errors.push('Yard fencing status is required.');
if (!data.whereDuringDay) errors.push('Where animal stays during the day is required.');
if (!data.whereSleepNight) errors.push('Where animal sleeps at night is required.');
if (!data.hoursAlone) errors.push('Hours alone is required.');
if (!data.activityLevel) errors.push('Activity level is required.');
if (!data.whyFoster) errors.push('Why you want to foster is required.');
if (!data.howHeard) errors.push('How you heard about us is required.');
if (!data.agreeLeash) errors.push('Leash agreement is required.');
if (!data.integratedBefore) errors.push('Integration experience is required.');
if (!data.twoWeekShutdown) errors.push('2-week shutdown response is required.');
if (!data.felony) errors.push('Felony question is required.');
if (!data.animalNeglect) errors.push('Animal neglect question is required.');
if (!data.reference1Name || !data.reference1Phone) errors.push('Reference 1 is required.');
if (!data.reference2Name || !data.reference2Phone) errors.push('Reference 2 is required.');
if (!data.reference3Name || !data.reference3Phone) errors.push('Reference 3 is required.');
if (!data.vetName || !data.vetClinic || !data.vetPhone) errors.push('Veterinarian information is required.');
if (!data.landlordName || !data.landlordPhone) errors.push('Landlord information is required (put N/A if you own).');
if (data.acknowledge !== 'yes') errors.push('You must acknowledge the foster agreement.');
if (!data.signature) errors.push('Signature is required.');
for (let i = 1; i <= 20; i++) {
if (!data[`agreement${i}`]) {
errors.push(`Agreement item ${i} is required.`);
break;
}
}
if (errors.length > 0) {
return fail(400, {
error: errors.join(' '),
firstName,
lastName,
applicantName,
email,
phone,
...data
});
}
await db.insert(applications).values({
type: 'foster',
petId: null,
applicantName,
applicantEmail: email,
applicantPhone: phone || null,
formData: data
});
const orgConfig = locals.orgConfig;
if (orgConfig.orgInfo.email) {
try {
sendApplicationNotification(
orgConfig.orgInfo.name,
orgConfig.orgInfo.email,
applicantName,
'foster'
);
} catch {
// Email failure should not break form submission
}
}
return { success: true };
}
};
@@ -0,0 +1,582 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form }: { data: any; form: Record<string, any> | null } = $props();
let ownRent = $state(form?.ownRent ?? '');
let yardFenced = $state(form?.yardFenced ?? '');
let felony = $state(form?.felony ?? '');
let animalNeglect = $state(form?.animalNeglect ?? '');
// Signature pad
let canvas: HTMLCanvasElement;
let isDrawing = false;
let signatureData = $state('');
$effect(() => {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.strokeStyle = '#1a1a1a';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
});
function getCtx() {
return canvas?.getContext('2d');
}
function getPos(e: MouseEvent | TouchEvent) {
const rect = canvas.getBoundingClientRect();
if ('touches' in e) {
return { x: e.touches[0].clientX - rect.left, y: e.touches[0].clientY - rect.top };
}
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
function startDraw(e: MouseEvent | TouchEvent) {
e.preventDefault();
isDrawing = true;
const ctx = getCtx();
if (!ctx) return;
const pos = getPos(e);
ctx.beginPath();
ctx.moveTo(pos.x, pos.y);
}
function draw(e: MouseEvent | TouchEvent) {
if (!isDrawing) return;
e.preventDefault();
const ctx = getCtx();
if (!ctx) return;
const pos = getPos(e);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
}
function endDraw() {
if (!isDrawing) return;
isDrawing = false;
signatureData = canvas.toDataURL('image/png');
}
function clearSignature() {
const ctx = getCtx();
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
signatureData = '';
}
const inputClass = 'w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
const radioGroupClass = 'flex items-center gap-6 mt-1';
const radioClass = 'h-4 w-4 border-gray-300 text-teal-600 focus:ring-teal-500/30';
const checkboxClass = 'mt-0.5 h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500/30';
const petTypeOptions = [
'Male', 'Female', 'Puppy', 'Young', 'Adult', 'Senior', 'Hospice',
'Kitten', 'Cat', 'Whelping Mother',
'Small Breed', 'Medium Breed', 'Large Breed', 'X-Large Breed'
];
const traitOptions = [
'Very Active (needing walks daily)',
'Mildly Active (yard to run in, walks throughout the week)',
'Lazy (couch-potato)',
'Laid Back',
'Kid Friendly',
'Dog Friendly',
'Cat Friendly',
'No Fence Required'
];
const agreements = [
'I agree to foster the pet(s) for the benefit of the pet(s) and not for any personal gain.',
'If I wish to adopt the pet(s) I am fostering, I understand I am required to pay the adoption fee.',
'I understand there will be no charge to the rescue from me for boarding/caring for the pet(s).',
'I understand that the rescue warrants that it has NO knowledge of any vicious or destructive tendencies of this pet. I agree to assume full responsibility.',
'I understand it is recommended that the pet(s) be isolated from my personal pets for a minimum of 2 weeks.',
'I understand the rescue is not liable for any illness or disease a person or pet may contract from the foster pet(s).',
'Should any damages occur against my pet, from the pet(s) which I am providing foster care to, I understand that I am responsible for any resulting veterinary costs.',
'Should any damages occur against the pet(s) which I am providing foster care to, caused by my pet, I understand that I am responsible for any resulting veterinary costs.',
'While responsible for the care of the pet(s) in a reasonable and loving manner, I am NOT liable for any veterinary bills.',
'If the pet(s) in my care dies, I will contact the rescue immediately.',
'The rescue will provide the pet(s) with the appropriate vaccinations and boosters.',
'I UNDERSTAND THE RESCUE MUST BE CONTACTED PRIOR TO ANY VETERINARY VISITS UNLESS IT IS AN ABSOLUTE EMERGENCY.',
'I understand in rare circumstances if the rescue is not able to provide veterinary care, I may need to transport the pet(s) to an approved vet.',
'The rescue will provide all necessary materials to foster this/these pet(s) (crate, food, leash, etc.).',
'All material borrowed from the rescue must be returned upon completion of fostering.',
'I agree to inform any individuals who are interested in one or more of the pet(s) that they must fill out an adoption application through the rescue.',
'I agree to immediately return all pet(s) fostered to the rescue when requested.',
'A representative of the rescue has permission to inspect my home and property at a mutually convenient time.',
'If it is determined by either party that the terms of this agreement cannot be fulfilled, I agree to return the pet(s) immediately.',
'If I have neglected to initial any item in this contract, it is still assumed that I have read and agree to abide by the stated term.'
];
</script>
<svelte:head>
<title>Foster Application — {data.orgConfig?.orgInfo?.name ?? 'Pet Rescue'}</title>
<meta
name="description"
content="Apply to become a foster home. Help save lives by providing a temporary safe haven."
/>
</svelte:head>
<section class="py-12 md:py-20">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-10">
<h1 class="text-3xl md:text-4xl font-bold text-gray-900">Foster Application</h1>
<p class="mt-3 text-gray-600 text-lg">
Fostering saves lives! We provide all supplies and cover vet expenses.
Please fill out this application completely and we will get back to you as soon as possible.
</p>
</div>
{#if form?.success}
<div class="bg-white rounded-2xl shadow-sm p-8 md:p-12 text-center">
<div class="w-16 h-16 mx-auto mb-6 bg-teal-50 rounded-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-8 h-8 text-teal-600">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-3">Application Submitted!</h2>
<p class="text-gray-600 mb-6">
Thank you for your interest in fostering! Our foster coordinator will review your
application and reach out to you soon. In the meantime, feel free to browse our
pets who need foster homes.
</p>
<a
href="/pets"
class="inline-flex items-center gap-2 px-6 py-3 bg-teal-600 text-white font-semibold rounded-full hover:bg-teal-700 transition-colors"
>
Meet Our Pets
</a>
</div>
{:else}
<form method="POST" use:enhance class="bg-white rounded-2xl shadow-sm divide-y divide-gray-100">
{#if form?.error}
<div class="px-6 md:px-8 pt-6">
<div class="bg-red-50 border border-red-200 text-red-700 rounded-lg px-4 py-3 text-sm">
{form.error}
</div>
</div>
{/if}
<!-- Contact Information -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Contact Information</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="firstName" class={labelClass}>First Name <span class="text-red-500">*</span></label>
<input type="text" id="firstName" name="firstName" value={form?.firstName ?? ''} required class={inputClass} />
</div>
<div>
<label for="lastName" class={labelClass}>Last Name <span class="text-red-500">*</span></label>
<input type="text" id="lastName" name="lastName" value={form?.lastName ?? ''} required class={inputClass} />
</div>
<div class="sm:col-span-2">
<label for="address1" class={labelClass}>Address 1 <span class="text-red-500">*</span></label>
<input type="text" id="address1" name="address1" value={form?.address1 ?? ''} required class={inputClass} />
</div>
<div class="sm:col-span-2">
<label for="address2" class={labelClass}>Address 2</label>
<input type="text" id="address2" name="address2" value={form?.address2 ?? ''} class={inputClass} />
</div>
<div>
<label for="city" class={labelClass}>City <span class="text-red-500">*</span></label>
<input type="text" id="city" name="city" value={form?.city ?? ''} required class={inputClass} />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="state" class={labelClass}>State <span class="text-red-500">*</span></label>
<input type="text" id="state" name="state" value={form?.state ?? ''} required maxlength="2" placeholder="OH" class={inputClass} />
</div>
<div>
<label for="zip" class={labelClass}>Zip Code <span class="text-red-500">*</span></label>
<input type="text" id="zip" name="zip" value={form?.zip ?? ''} required maxlength="10" class={inputClass} />
</div>
</div>
<div>
<label for="phone" class={labelClass}>Phone <span class="text-red-500">*</span></label>
<input type="tel" id="phone" name="phone" value={form?.phone ?? ''} required class={inputClass} />
</div>
<div>
<label for="email" class={labelClass}>Email Address <span class="text-red-500">*</span></label>
<input type="email" id="email" name="email" value={form?.email ?? ''} required class={inputClass} />
</div>
<div class="sm:col-span-2">
<span class={labelClass}>Do you own your home or rent? <span class="text-red-500">*</span></span>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="ownRent" value="own" required checked={ownRent === 'own'} onchange={() => ownRent = 'own'} class={radioClass} />
<span class="text-sm text-gray-700">Own</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="ownRent" value="rent" checked={ownRent === 'rent'} onchange={() => ownRent = 'rent'} class={radioClass} />
<span class="text-sm text-gray-700">Rent</span>
</label>
</div>
</div>
{#if ownRent === 'rent'}
<div class="sm:col-span-2">
<label for="landlordInfo" class={labelClass}>
If you rent, please provide your landlord's name and phone number.
</label>
<input type="text" id="landlordInfo" name="landlordInfo" value={form?.landlordInfo ?? ''} placeholder="Landlord name and phone number" class={inputClass} />
</div>
{/if}
</div>
</div>
<!-- Household & Home -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Your Family &amp; Home</h2>
<div class="space-y-4">
<div>
<label for="householdMembers" class={labelClass}>
Please list the names and ages of the people who live in your home. <span class="text-red-500">*</span>
</label>
<textarea id="householdMembers" name="householdMembers" rows="3" required class={inputClass}>{form?.householdMembers ?? ''}</textarea>
</div>
<div>
<label for="currentAnimals" class={labelClass}>
Please list the current animals you have in your home (breed, gender, age). <span class="text-red-500">*</span>
</label>
<textarea id="currentAnimals" name="currentAnimals" rows="3" required class={inputClass}>{form?.currentAnimals ?? ''}</textarea>
</div>
<div>
<label for="otherPropertyPets" class={labelClass}>Any other pets on your property not listed above?</label>
<textarea id="otherPropertyPets" name="otherPropertyPets" rows="2" class={inputClass}>{form?.otherPropertyPets ?? ''}</textarea>
</div>
<div>
<label for="previousPets" class={labelClass}>
Please list pets you had in the last 3 years not listed above and what happened to them. <span class="text-red-500">*</span>
</label>
<textarea id="previousPets" name="previousPets" rows="3" required class={inputClass}>{form?.previousPets ?? ''}</textarea>
</div>
<div>
<span class={labelClass}>Are your animals spayed/neutered and up to date on vaccines? <span class="text-red-500">*</span></span>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="petsVaccinated" value="yes" required checked={form?.petsVaccinated === 'yes'} class={radioClass} />
<span class="text-sm text-gray-700">Yes</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="petsVaccinated" value="no" checked={form?.petsVaccinated === 'no'} class={radioClass} />
<span class="text-sm text-gray-700">No</span>
</label>
</div>
</div>
<div>
<span class={labelClass}>Is your yard completely fenced in? <span class="text-red-500">*</span></span>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="yardFenced" value="yes" required checked={yardFenced === 'yes'} onchange={() => yardFenced = 'yes'} class={radioClass} />
<span class="text-sm text-gray-700">Yes</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="yardFenced" value="no" checked={yardFenced === 'no'} onchange={() => yardFenced = 'no'} class={radioClass} />
<span class="text-sm text-gray-700">No</span>
</label>
</div>
</div>
{#if yardFenced === 'yes'}
<div>
<label for="fenceHeight" class={labelClass}>What height of fence do you have?</label>
<input type="text" id="fenceHeight" name="fenceHeight" value={form?.fenceHeight ?? ''} placeholder="e.g. 4 feet, 6 feet" class={inputClass} />
</div>
{/if}
<div>
<label for="whereDuringDay" class={labelClass}>
Where would the animal stay during the day? <span class="text-red-500">*</span>
</label>
<textarea id="whereDuringDay" name="whereDuringDay" rows="3" required class={inputClass}>{form?.whereDuringDay ?? ''}</textarea>
</div>
<div>
<label for="whereSleepNight" class={labelClass}>
Where would the animal sleep at night? <span class="text-red-500">*</span>
</label>
<textarea id="whereSleepNight" name="whereSleepNight" rows="2" required class={inputClass}>{form?.whereSleepNight ?? ''}</textarea>
</div>
<div>
<label for="hoursAlone" class={labelClass}>
How many hours a day will your foster animal be home alone? <span class="text-red-500">*</span>
</label>
<input type="text" id="hoursAlone" name="hoursAlone" value={form?.hoursAlone ?? ''} required class={inputClass} />
</div>
<div>
<span class={labelClass}>How would you describe your level of activity at home? <span class="text-red-500">*</span></span>
<div class="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-6 mt-1">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="activityLevel" value="busy" required checked={form?.activityLevel === 'busy'} class={radioClass} />
<span class="text-sm text-gray-700">Busy/Noisy</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="activityLevel" value="moderate" checked={form?.activityLevel === 'moderate'} class={radioClass} />
<span class="text-sm text-gray-700">Moderate Comings/Goings</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="activityLevel" value="quiet" checked={form?.activityLevel === 'quiet'} class={radioClass} />
<span class="text-sm text-gray-700">Quiet with Occasional Guests</span>
</label>
</div>
</div>
</div>
</div>
<!-- Additional Information -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Additional Information</h2>
<div class="space-y-4">
<div>
<label for="whyFoster" class={labelClass}>
Why do you want to foster? <span class="text-red-500">*</span>
</label>
<textarea id="whyFoster" name="whyFoster" rows="3" required class={inputClass}>{form?.whyFoster ?? ''}</textarea>
</div>
<div>
<label for="howHeard" class={labelClass}>
How did you hear about us and what prompted you to apply? <span class="text-red-500">*</span>
</label>
<textarea id="howHeard" name="howHeard" rows="3" required class={inputClass}>{form?.howHeard ?? ''}</textarea>
</div>
<div>
<span class={labelClass}>What type of pet are you interested in fostering? <span class="text-red-500">*</span></span>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2">
{#each petTypeOptions as option}
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="petType" value={option} checked={form?.petType?.includes?.(option)} class={checkboxClass} />
<span class="text-sm text-gray-700">{option}</span>
</label>
{/each}
</div>
</div>
<div>
<span class={labelClass}>What traits should your foster pet have?</span>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
{#each traitOptions as option}
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="traits" value={option} checked={form?.traits?.includes?.(option)} class={checkboxClass} />
<span class="text-sm text-gray-700">{option}</span>
</label>
{/each}
</div>
</div>
<div>
<span class={labelClass}>Do you agree to keep pets leashed at all times when outside of a fenced area? <span class="text-red-500">*</span></span>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="agreeLeash" value="yes" required checked={form?.agreeLeash === 'yes'} class={radioClass} />
<span class="text-sm text-gray-700">Yes</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="agreeLeash" value="no" checked={form?.agreeLeash === 'no'} class={radioClass} />
<span class="text-sm text-gray-700">No</span>
</label>
</div>
</div>
<div>
<span class={labelClass}>Have you integrated a new pet into your home before? <span class="text-red-500">*</span></span>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="integratedBefore" value="yes" required checked={form?.integratedBefore === 'yes'} class={radioClass} />
<span class="text-sm text-gray-700">Yes</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="integratedBefore" value="no" checked={form?.integratedBefore === 'no'} class={radioClass} />
<span class="text-sm text-gray-700">No</span>
</label>
</div>
</div>
<div>
<span class={labelClass}>Are you comfortable doing a "2 week shut down" with your new foster pet? <span class="text-red-500">*</span></span>
<p class="text-xs text-gray-500 mb-1">A 2-week shut down is a decompression period where you limit the new pet's environment and stimulation to help them adjust.</p>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="twoWeekShutdown" value="yes" required checked={form?.twoWeekShutdown === 'yes'} class={radioClass} />
<span class="text-sm text-gray-700">Yes</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="twoWeekShutdown" value="no" checked={form?.twoWeekShutdown === 'no'} class={radioClass} />
<span class="text-sm text-gray-700">No</span>
</label>
</div>
</div>
<div>
<span class={labelClass}>Have you ever been charged with or convicted of a felony? <span class="text-red-500">*</span></span>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="felony" value="yes" required checked={felony === 'yes'} onchange={() => felony = 'yes'} class={radioClass} />
<span class="text-sm text-gray-700">Yes</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="felony" value="no" checked={felony === 'no'} onchange={() => felony = 'no'} class={radioClass} />
<span class="text-sm text-gray-700">No</span>
</label>
</div>
</div>
{#if felony === 'yes'}
<div>
<label for="felonyDetails" class={labelClass}>
Please provide information about the charge(s) and dates.
</label>
<textarea id="felonyDetails" name="felonyDetails" rows="3" class={inputClass}>{form?.felonyDetails ?? ''}</textarea>
</div>
{/if}
<div>
<span class={labelClass}>Have you ever been charged with neglect, abandonment, or mistreatment of an animal? <span class="text-red-500">*</span></span>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="animalNeglect" value="yes" required checked={animalNeglect === 'yes'} onchange={() => animalNeglect = 'yes'} class={radioClass} />
<span class="text-sm text-gray-700">Yes</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="animalNeglect" value="no" checked={animalNeglect === 'no'} onchange={() => animalNeglect = 'no'} class={radioClass} />
<span class="text-sm text-gray-700">No</span>
</label>
</div>
</div>
</div>
</div>
<!-- References -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">References</h2>
<p class="text-sm text-gray-600 mb-4">Please provide three personal references (non-relatives).</p>
<div class="space-y-6">
{#each [1, 2, 3] as num}
<div>
<h3 class="text-sm font-semibold text-gray-900 mb-3">Personal Reference {num} <span class="text-red-500">*</span></h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label for="reference{num}Name" class={labelClass}>Name</label>
<input type="text" id="reference{num}Name" name="reference{num}Name" value={form?.[`reference${num}Name`] ?? ''} required class={inputClass} />
</div>
<div>
<label for="reference{num}Phone" class={labelClass}>Phone</label>
<input type="tel" id="reference{num}Phone" name="reference{num}Phone" value={form?.[`reference${num}Phone`] ?? ''} required class={inputClass} />
</div>
<div>
<label for="reference{num}Relationship" class={labelClass}>Relationship</label>
<input type="text" id="reference{num}Relationship" name="reference{num}Relationship" value={form?.[`reference${num}Relationship`] ?? ''} class={inputClass} />
</div>
</div>
</div>
{/each}
<div>
<h3 class="text-sm font-semibold text-gray-900 mb-3">Veterinarian <span class="text-red-500">*</span></h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label for="vetName" class={labelClass}>Veterinarian Name</label>
<input type="text" id="vetName" name="vetName" value={form?.vetName ?? ''} required class={inputClass} />
</div>
<div>
<label for="vetClinic" class={labelClass}>Veterinarian Clinic</label>
<input type="text" id="vetClinic" name="vetClinic" value={form?.vetClinic ?? ''} required class={inputClass} />
</div>
<div>
<label for="vetPhone" class={labelClass}>Vet Phone</label>
<input type="tel" id="vetPhone" name="vetPhone" value={form?.vetPhone ?? ''} required class={inputClass} />
</div>
</div>
</div>
<div>
<h3 class="text-sm font-semibold text-gray-900 mb-3">Landlord</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="landlordName" class={labelClass}>Landlord Name (Put N/A if Own Home)</label>
<input type="text" id="landlordName" name="landlordName" value={form?.landlordName ?? ''} required class={inputClass} />
</div>
<div>
<label for="landlordPhone" class={labelClass}>Landlord Phone Number</label>
<input type="tel" id="landlordPhone" name="landlordPhone" value={form?.landlordPhone ?? ''} required class={inputClass} />
</div>
</div>
</div>
</div>
</div>
<!-- Foster Agreement -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Foster Agreement</h2>
<p class="text-sm text-gray-600 mb-4">Please read and respond to each item below.</p>
<div class="space-y-4">
{#each agreements as text, i}
<div class="border border-gray-200 rounded-lg p-4">
<p class="text-sm text-gray-700 mb-2">{text}</p>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="agreement{i + 1}" value="yes" required checked={form?.[`agreement${i + 1}`] === 'yes'} class={radioClass} />
<span class="text-sm text-gray-700">Yes</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="agreement{i + 1}" value="no" checked={form?.[`agreement${i + 1}`] === 'no'} class={radioClass} />
<span class="text-sm text-gray-700">No</span>
</label>
</div>
</div>
{/each}
<div class="border border-gray-200 rounded-lg p-4">
<p class="text-sm text-gray-700 mb-2">I acknowledge that my responses above indicate that I have read and understood each item in this foster agreement.</p>
<div class={radioGroupClass}>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="acknowledge" value="yes" required checked={form?.acknowledge === 'yes'} class={radioClass} />
<span class="text-sm text-gray-700">Yes, I acknowledge</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="acknowledge" value="no" checked={form?.acknowledge === 'no'} class={radioClass} />
<span class="text-sm text-gray-700">No, I do not acknowledge</span>
</label>
</div>
</div>
</div>
</div>
<!-- Signature -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Signature</h2>
<p class="text-sm text-gray-600 mb-3">
My signature indicates that I have read and filled out this application truthfully and completely. Please sign below using your mouse or finger.
</p>
<input type="hidden" name="signature" value={signatureData} />
<div class="border-2 border-gray-200 rounded-lg overflow-hidden bg-white">
<canvas
bind:this={canvas}
width="600"
height="150"
class="w-full cursor-crosshair touch-none"
onmousedown={startDraw}
onmousemove={draw}
onmouseup={endDraw}
onmouseleave={endDraw}
ontouchstart={startDraw}
ontouchmove={draw}
ontouchend={endDraw}
></canvas>
</div>
<button
type="button"
onclick={clearSignature}
class="mt-2 text-sm text-teal-600 hover:text-teal-700 transition-colors"
>
Clear Signature
</button>
</div>
<!-- Submit -->
<div class="px-6 md:px-8 py-6">
<button
type="submit"
class="w-full sm:w-auto px-8 py-3 bg-teal-600 text-white font-semibold rounded-full hover:bg-teal-700 transition-colors focus:outline-none focus:ring-2 focus:ring-teal-500/50 focus:ring-offset-2"
>
Submit Foster Application
</button>
</div>
</form>
{/if}
</div>
</section>
@@ -0,0 +1,158 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { applications, pets } from '$lib/server/schema';
import { eq } from 'drizzle-orm';
import { sendApplicationNotification } from '$lib/server/email';
import { loginLimiter } from '$lib/server/rate-limit';
import { slugify } from '$lib/slugify';
export const load: PageServerLoad = async ({ parent }) => {
const parentData = await parent();
if (!parentData.orgConfig.applications.surrenderEnabled) {
throw redirect(302, '/');
}
return parentData;
};
export const actions: Actions = {
default: async ({ request, getClientAddress, locals }) => {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? getClientAddress();
if (!loginLimiter.check(`apply-surrender:${ip}`)) {
return fail(429, { error: 'Too many submissions. Please try again later.' });
}
const formData = await request.formData();
const applicantName = formData.get('applicantName')?.toString().trim() ?? '';
const email = formData.get('email')?.toString().trim() ?? '';
const phone = formData.get('phone')?.toString().trim() ?? '';
const data: Record<string, unknown> = {};
const fieldNames = [
'street', 'city', 'state', 'zip',
'petName', 'species', 'petBreed', 'petAge', 'petSex', 'petWeight',
'spayedNeutered', 'microchipped',
'vaccinationsCurrent', 'medicalConditions', 'medications', 'vetName',
'goodWithKids', 'goodWithDogs', 'goodWithCats',
'houseTrained', 'crateTrained', 'leashTrained',
'aggressionOrAnxiety',
'reasonForSurrender', 'timeline'
];
for (const name of fieldNames) {
data[name] = formData.get(name)?.toString() ?? '';
}
const errors: string[] = [];
if (!applicantName) errors.push('Your name is required.');
if (!email) errors.push('Email is required.');
if (!phone) errors.push('Phone number is required.');
if (!data.petName) errors.push('Pet name is required.');
if (!data.species) errors.push('Species is required.');
if (!data.petBreed) errors.push('Breed is required.');
if (!data.petAge) errors.push('Pet age is required.');
if (!data.petSex) errors.push('Pet sex is required.');
if (!data.reasonForSurrender) errors.push('Reason for surrender is required.');
if (!data.timeline) errors.push('Timeline is required.');
if (errors.length > 0) {
return fail(400, {
error: errors.join(' '),
applicantName,
email,
phone,
...data
});
}
// Insert the surrender application first
const [appResult] = await db.insert(applications).values({
type: 'surrender',
petId: null,
applicantName,
applicantEmail: email,
applicantPhone: phone || null,
formData: data
}).$returningId();
// Auto-create a pet record
const petName = String(data.petName).trim();
let slug = slugify(petName);
const [existing] = await db
.select({ id: pets.id })
.from(pets)
.where(eq(pets.slug, slug))
.limit(1);
if (existing) {
slug = `${slug}-${Date.now()}`;
}
const rawSex = String(data.petSex).toLowerCase().trim();
const sex: 'male' | 'female' | 'unknown' =
rawSex === 'male' || rawSex === 'm' ? 'male' :
rawSex === 'female' || rawSex === 'f' ? 'female' :
'unknown';
const rawSpecies = String(data.species).toLowerCase().trim();
const species: 'dog' | 'cat' | 'other' =
rawSpecies === 'dog' ? 'dog' :
rawSpecies === 'cat' ? 'cat' :
'other';
const notesParts: string[] = [];
if (data.medicalConditions) notesParts.push(`Medical conditions: ${data.medicalConditions}`);
if (data.medications) notesParts.push(`Medications: ${data.medications}`);
if (data.vaccinationsCurrent) notesParts.push(`Vaccinations current: ${data.vaccinationsCurrent}`);
if (data.spayedNeutered) notesParts.push(`Spayed/Neutered: ${data.spayedNeutered}`);
if (data.aggressionOrAnxiety) notesParts.push(`Aggression/Anxiety: ${data.aggressionOrAnxiety}`);
if (data.goodWithKids) notesParts.push(`Good with kids: ${data.goodWithKids}`);
if (data.goodWithDogs) notesParts.push(`Good with dogs: ${data.goodWithDogs}`);
if (data.goodWithCats) notesParts.push(`Good with cats: ${data.goodWithCats}`);
if (data.houseTrained) notesParts.push(`House trained: ${data.houseTrained}`);
if (data.crateTrained) notesParts.push(`Crate trained: ${data.crateTrained}`);
if (data.leashTrained) notesParts.push(`Leash trained: ${data.leashTrained}`);
if (data.reasonForSurrender) notesParts.push(`Reason for surrender: ${data.reasonForSurrender}`);
if (data.vetName) notesParts.push(`Previous vet: ${data.vetName}`);
const medicalNotes = notesParts.length > 0 ? notesParts.join('\n') : null;
const [petResult] = await db.insert(pets).values({
name: petName,
species,
breed: String(data.petBreed || '') || null,
age: String(data.petAge || '') || null,
sex,
weight: String(data.petWeight || '') || null,
status: 'pending',
slug,
intakeDate: new Date(),
adopterName: applicantName,
medicalNotes
}).$returningId();
// Link the pet to the application
await db
.update(applications)
.set({ petId: petResult.id })
.where(eq(applications.id, appResult.id));
const orgConfig = locals.orgConfig;
if (orgConfig.orgInfo.email) {
try {
sendApplicationNotification(
orgConfig.orgInfo.name,
orgConfig.orgInfo.email,
applicantName,
'surrender'
);
} catch {
// Email failure should not break form submission
}
}
return { success: true };
}
};
@@ -0,0 +1,323 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form }: { data: any; form: Record<string, any> | null } = $props();
</script>
<svelte:head>
<title>Surrender a Pet — {data.orgConfig?.orgInfo?.name ?? 'Pet Rescue'}</title>
<meta
name="description"
content="If you need to surrender your pet, we may be able to help. Please fill out this form so we can learn about your pet."
/>
</svelte:head>
<section class="py-12 md:py-20">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-10">
<h1 class="text-3xl md:text-4xl font-bold text-gray-900">Surrender a Pet</h1>
<p class="mt-3 text-gray-600 text-lg">
We understand that sometimes circumstances change and you may no longer be able to
care for your pet. Please complete this form so we can learn about your pet and
determine if we can help.
</p>
</div>
{#if form?.success}
<div class="bg-white rounded-2xl shadow-sm p-8 md:p-12 text-center">
<div class="w-16 h-16 mx-auto mb-6 bg-teal-50 rounded-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-8 h-8 text-teal-600">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-3">Form Submitted</h2>
<p class="text-gray-600 mb-6">
Thank you for reaching out. We know this is a difficult decision. Our team will
review your submission and contact you as soon as possible. Please note that due
to limited capacity, we may not be able to accept every surrender request.
</p>
<a
href="/"
class="inline-flex items-center gap-2 px-6 py-3 bg-teal-600 text-white font-semibold rounded-full hover:bg-teal-700 transition-colors"
>
Back to Home
</a>
</div>
{:else}
<form method="POST" use:enhance class="bg-white rounded-2xl shadow-sm divide-y divide-gray-100">
{#if form?.error}
<div class="px-6 md:px-8 pt-6">
<div class="bg-red-50 border border-red-200 text-red-700 rounded-lg px-4 py-3 text-sm">
{form.error}
</div>
</div>
{/if}
<!-- Owner Information -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Your Information</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="sm:col-span-2">
<label for="applicantName" class="block text-sm font-medium text-gray-700 mb-1">
Full Name <span class="text-red-500">*</span>
</label>
<input type="text" id="applicantName" name="applicantName" value={form?.applicantName ?? ''} required
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500" />
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">
Email <span class="text-red-500">*</span>
</label>
<input type="email" id="email" name="email" value={form?.email ?? ''} required
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500" />
</div>
<div>
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">
Phone <span class="text-red-500">*</span>
</label>
<input type="tel" id="phone" name="phone" value={form?.phone ?? ''} required
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500" />
</div>
<div class="sm:col-span-2">
<label for="street" class="block text-sm font-medium text-gray-700 mb-1">Street Address</label>
<input type="text" id="street" name="street" value={form?.street ?? ''}
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500" />
</div>
<div>
<label for="city" class="block text-sm font-medium text-gray-700 mb-1">City</label>
<input type="text" id="city" name="city" value={form?.city ?? ''}
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="state" class="block text-sm font-medium text-gray-700 mb-1">State</label>
<input type="text" id="state" name="state" value={form?.state ?? ''} maxlength="2" placeholder="OH"
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500" />
</div>
<div>
<label for="zip" class="block text-sm font-medium text-gray-700 mb-1">ZIP</label>
<input type="text" id="zip" name="zip" value={form?.zip ?? ''} maxlength="10"
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500" />
</div>
</div>
</div>
</div>
<!-- Pet Information -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Pet Information</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="petName" class="block text-sm font-medium text-gray-700 mb-1">
Pet's Name <span class="text-red-500">*</span>
</label>
<input type="text" id="petName" name="petName" value={form?.petName ?? ''} required
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500" />
</div>
<div>
<label for="species" class="block text-sm font-medium text-gray-700 mb-1">
Species <span class="text-red-500">*</span>
</label>
<select id="species" name="species" required
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500">
<option value="" disabled selected={!form?.species}>Select...</option>
<option value="dog" selected={form?.species === 'dog'}>Dog</option>
<option value="cat" selected={form?.species === 'cat'}>Cat</option>
<option value="other" selected={form?.species === 'other'}>Other</option>
</select>
</div>
<div>
<label for="petBreed" class="block text-sm font-medium text-gray-700 mb-1">
Breed <span class="text-red-500">*</span>
</label>
<input type="text" id="petBreed" name="petBreed" value={form?.petBreed ?? ''} required
placeholder="e.g. Lab mix, Domestic Shorthair"
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500" />
</div>
<div>
<label for="petAge" class="block text-sm font-medium text-gray-700 mb-1">
Age <span class="text-red-500">*</span>
</label>
<input type="text" id="petAge" name="petAge" value={form?.petAge ?? ''} required
placeholder="e.g. 3 years, 6 months"
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500" />
</div>
<div>
<label for="petSex" class="block text-sm font-medium text-gray-700 mb-1">
Sex <span class="text-red-500">*</span>
</label>
<select id="petSex" name="petSex" required
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500">
<option value="" disabled selected={!form?.petSex}>Select...</option>
<option value="male" selected={form?.petSex === 'male'}>Male</option>
<option value="female" selected={form?.petSex === 'female'}>Female</option>
</select>
</div>
<div>
<label for="petWeight" class="block text-sm font-medium text-gray-700 mb-1">Weight (approx.)</label>
<input type="text" id="petWeight" name="petWeight" value={form?.petWeight ?? ''}
placeholder="e.g. 45 lbs"
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500" />
</div>
<div>
<label for="spayedNeutered" class="block text-sm font-medium text-gray-700 mb-1">Spayed/Neutered?</label>
<select id="spayedNeutered" name="spayedNeutered"
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500">
<option value="" selected={!form?.spayedNeutered}>Select...</option>
<option value="yes" selected={form?.spayedNeutered === 'yes'}>Yes</option>
<option value="no" selected={form?.spayedNeutered === 'no'}>No</option>
<option value="unknown" selected={form?.spayedNeutered === 'unknown'}>Unknown</option>
</select>
</div>
<div>
<label for="microchipped" class="block text-sm font-medium text-gray-700 mb-1">Microchipped?</label>
<select id="microchipped" name="microchipped"
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500">
<option value="" selected={!form?.microchipped}>Select...</option>
<option value="yes" selected={form?.microchipped === 'yes'}>Yes</option>
<option value="no" selected={form?.microchipped === 'no'}>No</option>
<option value="unknown" selected={form?.microchipped === 'unknown'}>Unknown</option>
</select>
</div>
</div>
</div>
<!-- Medical History -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Medical History</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="vaccinationsCurrent" class="block text-sm font-medium text-gray-700 mb-1">Vaccinations current?</label>
<select id="vaccinationsCurrent" name="vaccinationsCurrent"
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500">
<option value="" selected={!form?.vaccinationsCurrent}>Select...</option>
<option value="yes" selected={form?.vaccinationsCurrent === 'yes'}>Yes</option>
<option value="no" selected={form?.vaccinationsCurrent === 'no'}>No</option>
<option value="unknown" selected={form?.vaccinationsCurrent === 'unknown'}>Unknown</option>
</select>
</div>
<div>
<label for="vetName" class="block text-sm font-medium text-gray-700 mb-1">Veterinarian Name/Clinic</label>
<input type="text" id="vetName" name="vetName" value={form?.vetName ?? ''}
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500" />
</div>
<div class="sm:col-span-2">
<label for="medicalConditions" class="block text-sm font-medium text-gray-700 mb-1">Known Medical Conditions</label>
<textarea id="medicalConditions" name="medicalConditions" rows="2"
placeholder="Allergies, injuries, chronic conditions, etc."
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500"
>{form?.medicalConditions ?? ''}</textarea>
</div>
<div class="sm:col-span-2">
<label for="medications" class="block text-sm font-medium text-gray-700 mb-1">Current Medications</label>
<textarea id="medications" name="medications" rows="2"
placeholder="List any medications the pet is currently taking"
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500"
>{form?.medications ?? ''}</textarea>
</div>
</div>
</div>
<!-- Behavior -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Behavior</h2>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
{#each [
{ id: 'goodWithKids', label: 'Good with kids?' },
{ id: 'goodWithDogs', label: 'Good with dogs?' },
{ id: 'goodWithCats', label: 'Good with cats?' }
] as field}
<div>
<label for={field.id} class="block text-sm font-medium text-gray-700 mb-1">{field.label}</label>
<select id={field.id} name={field.id}
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500">
<option value="" selected={!form?.[field.id]}>Select...</option>
<option value="yes" selected={form?.[field.id] === 'yes'}>Yes</option>
<option value="no" selected={form?.[field.id] === 'no'}>No</option>
<option value="unknown" selected={form?.[field.id] === 'unknown'}>Unknown</option>
</select>
</div>
{/each}
<div>
<label for="houseTrained" class="block text-sm font-medium text-gray-700 mb-1">House trained?</label>
<select id="houseTrained" name="houseTrained"
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500">
<option value="" selected={!form?.houseTrained}>Select...</option>
<option value="yes" selected={form?.houseTrained === 'yes'}>Yes</option>
<option value="no" selected={form?.houseTrained === 'no'}>No</option>
<option value="partially" selected={form?.houseTrained === 'partially'}>Partially</option>
</select>
</div>
<div>
<label for="crateTrained" class="block text-sm font-medium text-gray-700 mb-1">Crate trained?</label>
<select id="crateTrained" name="crateTrained"
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500">
<option value="" selected={!form?.crateTrained}>Select...</option>
<option value="yes" selected={form?.crateTrained === 'yes'}>Yes</option>
<option value="no" selected={form?.crateTrained === 'no'}>No</option>
<option value="unknown" selected={form?.crateTrained === 'unknown'}>Unknown</option>
</select>
</div>
<div>
<label for="leashTrained" class="block text-sm font-medium text-gray-700 mb-1">Leash trained?</label>
<select id="leashTrained" name="leashTrained"
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500">
<option value="" selected={!form?.leashTrained}>Select...</option>
<option value="yes" selected={form?.leashTrained === 'yes'}>Yes</option>
<option value="no" selected={form?.leashTrained === 'no'}>No</option>
<option value="partially" selected={form?.leashTrained === 'partially'}>Partially</option>
</select>
</div>
</div>
<div class="mt-4">
<label for="aggressionOrAnxiety" class="block text-sm font-medium text-gray-700 mb-1">
Any aggression, anxiety, or behavioral concerns?
</label>
<textarea id="aggressionOrAnxiety" name="aggressionOrAnxiety" rows="3"
placeholder="Please describe any behavioral issues honestly — this helps us find the right placement."
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500"
>{form?.aggressionOrAnxiety ?? ''}</textarea>
</div>
</div>
<!-- Reason for Surrender -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Reason for Surrender</h2>
<div class="space-y-4">
<div>
<label for="reasonForSurrender" class="block text-sm font-medium text-gray-700 mb-1">
Why are you surrendering this pet? <span class="text-red-500">*</span>
</label>
<textarea id="reasonForSurrender" name="reasonForSurrender" rows="4" required
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500"
>{form?.reasonForSurrender ?? ''}</textarea>
</div>
<div>
<label for="timeline" class="block text-sm font-medium text-gray-700 mb-1">
How soon do you need to rehome? <span class="text-red-500">*</span>
</label>
<select id="timeline" name="timeline" required
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500">
<option value="" disabled selected={!form?.timeline}>Select...</option>
<option value="immediate" selected={form?.timeline === 'immediate'}>Immediate</option>
<option value="1_week" selected={form?.timeline === '1_week'}>Within 1 week</option>
<option value="2_weeks" selected={form?.timeline === '2_weeks'}>Within 2 weeks</option>
<option value="1_month" selected={form?.timeline === '1_month'}>Within 1 month</option>
</select>
</div>
</div>
</div>
<!-- Submit -->
<div class="px-6 md:px-8 py-6">
<button
type="submit"
class="w-full sm:w-auto px-8 py-3 bg-teal-600 text-white font-semibold rounded-full hover:bg-teal-700 transition-colors focus:outline-none focus:ring-2 focus:ring-teal-500/50 focus:ring-offset-2"
>
Submit Surrender Request
</button>
</div>
</form>
{/if}
</div>
</section>
@@ -0,0 +1,96 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { applications } from '$lib/server/schema';
import { sendApplicationNotification } from '$lib/server/email';
import { loginLimiter } from '$lib/server/rate-limit';
export const load: PageServerLoad = async ({ parent }) => {
const parentData = await parent();
if (!parentData.orgConfig.applications.volunteerEnabled) {
throw redirect(302, '/');
}
return parentData;
};
export const actions: Actions = {
default: async ({ request, getClientAddress, locals }) => {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? getClientAddress();
if (!loginLimiter.check(`apply-volunteer:${ip}`)) {
return fail(429, { error: 'Too many submissions. Please try again later.' });
}
const formData = await request.formData();
const applicantName = formData.get('applicantName')?.toString().trim() ?? '';
const email = formData.get('email')?.toString().trim() ?? '';
const phone = formData.get('phone')?.toString().trim() ?? '';
const data: Record<string, unknown> = {};
const fieldNames = [
'availabilityDays', 'hoursPerWeek',
'dogExperience', 'rescueExperience', 'whyInterested'
];
for (const name of fieldNames) {
data[name] = formData.get(name)?.toString() ?? '';
}
const interestOptions = [
'eventHelp', 'transport', 'photography', 'socialMedia',
'adminDataEntry', 'fundraising', 'fosterMentoring'
];
const interests: string[] = [];
for (const opt of interestOptions) {
if (formData.get(opt)?.toString() === 'on') {
interests.push(opt);
}
}
data.interests = interests;
const errors: string[] = [];
if (!applicantName) errors.push('Full name is required.');
if (!email) errors.push('Email is required.');
if (!phone) errors.push('Phone number is required.');
if (!data.whyInterested) errors.push('Please tell us why you are interested in volunteering.');
if (errors.length > 0) {
return fail(400, {
error: errors.join(' '),
applicantName,
email,
phone,
...data,
interests
});
}
await db.insert(applications).values({
type: 'volunteer',
petId: null,
applicantName,
applicantEmail: email,
applicantPhone: phone || null,
formData: data
});
const orgConfig = locals.orgConfig;
if (orgConfig.orgInfo.email) {
try {
sendApplicationNotification(
orgConfig.orgInfo.name,
orgConfig.orgInfo.email,
applicantName,
'volunteer'
);
} catch {
// Email failure should not break form submission
}
}
return { success: true };
}
};
@@ -0,0 +1,226 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form }: { data: any; form: Record<string, any> | null } = $props();
</script>
<svelte:head>
<title>Volunteer Application — {data.orgConfig?.orgInfo?.name ?? 'Pet Rescue'}</title>
<meta
name="description"
content="Join our volunteer team. Help with events, transport, photography, and more."
/>
</svelte:head>
<section class="py-12 md:py-20">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-10">
<h1 class="text-3xl md:text-4xl font-bold text-gray-900">Volunteer Application</h1>
<p class="mt-3 text-gray-600 text-lg">
Our volunteers are the backbone of our rescue. Whether you have a few hours a week
or can help at events, we would love to have you on our team!
</p>
</div>
{#if form?.success}
<div class="bg-white rounded-2xl shadow-sm p-8 md:p-12 text-center">
<div class="w-16 h-16 mx-auto mb-6 bg-teal-50 rounded-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-8 h-8 text-teal-600">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-3">Thank You for Volunteering!</h2>
<p class="text-gray-600 mb-6">
We appreciate your willingness to help! Our volunteer manager will review your
application and be in touch soon with next steps.
</p>
<a
href="/"
class="inline-flex items-center gap-2 px-6 py-3 bg-teal-600 text-white font-semibold rounded-full hover:bg-teal-700 transition-colors"
>
Back to Home
</a>
</div>
{:else}
<form method="POST" use:enhance class="bg-white rounded-2xl shadow-sm divide-y divide-gray-100">
{#if form?.error}
<div class="px-6 md:px-8 pt-6">
<div class="bg-red-50 border border-red-200 text-red-700 rounded-lg px-4 py-3 text-sm">
{form.error}
</div>
</div>
{/if}
<!-- Personal Information -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Personal Information</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="sm:col-span-2">
<label for="applicantName" class="block text-sm font-medium text-gray-700 mb-1">
Full Name <span class="text-red-500">*</span>
</label>
<input
type="text"
id="applicantName"
name="applicantName"
value={form?.applicantName ?? ''}
required
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500"
/>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">
Email <span class="text-red-500">*</span>
</label>
<input
type="email"
id="email"
name="email"
value={form?.email ?? ''}
required
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500"
/>
</div>
<div>
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">
Phone <span class="text-red-500">*</span>
</label>
<input
type="tel"
id="phone"
name="phone"
value={form?.phone ?? ''}
required
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500"
/>
</div>
</div>
</div>
<!-- Availability -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Availability</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="availabilityDays" class="block text-sm font-medium text-gray-700 mb-1">
When are you available?
</label>
<select
id="availabilityDays"
name="availabilityDays"
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500"
>
<option value="" selected={!form?.availabilityDays}>Select...</option>
<option value="weekdays" selected={form?.availabilityDays === 'weekdays'}>Weekdays</option>
<option value="weekends" selected={form?.availabilityDays === 'weekends'}>Weekends</option>
<option value="both" selected={form?.availabilityDays === 'both'}>Both weekdays and weekends</option>
<option value="flexible" selected={form?.availabilityDays === 'flexible'}>Flexible</option>
</select>
</div>
<div>
<label for="hoursPerWeek" class="block text-sm font-medium text-gray-700 mb-1">
Hours per week
</label>
<select
id="hoursPerWeek"
name="hoursPerWeek"
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500"
>
<option value="" selected={!form?.hoursPerWeek}>Select...</option>
<option value="1-3" selected={form?.hoursPerWeek === '1-3'}>1-3 hours</option>
<option value="4-8" selected={form?.hoursPerWeek === '4-8'}>4-8 hours</option>
<option value="8+" selected={form?.hoursPerWeek === '8+'}>8+ hours</option>
<option value="events_only" selected={form?.hoursPerWeek === 'events_only'}>Events only</option>
</select>
</div>
</div>
</div>
<!-- Interests -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Areas of Interest</h2>
<p class="text-sm text-gray-600 mb-4">Check all that apply.</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{#each [
{ name: 'eventHelp', label: 'Event help' },
{ name: 'transport', label: 'Transport (vet visits, meetups)' },
{ name: 'photography', label: 'Photography' },
{ name: 'socialMedia', label: 'Social media' },
{ name: 'adminDataEntry', label: 'Admin / data entry' },
{ name: 'fundraising', label: 'Fundraising' },
{ name: 'fosterMentoring', label: 'Foster mentoring' }
] as interest}
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
name={interest.name}
checked={form?.interests?.includes?.(interest.name)}
class="h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500/30"
/>
<span class="text-sm text-gray-700">{interest.label}</span>
</label>
{/each}
</div>
</div>
<!-- Experience -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Experience</h2>
<div class="space-y-4">
<div>
<label for="dogExperience" class="block text-sm font-medium text-gray-700 mb-1">
Experience with animals
</label>
<textarea
id="dogExperience"
name="dogExperience"
rows="2"
placeholder="Tell us about your experience with animals"
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500"
>{form?.dogExperience ?? ''}</textarea>
</div>
<div>
<label for="rescueExperience" class="block text-sm font-medium text-gray-700 mb-1">
Experience with rescue or nonprofit organizations
</label>
<textarea
id="rescueExperience"
name="rescueExperience"
rows="2"
placeholder="Any previous volunteer or nonprofit work"
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500"
>{form?.rescueExperience ?? ''}</textarea>
</div>
</div>
</div>
<!-- Why Interested -->
<div class="px-6 md:px-8 py-6">
<h2 class="text-lg font-bold text-gray-900 mb-4">Why Are You Interested?</h2>
<div>
<label for="whyInterested" class="block text-sm font-medium text-gray-700 mb-1">
Tell us why you want to volunteer with us <span class="text-red-500">*</span>
</label>
<textarea
id="whyInterested"
name="whyInterested"
rows="4"
required
class="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500/30 focus:border-teal-500"
>{form?.whyInterested ?? ''}</textarea>
</div>
</div>
<!-- Submit -->
<div class="px-6 md:px-8 py-6">
<button
type="submit"
class="w-full sm:w-auto px-8 py-3 bg-teal-600 text-white font-semibold rounded-full hover:bg-teal-700 transition-colors focus:outline-none focus:ring-2 focus:ring-teal-500/50 focus:ring-offset-2"
>
Submit Volunteer Application
</button>
</div>
</form>
{/if}
</div>
</section>
@@ -0,0 +1,8 @@
import { getPageBlocks } from '$lib/server/content';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
const parentData = await parent();
const blocks = await getPageBlocks('become-a-foster');
return { ...parentData, blocks };
};
@@ -0,0 +1,89 @@
<script lang="ts">
let { data } = $props();
const orgName = $derived(data.orgConfig.orgInfo.name);
const orgEmail = $derived(data.orgConfig.orgInfo.email);
const fosterEnabled = $derived(data.orgConfig.applications.fosterEnabled);
</script>
<svelte:head>
<title>Become a Foster | {orgName}</title>
<meta name="description" content="Learn about fostering a pet with {orgName}. We provide supplies and vetting — you provide the love." />
</svelte:head>
<!-- Hero -->
<section class="bg-teal-brand py-16 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h1 class="text-4xl font-bold sm:text-5xl">Become a Foster</h1>
<p class="mt-4 text-lg text-teal-light/90">Open your heart and home to a pet in need.</p>
</div>
</section>
<!-- Intro -->
<section class="bg-warm-bg py-16">
<div class="mx-auto max-w-3xl px-4">
<p class="text-lg leading-relaxed text-gray-mid">
Have you ever thought about fostering a pet? All of our animals reside in foster homes so they can have a normal life while awaiting a forever family. Fostering is the backbone of what we do, and we are always in need of caring individuals willing to open their homes.
</p>
</div>
</section>
<!-- What does fostering entail? -->
<section class="bg-white py-16">
<div class="mx-auto max-w-6xl px-4">
<div class="mx-auto max-w-4xl">
<h2 class="text-3xl font-bold text-gray-brand text-center">What Does Fostering Entail?</h2>
<div class="mx-auto mt-3 h-1 w-16 rounded bg-teal-brand"></div>
<div class="mt-8 space-y-4 text-lg leading-relaxed text-gray-mid">
<p>
Fostering means taking in a pet and providing them with a safe, loving environment while they wait for their forever home. You are giving them the gift of a normal home life.
</p>
<p>
{orgName} provides all necessary supplies including food, crates, leashes, and toys. All vetting is paid for by the rescue, including vaccinations, spay/neuter, and any medical treatment needed.
</p>
<p>
We also need temporary fosters! Whether you can commit for a few weeks or a few months, every bit of help matters.
</p>
</div>
</div>
</div>
</section>
<!-- Foster Fail -->
<section class="bg-warm-bg py-16">
<div class="mx-auto max-w-6xl px-4">
<div class="mx-auto max-w-4xl">
<h2 class="text-3xl font-bold text-gray-brand text-center">What If I Want to Adopt My Foster Pet?</h2>
<div class="mx-auto mt-3 h-1 w-16 rounded bg-teal-brand"></div>
<div class="mt-8 space-y-4 text-lg leading-relaxed text-gray-mid">
<p>
We completely understand that you may fall in love with your foster pet — it happens more often than you might think! Foster families always get first consideration for adopting the animal in their care.
</p>
<p>
As a foster, you also play an important role in helping choose the right adopter for your foster pet. Your input is invaluable in making sure each animal ends up in the best possible home.
</p>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="bg-teal-brand py-16 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h2 class="text-3xl font-bold">Ready to Foster?</h2>
<p class="mt-4 text-lg text-teal-light/90">
Fill out our foster application or reach out with any questions.
</p>
<div class="mt-8 flex flex-wrap items-center justify-center gap-4">
{#if fosterEnabled}
<a href="/apply/foster" class="inline-block rounded-lg bg-white px-8 py-3 font-semibold text-teal-brand shadow-lg transition hover:bg-teal-light hover:shadow-xl">
Apply to Foster
</a>
{/if}
{#if orgEmail}
<a href="mailto:{orgEmail}" class="inline-block rounded-lg border-2 border-white px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-white hover:text-teal-brand">
Email Us
</a>
{/if}
</div>
</div>
</section>
@@ -0,0 +1,60 @@
import { getPageBlocks } from '$lib/server/content';
import { db } from '$lib/server/db';
import { contactMessages } from '$lib/server/schema';
import { fail } from '@sveltejs/kit';
import { RateLimiter } from '$lib/server/rate-limit';
import type { PageServerLoad, Actions } from './$types';
const contactLimiter = new RateLimiter(3, 60 * 60 * 1000);
export const load: PageServerLoad = async ({ parent }) => {
const parentData = await parent();
const blocks = await getPageBlocks('contact');
return { ...parentData, blocks };
};
export const actions: Actions = {
default: async ({ request, getClientAddress }) => {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? getClientAddress();
if (!contactLimiter.check(ip)) {
return fail(429, { error: 'Too many submissions. Please try again later.' });
}
const formData = await request.formData();
// Honeypot — bots fill this hidden field
const website = formData.get('website')?.toString() ?? '';
if (website) {
return { success: true };
}
// Timing check — reject submissions under 2 seconds
const loadedAt = formData.get('_t')?.toString() ?? '';
if (loadedAt) {
const elapsed = Date.now() - parseInt(loadedAt, 10);
if (elapsed < 2000) {
return { success: true };
}
}
const name = formData.get('name')?.toString().trim() ?? '';
const email = formData.get('email')?.toString().trim() ?? '';
const subject = formData.get('subject')?.toString().trim() || null;
const message = formData.get('message')?.toString().trim() ?? '';
const errors: Record<string, string> = {};
if (!name) errors.name = 'Name is required.';
if (!email) errors.email = 'Email is required.';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Please enter a valid email.';
if (!message) errors.message = 'Message is required.';
if (Object.keys(errors).length > 0) {
return fail(400, { errors, values: { name, email, subject, message } });
}
await db.insert(contactMessages).values({ name, email, subject, message });
return { success: true };
}
};
+216
View File
@@ -0,0 +1,216 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form } = $props();
const orgName = $derived(data.orgConfig.orgInfo.name);
const orgEmail = $derived(data.orgConfig.orgInfo.email);
const facebookUrl = $derived(data.orgConfig.orgInfo.facebookUrl);
const instagramUrl = $derived(data.orgConfig.orgInfo.instagramUrl);
type Blocks = Record<string, Record<string, string>>;
const blocks = $derived(data.blocks as Blocks);
</script>
<svelte:head>
<title>Contact Us | {orgName}</title>
<meta name="description" content="Get in touch with {orgName}. We would love to hear from you." />
</svelte:head>
<!-- Hero -->
<section class="bg-teal-brand py-16 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h1 class="text-4xl font-bold sm:text-5xl">{blocks?.intro?.heading ?? 'Contact Us'}</h1>
<p class="mt-4 text-lg text-teal-light/90">
{blocks?.intro?.text ?? 'We would love to hear from you.'}
</p>
</div>
</section>
<!-- Contact Info -->
<section class="bg-warm-bg py-16">
<div class="mx-auto max-w-4xl px-4">
<p class="text-center text-lg leading-relaxed text-gray-mid">
{orgName} is 100% volunteer-based and we will respond to your message as soon as possible.
</p>
<div class="mt-12 grid gap-8 md:grid-cols-2">
{#if orgEmail}
<div class="rounded-xl border border-gray-soft bg-white p-8 text-center shadow-sm">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-teal-light">
<svg class="h-8 w-8 text-teal-brand" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h2 class="mt-4 text-xl font-semibold text-gray-brand">Email</h2>
<a href="mailto:{orgEmail}" class="mt-3 inline-block text-lg font-medium text-teal-brand hover:text-teal-dark underline">
{orgEmail}
</a>
</div>
{/if}
{#if blocks?.info?.address}
<div class="rounded-xl border border-gray-soft bg-white p-8 text-center shadow-sm">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-teal-light">
<svg class="h-8 w-8 text-teal-brand" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h2 class="mt-4 text-xl font-semibold text-gray-brand">Mailing Address</h2>
<p class="mt-3 text-lg text-gray-mid">{blocks.info.address}</p>
</div>
{/if}
</div>
<!-- Social Links -->
{#if facebookUrl || instagramUrl}
<div class="mt-12">
<h2 class="text-center text-2xl font-bold text-gray-brand">Follow Us</h2>
<div class="mx-auto mt-3 h-1 w-16 rounded bg-teal-brand"></div>
<div class="mt-8 flex flex-wrap items-center justify-center gap-6">
{#if facebookUrl}
<a href={facebookUrl} target="_blank" rel="noopener noreferrer" class="flex items-center gap-3 rounded-lg border border-gray-soft bg-white px-6 py-4 shadow-sm transition hover:shadow-md hover:border-teal-brand">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-6 w-6 text-teal-brand">
<path d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z" />
</svg>
<span class="font-medium text-gray-brand">Facebook</span>
</a>
{/if}
{#if instagramUrl}
<a href={instagramUrl} target="_blank" rel="noopener noreferrer" class="flex items-center gap-3 rounded-lg border border-gray-soft bg-white px-6 py-4 shadow-sm transition hover:shadow-md hover:border-teal-brand">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-6 w-6 text-teal-brand">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" />
</svg>
<span class="font-medium text-gray-brand">Instagram</span>
</a>
{/if}
</div>
</div>
{/if}
</div>
</section>
<!-- Contact Form -->
<section class="bg-white py-16">
<div class="mx-auto max-w-2xl px-4">
<h2 class="text-center text-2xl font-bold text-gray-brand">Send Us a Message</h2>
<div class="mx-auto mt-3 h-1 w-16 rounded bg-teal-brand"></div>
{#if form?.success}
<div class="mt-8 rounded-xl border border-green-200 bg-green-50 p-8 text-center">
<svg class="mx-auto h-12 w-12 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="mt-4 text-xl font-semibold text-green-800">Thank you! We'll get back to you soon.</h3>
<p class="mt-2 text-green-700">Our volunteers typically respond within 24-48 hours.</p>
</div>
{:else}
{#if form?.error}
<div class="mt-6 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">{form.error}</div>
{/if}
<form method="POST" use:enhance class="mt-8 space-y-6">
<div class="grid gap-6 sm:grid-cols-2">
<div>
<label for="name" class="block text-sm font-medium text-gray-brand">
Name <span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
required
value={form?.values?.name ?? ''}
class="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 shadow-sm focus:border-teal-brand focus:ring-2 focus:ring-teal-brand/20 transition {form?.errors?.name ? 'border-red-400' : ''}"
placeholder="Your name"
/>
{#if form?.errors?.name}
<p class="mt-1 text-sm text-red-600">{form.errors.name}</p>
{/if}
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-brand">
Email <span class="text-red-500">*</span>
</label>
<input
type="email"
id="email"
name="email"
required
value={form?.values?.email ?? ''}
class="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 shadow-sm focus:border-teal-brand focus:ring-2 focus:ring-teal-brand/20 transition {form?.errors?.email ? 'border-red-400' : ''}"
placeholder="you@example.com"
/>
{#if form?.errors?.email}
<p class="mt-1 text-sm text-red-600">{form.errors.email}</p>
{/if}
</div>
</div>
<div>
<label for="subject" class="block text-sm font-medium text-gray-brand">Subject</label>
<input
type="text"
id="subject"
name="subject"
value={form?.values?.subject ?? ''}
class="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 shadow-sm focus:border-teal-brand focus:ring-2 focus:ring-teal-brand/20 transition"
placeholder="How can we help?"
/>
</div>
<div>
<label for="message" class="block text-sm font-medium text-gray-brand">
Message <span class="text-red-500">*</span>
</label>
<textarea
id="message"
name="message"
required
rows="5"
class="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 shadow-sm focus:border-teal-brand focus:ring-2 focus:ring-teal-brand/20 transition resize-y {form?.errors?.message ? 'border-red-400' : ''}"
placeholder="Tell us what's on your mind..."
>{form?.values?.message ?? ''}</textarea>
{#if form?.errors?.message}
<p class="mt-1 text-sm text-red-600">{form.errors.message}</p>
{/if}
</div>
<!-- Honeypot + timing (anti-spam) -->
<div class="absolute -left-[9999px]" aria-hidden="true" tabindex="-1">
<input type="text" name="website" autocomplete="off" tabindex="-1" />
</div>
<input type="hidden" name="_t" value={Date.now()} />
<div class="text-center">
<button type="submit" class="rounded-lg bg-teal-brand px-8 py-3 text-lg font-semibold text-white shadow-sm transition hover:bg-teal-dark focus:ring-2 focus:ring-teal-brand/50 focus:ring-offset-2">
Send Message
</button>
</div>
</form>
{/if}
</div>
</section>
<!-- Adoption Note -->
{#if data.orgConfig.applications.adoptionEnabled}
<section class="bg-warm-bg py-16">
<div class="mx-auto max-w-3xl px-4 text-center">
<div class="rounded-xl border border-gray-soft bg-white p-8 shadow-sm">
<h2 class="text-2xl font-bold text-gray-brand">Interested in Adopting?</h2>
<p class="mt-4 text-lg text-gray-mid">
If you are inquiring about an adoptable pet, please fill out our adoption application first.
</p>
<div class="mt-6 flex flex-wrap items-center justify-center gap-4">
<a href="/pets" class="rounded-lg bg-teal-brand px-6 py-3 font-semibold text-white transition hover:bg-teal-dark">
View Available Pets
</a>
<a href="/apply/adopt" class="rounded-lg border-2 border-teal-brand px-6 py-3 font-semibold text-teal-brand transition hover:bg-teal-light">
Apply to Adopt
</a>
</div>
</div>
</div>
</section>
{/if}
@@ -0,0 +1,56 @@
import { getPageBlocks } from '$lib/server/content';
import { createDonationCheckout } from '$lib/server/stripe';
import { fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async ({ url, parent }) => {
const parentData = await parent();
const content = await getPageBlocks('donate');
const paymentStatus = url.searchParams.get('payment');
const stripeEnabled = Boolean(process.env.STRIPE_SECRET_KEY);
return { ...parentData, content, stripeEnabled, paymentStatus };
};
export const actions: Actions = {
donate: async ({ request, url }) => {
if (!process.env.STRIPE_SECRET_KEY) {
return fail(503, { donateError: 'Online payment is not available at this time.' });
}
const fd = await request.formData();
const amountStr = fd.get('amount')?.toString().trim();
const email = fd.get('email')?.toString().trim() || undefined;
if (!amountStr) {
return fail(400, { donateError: 'Please enter a donation amount.' });
}
const amount = parseFloat(amountStr);
if (isNaN(amount) || amount < 1) {
return fail(400, { donateError: 'Please enter a valid amount of at least $1.00.' });
}
if (amount > 99999) {
return fail(400, {
donateError: 'Please enter a smaller amount or contact us directly.'
});
}
const baseUrl = url.origin;
const checkoutUrl = await createDonationCheckout({
amount: Math.round(amount * 100), // convert to cents
donorEmail: email,
successUrl: `${baseUrl}/donate?payment=success`,
cancelUrl: `${baseUrl}/donate?payment=cancelled`
});
if (!checkoutUrl) {
return fail(503, {
donateError: 'Unable to create payment session. Please try again later.'
});
}
redirect(303, checkoutUrl);
}
};
+401
View File
@@ -0,0 +1,401 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form } = $props();
const orgName = $derived(data.orgConfig.orgInfo.name);
const orgEmail = $derived(data.orgConfig.orgInfo.email);
const facebookUrl = $derived(data.orgConfig.orgInfo.facebookUrl);
let donationAmount = $state('');
let submitting = $state(false);
const presetAmounts = [10, 25, 50, 100, 250];
function selectPreset(amount: number) {
donationAmount = String(amount);
}
function handleDonateEnhance() {
submitting = true;
return async ({ update }: { update: () => Promise<void> }) => {
submitting = false;
await update();
};
}
</script>
<svelte:head>
<title>Donate | {orgName}</title>
<meta
name="description"
content="Support {orgName} with a donation. Every dollar helps provide veterinary care, food, and shelter for animals in need."
/>
</svelte:head>
<!-- Hero -->
<section class="bg-teal-brand py-16 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h1 class="text-4xl font-bold sm:text-5xl">
{(data.content as Record<string, Record<string, string>>)?.intro?.heading ?? 'Support Our Mission'}
</h1>
<p class="mt-4 text-lg text-teal-light/90">
{(data.content as Record<string, Record<string, string>>)?.intro?.text ??
'Your generosity saves lives. Every donation directly supports the animals in our care.'}
</p>
</div>
</section>
<!-- Payment status banners -->
{#if data.paymentStatus === 'success'}
<section class="border-b border-green-200 bg-green-50 py-6">
<div class="mx-auto max-w-4xl px-4 text-center">
<div class="flex items-center justify-center gap-3">
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<p class="text-lg font-semibold text-green-800">Thank you for your generous donation!</p>
</div>
<p class="mt-1 text-sm text-green-700">
Your payment has been received. You will get a receipt via email.
</p>
</div>
</section>
{/if}
{#if data.paymentStatus === 'cancelled'}
<section class="border-b border-amber-200 bg-amber-50 py-4">
<div class="mx-auto max-w-4xl px-4 text-center">
<p class="text-sm text-amber-700">
Your donation was cancelled. No payment was processed. You can try again below.
</p>
</div>
</section>
{/if}
<!-- Donate with Card (Stripe) -->
{#if data.stripeEnabled}
<section class="bg-warm-bg py-16">
<div class="mx-auto max-w-4xl px-4">
<div class="text-center">
<h2 class="text-3xl font-bold text-gray-brand">Donate with Card</h2>
<div class="mx-auto mt-3 h-1 w-16 rounded bg-teal-brand"></div>
<p class="mt-4 text-gray-mid">Make a secure one-time donation with your credit or debit card.</p>
</div>
<div class="mx-auto mt-8 max-w-md">
<div class="rounded-xl border border-gray-soft bg-white p-6 shadow-sm">
<form method="POST" action="?/donate" use:enhance={handleDonateEnhance}>
<!-- Preset amounts -->
<div class="mb-4">
<label class="mb-2 block text-sm font-medium text-gray-700">Select an amount</label>
<div class="grid grid-cols-5 gap-2">
{#each presetAmounts as amount}
<button
type="button"
onclick={() => selectPreset(amount)}
class="cursor-pointer rounded-lg border-2 px-3 py-2.5 text-sm font-semibold transition {donationAmount ===
String(amount)
? 'border-teal-brand bg-teal-brand text-white'
: 'border-gray-soft bg-white text-gray-brand hover:border-teal-brand'}"
>
${amount}
</button>
{/each}
</div>
</div>
<!-- Custom amount -->
<div class="mb-4">
<label for="donation-amount" class="mb-1 block text-sm font-medium text-gray-700">
Or enter a custom amount
</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 font-medium text-gray-400"
>$</span
>
<input
type="number"
id="donation-amount"
name="amount"
bind:value={donationAmount}
min="1"
step="0.01"
placeholder="0.00"
required
class="w-full rounded-lg border border-gray-soft bg-white py-2.5 pl-8 pr-3 text-sm text-gray-brand placeholder-gray-400 focus:border-teal-brand focus:outline-none focus:ring-2 focus:ring-teal-brand"
/>
</div>
</div>
<!-- Email -->
<div class="mb-5">
<label for="donation-email" class="mb-1 block text-sm font-medium text-gray-700">
Email <span class="font-normal text-gray-400">(optional, for receipt)</span>
</label>
<input
type="email"
id="donation-email"
name="email"
placeholder="your@email.com"
class="w-full rounded-lg border border-gray-soft bg-white px-3 py-2.5 text-sm text-gray-brand placeholder-gray-400 focus:border-teal-brand focus:outline-none focus:ring-2 focus:ring-teal-brand"
/>
</div>
{#if form?.donateError}
<div
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-center text-sm text-red-700"
>
{form.donateError}
</div>
{/if}
<button
type="submit"
disabled={submitting || !donationAmount}
class="w-full cursor-pointer rounded-xl bg-teal-brand px-8 py-3.5 text-lg font-bold text-white shadow-lg transition hover:bg-teal-dark hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-teal-brand focus:ring-offset-2 disabled:opacity-50"
>
{#if submitting}
Processing...
{:else if donationAmount && Number(donationAmount) > 0}
Donate ${Number(donationAmount).toFixed(2)}
{:else}
Donate
{/if}
</button>
</form>
<p class="mt-3 text-center text-xs text-gray-400">
Secure payment powered by Stripe. You will be redirected to complete your donation.
</p>
</div>
</div>
</div>
</section>
{:else}
<!-- No Stripe — simple CTA -->
<section class="bg-warm-bg py-16">
<div class="mx-auto max-w-4xl px-4 text-center">
<h2 class="text-3xl font-bold text-gray-brand">Make a Donation</h2>
<div class="mx-auto mt-3 h-1 w-16 rounded bg-teal-brand"></div>
<p class="mt-4 text-gray-mid">
Ready to make a difference? Get in touch with us to learn how you can donate.
</p>
{#if orgEmail}
<a
href="mailto:{orgEmail}?subject=Donation%20Inquiry"
class="mt-6 inline-block rounded-lg bg-teal-brand px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-teal-dark hover:shadow-xl"
>
Contact Us to Donate
</a>
{:else}
<a
href="/contact"
class="mt-6 inline-block rounded-lg bg-teal-brand px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-teal-dark hover:shadow-xl"
>
Contact Us to Donate
</a>
{/if}
</div>
</section>
{/if}
<!-- Other Ways to Give -->
<section class="bg-white py-16">
<div class="mx-auto max-w-6xl px-4">
<h2 class="text-center text-3xl font-bold text-gray-brand">Other Ways to Give</h2>
<div class="mx-auto mt-3 h-1 w-16 rounded bg-teal-brand"></div>
<div class="mt-12 grid gap-8 md:grid-cols-2 lg:grid-cols-3">
<!-- Volunteer -->
<div class="rounded-xl border border-gray-soft bg-warm-bg p-8 text-center">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-teal-light">
<svg
class="h-8 w-8 text-teal-brand"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<h3 class="mt-4 text-xl font-semibold text-gray-brand">Volunteer Your Time</h3>
<p class="mt-3 text-gray-mid">
Your time is just as valuable as money. Help with fostering, events, transport, and more.
</p>
<a
href="/volunteer"
class="mt-5 inline-block rounded-lg bg-teal-brand px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-teal-dark"
>
Learn How to Help
</a>
</div>
<!-- Foster -->
<div class="rounded-xl border border-gray-soft bg-warm-bg p-8 text-center">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-teal-light">
<svg
class="h-8 w-8 text-teal-brand"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
</div>
<h3 class="mt-4 text-xl font-semibold text-gray-brand">Become a Foster</h3>
<p class="mt-3 text-gray-mid">
Open your home to an animal in need. Fosters provide the care and love that makes all the
difference.
</p>
<a
href="/become-a-foster"
class="mt-5 inline-block rounded-lg bg-teal-brand px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-teal-dark"
>
Learn About Fostering
</a>
</div>
<!-- Spread the Word -->
<div class="rounded-xl border border-gray-soft bg-warm-bg p-8 text-center">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-teal-light">
{#if facebookUrl}
<svg class="h-8 w-8 text-teal-brand" fill="currentColor" viewBox="0 0 24 24">
<path
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
/>
</svg>
{:else}
<svg
class="h-8 w-8 text-teal-brand"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
/>
</svg>
{/if}
</div>
<h3 class="mt-4 text-xl font-semibold text-gray-brand">Spread the Word</h3>
<p class="mt-3 text-gray-mid">
Share our animals on social media, tell friends and family, and help us find forever homes.
</p>
{#if facebookUrl}
<a
href={facebookUrl}
target="_blank"
rel="noopener noreferrer"
class="mt-5 inline-block rounded-lg bg-teal-brand px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-teal-dark"
>
Follow on Facebook
</a>
{:else}
<a
href="/pets"
class="mt-5 inline-block rounded-lg bg-teal-brand px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-teal-dark"
>
Share Our Animals
</a>
{/if}
</div>
</div>
</div>
</section>
<!-- Donate by Mail -->
<section class="bg-warm-bg py-16">
<div class="mx-auto max-w-4xl px-4">
<div class="rounded-xl border border-gray-soft bg-white p-8 md:p-12">
<div class="flex flex-col items-center gap-8 md:flex-row md:items-start">
<div
class="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-teal-light"
>
<svg
class="h-8 w-8 text-teal-brand"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<div>
<h3 class="text-xl font-semibold text-gray-brand">Donate by Mail</h3>
<p class="mt-3 text-gray-mid">
Prefer to send a check? Make it payable to <strong>{orgName}</strong> and contact us for
mailing details.
</p>
{#if orgEmail}
<a
href="mailto:{orgEmail}?subject=Donation%20by%20Mail"
class="mt-4 inline-block font-medium text-teal-brand underline hover:text-teal-dark"
>
{orgEmail}
</a>
{/if}
</div>
</div>
</div>
</div>
</section>
<!-- Tax Info -->
<section class="bg-white py-12">
<div class="mx-auto max-w-4xl px-4 text-center">
<div class="rounded-lg border border-teal-light bg-teal-light/30 p-6">
<p class="text-gray-mid">
<strong class="text-gray-brand">Tax-Deductible Giving:</strong>
{orgName} is a registered 501(c)(3) nonprofit organization. All donations are tax-deductible to
the extent allowed by law. You will receive a receipt for your records.
</p>
</div>
</div>
</section>
<!-- Bottom CTA -->
<section class="bg-teal-brand py-12 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h2 class="text-2xl font-bold">Questions About Donating?</h2>
<p class="mt-3 text-teal-light/90">We're happy to help. Reach out to us anytime.</p>
{#if orgEmail}
<a
href="mailto:{orgEmail}?subject=Donation%20Question"
class="mt-6 inline-block rounded-lg bg-white px-8 py-3 font-semibold text-teal-brand shadow-lg transition hover:bg-teal-light hover:shadow-xl"
>
Contact Us
</a>
{:else}
<a
href="/contact"
class="mt-6 inline-block rounded-lg bg-white px-8 py-3 font-semibold text-teal-brand shadow-lg transition hover:bg-teal-light hover:shadow-xl"
>
Contact Us
</a>
{/if}
</div>
</section>
@@ -0,0 +1,20 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { events } from '$lib/server/schema';
import { eq, asc } from 'drizzle-orm';
export const load: PageServerLoad = async ({ parent }) => {
const parentData = await parent();
const now = new Date();
const allEvents = await db
.select()
.from(events)
.where(eq(events.published, true))
.orderBy(asc(events.startDate));
const upcomingEvents = allEvents.filter((e) => new Date(e.startDate) >= now);
const pastEvents = allEvents.filter((e) => new Date(e.startDate) < now).reverse().slice(0, 6);
return { ...parentData, upcomingEvents, pastEvents };
};
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts">
let { data } = $props();
const orgName = $derived(data.orgConfig.orgInfo.name);
const facebookUrl = $derived(data.orgConfig.orgInfo.facebookUrl);
function formatDate(dateStr: string | Date): string {
const d = new Date(dateStr);
return d.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
function formatTime(dateStr: string | Date): string {
const d = new Date(dateStr);
return d.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit'
});
}
function formatShortDate(dateStr: string | Date): string {
const d = new Date(dateStr);
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
</script>
<svelte:head>
<title>Events | {orgName}</title>
<meta name="description" content="Upcoming adoption events, fundraisers, and community gatherings hosted by {orgName}." />
</svelte:head>
<!-- Hero -->
<section class="bg-teal-brand py-16 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h1 class="text-4xl font-bold sm:text-5xl">Events</h1>
<p class="mt-4 text-lg text-teal-light/90">
Join us at adoption events, fundraisers, and community gatherings.
</p>
</div>
</section>
<!-- Upcoming Events -->
<section class="bg-warm-bg py-16">
<div class="mx-auto max-w-6xl px-4">
<h2 class="text-center text-3xl font-bold text-gray-brand">Upcoming Events</h2>
<div class="mx-auto mt-3 h-1 w-16 rounded bg-teal-brand"></div>
{#if data.upcomingEvents.length > 0}
<div class="mt-12 grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{#each data.upcomingEvents as event}
<article class="flex flex-col overflow-hidden rounded-xl border border-gray-soft bg-white shadow-sm transition hover:shadow-md">
{#if event.imageUrl}
<div class="aspect-video overflow-hidden bg-teal-light">
<img src={event.imageUrl} alt={event.title} class="h-full w-full object-cover" />
</div>
{:else}
<div class="flex aspect-video items-center justify-center bg-teal-light">
<svg class="h-16 w-16 text-teal-brand/30" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
{/if}
<div class="flex flex-1 flex-col p-6">
<div class="flex items-center gap-2 text-sm text-teal-brand">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span class="font-medium">{formatDate(event.startDate)}</span>
</div>
<div class="mt-1 flex items-center gap-2 text-sm text-gray-mid">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{formatTime(event.startDate)}</span>
</div>
<h3 class="mt-3 text-xl font-bold text-gray-brand">{event.title}</h3>
{#if event.location}
<div class="mt-2 flex items-start gap-2 text-sm text-gray-mid">
<svg class="mt-0.5 h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>{event.location}</span>
</div>
{/if}
{#if event.description}
<p class="mt-3 flex-1 text-gray-mid">{event.description}</p>
{/if}
</div>
</article>
{/each}
</div>
{:else}
<div class="mt-12 rounded-xl border border-gray-soft bg-white p-12 text-center">
<div class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-teal-light">
<svg class="h-10 w-10 text-teal-brand/50" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<h3 class="mt-4 text-xl font-semibold text-gray-brand">No Upcoming Events</h3>
<p class="mt-2 text-gray-mid">
Check back soon! {#if facebookUrl}Follow us on Facebook for the latest updates.{/if}
</p>
{#if facebookUrl}
<a href={facebookUrl} target="_blank" rel="noopener noreferrer" class="mt-6 inline-block rounded-lg bg-teal-brand px-6 py-2.5 font-semibold text-white transition hover:bg-teal-dark">
Follow Us on Facebook
</a>
{/if}
</div>
{/if}
</div>
</section>
<!-- Past Events -->
{#if data.pastEvents.length > 0}
<section class="bg-white py-16">
<div class="mx-auto max-w-6xl px-4">
<h2 class="text-center text-2xl font-bold text-gray-brand">Past Events</h2>
<div class="mx-auto mt-3 h-1 w-16 rounded bg-teal-brand"></div>
<div class="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.pastEvents as event}
<div class="flex items-start gap-4 rounded-lg border border-gray-soft bg-warm-bg p-4">
{#if event.imageUrl}
<div class="h-16 w-16 shrink-0 overflow-hidden rounded-lg">
<img src={event.imageUrl} alt={event.title} class="h-full w-full object-cover" />
</div>
{:else}
<div class="flex h-16 w-16 shrink-0 items-center justify-center rounded-lg bg-teal-light">
<svg class="h-6 w-6 text-teal-brand/40" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
{/if}
<div class="min-w-0 flex-1">
<p class="text-xs font-medium text-teal-brand">{formatShortDate(event.startDate)}</p>
<h3 class="mt-0.5 truncate font-semibold text-gray-brand">{event.title}</h3>
{#if event.location}
<p class="mt-0.5 truncate text-sm text-gray-mid">{event.location}</p>
{/if}
</div>
</div>
{/each}
</div>
</div>
</section>
{/if}
@@ -0,0 +1,41 @@
import type { Actions, PageServerLoad } from './$types';
import { fail } from '@sveltejs/kit';
import { getPageBlocks } from '$lib/server/content';
const FOSTER_PASSWORD = process.env.FOSTER_RESOURCES_PASSWORD ?? '';
export const load: PageServerLoad = async ({ cookies, parent }) => {
const parentData = await parent();
// If no password is configured, show content freely
const passwordRequired = FOSTER_PASSWORD.length > 0;
const authed = !passwordRequired || cookies.get('foster_resources_auth') === 'true';
let blocks = {};
if (authed) {
blocks = await getPageBlocks('foster-resources');
}
return { ...parentData, authed, passwordRequired, blocks };
};
export const actions: Actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const password = data.get('password')?.toString();
if (!FOSTER_PASSWORD || password !== FOSTER_PASSWORD) {
return fail(401, { error: 'Incorrect password' });
}
cookies.set('foster_resources_auth', 'true', {
path: '/foster-resources',
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30 // 30 days
});
return { success: true };
}
};
@@ -0,0 +1,72 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form } = $props();
const orgName = $derived(data.orgConfig.orgInfo.name);
type Blocks = Record<string, string>;
const blocks = $derived(data.blocks as Blocks);
</script>
<svelte:head>
<title>Foster Resources | {orgName}</title>
<meta name="description" content="Resources and documents for {orgName} foster families." />
</svelte:head>
<!-- Hero -->
<section class="bg-teal-brand py-16 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h1 class="text-4xl font-bold sm:text-5xl">Foster Resources</h1>
<p class="mt-4 text-lg text-teal-light/90">Resources and documents for our foster families.</p>
</div>
</section>
<section class="bg-warm-bg py-16">
<div class="mx-auto max-w-4xl px-4">
{#if !data.authed && !form?.success}
<!-- Password Gate -->
<div class="mx-auto max-w-md rounded-xl border border-gray-soft bg-white p-8 shadow-sm">
<h2 class="text-xl font-bold text-gray-brand text-center">This page is password-protected</h2>
<p class="mt-2 text-center text-gray-mid text-sm">Enter the foster resources password to view this content.</p>
{#if form?.error}
<div class="mt-4 rounded-lg bg-red-50 p-3 text-sm text-red-700">{form.error}</div>
{/if}
<form method="POST" use:enhance class="mt-6">
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
type="password"
id="password"
name="password"
required
class="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-teal-brand focus:ring-2 focus:ring-teal-brand/20 outline-none"
/>
<button type="submit" class="mt-4 w-full rounded-lg bg-teal-brand px-6 py-2.5 font-semibold text-white transition hover:bg-teal-dark">
Enter
</button>
</form>
</div>
{:else}
<!-- Resources Content -->
<div class="space-y-8">
{#if blocks && Object.keys(blocks).length > 0}
{#each Object.entries(blocks) as [key, content]}
<div>
<h2 class="text-2xl font-bold text-gray-brand capitalize">{key.replace(/-/g, ' ')}</h2>
<div class="mt-2 h-1 w-12 rounded bg-teal-brand"></div>
<div class="mt-4 prose prose-gray max-w-none text-gray-mid">{@html content}</div>
</div>
{/each}
{:else}
<div class="rounded-xl border border-gray-soft bg-white p-8 text-center">
<svg class="mx-auto h-12 w-12 text-gray-300 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="text-lg font-semibold text-gray-brand">Resources Coming Soon</h3>
<p class="mt-2 text-gray-mid">Foster resource documents will be published here. Contact us for current resources.</p>
</div>
{/if}
</div>
{/if}
</div>
</section>
@@ -0,0 +1,28 @@
import { db } from '$lib/server/db';
import { happyTails, pets, petPhotos } from '$lib/server/schema';
import { eq, desc, and } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
const parentData = await parent();
const stories = await db
.select({
id: happyTails.id,
title: happyTails.title,
story: happyTails.story,
adopterName: happyTails.adopterName,
imageUrl: happyTails.imageUrl,
featured: happyTails.featured,
createdAt: happyTails.createdAt,
petName: pets.name,
petPhotoUrl: petPhotos.url
})
.from(happyTails)
.leftJoin(pets, eq(happyTails.petId, pets.id))
.leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true)))
.where(eq(happyTails.published, true))
.orderBy(desc(happyTails.featured), desc(happyTails.createdAt));
return { ...parentData, stories };
};
@@ -0,0 +1,149 @@
<script lang="ts">
let { data } = $props();
const orgName = $derived(data.orgConfig.orgInfo.name);
let expandedId = $state<number | null>(null);
function toggleStory(id: number) {
expandedId = expandedId === id ? null : id;
}
function getPhoto(story: (typeof data.stories)[0]): string | null {
return story.imageUrl || story.petPhotoUrl || null;
}
function truncate(text: string, length = 150): string {
if (text.length <= length) return text;
return text.slice(0, length).replace(/\s+\S*$/, '') + '...';
}
const featuredStories = $derived(data.stories.filter((s) => s.featured));
const regularStories = $derived(data.stories.filter((s) => !s.featured));
</script>
<svelte:head>
<title>Happy Tails — Success Stories | {orgName}</title>
<meta name="description" content="Read heartwarming success stories from families who adopted their forever friends through {orgName}." />
</svelte:head>
<!-- Hero -->
<section class="bg-gradient-to-br from-teal-dark via-teal-brand to-teal-dark py-16 md:py-20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 class="text-4xl md:text-5xl font-extrabold text-white">Happy Tails</h1>
<p class="mt-4 text-lg text-white/80 max-w-2xl mx-auto">
Every adoption is a success story. Read about the families and pets who found their perfect match.
</p>
</div>
</section>
<section class="py-16 md:py-24">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{#if data.stories.length === 0}
<div class="text-center py-16">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-16 h-16 mx-auto text-gray-300 mb-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
</svg>
<h2 class="text-xl font-bold text-gray-brand mb-2">No stories yet</h2>
<p class="text-gray-500">Check back soon for heartwarming adoption stories!</p>
<a href="/happy-tails/submit" class="mt-6 inline-block text-teal-brand font-semibold hover:text-teal-dark">Share Your Story</a>
</div>
{:else}
<!-- Featured Stories -->
{#if featuredStories.length > 0}
<div class="mb-16">
<h2 class="text-2xl font-bold text-gray-brand mb-8 text-center">Featured Stories</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
{#each featuredStories as story}
<div class="bg-white rounded-2xl overflow-hidden shadow-md hover:shadow-xl transition-shadow duration-300">
{#if getPhoto(story)}
<div class="aspect-[16/9] overflow-hidden">
<img src={getPhoto(story)} alt={story.title} class="w-full h-full object-cover" />
</div>
{:else}
<div class="aspect-[16/9] bg-teal-light flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-16 h-16 text-teal-brand/30">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
</svg>
</div>
{/if}
<div class="p-6 md:p-8">
<h3 class="text-xl font-bold text-gray-brand mb-1">{story.title}</h3>
{#if story.adopterName}
<p class="text-sm text-teal-brand font-medium mb-3">The {story.adopterName} Family</p>
{/if}
{#if story.petName}
<p class="text-xs text-gray-400 mb-3">Pet: {story.petName}</p>
{/if}
{#if expandedId === story.id}
<p class="text-gray-600 leading-relaxed whitespace-pre-line">{story.story}</p>
<button onclick={() => toggleStory(story.id)} class="mt-4 text-sm font-semibold text-teal-brand hover:text-teal-dark transition-colors">
Read less
</button>
{:else}
<p class="text-gray-600 leading-relaxed">{truncate(story.story, 250)}</p>
{#if story.story.length > 250}
<button onclick={() => toggleStory(story.id)} class="mt-4 text-sm font-semibold text-teal-brand hover:text-teal-dark transition-colors">
Read more
</button>
{/if}
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Regular Stories -->
{#if regularStories.length > 0}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{#each regularStories as story}
<div class="bg-white rounded-2xl overflow-hidden shadow-sm hover:shadow-lg transition-shadow duration-300">
{#if getPhoto(story)}
<div class="aspect-[4/3] overflow-hidden">
<img src={getPhoto(story)} alt={story.title} class="w-full h-full object-cover" />
</div>
{:else}
<div class="aspect-[4/3] bg-teal-light flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-12 h-12 text-teal-brand/30">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
</svg>
</div>
{/if}
<div class="p-5">
<h3 class="text-lg font-bold text-gray-brand mb-1">{story.title}</h3>
{#if story.adopterName}
<p class="text-sm text-teal-brand font-medium mb-2">The {story.adopterName} Family</p>
{/if}
{#if story.petName}
<p class="text-xs text-gray-400 mb-2">Pet: {story.petName}</p>
{/if}
{#if expandedId === story.id}
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-line">{story.story}</p>
<button onclick={() => toggleStory(story.id)} class="mt-3 text-sm font-semibold text-teal-brand hover:text-teal-dark transition-colors">
Read less
</button>
{:else}
<p class="text-sm text-gray-600 leading-relaxed">{truncate(story.story)}</p>
{#if story.story.length > 150}
<button onclick={() => toggleStory(story.id)} class="mt-3 text-sm font-semibold text-teal-brand hover:text-teal-dark transition-colors">
Read more
</button>
{/if}
{/if}
</div>
</div>
{/each}
</div>
{/if}
{/if}
<!-- Submit CTA -->
<div class="mt-16 text-center">
<a href="/happy-tails/submit" class="inline-flex items-center gap-2 px-8 py-3 bg-teal-brand text-white rounded-lg font-semibold hover:bg-teal-dark transition">
Share Your Happy Tail
</a>
</div>
</div>
</section>
@@ -0,0 +1,44 @@
import type { Actions, PageServerLoad } from './$types';
import { fail } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { happyTails } from '$lib/server/schema';
import { RateLimiter } from '$lib/server/rate-limit';
const submitLimiter = new RateLimiter(3, 60 * 60 * 1000);
export const load: PageServerLoad = async ({ parent }) => {
const parentData = await parent();
return { ...parentData };
};
export const actions: Actions = {
default: async ({ request, getClientAddress }) => {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? getClientAddress();
if (!submitLimiter.check(ip)) {
return fail(429, { error: 'Too many submissions. Please try again later.' });
}
const fd = await request.formData();
const title = fd.get('title')?.toString().trim();
const story = fd.get('story')?.toString().trim();
const adopterName = fd.get('adopterName')?.toString().trim();
const submittedEmail = fd.get('email')?.toString().trim() || null;
if (!title || !story) {
return fail(400, { error: 'Please fill in all required fields.', title, story, adopterName });
}
await db.insert(happyTails).values({
title,
story,
adopterName: adopterName || null,
submittedBy: adopterName || null,
submittedEmail,
published: false,
featured: false
});
return { success: true };
}
};
@@ -0,0 +1,105 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form } = $props();
const orgName = $derived(data.orgConfig.orgInfo.name);
</script>
<svelte:head>
<title>Share Your Happy Tail | {orgName}</title>
<meta name="description" content="Adopted from {orgName}? Share your happy tail story with us!" />
</svelte:head>
<section class="bg-teal-brand py-16 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h1 class="text-4xl font-bold sm:text-5xl">Share Your Happy Tail</h1>
<p class="mt-4 text-lg text-teal-light/90">
Adopted from {orgName}? We'd love to hear how your new family member is doing!
</p>
</div>
</section>
<section class="bg-warm-bg py-16">
<div class="mx-auto max-w-2xl px-4">
{#if form?.success}
<div class="bg-white rounded-2xl shadow-sm border border-gray-soft p-8 text-center">
<div class="w-16 h-16 mx-auto mb-4 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 class="text-2xl font-bold text-gray-brand">Thank You!</h2>
<p class="mt-2 text-gray-mid">Your story has been submitted and will be reviewed by our team. Once approved, it'll appear on our Happy Tails page!</p>
<a href="/happy-tails" class="mt-6 inline-block text-teal-brand font-semibold hover:text-teal-dark">
&larr; Back to Happy Tails
</a>
</div>
{:else}
<div class="bg-white rounded-2xl shadow-sm border border-gray-soft p-6 sm:p-8">
{#if form?.error}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{form.error}</div>
{/if}
<form method="POST" use:enhance class="space-y-6">
<div>
<label for="adopterName" class="block text-sm font-medium text-gray-700 mb-1">Your Name <span class="text-red-500">*</span></label>
<input
type="text"
id="adopterName"
name="adopterName"
required
value={form?.adopterName ?? ''}
placeholder="Your name or family name"
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-teal-brand/20 focus:border-teal-brand outline-none text-sm"
/>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email <span class="text-gray-400 font-normal">(optional)</span></label>
<input
type="email"
id="email"
name="email"
placeholder="your@email.com"
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-teal-brand/20 focus:border-teal-brand outline-none text-sm"
/>
</div>
<div>
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">Story Title <span class="text-red-500">*</span></label>
<input
type="text"
id="title"
name="title"
required
value={form?.title ?? ''}
placeholder="Our life with Buddy"
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-teal-brand/20 focus:border-teal-brand outline-none text-sm"
/>
</div>
<div>
<label for="story" class="block text-sm font-medium text-gray-700 mb-1">Your Story <span class="text-red-500">*</span></label>
<textarea
id="story"
name="story"
required
rows="8"
placeholder="Tell us about your experience adopting and how your pet is doing..."
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-teal-brand/20 focus:border-teal-brand outline-none text-sm resize-y"
>{form?.story ?? ''}</textarea>
</div>
<button type="submit" class="w-full py-3 rounded-lg bg-teal-brand text-white font-semibold hover:bg-teal-dark transition cursor-pointer">
Submit Your Story
</button>
<p class="text-xs text-gray-400 text-center">
Your story will be reviewed by our team before being published.
</p>
</form>
</div>
{/if}
</div>
</section>
@@ -0,0 +1,8 @@
import { getPageBlocks } from '$lib/server/content';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
const parentData = await parent();
const blocks = await getPageBlocks('how-to-help');
return { ...parentData, blocks };
};
@@ -0,0 +1,165 @@
<script lang="ts">
let { data } = $props();
const orgName = $derived(data.orgConfig.orgInfo.name);
const orgEmail = $derived(data.orgConfig.orgInfo.email);
</script>
<svelte:head>
<title>How to Help | {orgName}</title>
<meta name="description" content="Discover the many ways you can help {orgName} — adopt, foster, volunteer, donate, or become a sponsor." />
</svelte:head>
<!-- Hero -->
<section class="bg-teal-brand py-16 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h1 class="text-4xl font-bold sm:text-5xl">Make a Difference</h1>
<p class="mt-4 text-lg text-teal-light/90">
There are so many ways to support our rescue pets on their journey home.
</p>
</div>
</section>
<section class="bg-warm-bg py-16">
<div class="mx-auto max-w-6xl px-4">
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
<!-- Adopt -->
<div class="flex flex-col rounded-xl border border-gray-soft bg-white shadow-sm transition hover:shadow-md overflow-hidden">
<div class="flex h-14 items-center gap-3 bg-teal-brand px-6">
<svg class="h-7 w-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
<h2 class="text-xl font-bold text-white">Adopt</h2>
</div>
<div class="flex flex-1 flex-col p-6">
<p class="flex-1 text-gray-mid">
Give a rescue pet their forever home. Our adoption process includes an application, meet-and-greet, and a trial period to ensure a great match. All pets come fully vetted, spayed/neutered, and microchipped.
</p>
<div class="mt-6 flex flex-wrap gap-3">
<a href="/pets" class="rounded-lg bg-teal-brand px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-teal-dark">
View Available Pets
</a>
{#if data.orgConfig.applications.adoptionEnabled}
<a href="/apply/adopt" class="rounded-lg border-2 border-teal-brand px-5 py-2.5 text-sm font-semibold text-teal-brand transition hover:bg-teal-light">
Apply to Adopt
</a>
{/if}
</div>
</div>
</div>
<!-- Foster -->
<div class="flex flex-col rounded-xl border border-gray-soft bg-white shadow-sm transition hover:shadow-md overflow-hidden">
<div class="flex h-14 items-center gap-3 bg-teal-brand px-6">
<svg class="h-7 w-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4" />
</svg>
<h2 class="text-xl font-bold text-white">Foster</h2>
</div>
<div class="flex flex-1 flex-col p-6">
<p class="flex-1 text-gray-mid">
Open your home and heart to a pet in need. Fostering saves lives by making room to take in more pets. We provide all supplies, food, and medical care. You provide the love.
</p>
<div class="mt-6">
{#if data.orgConfig.applications.fosterEnabled}
<a href="/apply/foster" class="inline-block rounded-lg bg-teal-brand px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-teal-dark">
Apply to Foster
</a>
{:else}
<a href="/become-a-foster" class="inline-block rounded-lg bg-teal-brand px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-teal-dark">
Learn More
</a>
{/if}
</div>
</div>
</div>
<!-- Volunteer -->
<div class="flex flex-col rounded-xl border border-gray-soft bg-white shadow-sm transition hover:shadow-md overflow-hidden">
<div class="flex h-14 items-center gap-3 bg-teal-brand px-6">
<svg class="h-7 w-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<h2 class="text-xl font-bold text-white">Volunteer</h2>
</div>
<div class="flex flex-1 flex-col p-6">
<p class="flex-1 text-gray-mid">
Lend your time and talents to help our rescue. We need help with events, transport, photography, social media, and more. Every skill set has a place here.
</p>
<div class="mt-6">
{#if data.orgConfig.applications.volunteerEnabled}
<a href="/apply/volunteer" class="inline-block rounded-lg bg-teal-brand px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-teal-dark">
Apply to Volunteer
</a>
{:else}
<a href="/volunteer" class="inline-block rounded-lg bg-teal-brand px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-teal-dark">
Learn More
</a>
{/if}
</div>
</div>
</div>
<!-- Donate -->
<div class="flex flex-col rounded-xl border border-gray-soft bg-white shadow-sm transition hover:shadow-md overflow-hidden">
<div class="flex h-14 items-center gap-3 bg-teal-brand px-6">
<svg class="h-7 w-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h2 class="text-xl font-bold text-white">Donate</h2>
</div>
<div class="flex flex-1 flex-col p-6">
<p class="flex-1 text-gray-mid">
Your financial support directly funds veterinary care, food, and supplies for our pets. Every dollar makes a difference.
</p>
<div class="mt-6">
<a href="/donate" class="inline-block rounded-lg bg-teal-brand px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-teal-dark">
Donate Now
</a>
</div>
</div>
</div>
<!-- Sponsor -->
<div class="flex flex-col rounded-xl border border-gray-soft bg-white shadow-sm transition hover:shadow-md overflow-hidden">
<div class="flex h-14 items-center gap-3 bg-teal-brand px-6">
<svg class="h-7 w-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<h2 class="text-xl font-bold text-white">Sponsor</h2>
</div>
<div class="flex flex-1 flex-col p-6">
<p class="flex-1 text-gray-mid">
Become a business sponsor and support our mission while gaining visibility in the community. We offer multiple sponsorship tiers.
</p>
<div class="mt-6 flex flex-wrap gap-3">
<a href="/sponsors" class="rounded-lg bg-teal-brand px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-teal-dark">
View Sponsors
</a>
{#if orgEmail}
<a href="mailto:{orgEmail}?subject=Sponsorship%20Inquiry" class="rounded-lg border-2 border-teal-brand px-5 py-2.5 text-sm font-semibold text-teal-brand transition hover:bg-teal-light">
Contact Us
</a>
{/if}
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Bottom CTA -->
<section class="bg-teal-brand py-12 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h2 class="text-2xl font-bold">Not Sure Where to Start?</h2>
<p class="mt-3 text-teal-light/90">Reach out and we'll help you find the best way to get involved.</p>
{#if orgEmail}
<a href="mailto:{orgEmail}" class="mt-6 inline-block rounded-lg bg-white px-8 py-3 font-semibold text-teal-brand shadow-lg transition hover:bg-teal-light hover:shadow-xl">
Email Us
</a>
{:else}
<a href="/contact" class="mt-6 inline-block rounded-lg bg-white px-8 py-3 font-semibold text-teal-brand shadow-lg transition hover:bg-teal-light hover:shadow-xl">
Contact Us
</a>
{/if}
</div>
</section>
@@ -0,0 +1,233 @@
<script lang="ts">
let species = $state('dog');
let theme = $state('classic');
let names = $state<string[]>([]);
let loading = $state(false);
let error = $state('');
const themes = [
{ value: 'classic', label: 'Classic' },
{ value: 'food', label: 'Foodie Names' },
{ value: 'disney', label: 'Disney' },
{ value: 'nature', label: 'Nature' },
{ value: 'funny', label: 'Funny & Punny' },
{ value: 'celebrity', label: 'Celebrity' },
{ value: 'random', label: 'Surprise Me!' }
];
const thinkingMessages = [
'Our AI is thinking...',
'Brainstorming names...',
'Consulting the name archives...',
'Sniffing out good names...',
'Almost there...'
];
let thinkingIndex = $state(0);
let thinkingInterval: ReturnType<typeof setInterval> | null = null;
function startThinking() {
thinkingIndex = 0;
thinkingInterval = setInterval(() => {
thinkingIndex = (thinkingIndex + 1) % thinkingMessages.length;
}, 3000);
}
function stopThinking() {
if (thinkingInterval) {
clearInterval(thinkingInterval);
thinkingInterval = null;
}
}
async function generate() {
loading = true;
error = '';
names = [];
startThinking();
try {
// Step 1: Submit job
const submitRes = await fetch('/api/pet-names', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ species, theme, count: 10 })
});
const submitData = await submitRes.json();
if (submitData.error) {
error = submitData.error;
loading = false;
stopThinking();
return;
}
const jobId = submitData.jobId;
// Step 2: Poll for results
const maxAttempts = 60;
let attempts = 0;
while (attempts < maxAttempts) {
await new Promise(r => setTimeout(r, 1500));
attempts++;
const pollRes = await fetch(`/api/pet-names?jobId=${jobId}`);
const pollData = await pollRes.json();
if (pollData.status === 'completed') {
names = pollData.names ?? [];
break;
}
if (pollData.status === 'failed') {
error = pollData.error || 'Name generation failed. Please try again.';
break;
}
}
if (attempts >= maxAttempts && names.length === 0 && !error) {
error = 'Request timed out. The AI might be busy — please try again in a moment.';
}
} catch {
error = 'Something went wrong. Please try again.';
}
loading = false;
stopThinking();
}
function copyName(name: string) {
navigator.clipboard.writeText(name);
}
</script>
<svelte:head>
<title>Pet Name Generator</title>
<meta name="description" content="Need a name for your new rescue pet? Our AI-powered name generator creates creative, themed pet names. Try it!" />
</svelte:head>
<section class="bg-gradient-to-br from-teal-700 via-teal-600 to-teal-700 py-16 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h1 class="text-4xl font-bold sm:text-5xl">Pet Name Generator</h1>
<p class="mt-4 text-lg text-teal-100">
Can't decide on a name? Let our AI help! Powered by local AI — no data leaves our servers.
</p>
</div>
</section>
<section class="bg-gray-50 py-16">
<div class="mx-auto max-w-2xl px-4">
<!-- Controls -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 sm:p-8">
<div class="grid gap-6">
<!-- Species -->
<div>
<label class="block text-sm font-semibold text-gray-800 mb-3">I'm naming a...</label>
<div class="flex gap-3">
<button
onclick={() => species = 'dog'}
class="flex-1 py-3 px-4 rounded-xl text-center font-medium transition cursor-pointer {species === 'dog'
? 'bg-teal-600 text-white shadow-md'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
>
Dog
</button>
<button
onclick={() => species = 'cat'}
class="flex-1 py-3 px-4 rounded-xl text-center font-medium transition cursor-pointer {species === 'cat'
? 'bg-teal-600 text-white shadow-md'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
>
Cat
</button>
</div>
</div>
<!-- Theme -->
<div>
<label class="block text-sm font-semibold text-gray-800 mb-3">Name theme</label>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
{#each themes as t}
<button
onclick={() => theme = t.value}
class="py-2.5 px-3 rounded-lg text-sm font-medium transition cursor-pointer {theme === t.value
? 'bg-teal-600 text-white shadow-md'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
>
{t.label}
</button>
{/each}
</div>
</div>
<!-- Generate Button -->
<button
onclick={generate}
disabled={loading}
class="w-full py-4 rounded-xl bg-teal-600 text-white text-lg font-bold hover:bg-teal-700 disabled:opacity-50 transition shadow-lg hover:shadow-xl cursor-pointer"
>
{#if loading}
<span class="inline-flex items-center gap-2">
<svg class="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{thinkingMessages[thinkingIndex]}
</span>
{:else}
Generate Names!
{/if}
</button>
</div>
</div>
<!-- Error -->
{#if error}
<div class="mt-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
{error}
</div>
{/if}
<!-- Results -->
{#if names.length > 0}
<div class="mt-8">
<h2 class="text-xl font-bold text-gray-800 mb-4 text-center">
Here are your {species} names!
</h2>
<div class="grid grid-cols-2 gap-3">
{#each names as name}
<button
onclick={() => copyName(name)}
class="group bg-white rounded-xl border border-gray-200 p-4 text-left hover:border-teal-500 hover:shadow-md transition cursor-pointer"
title="Click to copy"
>
<span class="text-lg font-semibold text-gray-800 group-hover:text-teal-600">{name}</span>
<span class="block text-xs text-gray-400 mt-1 group-hover:text-teal-500/60">Click to copy</span>
</button>
{/each}
</div>
<p class="text-center text-xs text-gray-400 mt-4">
Generated by AI running locally
</p>
<div class="text-center mt-4">
<button
onclick={generate}
class="text-teal-600 font-semibold hover:text-teal-700 transition cursor-pointer"
>
Generate more &rarr;
</button>
</div>
</div>
{/if}
<!-- Fun note -->
<div class="mt-12 text-center">
<p class="text-gray-500 text-sm">
Looking to adopt? Check out our
<a href="/pets" class="text-teal-600 font-medium hover:text-teal-700">available pets</a>
&mdash; they already have great names!
</p>
</div>
</div>
</section>
+63
View File
@@ -0,0 +1,63 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { pets, petPhotos } from '$lib/server/schema';
import { eq, and, like, count, or, isNotNull, desc } from 'drizzle-orm';
import type { SQL } from 'drizzle-orm';
const PER_PAGE = 12;
export const load: PageServerLoad = async ({ url, parent }) => {
const parentData = await parent();
const q = url.searchParams.get('q')?.trim() || '';
const species = url.searchParams.get('species') || '';
const sex = url.searchParams.get('sex') || '';
const conditions: SQL[] = [eq(pets.status, 'available'), eq(pets.published, true)];
if (q) {
conditions.push(like(pets.name, `%${q}%`));
}
if (species === 'dog' || species === 'cat') {
conditions.push(eq(pets.species, species));
}
if (sex === 'male' || sex === 'female') {
conditions.push(eq(pets.sex, sex));
}
const whereClause = and(...conditions)!;
const [{ total }] = await db
.select({ total: count() })
.from(pets)
.leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true)))
.where(and(whereClause, or(eq(pets.featured, true), isNotNull(petPhotos.url))));
const rows = await db
.select({
id: pets.id,
name: pets.name,
slug: pets.slug,
species: pets.species,
breed: pets.breed,
age: pets.age,
sex: pets.sex,
primaryPhoto: petPhotos.url
})
.from(pets)
.leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true)))
.where(and(whereClause, or(eq(pets.featured, true), isNotNull(petPhotos.url))))
.orderBy(desc(pets.featured), desc(pets.createdAt))
.limit(PER_PAGE)
.offset(0);
return {
...parentData,
pets: rows,
total,
hasMore: rows.length < total,
filters: { q, species, sex }
};
};
+247
View File
@@ -0,0 +1,247 @@
<script lang="ts">
import PetCard from '$lib/components/public/PetCard.svelte';
import { goto } from '$app/navigation';
let { data } = $props();
let q = $state('');
let species = $state('');
let sex = $state('');
type Pet = (typeof data.pets)[number];
let allPets = $state<Pet[]>([]);
let hasMore = $state(true);
let loading = $state(false);
let offset = $state(0);
let totalCount = $state(0);
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
const LIMIT = 12;
$effect(() => {
const f = data.filters;
q = f.q;
species = f.species;
sex = f.sex;
allPets = [...data.pets];
hasMore = data.hasMore;
offset = data.pets.length;
totalCount = data.total;
});
function handleScroll() {
if (loading || !hasMore) return;
const scrollBottom = window.innerHeight + window.scrollY;
const docHeight = document.documentElement.scrollHeight;
if (scrollBottom >= docHeight - 600) {
loadMore();
}
}
$effect(() => {
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
});
async function loadMore() {
if (loading || !hasMore) return;
loading = true;
try {
const params = new URLSearchParams();
params.set('offset', String(offset));
params.set('limit', String(LIMIT));
if (q) params.set('q', q);
if (species) params.set('species', species);
if (sex) params.set('sex', sex);
const res = await fetch(`/api/pets?${params.toString()}`);
if (!res.ok) throw new Error('Failed to fetch pets');
const json = await res.json();
allPets = [...allPets, ...json.pets];
hasMore = json.hasMore;
offset = allPets.length;
totalCount = json.total;
} catch (err) {
console.error('Error loading more pets:', err);
} finally {
loading = false;
}
}
function buildUrl(overrides: Record<string, string> = {}) {
const params = new URLSearchParams();
const vals = {
q: overrides.q ?? q,
species: overrides.species ?? species,
sex: overrides.sex ?? sex
};
for (const [key, val] of Object.entries(vals)) {
if (val) params.set(key, val);
}
const qs = params.toString();
return `/pets${qs ? `?${qs}` : ''}`;
}
function applyFilters() {
goto(buildUrl());
}
function clearFilters() {
q = '';
species = '';
sex = '';
goto('/pets');
}
function handleSearch(e: SubmitEvent) {
e.preventDefault();
applyFilters();
}
function onFilterChange() {
applyFilters();
}
function onSearchInput() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const params = new URLSearchParams();
params.set('offset', '0');
params.set('limit', String(LIMIT));
if (q) params.set('q', q);
if (species) params.set('species', species);
if (sex) params.set('sex', sex);
fetch(`/api/pets?${params.toString()}`)
.then((res) => res.json())
.then((json) => {
allPets = json.pets;
hasMore = json.hasMore;
offset = json.pets.length;
totalCount = json.total;
history.replaceState({}, '', buildUrl());
})
.catch(() => {});
}, 400);
}
const hasActiveFilters = $derived(
data.filters.q || data.filters.species || data.filters.sex
);
const orgName = $derived(data.orgConfig.orgInfo.name);
</script>
<svelte:head>
<title>Available Pets | {orgName}</title>
<meta name="description" content="Browse adoptable dogs and cats at {orgName}. Find your perfect companion today." />
<meta property="og:type" content="website" />
<meta property="og:title" content="Available Pets | {orgName}" />
<meta property="og:description" content="Browse adoptable dogs and cats at {orgName}. Find your perfect companion today." />
<meta property="og:site_name" content={orgName} />
<meta name="twitter:card" content="summary_large_image" />
</svelte:head>
<!-- Hero -->
<section class="bg-gradient-to-br from-teal-brand to-teal-dark py-12 sm:py-16">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 text-center">
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-extrabold text-white">Available Pets</h1>
<p class="mt-3 text-lg text-teal-light max-w-2xl mx-auto">
Every one of these pets is looking for a loving forever home. Could you be the one?
</p>
</div>
</section>
<!-- Search & Filters -->
<section class="bg-white border-b border-gray-soft shadow-sm sticky top-0 z-20">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-4">
<form onsubmit={handleSearch} class="flex flex-col sm:flex-row gap-3">
<div class="relative flex-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none">
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11ZM2 9a7 7 0 1 1 12.452 4.391l3.328 3.329a.75.75 0 1 1-1.06 1.06l-3.329-3.328A7 7 0 0 1 2 9Z" clip-rule="evenodd" />
</svg>
<input
type="text"
bind:value={q}
oninput={onSearchInput}
placeholder="Search by name..."
class="w-full pl-10 pr-4 py-2.5 rounded-lg border border-gray-soft bg-warm-bg text-gray-brand placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-brand/40 focus:border-teal-brand transition"
/>
</div>
<div class="flex flex-wrap gap-3">
<select bind:value={species} onchange={onFilterChange} class="px-4 py-2.5 rounded-lg border border-gray-soft bg-warm-bg text-gray-brand focus:outline-none focus:ring-2 focus:ring-teal-brand/40 focus:border-teal-brand transition">
<option value="">All Species</option>
<option value="dog">Dogs</option>
<option value="cat">Cats</option>
</select>
<select bind:value={sex} onchange={onFilterChange} class="px-4 py-2.5 rounded-lg border border-gray-soft bg-warm-bg text-gray-brand focus:outline-none focus:ring-2 focus:ring-teal-brand/40 focus:border-teal-brand transition">
<option value="">Any Sex</option>
<option value="male">Male</option>
<option value="female">Female</option>
</select>
<button type="submit" class="px-6 py-2.5 bg-teal-brand text-white rounded-lg font-semibold hover:bg-teal-dark transition focus:outline-none focus:ring-2 focus:ring-teal-brand/40">
Search
</button>
</div>
</form>
{#if hasActiveFilters}
<div class="mt-3 flex items-center gap-2 text-sm text-gray-mid">
<span>Showing {totalCount} result{totalCount === 1 ? '' : 's'}</span>
<span class="text-gray-soft">|</span>
<button type="button" onclick={clearFilters} class="text-teal-brand hover:text-teal-dark font-medium underline underline-offset-2 transition">
Clear All Filters
</button>
</div>
{/if}
</div>
</section>
<!-- Pet Grid -->
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-10">
{#if allPets.length === 0 && !loading}
<div class="text-center py-20">
<div class="mx-auto w-24 h-24 rounded-full bg-teal-light flex items-center justify-center mb-6">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-12 h-12 text-teal-brand">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.182 16.318A4.486 4.486 0 0 0 12.016 15a4.486 4.486 0 0 0-3.198 1.318M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Z" />
</svg>
</div>
<h2 class="text-2xl font-bold text-gray-brand mb-2">No pets found</h2>
<p class="text-gray-mid mb-6 max-w-md mx-auto">
No pets match your current criteria. Try adjusting your filters or check back soon — new pets arrive regularly!
</p>
<a href="/pets" class="inline-flex items-center gap-2 px-6 py-3 bg-teal-brand text-white rounded-lg font-semibold hover:bg-teal-dark transition">
Clear Filters
</a>
</div>
{:else}
<div class="columns-1 sm:columns-2 lg:columns-3 xl:columns-4 gap-5 space-y-5">
{#each allPets as pet (pet.id)}
<div class="break-inside-avoid">
<PetCard {pet} />
</div>
{/each}
</div>
<div class="h-4"></div>
{#if loading}
<div class="flex items-center justify-center py-10 gap-3">
<svg class="animate-spin h-6 w-6 text-teal-brand" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-gray-mid text-sm font-medium">Loading more pets...</span>
</div>
{/if}
{#if !hasMore && allPets.length > 0 && !loading}
<p class="text-center text-gray-mid text-sm py-8">That's everyone!</p>
{/if}
{/if}
</section>
@@ -0,0 +1,79 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { pets, petPhotos } from '$lib/server/schema';
import { eq, and, ne, asc } from 'drizzle-orm';
import { error } from '@sveltejs/kit';
import { trackPageView } from '$lib/server/track';
export const load: PageServerLoad = async ({ params, request, getClientAddress, url, parent }) => {
const parentData = await parent();
const [pet] = await db
.select()
.from(pets)
.where(eq(pets.slug, params.slug))
.limit(1);
if (!pet) {
throw error(404, 'Pet not found');
}
// Track page view (fire and forget)
const ipAddress =
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
getClientAddress();
trackPageView({
path: `/pets/${params.slug}`,
petId: pet.id,
ipAddress,
userAgent: request.headers.get('user-agent') ?? undefined,
referrer: request.headers.get('referer') ?? undefined
});
const photos = await db
.select()
.from(petPhotos)
.where(eq(petPhotos.petId, pet.id))
.orderBy(asc(petPhotos.sortOrder));
const primaryPhoto = photos.find((p) => p.isPrimary) ?? photos[0] ?? null;
// Related pets: same species, available, not this pet, limit 4
const relatedRows = await db
.select({
id: pets.id,
name: pets.name,
slug: pets.slug,
species: pets.species,
breed: pets.breed,
age: pets.age,
sex: pets.sex,
primaryPhoto: petPhotos.url
})
.from(pets)
.leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true)))
.where(
and(
eq(pets.species, pet.species),
eq(pets.status, 'available'),
eq(pets.published, true),
ne(pets.id, pet.id)
)
)
.orderBy(pets.name)
.limit(4);
const origin = url.origin;
const pageUrl = `${origin}/pets/${params.slug}`;
const ogImageUrl = primaryPhoto ? `${origin}${primaryPhoto.url}` : null;
return {
...parentData,
pet,
photos,
primaryPhoto,
relatedPets: relatedRows,
pageUrl,
ogImageUrl
};
};
@@ -0,0 +1,426 @@
<script lang="ts">
import PetCard from '$lib/components/public/PetCard.svelte';
let { data } = $props();
let selectedPhotoIndex = $state(0);
let lightboxOpen = $state(false);
let lightboxIndex = $state(0);
let touchStartX = $state(0);
const pet = $derived(data.pet);
const photos = $derived(data.photos);
const relatedPets = $derived(data.relatedPets);
const pageUrl = $derived(data.pageUrl);
const ogImageUrl = $derived(data.ogImageUrl);
const orgName = $derived(data.orgConfig.orgInfo.name);
let linkCopied = $state(false);
let copyTimeout: ReturnType<typeof setTimeout> | undefined;
function copyLink() {
navigator.clipboard.writeText(pageUrl).then(() => {
linkCopied = true;
clearTimeout(copyTimeout);
copyTimeout = setTimeout(() => { linkCopied = false; }, 2000);
});
}
function openLightbox(index: number) {
if (photos.length === 0) return;
lightboxIndex = index;
lightboxOpen = true;
}
function closeLightbox() {
lightboxOpen = false;
}
function lightboxPrev() {
lightboxIndex = (lightboxIndex - 1 + photos.length) % photos.length;
}
function lightboxNext() {
lightboxIndex = (lightboxIndex + 1) % photos.length;
}
function handleLightboxKey(e: KeyboardEvent) {
if (!lightboxOpen) return;
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') lightboxPrev();
if (e.key === 'ArrowRight') lightboxNext();
}
function handleTouchStart(e: TouchEvent) {
touchStartX = e.touches[0].clientX;
}
function handleTouchEnd(e: TouchEvent) {
const diff = touchStartX - e.changedTouches[0].clientX;
if (Math.abs(diff) > 50) {
if (diff > 0) lightboxNext();
else lightboxPrev();
}
}
$effect(() => {
data.pet.id;
selectedPhotoIndex = 0;
});
const currentPhoto = $derived(photos.length > 0 ? photos[selectedPhotoIndex] : null);
const statusLabels: Record<string, string> = {
available: 'Available for Adoption',
adopted: 'Adopted',
foster: 'In Foster Care',
medical_hold: 'Medical Hold',
behavioral_hold: 'Behavioral Hold',
pending: 'Adoption Pending',
trial: 'Trial Adoption',
hospice: 'Hospice Care',
nursing: 'Nursing',
whelping: 'Whelping',
deceased: 'Deceased',
transferred: 'Transferred'
};
const statusColors: Record<string, string> = {
available: 'bg-green-100 text-green-800',
adopted: 'bg-blue-100 text-blue-800',
pending: 'bg-amber-100 text-amber-800',
foster: 'bg-purple-100 text-purple-800'
};
function formatDate(dateStr: string | Date | null): string {
if (!dateStr) return '';
const d = dateStr instanceof Date ? dateStr : new Date(dateStr);
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
}
const ogDescription = $derived(
`${pet.breed ? pet.breed + ' \u00b7 ' : ''}${pet.age || pet.species} \u00b7 Available for adoption`
);
</script>
<svelte:head>
<title>{pet.name} | {orgName}</title>
<meta name="description" content={ogDescription} />
<meta property="og:type" content="article" />
<meta property="og:title" content="{pet.name} — {orgName}" />
<meta property="og:description" content={ogDescription} />
{#if ogImageUrl}
<meta property="og:image" content={ogImageUrl} />
{/if}
<meta property="og:url" content={pageUrl} />
<meta property="og:site_name" content={orgName} />
<meta name="twitter:card" content="summary_large_image" />
<link rel="canonical" href={data.pageUrl} />
</svelte:head>
<!-- Breadcrumb -->
<div class="bg-white border-b border-gray-soft">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-3">
<nav class="flex items-center gap-2 text-sm text-gray-mid">
<a href="/" class="hover:text-teal-brand transition">Home</a>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4 text-gray-soft">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 0 1 .02-1.06L11.168 10 7.23 6.29a.75.75 0 1 1 1.04-1.08l4.5 4.25a.75.75 0 0 1 0 1.08l-4.5 4.25a.75.75 0 0 1-1.06-.02Z" clip-rule="evenodd" />
</svg>
<a href="/pets" class="hover:text-teal-brand transition">Available Pets</a>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4 text-gray-soft">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 0 1 .02-1.06L11.168 10 7.23 6.29a.75.75 0 1 1 1.04-1.08l4.5 4.25a.75.75 0 0 1 0 1.08l-4.5 4.25a.75.75 0 0 1-1.06-.02Z" clip-rule="evenodd" />
</svg>
<span class="text-gray-brand font-medium">{pet.name}</span>
</nav>
</div>
</div>
<article class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
<!-- Photo Gallery -->
<div>
<div class="aspect-[4/3] rounded-2xl overflow-hidden bg-teal-light shadow-md {currentPhoto ? 'cursor-zoom-in' : ''}">
{#if currentPhoto}
<img
src={currentPhoto.url}
alt="{pet.name} - Photo {selectedPhotoIndex + 1}"
class="w-full h-full object-cover"
onclick={() => openLightbox(selectedPhotoIndex)}
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') openLightbox(selectedPhotoIndex); }}
/>
{:else}
<div class="w-full h-full flex flex-col items-center justify-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="w-28 h-28 text-teal-brand/25">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3.744c-.066.028-.132.058-.195.092a7.486 7.486 0 0 0-3.43 5.13 4.323 4.323 0 0 0 1.058 3.79 3.724 3.724 0 0 0 1.14.865c.28.14.437.45.372.756a6.04 6.04 0 0 0-.14.871c-.036.532.04 1.108.288 1.632.249.524.671.964 1.247 1.202.554.23 1.11.247 1.619.126a3.25 3.25 0 0 0 .825-.347c.214-.13.49-.127.7.01a5.015 5.015 0 0 0 2.766.827 5.015 5.015 0 0 0 2.766-.828c.21-.136.486-.14.7-.009.244.151.53.268.825.347.51.121 1.065.104 1.619-.126.576-.238.998-.678 1.247-1.202.248-.524.324-1.1.288-1.632a6.04 6.04 0 0 0-.14-.87c-.065-.307.092-.617.372-.757a3.72 3.72 0 0 0 1.14-.865 4.323 4.323 0 0 0 1.058-3.79 7.486 7.486 0 0 0-3.43-5.13 2.258 2.258 0 0 0-.195-.092" />
</svg>
<p class="text-teal-brand/40 text-sm font-medium">Photo coming soon</p>
</div>
{/if}
</div>
{#if photos.length > 1}
<div class="mt-4 flex gap-3 overflow-x-auto pb-2">
{#each photos as photo, i}
<button
type="button"
onclick={() => (selectedPhotoIndex = i)}
class="flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 transition-all {i === selectedPhotoIndex ? 'border-teal-brand ring-2 ring-teal-brand/30 shadow-md' : 'border-transparent opacity-70 hover:opacity-100'}"
>
<img src={photo.url} alt="{pet.name} thumbnail {i + 1}" class="w-full h-full object-cover" />
</button>
{/each}
</div>
{/if}
</div>
<!-- Pet Info -->
<div>
<div class="flex flex-wrap items-center gap-3 mb-1">
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold uppercase tracking-wide {statusColors[pet.status] ?? 'bg-gray-100 text-gray-700'}">
{statusLabels[pet.status] ?? pet.status}
</span>
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold text-gray-brand mt-2">{pet.name}</h1>
{#if pet.breed}
<p class="text-lg text-gray-mid mt-1">{pet.breed}</p>
{/if}
<!-- Share buttons -->
<div class="mt-4 flex items-center gap-2">
<span class="text-sm text-gray-500">Share:</span>
<a
href="https://www.facebook.com/sharer/sharer.php?u={encodeURIComponent(pageUrl)}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-[#1877F2] text-white hover:bg-[#1565C0] transition"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
</svg>
Share
</a>
<button
type="button"
onclick={copyLink}
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-gray-soft text-gray-600 hover:bg-gray-50 transition cursor-pointer"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
{linkCopied ? 'Copied!' : 'Copy Link'}
</button>
</div>
<!-- Quick stats -->
<div class="mt-6 grid grid-cols-2 sm:grid-cols-3 gap-4">
{#if pet.species}
<div class="bg-warm-bg rounded-xl p-4 text-center">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-mid mb-1">Species</p>
<p class="text-lg font-bold text-gray-brand capitalize">{pet.species}</p>
</div>
{/if}
{#if pet.age}
<div class="bg-warm-bg rounded-xl p-4 text-center">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-mid mb-1">Age</p>
<p class="text-lg font-bold text-gray-brand">{pet.age}</p>
</div>
{/if}
{#if pet.sex && pet.sex !== 'unknown'}
<div class="bg-warm-bg rounded-xl p-4 text-center">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-mid mb-1">Sex</p>
<p class="text-lg font-bold text-gray-brand capitalize">{pet.sex}</p>
</div>
{/if}
{#if pet.weight}
<div class="bg-warm-bg rounded-xl p-4 text-center">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-mid mb-1">Weight</p>
<p class="text-lg font-bold text-gray-brand">{pet.weight}</p>
</div>
{/if}
</div>
<!-- Compatibility -->
{#if pet.goodWithDogs !== null || pet.goodWithCats !== null || pet.goodWithKids !== null}
<div class="mt-6 flex flex-wrap gap-2">
{#if pet.goodWithDogs === true}
<span class="px-3 py-1 rounded-full bg-green-100 text-green-800 text-xs font-semibold">Good with dogs</span>
{:else if pet.goodWithDogs === false}
<span class="px-3 py-1 rounded-full bg-red-100 text-red-800 text-xs font-semibold">Not good with dogs</span>
{/if}
{#if pet.goodWithCats === true}
<span class="px-3 py-1 rounded-full bg-green-100 text-green-800 text-xs font-semibold">Good with cats</span>
{:else if pet.goodWithCats === false}
<span class="px-3 py-1 rounded-full bg-red-100 text-red-800 text-xs font-semibold">Not good with cats</span>
{/if}
{#if pet.goodWithKids === true}
<span class="px-3 py-1 rounded-full bg-green-100 text-green-800 text-xs font-semibold">Good with kids</span>
{:else if pet.goodWithKids === false}
<span class="px-3 py-1 rounded-full bg-red-100 text-red-800 text-xs font-semibold">Not good with kids</span>
{/if}
{#if pet.houseTrained}
<span class="px-3 py-1 rounded-full bg-blue-100 text-blue-800 text-xs font-semibold">House trained</span>
{/if}
{#if pet.spayedNeutered}
<span class="px-3 py-1 rounded-full bg-blue-100 text-blue-800 text-xs font-semibold">Spayed/Neutered</span>
{/if}
{#if pet.microchipped}
<span class="px-3 py-1 rounded-full bg-blue-100 text-blue-800 text-xs font-semibold">Microchipped</span>
{/if}
</div>
{/if}
<!-- Description -->
{#if pet.description}
<div class="mt-8">
<h2 class="text-xl font-bold text-gray-brand mb-3">About {pet.name}</h2>
<div class="prose prose-gray max-w-none text-gray-mid leading-relaxed whitespace-pre-line">
{pet.description}
</div>
</div>
{/if}
<!-- CTA -->
{#if pet.status === 'available'}
<div class="mt-8">
<a
href="/apply/adopt?pet={pet.slug}"
class="inline-flex items-center gap-2 px-8 py-4 bg-teal-brand text-white text-lg font-bold rounded-xl hover:bg-teal-dark shadow-lg hover:shadow-xl transition-all duration-200 focus:outline-none focus:ring-4 focus:ring-teal-brand/30"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" />
</svg>
Apply to Adopt {pet.name}
</a>
</div>
{:else if pet.status === 'pending'}
<div class="mt-8 p-4 bg-amber-50 border border-amber-200 rounded-xl">
<p class="text-amber-800 font-semibold">
An adoption application for {pet.name} is currently being reviewed. You may still
<a href="/apply/adopt?pet={pet.slug}" class="underline hover:text-amber-900">submit an application</a>
to be considered.
</p>
</div>
{/if}
{#if pet.adoptionFee}
<p class="mt-3 text-sm text-gray-mid">
Adoption fee: <span class="font-semibold text-gray-brand">${(pet.adoptionFee / 100).toFixed(2)}</span>
</p>
{/if}
</div>
</div>
<!-- Details Card -->
{#if pet.intakeDate || pet.microchipId || pet.birthday}
<div class="mt-12 bg-white rounded-2xl shadow-sm border border-gray-soft p-6 sm:p-8">
<h2 class="text-xl font-bold text-gray-brand mb-4">Details</h2>
<dl class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-4">
{#if pet.intakeDate}
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-mid">Intake Date</dt>
<dd class="mt-1 text-gray-brand font-medium">{formatDate(pet.intakeDate)}</dd>
</div>
{/if}
{#if pet.birthday}
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-mid">Date of Birth</dt>
<dd class="mt-1 text-gray-brand font-medium">{formatDate(pet.birthday)}</dd>
</div>
{/if}
{#if pet.microchipId}
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-mid">Microchip</dt>
<dd class="mt-1 text-gray-brand font-medium">{pet.microchipId}</dd>
</div>
{/if}
</dl>
</div>
{/if}
<!-- Medical Notes -->
{#if pet.medicalNotes}
<div class="mt-8 bg-blue-50 border border-blue-200 rounded-2xl p-6">
<h2 class="text-lg font-bold text-blue-900 mb-2">Medical Notes</h2>
<p class="text-blue-800 text-sm leading-relaxed whitespace-pre-line">{pet.medicalNotes}</p>
</div>
{/if}
<!-- Related Pets -->
{#if relatedPets.length > 0}
<section class="mt-16">
<h2 class="text-2xl sm:text-3xl font-extrabold text-gray-brand mb-6 text-center">You Might Also Like</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{#each relatedPets as rp (rp.id)}
<PetCard pet={rp} />
{/each}
</div>
</section>
{/if}
</article>
<!-- Lightbox -->
{#if lightboxOpen && photos.length > 0}
<div
class="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
onclick={closeLightbox}
onkeydown={handleLightboxKey}
ontouchstart={handleTouchStart}
ontouchend={handleTouchEnd}
role="dialog"
aria-label="Photo lightbox"
tabindex="-1"
>
<button onclick={closeLightbox} class="absolute top-4 right-4 z-10 p-2 rounded-full bg-black/50 text-white hover:bg-black/70 transition cursor-pointer" aria-label="Close lightbox">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div class="absolute top-4 left-4 z-10 px-3 py-1.5 rounded-full bg-black/50 text-white text-sm font-medium">
{lightboxIndex + 1} / {photos.length}
</div>
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 text-center">
<p class="text-white font-bold text-lg drop-shadow-lg">{pet.name}</p>
<p class="text-white/70 text-sm drop-shadow-lg">
{pet.breed ?? ''}{pet.breed && pet.sex ? ' · ' : ''}{pet.sex && pet.sex !== 'unknown' ? pet.sex.charAt(0).toUpperCase() + pet.sex.slice(1) : ''}
</p>
</div>
{#if photos.length > 1}
<button onclick={(e) => { e.stopPropagation(); lightboxPrev(); }} class="absolute left-2 sm:left-6 top-1/2 -translate-y-1/2 z-10 p-3 rounded-full bg-black/50 text-white hover:bg-black/70 transition cursor-pointer" aria-label="Previous photo">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<button onclick={(e) => { e.stopPropagation(); lightboxNext(); }} class="absolute right-2 sm:right-6 top-1/2 -translate-y-1/2 z-10 p-3 rounded-full bg-black/50 text-white hover:bg-black/70 transition cursor-pointer" aria-label="Next photo">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
{/if}
<img
src={photos[lightboxIndex].url}
alt="{pet.name} - Photo {lightboxIndex + 1}"
class="max-h-[85vh] max-w-[90vw] object-contain rounded-lg shadow-2xl"
onclick={(e) => e.stopPropagation()}
/>
{#if photos.length > 1}
<div class="absolute bottom-16 left-1/2 -translate-x-1/2 z-10 flex gap-2 px-4 py-2 rounded-full bg-black/50 overflow-x-auto max-w-[80vw]">
{#each photos as photo, i}
<button onclick={(e) => { e.stopPropagation(); lightboxIndex = i; }} class="flex-shrink-0 w-12 h-12 rounded-lg overflow-hidden border-2 transition {i === lightboxIndex ? 'border-white' : 'border-transparent opacity-50 hover:opacity-80'} cursor-pointer">
<img src={photo.url} alt="" class="w-full h-full object-cover" />
</button>
{/each}
</div>
{/if}
</div>
{/if}
<svelte:window onkeydown={handleLightboxKey} />
@@ -0,0 +1,22 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { sponsors } from '$lib/server/schema';
import { eq, asc } from 'drizzle-orm';
export const load: PageServerLoad = async ({ parent }) => {
const parentData = await parent();
const allSponsors = await db
.select()
.from(sponsors)
.where(eq(sponsors.active, true))
.orderBy(asc(sponsors.sortOrder));
const grouped = {
top_dog: allSponsors.filter((s) => s.tier === 'top_dog'),
wagging_tails: allSponsors.filter((s) => s.tier === 'wagging_tails'),
wet_noses: allSponsors.filter((s) => s.tier === 'wet_noses')
};
return { ...parentData, sponsorsByTier: grouped };
};
+183
View File
@@ -0,0 +1,183 @@
<script lang="ts">
let { data } = $props();
const orgName = $derived(data.orgConfig.orgInfo.name);
const orgEmail = $derived(data.orgConfig.orgInfo.email);
const tierConfig = {
top_dog: {
label: 'Top Tier',
description: 'Our highest level of sponsors whose generosity makes the biggest impact.',
logoSize: 'h-40 w-auto max-w-[360px]',
gridCols: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
cardPadding: 'p-8'
},
wagging_tails: {
label: 'Premier Sponsors',
description: 'Generous sponsors making a significant difference for our animals.',
logoSize: 'h-32 w-auto max-w-[300px]',
gridCols: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
cardPadding: 'p-6'
},
wet_noses: {
label: 'Supporting Sponsors',
description: 'Community supporters helping us care for our rescue animals.',
logoSize: 'h-24 w-auto max-w-[240px]',
gridCols: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4',
cardPadding: 'p-6'
}
} as const;
</script>
<svelte:head>
<title>Our Sponsors | {orgName}</title>
<meta name="description" content="Meet the generous sponsors who support {orgName} and help us save animals in need." />
</svelte:head>
<!-- Hero -->
<section class="bg-teal-brand py-16 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h1 class="text-4xl font-bold sm:text-5xl">Our Sponsors</h1>
<p class="mt-4 text-lg text-teal-light/90">
We're grateful for the businesses and organizations that support our mission.
</p>
</div>
</section>
<!-- Sponsor Tiers -->
<section class="bg-warm-bg py-16">
<div class="mx-auto max-w-6xl px-4 space-y-16">
{#each Object.entries(tierConfig) as [tier, config]}
{@const tierSponsors = data.sponsorsByTier[tier as keyof typeof data.sponsorsByTier]}
{#if tierSponsors && tierSponsors.length > 0}
<div>
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-brand">{config.label}</h2>
<div class="mx-auto mt-3 h-1 w-16 rounded bg-teal-brand"></div>
<p class="mt-3 text-gray-mid">{config.description}</p>
</div>
<div class="mt-8 grid {config.gridCols} gap-6">
{#each tierSponsors as sponsor}
<a
href={sponsor.websiteUrl || '#'}
target={sponsor.websiteUrl ? '_blank' : undefined}
rel={sponsor.websiteUrl ? 'noopener noreferrer' : undefined}
class="flex flex-col items-center justify-center rounded-xl border border-gray-soft bg-white shadow-sm transition hover:shadow-md {config.cardPadding}"
>
{#if sponsor.logoUrl}
<img src={sponsor.logoUrl} alt={sponsor.name} class="{config.logoSize} object-contain" />
<span class="mt-3 text-sm font-medium text-gray-mid">{sponsor.name}</span>
{:else}
<span class="text-lg font-semibold text-gray-brand">{sponsor.name}</span>
{/if}
</a>
{/each}
</div>
</div>
{/if}
{/each}
{#if !data.sponsorsByTier.top_dog?.length && !data.sponsorsByTier.wagging_tails?.length && !data.sponsorsByTier.wet_noses?.length}
<div class="py-12 text-center">
<p class="text-lg text-gray-mid">
We're looking for sponsors to support our mission. Interested?
</p>
</div>
{/if}
</div>
</section>
<!-- Become a Sponsor -->
<section class="bg-white py-16">
<div class="mx-auto max-w-6xl px-4">
<h2 class="text-center text-3xl font-bold text-gray-brand">Become a Sponsor</h2>
<div class="mx-auto mt-3 h-1 w-16 rounded bg-teal-brand"></div>
<p class="mx-auto mt-4 max-w-2xl text-center text-gray-mid">
Support our rescue while gaining visibility in the community. Your sponsorship directly funds the care of animals in our program.
</p>
<div class="mt-12 grid gap-8 md:grid-cols-3">
<div class="flex flex-col rounded-xl border-2 border-teal-brand bg-warm-bg p-8 text-center shadow-sm">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-teal-brand">
<svg class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
</div>
<h3 class="mt-4 text-xl font-bold text-gray-brand">Top Tier</h3>
<ul class="mt-4 flex-1 space-y-2 text-left text-sm text-gray-mid">
<li class="flex items-start gap-2">
<svg class="mt-0.5 h-4 w-4 shrink-0 text-teal-brand" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
Proud Sponsor certificate for your business
</li>
<li class="flex items-start gap-2">
<svg class="mt-0.5 h-4 w-4 shrink-0 text-teal-brand" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
Logo on our sponsor page
</li>
<li class="flex items-start gap-2">
<svg class="mt-0.5 h-4 w-4 shrink-0 text-teal-brand" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
Social media shout-out
</li>
<li class="flex items-start gap-2">
<svg class="mt-0.5 h-4 w-4 shrink-0 text-teal-brand" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
Logo displayed at events
</li>
</ul>
</div>
<div class="flex flex-col rounded-xl border border-gray-soft bg-warm-bg p-8 text-center shadow-sm">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-teal-light">
<svg class="h-6 w-6 text-teal-brand" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</div>
<h3 class="mt-4 text-xl font-bold text-gray-brand">Premier</h3>
<ul class="mt-4 flex-1 space-y-2 text-left text-sm text-gray-mid">
<li class="flex items-start gap-2">
<svg class="mt-0.5 h-4 w-4 shrink-0 text-teal-brand" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
Proud Sponsor certificate for your business
</li>
<li class="flex items-start gap-2">
<svg class="mt-0.5 h-4 w-4 shrink-0 text-teal-brand" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
Logo on our sponsor page
</li>
<li class="flex items-start gap-2">
<svg class="mt-0.5 h-4 w-4 shrink-0 text-teal-brand" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
Social media shout-out
</li>
</ul>
</div>
<div class="flex flex-col rounded-xl border border-gray-soft bg-warm-bg p-8 text-center shadow-sm">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-teal-light">
<svg class="h-6 w-6 text-teal-brand" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" />
</svg>
</div>
<h3 class="mt-4 text-xl font-bold text-gray-brand">Supporting</h3>
<ul class="mt-4 flex-1 space-y-2 text-left text-sm text-gray-mid">
<li class="flex items-start gap-2">
<svg class="mt-0.5 h-4 w-4 shrink-0 text-teal-brand" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
Sponsor recognition at your business
</li>
<li class="flex items-start gap-2">
<svg class="mt-0.5 h-4 w-4 shrink-0 text-teal-brand" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
Logo on our sponsor page
</li>
</ul>
</div>
</div>
<div class="mt-12 text-center">
<p class="text-gray-mid">Interested in becoming a sponsor? We'd love to hear from you.</p>
{#if orgEmail}
<a href="mailto:{orgEmail}?subject=Sponsorship%20Inquiry" class="mt-4 inline-block rounded-lg bg-teal-brand px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-teal-dark hover:shadow-xl">
Contact Us About Sponsorship
</a>
{:else}
<a href="/contact" class="mt-4 inline-block rounded-lg bg-teal-brand px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-teal-dark hover:shadow-xl">
Contact Us About Sponsorship
</a>
{/if}
</div>
</div>
</section>
@@ -0,0 +1,8 @@
import { getPageBlocks } from '$lib/server/content';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
const parentData = await parent();
const blocks = await getPageBlocks('volunteer');
return { ...parentData, blocks };
};
@@ -0,0 +1,82 @@
<script lang="ts">
let { data } = $props();
const orgName = $derived(data.orgConfig.orgInfo.name);
</script>
<svelte:head>
<title>Volunteer Opportunities | {orgName}</title>
<meta name="description" content="Volunteer with {orgName}. Help with events, transport, photography, social media, and more." />
</svelte:head>
<!-- Hero -->
<section class="bg-teal-brand py-16 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h1 class="text-4xl font-bold sm:text-5xl">Volunteer Opportunities</h1>
<p class="mt-4 text-lg text-teal-light/90">Every skill set has a place here. Find out how you can help.</p>
</div>
</section>
<!-- Intro -->
<section class="bg-warm-bg py-16">
<div class="mx-auto max-w-3xl px-4">
<p class="text-lg leading-relaxed text-gray-mid">
{orgName} runs entirely on volunteers, and we could not do what we do without the incredible people who donate their time and talents. Whether you have a few hours a month or want to get deeply involved, there is a role for you.
</p>
</div>
</section>
<!-- Volunteer Roles -->
<section class="bg-white py-16">
<div class="mx-auto max-w-6xl px-4">
<h2 class="text-center text-3xl font-bold text-gray-brand">Ways to Volunteer</h2>
<div class="mx-auto mt-3 h-1 w-16 rounded bg-teal-brand"></div>
<div class="mt-12 grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{#each [
{ icon: 'calendar', title: 'Event Help', desc: 'Help plan and run adoption events, fundraisers, and community outreach activities.' },
{ icon: 'truck', title: 'Transport', desc: 'Drive pets to vet appointments, foster homes, or adoption events. Reliable transport volunteers are critical.' },
{ icon: 'camera', title: 'Photography', desc: 'Great photos help animals find homes faster. Volunteer for adoption photos, event shots, or social content.' },
{ icon: 'chat', title: 'Social Media', desc: 'Help share stories, create engaging posts, and grow our online community.' },
{ icon: 'doc', title: 'Administrative Support', desc: 'Assist with data entry, application processing, email communication, and other behind-the-scenes tasks.' },
{ icon: 'star', title: 'Fundraising & Mentoring', desc: 'Organize fundraising campaigns or mentor new foster families as they learn the ropes.' }
] as role}
<div class="rounded-xl border border-gray-soft bg-warm-bg p-8 text-center">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-teal-light mb-4">
<svg class="h-8 w-8 text-teal-brand" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
{#if role.icon === 'calendar'}
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
{:else if role.icon === 'truck'}
<path stroke-linecap="round" stroke-linejoin="round" d="M8 17h8M8 17v4m8-4v4m-8-4H5a2 2 0 01-2-2V7a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3" />
{:else if role.icon === 'camera'}
<path stroke-linecap="round" stroke-linejoin="round" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
{:else if role.icon === 'chat'}
<path stroke-linecap="round" stroke-linejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
{:else if role.icon === 'doc'}
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
{:else}
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
{/if}
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-brand">{role.title}</h3>
<p class="mt-3 text-gray-mid">{role.desc}</p>
</div>
{/each}
</div>
</div>
</section>
<!-- CTA -->
{#if data.orgConfig.applications.volunteerEnabled}
<section class="bg-teal-brand py-16 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h2 class="text-3xl font-bold">Ready to Get Involved?</h2>
<p class="mt-4 text-lg text-teal-light/90">
Fill out our volunteer application and we will find the perfect role for you.
</p>
<a href="/apply/volunteer" class="mt-8 inline-block rounded-lg bg-white px-8 py-3 font-semibold text-teal-brand shadow-lg transition hover:bg-teal-light hover:shadow-xl">
Apply to Volunteer
</a>
</div>
</section>
{/if}
@@ -0,0 +1,8 @@
import { getPageBlocks } from '$lib/server/content';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
const parentData = await parent();
const blocks = await getPageBlocks('why-adopt');
return { ...parentData, blocks };
};
@@ -0,0 +1,64 @@
<script lang="ts">
let { data } = $props();
const orgName = $derived(data.orgConfig.orgInfo.name);
type Blocks = Record<string, Record<string, string>>;
const blocks = $derived(data.blocks as Blocks);
</script>
<svelte:head>
<title>Why Adopt | {orgName}</title>
<meta name="description" content="Discover why adopting a rescue pet is one of the most rewarding decisions you can make." />
</svelte:head>
<!-- Hero -->
<section class="bg-teal-brand py-16 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h1 class="text-4xl font-bold sm:text-5xl">{blocks?.intro?.heading ?? 'Why Adopt?'}</h1>
<p class="mt-4 text-lg text-teal-light/90">
{blocks?.intro?.text ?? 'Adopting a rescue pet is one of the most rewarding decisions you can make.'}
</p>
</div>
</section>
<section class="bg-warm-bg py-16">
<div class="mx-auto max-w-4xl px-4">
{#if blocks?.main?.text}
<div class="prose prose-gray max-w-none text-gray-mid">{@html blocks.main.text}</div>
{:else}
<div class="space-y-8">
{#each [
{ title: 'Save a Life', desc: 'When you adopt from a rescue, you are giving a pet a second chance they might not otherwise have. Every adoption makes room for another animal in need.' },
{ title: 'Fully Vetted Pets', desc: 'All of our animals come spayed or neutered, vaccinated, and microchipped. We cover all veterinary costs so you can welcome a healthy pet into your home.' },
{ title: 'Know What You Are Getting', desc: 'Foster families observe our pets in a home environment, so we can tell you about their personality, habits, and compatibility with other animals and children.' },
{ title: 'Ongoing Support', desc: 'Adoption is not just a transaction. We are here to help you with questions and guidance throughout your pet\'s life.' },
{ title: 'Feel Good About It', desc: 'Adoption means you are not supporting puppy mills or irresponsible breeding. You are changing the world, one pet at a time.' }
] as reason}
<div class="flex gap-6 items-start">
<div class="flex-shrink-0 w-12 h-12 rounded-full bg-teal-light flex items-center justify-center">
<svg class="w-6 h-6 text-teal-brand" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<h3 class="text-xl font-bold text-gray-brand">{reason.title}</h3>
<p class="mt-2 text-gray-mid">{reason.desc}</p>
</div>
</div>
{/each}
</div>
{/if}
</div>
</section>
<!-- CTA -->
{#if data.orgConfig.applications.adoptionEnabled}
<section class="bg-teal-brand py-16 text-center text-white">
<div class="mx-auto max-w-4xl px-4">
<h2 class="text-3xl font-bold">Ready to Meet Your New Best Friend?</h2>
<p class="mt-4 text-lg text-teal-light/90">Browse our available pets and start your adoption journey today.</p>
<a href="/pets" class="mt-8 inline-block rounded-lg bg-white px-8 py-3 font-semibold text-teal-brand shadow-lg transition hover:bg-teal-light hover:shadow-xl">
Browse Available Pets
</a>
</div>
</section>
{/if}
+89
View File
@@ -0,0 +1,89 @@
<script lang="ts">
import { page } from '$app/stores';
const statusMessages: Record<number, { title: string; description: string }> = {
400: {
title: 'Bad Request',
description: 'Something was wrong with your request. Please try again.'
},
401: {
title: 'Not Logged In',
description: 'You need to log in to access this page.'
},
403: {
title: 'Access Denied',
description: "You don't have permission to view this page."
},
404: {
title: 'Page Not Found',
description: "We couldn't find the page you were looking for. It may have moved or been removed."
},
500: {
title: 'Server Error',
description: 'Something went wrong on our end. Please try again in a moment.'
}
};
const status = $derived($page.status);
const message = $derived(statusMessages[status] ?? {
title: `Error ${status}`,
description: $page.error?.message ?? 'An unexpected error occurred.'
});
function getEmoji(s: number): string {
if (s === 404) return '🐾';
if (s === 403) return '🔒';
if (s === 401) return '🔑';
if (s >= 500) return '🐕';
return '😕';
}
</script>
<svelte:head>
<title>{message.title} — FosterFlow</title>
</svelte:head>
<div class="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div class="max-w-md w-full text-center">
<div class="text-6xl mb-6">{getEmoji(status)}</div>
<div class="mb-2 inline-flex items-center px-3 py-1 rounded-full bg-gray-100 text-gray-600 text-sm font-mono font-medium">
{status}
</div>
<h1 class="text-3xl font-bold text-gray-900 mt-3 mb-3">{message.title}</h1>
<p class="text-gray-500 mb-8 leading-relaxed">{message.description}</p>
{#if $page.error?.message && !statusMessages[status]}
<p class="text-sm text-gray-400 mb-6 font-mono bg-gray-100 rounded-lg p-3 text-left break-all">
{$page.error.message}
</p>
{/if}
<div class="flex flex-col sm:flex-row gap-3 justify-center">
{#if status === 401 || status === 403}
<a
href="/login"
class="inline-flex items-center justify-center px-6 py-3 text-sm font-medium rounded-xl text-white bg-teal-600 hover:bg-teal-700 transition-colors"
>
Log In
</a>
{/if}
<a
href="/"
class="inline-flex items-center justify-center px-6 py-3 text-sm font-medium rounded-xl border border-gray-200 text-gray-700 hover:bg-gray-50 transition-colors"
>
Go Home
</a>
<button
type="button"
onclick={() => history.back()}
class="inline-flex items-center justify-center px-6 py-3 text-sm font-medium rounded-xl border border-gray-200 text-gray-700 hover:bg-gray-50 transition-colors cursor-pointer"
>
Go Back
</button>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More