diff --git a/src/routes/(public)/contact/+page.server.ts b/src/routes/(public)/contact/+page.server.ts new file mode 100644 index 0000000..3d0f3d5 --- /dev/null +++ b/src/routes/(public)/contact/+page.server.ts @@ -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 = {}; + 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 }; + } +}; diff --git a/src/routes/(public)/contact/+page.svelte b/src/routes/(public)/contact/+page.svelte new file mode 100644 index 0000000..bedf8af --- /dev/null +++ b/src/routes/(public)/contact/+page.svelte @@ -0,0 +1,216 @@ + + + + Contact Us | {orgName} + + + + +
+
+

{blocks?.intro?.heading ?? 'Contact Us'}

+

+ {blocks?.intro?.text ?? 'We would love to hear from you.'} +

+
+
+ + +
+
+

+ {orgName} is 100% volunteer-based and we will respond to your message as soon as possible. +

+ +
+ {#if orgEmail} +
+
+ + + +
+

Email

+ + {orgEmail} + +
+ {/if} + + {#if blocks?.info?.address} +
+
+ + + + +
+

Mailing Address

+

{blocks.info.address}

+
+ {/if} +
+ + + {#if facebookUrl || instagramUrl} +
+

Follow Us

+
+
+ {#if facebookUrl} + + + + + Facebook + + {/if} + {#if instagramUrl} + + + + + Instagram + + {/if} +
+
+ {/if} +
+
+ + +
+
+

Send Us a Message

+
+ + {#if form?.success} +
+ + + +

Thank you! We'll get back to you soon.

+

Our volunteers typically respond within 24-48 hours.

+
+ {:else} + {#if form?.error} +
{form.error}
+ {/if} + +
+
+
+ + + {#if form?.errors?.name} +

{form.errors.name}

+ {/if} +
+ +
+ + + {#if form?.errors?.email} +

{form.errors.email}

+ {/if} +
+
+ +
+ + +
+ +
+ + + {#if form?.errors?.message} +

{form.errors.message}

+ {/if} +
+ + + + + +
+ +
+
+ {/if} +
+
+ + +{#if data.orgConfig.applications.adoptionEnabled} +
+
+
+

Interested in Adopting?

+

+ If you are inquiring about an adoptable pet, please fill out our adoption application first. +

+ +
+
+
+{/if} diff --git a/src/routes/(public)/donate/+page.server.ts b/src/routes/(public)/donate/+page.server.ts new file mode 100644 index 0000000..47c12a9 --- /dev/null +++ b/src/routes/(public)/donate/+page.server.ts @@ -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); + } +}; diff --git a/src/routes/(public)/donate/+page.svelte b/src/routes/(public)/donate/+page.svelte new file mode 100644 index 0000000..cc49ffa --- /dev/null +++ b/src/routes/(public)/donate/+page.svelte @@ -0,0 +1,401 @@ + + + + Donate | {orgName} + + + + +
+
+

+ {(data.content as Record>)?.intro?.heading ?? 'Support Our Mission'} +

+

+ {(data.content as Record>)?.intro?.text ?? + 'Your generosity saves lives. Every donation directly supports the animals in our care.'} +

+
+
+ + +{#if data.paymentStatus === 'success'} +
+
+
+ + + +

Thank you for your generous donation!

+
+

+ Your payment has been received. You will get a receipt via email. +

+
+
+{/if} + +{#if data.paymentStatus === 'cancelled'} +
+
+

+ Your donation was cancelled. No payment was processed. You can try again below. +

+
+
+{/if} + + +{#if data.stripeEnabled} +
+
+
+

Donate with Card

+
+

Make a secure one-time donation with your credit or debit card.

+
+ +
+
+
+ +
+ +
+ {#each presetAmounts as amount} + + {/each} +
+
+ + +
+ +
+ $ + +
+
+ + +
+ + +
+ + {#if form?.donateError} +
+ {form.donateError} +
+ {/if} + + +
+

+ Secure payment powered by Stripe. You will be redirected to complete your donation. +

+
+
+
+
+{:else} + +
+
+

Make a Donation

+
+

+ Ready to make a difference? Get in touch with us to learn how you can donate. +

+ {#if orgEmail} + + Contact Us to Donate + + {:else} + + Contact Us to Donate + + {/if} +
+
+{/if} + + +
+
+

Other Ways to Give

+
+ +
+ +
+
+ + + +
+

Volunteer Your Time

+

+ Your time is just as valuable as money. Help with fostering, events, transport, and more. +

+ + Learn How to Help + +
+ + +
+
+ + + +
+

Become a Foster

+

+ Open your home to an animal in need. Fosters provide the care and love that makes all the + difference. +

+ + Learn About Fostering + +
+ + +
+
+ {#if facebookUrl} + + + + {:else} + + + + {/if} +
+

Spread the Word

+

+ Share our animals on social media, tell friends and family, and help us find forever homes. +

+ {#if facebookUrl} + + Follow on Facebook + + {:else} + + Share Our Animals + + {/if} +
+
+
+
+ + +
+
+
+
+
+ + + +
+
+

Donate by Mail

+

+ Prefer to send a check? Make it payable to {orgName} and contact us for + mailing details. +

+ {#if orgEmail} + + {orgEmail} + + {/if} +
+
+
+
+
+ + +
+
+
+

+ Tax-Deductible Giving: + {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. +

+
+
+
+ + +
+
+

Questions About Donating?

+

We're happy to help. Reach out to us anytime.

+ {#if orgEmail} + + Contact Us + + {:else} + + Contact Us + + {/if} +
+
diff --git a/src/routes/(public)/events/+page.server.ts b/src/routes/(public)/events/+page.server.ts new file mode 100644 index 0000000..a4ec34c --- /dev/null +++ b/src/routes/(public)/events/+page.server.ts @@ -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 }; +}; diff --git a/src/routes/(public)/events/+page.svelte b/src/routes/(public)/events/+page.svelte new file mode 100644 index 0000000..a36b4d3 --- /dev/null +++ b/src/routes/(public)/events/+page.svelte @@ -0,0 +1,155 @@ + + + + Events | {orgName} + + + + +
+
+

Events

+

+ Join us at adoption events, fundraisers, and community gatherings. +

+
+
+ + +
+
+

Upcoming Events

+
+ + {#if data.upcomingEvents.length > 0} +
+ {#each data.upcomingEvents as event} +
+ {#if event.imageUrl} +
+ {event.title} +
+ {:else} +
+ + + +
+ {/if} +
+
+ + + + {formatDate(event.startDate)} +
+
+ + + + {formatTime(event.startDate)} +
+

{event.title}

+ {#if event.location} +
+ + + + + {event.location} +
+ {/if} + {#if event.description} +

{event.description}

+ {/if} +
+
+ {/each} +
+ {:else} +
+
+ + + +
+

No Upcoming Events

+

+ Check back soon! {#if facebookUrl}Follow us on Facebook for the latest updates.{/if} +

+ {#if facebookUrl} + + Follow Us on Facebook + + {/if} +
+ {/if} +
+
+ + +{#if data.pastEvents.length > 0} +
+
+

Past Events

+
+ +
+ {#each data.pastEvents as event} +
+ {#if event.imageUrl} +
+ {event.title} +
+ {:else} +
+ + + +
+ {/if} +
+

{formatShortDate(event.startDate)}

+

{event.title}

+ {#if event.location} +

{event.location}

+ {/if} +
+
+ {/each} +
+
+
+{/if} diff --git a/src/routes/(public)/happy-tails/+page.server.ts b/src/routes/(public)/happy-tails/+page.server.ts new file mode 100644 index 0000000..da40553 --- /dev/null +++ b/src/routes/(public)/happy-tails/+page.server.ts @@ -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 }; +}; diff --git a/src/routes/(public)/happy-tails/+page.svelte b/src/routes/(public)/happy-tails/+page.svelte new file mode 100644 index 0000000..e672e6e --- /dev/null +++ b/src/routes/(public)/happy-tails/+page.svelte @@ -0,0 +1,149 @@ + + + + Happy Tails — Success Stories | {orgName} + + + + +
+
+

Happy Tails

+

+ Every adoption is a success story. Read about the families and pets who found their perfect match. +

+
+
+ +
+
+ {#if data.stories.length === 0} +
+ + + +

No stories yet

+

Check back soon for heartwarming adoption stories!

+ Share Your Story +
+ {:else} + + {#if featuredStories.length > 0} +
+

Featured Stories

+
+ {#each featuredStories as story} +
+ {#if getPhoto(story)} +
+ {story.title} +
+ {:else} +
+ + + +
+ {/if} +
+

{story.title}

+ {#if story.adopterName} +

The {story.adopterName} Family

+ {/if} + {#if story.petName} +

Pet: {story.petName}

+ {/if} + {#if expandedId === story.id} +

{story.story}

+ + {:else} +

{truncate(story.story, 250)}

+ {#if story.story.length > 250} + + {/if} + {/if} +
+
+ {/each} +
+
+ {/if} + + + {#if regularStories.length > 0} +
+ {#each regularStories as story} +
+ {#if getPhoto(story)} +
+ {story.title} +
+ {:else} +
+ + + +
+ {/if} +
+

{story.title}

+ {#if story.adopterName} +

The {story.adopterName} Family

+ {/if} + {#if story.petName} +

Pet: {story.petName}

+ {/if} + {#if expandedId === story.id} +

{story.story}

+ + {:else} +

{truncate(story.story)}

+ {#if story.story.length > 150} + + {/if} + {/if} +
+
+ {/each} +
+ {/if} + {/if} + + + +
+
diff --git a/src/routes/(public)/happy-tails/submit/+page.server.ts b/src/routes/(public)/happy-tails/submit/+page.server.ts new file mode 100644 index 0000000..f1b7411 --- /dev/null +++ b/src/routes/(public)/happy-tails/submit/+page.server.ts @@ -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 }; + } +}; diff --git a/src/routes/(public)/happy-tails/submit/+page.svelte b/src/routes/(public)/happy-tails/submit/+page.svelte new file mode 100644 index 0000000..e07e114 --- /dev/null +++ b/src/routes/(public)/happy-tails/submit/+page.svelte @@ -0,0 +1,105 @@ + + + + Share Your Happy Tail | {orgName} + + + +
+
+

Share Your Happy Tail

+

+ Adopted from {orgName}? We'd love to hear how your new family member is doing! +

+
+
+ +
+
+ {#if form?.success} +
+
+ + + +
+

Thank You!

+

Your story has been submitted and will be reviewed by our team. Once approved, it'll appear on our Happy Tails page!

+ + ← Back to Happy Tails + +
+ {:else} +
+ {#if form?.error} +
{form.error}
+ {/if} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +

+ Your story will be reviewed by our team before being published. +

+
+
+ {/if} +
+
diff --git a/src/routes/(public)/sponsors/+page.server.ts b/src/routes/(public)/sponsors/+page.server.ts new file mode 100644 index 0000000..cfd522f --- /dev/null +++ b/src/routes/(public)/sponsors/+page.server.ts @@ -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 }; +}; diff --git a/src/routes/(public)/sponsors/+page.svelte b/src/routes/(public)/sponsors/+page.svelte new file mode 100644 index 0000000..dd9b3b5 --- /dev/null +++ b/src/routes/(public)/sponsors/+page.svelte @@ -0,0 +1,183 @@ + + + + Our Sponsors | {orgName} + + + + +
+
+

Our Sponsors

+

+ We're grateful for the businesses and organizations that support our mission. +

+
+
+ + +
+
+ {#each Object.entries(tierConfig) as [tier, config]} + {@const tierSponsors = data.sponsorsByTier[tier as keyof typeof data.sponsorsByTier]} + {#if tierSponsors && tierSponsors.length > 0} +
+
+

{config.label}

+
+

{config.description}

+
+ +
+ {/if} + {/each} + + {#if !data.sponsorsByTier.top_dog?.length && !data.sponsorsByTier.wagging_tails?.length && !data.sponsorsByTier.wet_noses?.length} +
+

+ We're looking for sponsors to support our mission. Interested? +

+
+ {/if} +
+
+ + +
+
+

Become a Sponsor

+
+

+ Support our rescue while gaining visibility in the community. Your sponsorship directly funds the care of animals in our program. +

+ +
+
+
+ + + +
+

Top Tier

+
    +
  • + + Proud Sponsor certificate for your business +
  • +
  • + + Logo on our sponsor page +
  • +
  • + + Social media shout-out +
  • +
  • + + Logo displayed at events +
  • +
+
+ +
+
+ + + +
+

Premier

+
    +
  • + + Proud Sponsor certificate for your business +
  • +
  • + + Logo on our sponsor page +
  • +
  • + + Social media shout-out +
  • +
+
+ +
+
+ + + +
+

Supporting

+
    +
  • + + Sponsor recognition at your business +
  • +
  • + + Logo on our sponsor page +
  • +
+
+
+ +
+

Interested in becoming a sponsor? We'd love to hear from you.

+ {#if orgEmail} + + Contact Us About Sponsorship + + {:else} + + Contact Us About Sponsorship + + {/if} +
+
+