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:
@@ -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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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">
|
||||||
|
← 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 };
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user