Add content pages (contact, events, happy tails, sponsors, donate)

All pages use config-driven org info via data.orgConfig — no hardcoded
org names, emails, or external URLs. Stripe donate page gracefully falls
back to a contact CTA when STRIPE_SECRET_KEY is not configured.
RateLimiter applied to contact form and happy tails submission.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 12:21:17 -05:00
co-authored by Claude Opus 4.6
parent c83f24084c
commit c1efb85c14
12 changed files with 1439 additions and 0 deletions
@@ -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,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,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>