This commit is contained in:
2026-05-26 08:30:08 +12:00
parent 005aab8139
commit 135a5a3b83
75 changed files with 22417 additions and 4288 deletions
+6 -4
View File
@@ -28,8 +28,12 @@ describe('reveal action', () => {
vi.unstubAllGlobals();
});
it('toggles visibility as the element enters and leaves the viewport', () => {
it('reveals the element when the observer reports intersection', () => {
vi.stubGlobal('IntersectionObserver', TestIntersectionObserver);
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0);
return 0;
});
vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: false,
media: '(prefers-reduced-motion: reduce)',
@@ -67,9 +71,7 @@ describe('reveal action', () => {
observer.trigger(node, true);
expect(node.classList.contains('reveal-visible')).toBe(true);
observer.trigger(node, false);
expect(node.classList.contains('reveal-visible')).toBe(false);
expect(observer.unobserve).toHaveBeenCalledWith(node);
action.destroy();
expect(observer.disconnect).toHaveBeenCalledTimes(1);
+12
View File
@@ -34,6 +34,17 @@ export function reveal(node: HTMLElement, options: RevealOptions = {}) {
let observer: IntersectionObserver | null = null;
let cancelled = false;
// Failsafe: if hydration mismatch or any other edge case prevents the rAF
// check and IntersectionObserver from running, force visibility after a
// short delay so content never stays stuck at opacity 0.
const failsafe = window.setTimeout(() => {
if (cancelled) return;
if (!node.classList.contains('reveal-visible')) {
node.classList.add('reveal-visible');
observer?.disconnect();
}
}, 1200);
// Defer the layout-reading initial check + observer setup to the next
// frame. With 20+ `use:reveal` instances mounting in a row, calling
// getBoundingClientRect() synchronously after a class mutation forces
@@ -72,6 +83,7 @@ export function reveal(node: HTMLElement, options: RevealOptions = {}) {
return {
destroy() {
cancelled = true;
window.clearTimeout(failsafe);
observer?.disconnect();
}
};
+21 -10
View File
@@ -35,12 +35,12 @@
{#each standardSections as section}
{@const enhanced = getEnhancedImage(section.imageUrl)}
<section
use:reveal
use:reveal={{ distance: 0 }}
class="about-section reveal-block"
class:about-section-gradient={section.accent === 'gradient'}
>
<div class="page-inner about-section-grid" class:about-section-reverse={section.reverse}>
<div class="about-copy">
<div class="about-copy fade-up">
{#if section.eyebrow}
<span class="eyebrow about-eyebrow">{section.eyebrow}</span>
{/if}
@@ -49,7 +49,7 @@
<p>{paragraph}</p>
{/each}
</div>
<div class="about-media">
<div class="about-media scale-soft">
{#if enhanced}
<enhanced:img src={enhanced} alt={section.imageAlt} loading="lazy" decoding="async" />
{:else}
@@ -63,9 +63,9 @@
<!-- ── Founder section ── -->
{#if founderSection}
{@const founderEnhanced = getEnhancedImage(founderSection.imageUrl)}
<section use:reveal={{ delay: 50 }} class="about-founder reveal-block">
<section use:reveal={{ delay: 50, distance: 0 }} class="about-founder reveal-block">
<div class="page-inner about-founder-grid">
<div class="about-founder-media">
<div class="about-founder-media scale-soft">
{#if founderEnhanced}
<enhanced:img
src={founderEnhanced}
@@ -82,7 +82,7 @@
/>
{/if}
</div>
<div class="about-founder-copy">
<div class="about-founder-copy fade-up">
<article class="about-founder-note">
{#if founderSection.eyebrow}
<span class="eyebrow about-eyebrow about-founder-kicker">{founderSection.eyebrow}</span>
@@ -122,7 +122,7 @@
<span>If you are unsure about anything, feel free to email me anytime.</span>
</a>
<a href="/contact-us" class="btn btn-green btn-mobile-center about-founder-cta">
<a href="/contact-us" class="btn btn-green btn-mobile-center about-founder-cta cta-shimmer">
Book a free Meet &amp; Greet
</a>
</article>
@@ -133,13 +133,13 @@
<!-- ── FAQs ── -->
{#if pageContent.faqs && pageContent.faqs.length}
<section use:reveal={{ delay: 30 }} class="about-faq reveal-block">
<section use:reveal={{ delay: 30, distance: 0 }} class="about-faq reveal-block">
<div class="page-inner">
<div class="about-faq-header">
<div class="about-faq-header fade-up">
<span class="eyebrow about-eyebrow">FAQ</span>
<h2>{pageContent.faqTitle ?? 'Common questions'}</h2>
</div>
<div use:accordion class="faq about-faq-list">
<div use:accordion class="faq about-faq-list fade-up">
{#each pageContent.faqs as item}
<details>
<summary>{item.question}</summary>
@@ -571,4 +571,15 @@
animation: none;
}
}
/* Per-section choreography — copy leads, then the media settles after a
short beat. Repeated across each about-section instance. */
@media (prefers-reduced-motion: no-preference) {
:global(.reveal-visible) .about-copy { transition-delay: 40ms; }
:global(.reveal-visible) .about-media { transition-delay: 200ms; }
:global(.reveal-visible) .about-founder-media { transition-delay: 60ms; }
:global(.reveal-visible) .about-founder-copy { transition-delay: 220ms; }
:global(.reveal-visible) .about-faq-header { transition-delay: 40ms; }
:global(.reveal-visible) .about-faq-list { transition-delay: 180ms; }
}
</style>
+6 -7
View File
@@ -10,21 +10,20 @@ async function fillOwnerStep() {
await fireEvent.input(screen.getByLabelText(/^Email/i), {
target: { value: 'alex@example.com' }
});
await fireEvent.input(screen.getByLabelText(/Contact #/i), {
await fireEvent.input(screen.getByLabelText(/Phone number/i), {
target: { value: '021 123 4567' }
});
}
async function fillDogStep() {
await fireEvent.click(screen.getByLabelText('Tiny Gang Pack Walks'));
await fireEvent.click(screen.getByLabelText('Other Services'));
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
target: { value: 'Maya' }
});
await fireEvent.input(screen.getByLabelText(/Location/i), {
await fireEvent.input(screen.getByLabelText(/Your suburb/i), {
target: { value: 'Kingsland' }
});
await fireEvent.input(screen.getByLabelText(/Tiny Gang Pack Walks fit/i), {
await fireEvent.click(screen.getByLabelText('Tiny Gang Pack Walks'));
await fireEvent.input(screen.getByLabelText(/A bit about your dog/i), {
target: { value: 'Loves small group walks.' }
});
}
@@ -103,7 +102,7 @@ describe('BookingSection', () => {
petName: 'Maya',
location: 'Kingsland',
message: 'Loves small group walks.',
services: ['Tiny Gang Pack Walks', 'Other Services'],
services: ['Tiny Gang Pack Walks'],
website: '',
referrer: 'https://www.google.com/',
stepChanges: 1,
@@ -144,7 +143,7 @@ describe('BookingSection', () => {
await fireEvent.click(container.querySelector('.booking-next-button')!);
expect(screen.getByText('Please tell us how we can help')).toBeInTheDocument();
await fireEvent.input(screen.getByLabelText(/Your Message/i), {
await fireEvent.input(screen.getByLabelText(/How can we help/i), {
target: { value: 'I would like to discuss a business partnership.' }
});
+22 -9
View File
@@ -22,20 +22,20 @@
$: founderStoryEnhanced = getEnhancedImage(founderStory.imageUrl);
</script>
<section id="promise" use:reveal={{ delay: 20 }} class="reveal-block">
<section id="promise" use:reveal={{ delay: 20, distance: 0 }} class="reveal-block">
<div class="founder-inner">
<article class="founder-note">
<div class="founder-intro">
<div class="founder-intro fade-up">
<span class="eyebrow founder-kicker">A note from Aless</span>
<span class="founder-greeting">Hi, Aless from Goodwalk <span class="founder-greeting-wave" aria-hidden="true">👋</span></span>
</div>
<h2 class="founder-heading">
<h2 class="founder-heading fade-up">
<span class="founder-heading-main">{founderStory.title}</span>
<span class="founder-heading-sub">Goodwalk is built around trust.</span>
</h2>
<div class="founder-trust-strip" aria-label="What owners can expect from Goodwalk">
<div class="founder-trust-strip fade-up" aria-label="What owners can expect from Goodwalk">
<span class="founder-trust-label">What owners notice first</span>
<ul class="founder-trust-list">
{#each founderTrustNotes as note}
@@ -44,29 +44,29 @@
</ul>
</div>
<div class="founder-body">
<div class="founder-body fade-up">
{#each founderStoryParagraphs as paragraph}
<p>{paragraph}</p>
{/each}
</div>
<p class="founder-closing">
<p class="founder-closing fade-up">
Ready to <strong>{founderStory.emphasis}</strong>
</p>
<div class="founder-actions">
<div class="founder-actions fade-up">
<a class="founder-contact-note" href="mailto:info@goodwalk.co.nz" aria-label="Email Aless at Goodwalk">
<span class="founder-contact-wave" aria-hidden="true">👋</span>
<span>If you are unsure about anything, feel free to email, call, or send me an Instagram DM anytime.</span>
</a>
<a href={founderStory.cta.href} class="btn btn-green btn-with-arrow btn-hide-arrow-mobile founder-cta">
<a href={founderStory.cta.href} class="btn btn-green btn-with-arrow btn-hide-arrow-mobile founder-cta cta-shimmer">
{founderStory.cta.label}
<Icon name="fas fa-arrow-right" />
</a>
</div>
<div class="founder-signoff">
<div class="founder-signoff fade-up">
<div class="founder-signoff-copy">
<p class="founder-signoff-name">Aless, founder of Goodwalk</p>
<p class="founder-signoff-line">The same calm face at the door, the same trusted routine for your dog.</p>
@@ -102,6 +102,19 @@
contain-intrinsic-size: 980px;
}
/* Narrative cascade — the article unfolds top-to-bottom in a steady,
unhurried rhythm. Intentionally slow steps (~90ms) so the eye reads
each block as it lands, not as a rush. */
@media (prefers-reduced-motion: no-preference) {
:global(.reveal-visible) .founder-intro { transition-delay: 40ms; }
:global(.reveal-visible) .founder-heading { transition-delay: 130ms; }
:global(.reveal-visible) .founder-trust-strip { transition-delay: 220ms; }
:global(.reveal-visible) .founder-body { transition-delay: 310ms; }
:global(.reveal-visible) .founder-closing { transition-delay: 400ms; }
:global(.reveal-visible) .founder-actions { transition-delay: 490ms; }
:global(.reveal-visible) .founder-signoff { transition-delay: 580ms; }
}
.founder-inner {
max-width: 880px;
margin: 0 auto;
+32 -8
View File
@@ -12,10 +12,10 @@
];
</script>
<section id="how-it-works" use:reveal={{ delay: 30 }} class="reveal-block">
<section id="how-it-works" use:reveal={{ delay: 30, distance: 0 }} class="reveal-block">
<div class="hiw-inner">
<div class="section-header hiw-header">
<div class="section-header hiw-header fade-up">
<span class="eyebrow hiw-eyebrow">Getting started</span>
<h2 class="section-heading">{content.title}</h2>
{#if content.intro}
@@ -23,18 +23,18 @@
{/if}
</div>
<div class="hiw-journey-bar" aria-label="How getting started works">
<div class="hiw-journey-bar stagger-children" aria-label="How getting started works">
{#each journeyChips as chip}
<span class="hiw-journey-pill">
<span class="hiw-journey-pill fade-up">
<Icon name={chip.icon} className="hiw-journey-icon" />
{chip.label}
</span>
{/each}
</div>
<div class="hiw-steps">
<div class="hiw-steps stagger-children">
{#each content.steps as step, index}
<div class="hiw-step">
<div class="hiw-step fade-up">
<div class="hiw-step-meta">
<span class="hiw-phase">{step.phase}</span>
<span class="hiw-num">0{index + 1}</span>
@@ -54,8 +54,8 @@
{/each}
</div>
<div class="hiw-cta">
<a href="#newlead" class="btn btn-green btn-mobile-center btn-with-arrow">
<div class="hiw-cta fade-up">
<a href="#newlead" class="btn btn-green btn-mobile-center btn-with-arrow cta-shimmer">
Book your free Meet &amp; Greet
<Icon name="fas fa-arrow-right" />
</a>
@@ -364,4 +364,28 @@
opacity: 1;
transform: translate3d(0, 0, 0);
}
/* Choreography: when the section reveal fires, the inner tiers come in
as a hierarchy — header settles first, journey pills follow, then
step cards cascade, then the CTA tail. Each tier's own stagger still
applies on top of these base offsets, so within a tier the children
ripple as expected. Kept inside @media so reduced-motion users skip
the cascade and see everything at rest immediately. */
@media (prefers-reduced-motion: no-preference) {
:global(.reveal-visible) .hiw-header {
transition-delay: 40ms;
}
:global(.reveal-visible) .hiw-journey-bar > :nth-child(1) { transition-delay: 160ms; }
:global(.reveal-visible) .hiw-journey-bar > :nth-child(2) { transition-delay: 220ms; }
:global(.reveal-visible) .hiw-journey-bar > :nth-child(3) { transition-delay: 280ms; }
:global(.reveal-visible) .hiw-steps > :nth-child(1) { transition-delay: 340ms; }
:global(.reveal-visible) .hiw-steps > :nth-child(2) { transition-delay: 420ms; }
:global(.reveal-visible) .hiw-steps > :nth-child(3) { transition-delay: 500ms; }
:global(.reveal-visible) .hiw-cta {
transition-delay: 600ms;
}
}
</style>
+175 -106
View File
@@ -4,24 +4,40 @@
export let instagram: HomePageContent['instagram'];
// Editorial layout — full-bleed photo with a dark scrim on the left.
// Photo source is the existing tiny-gang pack walk image already used
// elsewhere on the site, so no new assets are required.
const editorialPhoto = '/images/goodwalk-tiny-gang-pack-walk-small-dogs-auckland.webp';
const editorialPhotoAlt = 'Goodwalk Tiny Gang on a pack walk in Auckland';
</script>
<aside id="instagram" aria-label="Follow Goodwalk on Instagram">
<div class="instagram-stage">
<div class="instagram-panel">
<div class="instagram-copy">
<span class="eyebrow instagram-kicker">Daily walks, happy dogs</span>
<h2>{instagram.title}</h2>
<p class="instagram-blurb">See our dogs in action — walks, play, and happy pups</p>
<a href={instagram.href} target="_blank" rel="noopener" class="btn btn-green instagram-button">
<Icon name="fab fa-instagram" />
{instagram.label}
</a>
</div>
<div class="ig-editorial">
<div class="ig-editorial-photo">
<img src={editorialPhoto} alt={editorialPhotoAlt} loading="lazy" decoding="async" />
</div>
<div class="instagram-dog-wrap" aria-hidden="true">
<img src="/images/goodwalk-instagram-dog-cutout.webp" alt="" class="instagram-dog" loading="lazy" decoding="async" />
<div class="ig-editorial-scrim">
<div class="ig-editorial-copy">
<span class="ig-editorial-source">
<Icon name="fab fa-instagram" />
<span>{instagram.label}</span>
</span>
<h2>{instagram.title}</h2>
<p>Daily walks, real dogs, no filter. Park runs, pack moments, and the kind of updates you'd actually want to scroll.</p>
<a
href={instagram.href}
target={instagram.external ? '_blank' : undefined}
rel={instagram.external ? 'noopener' : undefined}
class="ig-editorial-cta"
>
<Icon name="fab fa-instagram" />
<span>Follow {instagram.label}</span>
<Icon name="fas fa-arrow-right" className="ig-editorial-arrow" />
</a>
</div>
</div>
</div>
</aside>
@@ -30,133 +46,186 @@
#instagram {
content-visibility: auto;
contain-intrinsic-size: 360px;
padding: 32px 24px 56px;
}
.ig-editorial {
position: relative;
overflow: hidden;
padding-bottom: 40px;
}
.instagram-stage {
position: relative;
max-width: 1040px;
max-width: 1180px;
margin: 0 auto;
}
.instagram-panel {
position: relative;
min-height: 150px;
padding: 24px 320px 24px 44px;
border-radius: 28px;
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.52), transparent 42%),
linear-gradient(135deg, rgba(255, 252, 242, 0.98), rgba(255, 243, 198, 0.96));
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.06),
0 26px 50px rgba(106, 80, 16, 0.14);
overflow: visible;
aspect-ratio: 2.6 / 1;
min-height: 280px;
box-shadow: 0 30px 70px rgba(17, 20, 24, 0.16);
}
.instagram-copy {
max-width: 580px;
margin: 0;
text-align: left;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.instagram-kicker {
/* All visual styling comes from the shared .eyebrow utility. */
display: inline-block;
margin: 0 0 10px;
}
.instagram-copy :global(h2) {
max-width: none;
margin: 0 0 6px;
font-size: 22px;
line-height: 1.2;
}
.instagram-blurb {
max-width: 520px;
margin: 0;
font-size: 14px;
}
.instagram-button {
margin-top: 14px;
position: relative;
z-index: 1;
}
.instagram-dog-wrap {
.ig-editorial-photo {
position: absolute;
right: -56px;
bottom: -14px;
display: flex;
align-items: flex-end;
justify-content: center;
width: 380px;
pointer-events: none;
inset: 0;
z-index: 0;
}
.instagram-dog {
display: block;
.ig-editorial-photo img {
width: 100%;
height: auto;
height: 100%;
object-fit: cover;
object-position: 60% 40%;
transform: scale(1.02);
transition: transform 0.8s cubic-bezier(0.22, 1, 0.36, 1);
}
@media (min-width: 1800px) {
.instagram-stage {
max-width: 1144px;
}
.ig-editorial:hover .ig-editorial-photo img {
transform: scale(1.06);
}
.ig-editorial-scrim {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
background: linear-gradient(
100deg,
rgba(15, 24, 15, 0.86) 0%,
rgba(15, 24, 15, 0.74) 36%,
rgba(15, 24, 15, 0.18) 70%,
rgba(15, 24, 15, 0) 100%
);
padding: 48px clamp(28px, 4vw, 56px);
}
.ig-editorial-copy {
max-width: 480px;
color: #fff;
text-align: left;
}
.ig-editorial-source {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px 6px 10px;
margin-bottom: 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
color: #fff;
font-family: var(--font-head);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
}
.ig-editorial-source :global(.icon) {
font-size: 13px;
}
/* Specificity bump: #instagram h2 (id+element) lives in the global
typography.css and would otherwise win. Prefixing #instagram here
matches that specificity so the editorial sizing applies. */
#instagram .ig-editorial-copy h2 {
margin: 0 0 12px;
font-family: var(--font-head);
font-size: clamp(26px, 3vw, 36px);
font-weight: 800;
letter-spacing: -0.025em;
line-height: 1.08;
color: #fff;
text-align: left;
}
.ig-editorial-copy p {
margin: 0 0 26px;
color: rgba(255, 255, 255, 0.78);
font-size: 15px;
line-height: 1.55;
max-width: 36ch;
}
.ig-editorial-cta {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 13px 22px;
border-radius: 999px;
background: var(--yellow);
color: var(--gw-green);
font-family: var(--font-head);
font-size: 14px;
font-weight: 800;
text-decoration: none;
box-shadow: 0 12px 24px rgba(255, 209, 0, 0.32);
transition: transform 0.22s ease, box-shadow 0.22s ease;
}
.ig-editorial-cta:hover {
transform: translateY(-2px);
box-shadow: 0 18px 32px rgba(255, 209, 0, 0.38);
}
.ig-editorial-cta :global(.ig-editorial-arrow) {
transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1);
}
.ig-editorial-cta:hover :global(.ig-editorial-arrow) {
transform: translateX(3px);
}
/* Mobile — stack the photo above the copy so neither gets crushed.
Copy centers inside the green panel for a calmer vertical rhythm. */
@media (max-width: 768px) {
#instagram {
padding-bottom: 120px;
padding: 24px 16px 40px;
}
.instagram-panel {
padding: 30px 24px 180px;
border-radius: 28px;
.ig-editorial {
aspect-ratio: auto;
min-height: 0;
border-radius: 24px;
}
.instagram-copy {
.ig-editorial-photo {
position: relative;
aspect-ratio: 4 / 3;
}
.ig-editorial-scrim {
position: relative;
inset: auto;
background: var(--gw-green);
padding: 28px 22px 32px;
justify-content: center;
}
.ig-editorial-copy {
max-width: none;
margin: 0 auto;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.instagram-copy :global(h2) {
max-width: none;
font-size: inherit;
line-height: inherit;
#instagram .ig-editorial-copy h2 {
text-align: center;
}
.instagram-blurb {
max-width: none;
font-size: inherit;
.ig-editorial-copy p {
margin-left: auto;
margin-right: auto;
}
}
.instagram-button {
margin-top: 24px;
@media (prefers-reduced-motion: reduce) {
.ig-editorial-photo img,
.ig-editorial-cta {
transition: none;
transform: none;
}
.instagram-dog-wrap {
left: 50%;
right: auto;
bottom: -96px;
width: min(300px, calc(100% - 32px));
transform: translateX(-50%);
}
.instagram-dog {
width: 100%;
.ig-editorial-cta:hover {
transform: none;
}
}
</style>
+24 -99
View File
@@ -155,8 +155,14 @@
});
}
function fieldValue(value: string): string {
return value.trim() || '—';
function toStr(value: unknown): string {
if (typeof value === 'string') return value;
if (value == null) return '';
return String(value);
}
function fieldValue(value: unknown): string {
return toStr(value).trim() || '—';
}
function resetIdle() {
@@ -528,33 +534,7 @@
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function clearOptionalHealthFields() {
noteInteraction();
accessInstructions = '';
hasFoodAllergies = 'no';
foodAllergiesDetail = '';
hasEnvAllergies = 'no';
envAllergiesDetail = '';
onSpecialDiet = 'no';
specialDietDetail = '';
onMedication = 'no';
medicationDetail = '';
errors = {
...errors,
accessInstructions: '',
hasFoodAllergies: '',
foodAllergiesDetail: '',
hasEnvAllergies: '',
envAllergiesDetail: '',
onSpecialDiet: '',
specialDietDetail: '',
onMedication: '',
medicationDetail: '',
};
}
function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
return {
submittedAt,
sections: [
@@ -651,22 +631,23 @@
}
submitting = true;
signedOnDate = new Date().toISOString();
const submissionSnapshot = buildSubmissionSnapshot(signedOnDate);
const medicalNotes = [
temperament.trim(),
hasFoodAllergies === 'yes' ? `Food allergies: ${foodAllergiesDetail.trim()}` : '',
hasEnvAllergies === 'yes' ? `Environmental allergies: ${envAllergiesDetail.trim()}` : '',
onSpecialDiet === 'yes' ? `Special diet: ${specialDietDetail.trim()}` : '',
onMedication === 'yes' ? `Medication: ${medicationDetail.trim()}` : '',
additionalNotes.trim(),
].filter(Boolean).join('\n');
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 25000);
try {
signedOnDate = new Date().toISOString();
const submissionSnapshot = buildSubmissionSnapshot(signedOnDate);
const medicalNotes = [
toStr(temperament).trim(),
hasFoodAllergies === 'yes' ? `Food allergies: ${toStr(foodAllergiesDetail).trim()}` : '',
hasEnvAllergies === 'yes' ? `Environmental allergies: ${toStr(envAllergiesDetail).trim()}` : '',
onSpecialDiet === 'yes' ? `Special diet: ${toStr(specialDietDetail).trim()}` : '',
onMedication === 'yes' ? `Medication: ${toStr(medicationDetail).trim()}` : '',
toStr(additionalNotes).trim(),
].filter(Boolean).join('\n');
const response = await fetch('/api/onboarding-submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1148,13 +1129,6 @@
</div>
</div>
<div class="panel-subsection-actions">
<button class="subsection-quick-action" type="button" on:click={clearOptionalHealthFields}>
<Icon name="fas fa-check" />
Clear optional health details
</button>
</div>
<label class="field">
<span>Home access instructions</span>
<textarea
@@ -2060,12 +2034,6 @@
margin-bottom: 18px;
}
.panel-subsection-actions {
display: flex;
justify-content: flex-start;
margin: -2px 0 4px;
}
.panel-subsection-head h3 {
margin: 0;
color: #171b20;
@@ -2097,40 +2065,7 @@
font-size: 15px;
}
.subsection-quick-action {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 0 14px;
border: 1px solid rgba(33, 48, 33, 0.12);
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: rgba(33, 48, 33, 0.84);
font: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition:
border-color 0.18s ease,
background 0.18s ease,
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.18s ease;
}
.subsection-quick-action:hover {
border-color: rgba(33, 48, 33, 0.22);
background: #fff;
transform: translateY(-1px);
box-shadow: 0 8px 18px rgba(17, 20, 24, 0.05);
}
.subsection-quick-action:focus-visible {
outline: none;
box-shadow: 0 0 0 4px rgba(255, 209, 0, 0.14);
}
.onboarding-step-icon {
.onboarding-step-icon {
flex: none;
width: 48px;
height: 48px;
@@ -2803,8 +2738,7 @@
.confirm-row,
.yn-btn,
.recall-btn,
.chip-option,
.subsection-quick-action {
.chip-option {
transition: none !important;
}
@@ -2965,10 +2899,6 @@
margin-bottom: 16px;
}
.panel-subsection-actions {
margin-bottom: 6px;
}
.panel-subsection-head h3 {
font-size: 17px;
}
@@ -2985,12 +2915,7 @@
font-size: 14px;
}
.subsection-quick-action {
width: 100%;
justify-content: center;
}
.signature-header {
.signature-header {
align-items: start;
flex-direction: column;
}
+15 -15
View File
@@ -54,36 +54,36 @@ describe('OnboardingPage', () => {
await waitFor(() => expect(screen.getByPlaceholderText('First name')).toBeInTheDocument());
await fireEvent.input(screen.getByPlaceholderText('First name'), { target: { value: 'Alex' } });
await fireEvent.input(screen.getByPlaceholderText('Surname'), { target: { value: 'Walker' } });
await fireEvent.input(screen.getByPlaceholderText('Last name'), { target: { value: 'Walker' } });
await fireEvent.input(screen.getByPlaceholderText('you@example.com'), { target: { value: 'alex@example.com' } });
await fireEvent.input(screen.getByPlaceholderText('021 234 5678'), { target: { value: '0212345678' } });
await fireEvent.input(screen.getByPlaceholderText('Street address'), { target: { value: '1 Test Street' } });
await fireEvent.input(screen.getByPlaceholderText('Street, suburb, city'), { target: { value: '1 Test Street' } });
await fireEvent.click(screen.getByRole('button', { name: /save and continue/i }));
await waitFor(() => expect(screen.getByPlaceholderText('Dog name')).toBeInTheDocument());
await fireEvent.input(screen.getByPlaceholderText('Dog name'), { target: { value: 'Milo' } });
await fireEvent.input(screen.getByPlaceholderText('Breed'), { target: { value: 'Spoodle' } });
await fireEvent.input(screen.getByPlaceholderText('e.g. Labrador, Cavoodle, mixed'), { target: { value: 'Spoodle' } });
await fireEvent.input(screen.getByLabelText(/date of birth/i), { target: { value: '2020-01-01' } });
await clickService('Tiny Gang Pack Walks');
await fireEvent.click(screen.getByRole('button', { name: /save and continue/i }));
await waitFor(() => expect(screen.getByPlaceholderText('Vet clinic or vet name')).toBeInTheDocument());
await fireEvent.input(screen.getByPlaceholderText('Vet clinic or vet name'), { target: { value: 'Grey Lynn Vets' } });
await fireEvent.input(screen.getByPlaceholderText('Vet phone number'), { target: { value: '099999999' } });
await fireEvent.input(screen.getByPlaceholderText('Vet address'), { target: { value: '2 Vet Street' } });
await fireEvent.input(screen.getByPlaceholderText('Emergency contact name'), { target: { value: 'Jamie Walker' } });
await fireEvent.input(screen.getByPlaceholderText('Emergency contact number'), { target: { value: '0211111111' } });
await chooseOption(/is your dog vaccinated/i, 'Yes');
await chooseOption(/does your dog have any food allergies/i, 'No');
await chooseOption(/does your dog have any environmental allergies/i, 'No');
await chooseOption(/is your dog on a special diet/i, 'No');
await chooseOption(/is your dog taking any medication/i, 'No');
await waitFor(() => expect(screen.getByPlaceholderText('Clinic name or your usual vet')).toBeInTheDocument());
await fireEvent.input(screen.getByPlaceholderText('Clinic name or your usual vet'), { target: { value: 'Grey Lynn Vets' } });
await fireEvent.input(screen.getByPlaceholderText('Clinic phone number'), { target: { value: '099999999' } });
await fireEvent.input(screen.getByPlaceholderText('Clinic address'), { target: { value: '2 Vet Street' } });
await fireEvent.input(screen.getByPlaceholderText('Full name'), { target: { value: 'Jamie Walker' } });
await fireEvent.input(screen.getByPlaceholderText('Phone number'), { target: { value: '0211111111' } });
await chooseOption(/vaccinations up to date/i, 'Yes');
await chooseOption(/^Any food allergies\?$/i, 'No');
await chooseOption(/^Any environmental allergies\?$/i, 'No');
await chooseOption(/^On a special diet\?$/i, 'No');
await chooseOption(/medication that could affect walks/i, 'No');
await fireEvent.click(screen.getByRole('button', { name: /save and continue/i }));
await waitFor(() => expect(screen.getByText(/registered with council/i)).toBeInTheDocument());
await chooseOption(/registered with council/i, 'Yes');
await fireEvent.click(screen.getByRole('button', { name: /save and continue/i }));
await waitFor(() => expect(screen.getByText(/anything else you'd like us to know/i)).toBeInTheDocument());
await waitFor(() => expect(screen.getByText(/anything else we should know/i)).toBeInTheDocument());
});
});
+15 -15
View File
@@ -41,18 +41,18 @@
/>
{#if pageContent.comparison}
<section use:reveal id="pricing-chooser" class="pricing-guide reveal-block">
<section use:reveal={{ distance: 0 }} id="pricing-chooser" class="pricing-guide reveal-block">
<div class="page-inner">
<div class="pricing-guide-heading">
<div class="pricing-guide-heading fade-up">
<span class="eyebrow">{pageContent.comparison.title}</span>
{#if pageContent.comparison.intro}
<h2>{pageContent.comparison.intro}</h2>
{/if}
</div>
<div class="pricing-chooser-grid">
<div class="pricing-chooser-grid stagger-children">
{#each pageContent.sections as section, sectionIndex}
<article class="pricing-chooser-card">
<article class="pricing-chooser-card fade-up">
{#if section.imageUrl}
<div class="pricing-chooser-image-wrap">
<img class="pricing-chooser-image" src={section.imageUrl} alt={section.imageAlt ?? section.title} loading="lazy" decoding="async" />
@@ -103,19 +103,19 @@
{#each pageContent.sections as section, sectionIndex}
<section
use:reveal
use:reveal={{ distance: 0 }}
id={`pricing-section-${sectionIndex}`}
class:pricing-detail--alt={sectionIndex % 2 === 1}
class="pricing-detail reveal-block"
>
<div class="page-inner pricing-detail-inner">
<div class:pricing-detail-media--right={sectionIndex % 2 === 1} class="pricing-detail-media">
<div class:pricing-detail-media--right={sectionIndex % 2 === 1} class="pricing-detail-media scale-soft">
{#if section.imageUrl}
<img src={section.imageUrl} alt={section.imageAlt ?? section.title} loading="lazy" decoding="async" />
{/if}
</div>
<div class="pricing-detail-copy">
<div class="pricing-detail-copy fade-up">
{#if section.eyebrow}
<p class="eyebrow pricing-detail-eyebrow">{section.eyebrow}</p>
{/if}
@@ -158,14 +158,14 @@
</div>
</div>
<div class="page-inner pricing-plan-shell">
<div class="page-inner pricing-plan-shell fade-up">
<div class:pricing-plan-grid-three={section.plans.length === 3} class="pricing-plan-grid">
{#each decoratePlans(section.plans) as plan}
<PricingPlanCard {plan} variant="pricing" hideCtaOnMobile={true} />
{/each}
</div>
<a class="btn btn-yellow pricing-section-mobile-cta" href="#newlead">
<a class="btn btn-yellow pricing-section-mobile-cta cta-shimmer" href="#newlead">
Book a Meet &amp; Greet
<Icon name="fas fa-arrow-right" />
</a>
@@ -173,8 +173,8 @@
</section>
{/each}
<section use:reveal class="pricing-worth reveal-block" aria-labelledby="pricing-worth-heading">
<div class="page-inner pricing-worth-inner">
<section use:reveal={{ distance: 0 }} class="pricing-worth reveal-block" aria-labelledby="pricing-worth-heading">
<div class="page-inner pricing-worth-inner fade-up">
<span class="eyebrow eyebrow--accent pricing-worth-kicker">On the price</span>
<h2 id="pricing-worth-heading">A better walk costs more.</h2>
@@ -188,16 +188,16 @@
</div>
</section>
<section use:reveal class="pricing-included reveal-block">
<section use:reveal={{ distance: 0 }} class="pricing-included reveal-block">
<div class="page-inner">
<div class="pricing-included-heading">
<div class="pricing-included-heading fade-up">
<span class="eyebrow">Every booking</span>
<h2>Easy to start. Easy to trust.</h2>
</div>
<ul class="pricing-included-list" aria-label="What every Goodwalk booking includes">
<ul class="pricing-included-list stagger-children" aria-label="What every Goodwalk booking includes">
{#each includedPromises as promise}
<li>
<li class="fade-up">
<span class="pricing-included-icon" aria-hidden="true">
<Icon name={promise.icon} />
</span>
+18 -18
View File
@@ -179,7 +179,7 @@
/>
{#if pageContent.highlight}
<section use:reveal class="service-highlight reveal-block">
<section use:reveal={{ distance: 0 }} class="service-highlight reveal-block">
<div class="page-inner">
<div class:service-highlight-layout-points={pageContent.highlight.points?.length} class="service-highlight-layout">
<div class="service-highlight-copy">
@@ -187,9 +187,9 @@
<h2>{pageContent.highlight.title}</h2>
{#if pageContent.highlight.points?.length}
<div class="service-highlight-points">
<div class="service-highlight-points stagger-children">
{#each pageContent.highlight.points as point}
<article class="service-highlight-point">
<article class="service-highlight-point fade-up">
<h3>{point.title}</h3>
<p>{point.body}</p>
</article>
@@ -234,7 +234,7 @@
</section>
{/if}
<section use:reveal class="service-benefits reveal-block">
<section use:reveal={{ distance: 0 }} class="service-benefits reveal-block">
<div class="page-inner">
<div class="service-section-heading">
<h2>{pageContent.benefits.title}</h2>
@@ -244,12 +244,12 @@
</div>
<div class="service-benefit-shell">
<div bind:this={benefitScroller} class:service-benefit-grid-simple-swipe={useSimpleBenefitSwipe} class="service-benefit-grid">
<div bind:this={benefitScroller} class:service-benefit-grid-simple-swipe={useSimpleBenefitSwipe} class="service-benefit-grid stagger-children">
{#each benefitCards as benefit, index}
<article
class:active={index === activeBenefitIndex}
class:service-benefit-card-featured={benefit.featured}
class={`service-benefit-card ${benefit.tintClass}`}
class={`service-benefit-card fade-up ${benefit.tintClass}`}
>
<div class="service-benefit-icon" aria-hidden="true">
<Icon name={benefit.icon ?? 'fas fa-paw'} />
@@ -344,7 +344,7 @@
{/if}
{#if pageContent.decision}
<section class:service-decision-pack={isPackWalks} class:service-decision-dog={isDogWalking} use:reveal class="service-decision reveal-block" aria-labelledby="service-decision-heading">
<section class:service-decision-pack={isPackWalks} class:service-decision-dog={isDogWalking} use:reveal={{ distance: 0 }} class="service-decision reveal-block" aria-labelledby="service-decision-heading">
<div class="page-inner">
<div class="service-decision-card">
<div class="service-decision-header">
@@ -356,8 +356,8 @@
{decisionIntro}
</p>
</div>
<div class="service-decision-grid">
<div class="service-decision-col service-decision-col-fit">
<div class="service-decision-grid stagger-children">
<div class="service-decision-col service-decision-col-fit fade-up">
<span class="service-decision-col-kicker service-decision-col-kicker-fit">{decisionFitKicker}</span>
<h3>{pageContent.decision.fitTitle ?? 'A good fit if your dog:'}</h3>
<ul>
@@ -371,7 +371,7 @@
{/each}
</ul>
</div>
<div class="service-decision-col service-decision-col-not">
<div class="service-decision-col service-decision-col-not fade-up">
<span class="service-decision-col-kicker service-decision-col-kicker-not">{decisionNotKicker}</span>
<h3>{pageContent.decision.notFitTitle ?? 'Probably not a fit if:'}</h3>
<ul>
@@ -395,7 +395,7 @@
{/if}
<section
use:reveal
use:reveal={{ distance: 0 }}
class:service-pricing-immediate-mobile={useSimpleBenefitSwipe}
class:service-pricing-pack={isPackWalks}
class="service-pricing reveal-block"
@@ -410,7 +410,7 @@
<div class:service-plan-grid-three={pageContent.pricing.plans.length === 3} class="service-plan-grid">
{#each pricingPlans as plan}
<PricingPlanCard {plan} variant="service" />
<PricingPlanCard {plan} variant="service" hideCtaOnMobile={true} />
{/each}
</div>
@@ -426,7 +426,7 @@
Every booking starts with a free, no-obligation Meet &amp; Greet.
</p>
<a class="btn btn-yellow btn-mobile-center service-plan-mobile-cta" href="#newlead">Book a Meet &amp; Greet</a>
<a class="btn btn-yellow btn-mobile-center service-plan-mobile-cta cta-shimmer" href="#newlead">Book a Meet &amp; Greet</a>
{#if pageContent.pricing.extras?.length}
<div class="service-extras" aria-labelledby="service-extras-heading">
@@ -437,9 +437,9 @@
</span>
<h3 id="service-extras-heading" class="service-extras-title">Optional extras</h3>
</div>
<div class="service-extras-grid">
<div class="service-extras-grid stagger-children">
{#each pageContent.pricing.extras as extra}
<article class="service-extra-card">
<article class="service-extra-card fade-up">
<div class="service-extra-card-body">
<span class="service-extra-card-label">{extra.label}</span>
{#if extra.note}
@@ -515,15 +515,15 @@
{/if}
{#if showRelatedServices}
<section use:reveal class="service-related reveal-block" aria-label="Other services">
<section use:reveal={{ distance: 0 }} class="service-related reveal-block" aria-label="Other services">
<div class="page-inner">
<div class="service-section-heading">
<h2>Explore our other services</h2>
</div>
<div class="service-related-grid">
<div class="service-related-grid stagger-children">
{#each relatedCards as card, i}
<a class="service-related-card service-related-tint-{i % 4}" href={card.href}>
<a class="service-related-card fade-up service-related-tint-{i % 4}" href={card.href}>
<div class="service-related-icon" aria-hidden="true">
<Icon name={card.icon} />
</div>
+16 -5
View File
@@ -67,22 +67,22 @@
.map(({ service }) => service);
</script>
<section id="services" use:reveal={{ delay: 20 }} class="reveal-block">
<section id="services" use:reveal={{ delay: 20, distance: 0 }} class="reveal-block">
<div class="services-inner">
<div class="section-header">
<div class="section-header fade-up">
<h2 class="section-heading">{heading}</h2>
<p class="section-intro services-intro">{intro}</p>
</div>
<div class="services-grid">
<div class="services-grid stagger-children">
{#each orderedServices as service}
{@const meta = serviceMeta[service.title]}
<a
href={service.href}
class:service-card-featured={meta?.featured}
class="service-card"
class="service-card fade-up"
aria-label={`${service.title} — view service page`}
>
<div class="service-card-media">
@@ -452,4 +452,15 @@
opacity: 1;
transform: translate3d(0, 0, 0);
}
/* Tier choreography — header settles, then the three cards cascade. */
@media (prefers-reduced-motion: no-preference) {
:global(.reveal-visible) .section-header {
transition-delay: 40ms;
}
:global(.reveal-visible) .services-grid > :nth-child(1) { transition-delay: 180ms; }
:global(.reveal-visible) .services-grid > :nth-child(2) { transition-delay: 260ms; }
:global(.reveal-visible) .services-grid > :nth-child(3) { transition-delay: 340ms; }
}
</style>
+13 -15
View File
@@ -62,7 +62,7 @@
cta={{ label: 'Book a Meet & Greet', href: '#newlead', variant: 'yellow' }}
/>
<section use:reveal class="testimonials-page-grid-section reveal-block">
<section use:reveal={{ threshold: 0.02 }} class="testimonials-page-grid-section reveal-block">
<div class="page-inner">
<div class="testimonials-page-grid">
{#each testimonialCards as testimonial}
@@ -399,43 +399,41 @@
opacity: 1;
transform: translateY(0);
transition:
opacity 0.56s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.56s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.28s cubic-bezier(0.22, 1, 0.36, 1),
border-color 0.24s ease;
opacity 0.42s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.42s cubic-bezier(0.22, 1, 0.36, 1);
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card:nth-child(1) {
transition-delay: 60ms;
transition-delay: 0ms;
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card:nth-child(2) {
transition-delay: 140ms;
transition-delay: 50ms;
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card:nth-child(3) {
transition-delay: 220ms;
transition-delay: 100ms;
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card:nth-child(4) {
transition-delay: 300ms;
transition-delay: 150ms;
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card:nth-child(5) {
transition-delay: 380ms;
transition-delay: 200ms;
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card:nth-child(6) {
transition-delay: 460ms;
transition-delay: 250ms;
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-google-cta-wrap {
opacity: 1;
transform: translateY(0);
transition:
opacity 0.56s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.56s cubic-bezier(0.22, 1, 0.36, 1);
transition-delay: 240ms;
opacity 0.42s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.42s cubic-bezier(0.22, 1, 0.36, 1);
transition-delay: 140ms;
}
@media (hover: hover) {
@@ -482,7 +480,7 @@
}
}
@media (min-width: 1400px) {
@media (min-width: 1180px) {
.testimonials-page-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
max-width: 1500px;
+11 -5
View File
@@ -181,10 +181,10 @@
});
</script>
<section id="testimonials" use:reveal={{ delay: 40 }} class="reveal-block">
<section id="testimonials" use:reveal={{ delay: 40, distance: 0 }} class="reveal-block">
<div class="testimonials-inner">
<div class="testimonials-header">
<div class="testimonials-header-main">
<div class="testimonials-header-main fade-up">
<span class="eyebrow testimonials-eyebrow">
<Icon name="fas fa-star" className="testimonials-eyebrow-star" />
{eyebrow}
@@ -192,13 +192,13 @@
<h2 class="section-heading">{heading}</h2>
</div>
<div class="testimonials-header-side">
<div class="testimonials-header-side fade-up">
<div class="testimonials-intro">
<p>{blurb}</p>
</div>
<div class="testimonials-cta-row">
<a href={testimonialsHref} class="btn btn-yellow testimonials-cta testimonials-cta-primary">
<a href={testimonialsHref} class="btn btn-yellow testimonials-cta testimonials-cta-primary cta-shimmer">
<Icon name="fas fa-comment-dots" />
<span class="testimonials-cta-label-desktop">All testimonials</span>
<span class="testimonials-cta-label-mobile">Testimonials</span>
@@ -215,7 +215,7 @@
{#if slides.length}
<div
bind:this={carouselEl}
class="testimonials-carousel"
class="testimonials-carousel scale-soft"
role="region"
aria-label="Customer testimonials"
on:mouseenter={() => (paused = true)}
@@ -527,6 +527,12 @@
transform: translate3d(0, 0, 0);
}
@media (prefers-reduced-motion: no-preference) {
:global(.reveal-visible) .testimonials-header-main { transition-delay: 40ms; }
:global(.reveal-visible) .testimonials-header-side { transition-delay: 140ms; }
:global(.reveal-visible) .testimonials-carousel { transition-delay: 260ms; }
}
.testimonial-stage {
position: relative;
overflow: hidden;
+30 -9
View File
@@ -84,15 +84,15 @@
.map(({ value }) => value);
</script>
<section id="values" use:reveal={{ delay: 30 }} class="reveal-block">
<section id="values" use:reveal={{ delay: 30, distance: 0 }} class="reveal-block">
<div class="values-inner">
<div class="section-header">
<div class="section-header fade-up">
<h2 class="section-heading">Calmer dogs. Calmer evenings.</h2>
</div>
<div class="values-photo-grid" aria-label="Goodwalk client dogs">
<div class="values-photo-grid stagger-children stagger-tight" aria-label="Goodwalk client dogs">
{#each clientPhotoCards as photo, index}
<figure class:values-photo-card-featured={index === 0} class="values-photo-card">
<figure class:values-photo-card-featured={index === 0} class="values-photo-card fade-up">
{#if photo.enhanced}
<enhanced:img
class="values-photo-image"
@@ -120,9 +120,9 @@
{/each}
</div>
<div class="values-bento values-contrast">
<div class="values-bento values-contrast stagger-children">
{#each stakes as stake, index}
<article class:values-contrast-cell-good={index === 1} class="values-contrast-cell">
<article class:values-contrast-cell-good={index === 1} class="values-contrast-cell fade-up">
<div class="values-contrast-head">
<span class:values-contrast-label-good={index === 1} class="values-contrast-label">
{stake.label}
@@ -149,13 +149,13 @@
{/each}
</div>
<div class="values-points-header">
<div class="values-points-header fade-up">
<h3 class="values-points-title">Things we don't treat as extras</h3>
</div>
<div class="values-bento values-points">
<div class="values-bento values-points stagger-children">
{#each orderedValues as value}
<div class="values-point">
<div class="values-point fade-up">
<div class="values-point-icon">
<Icon name={value.icon} className="values-point-glyph" />
</div>
@@ -627,4 +627,25 @@
opacity: 1;
transform: translate3d(0, 0, 0);
}
/* Tier choreography — header, then photo gallery cascades tight,
contrast cells settle, then the bottom-row "extras" cascade. */
@media (prefers-reduced-motion: no-preference) {
:global(.reveal-visible) .values-inner > .section-header { transition-delay: 40ms; }
:global(.reveal-visible) .values-photo-grid > :nth-child(1) { transition-delay: 160ms; }
:global(.reveal-visible) .values-photo-grid > :nth-child(2) { transition-delay: 220ms; }
:global(.reveal-visible) .values-photo-grid > :nth-child(3) { transition-delay: 280ms; }
:global(.reveal-visible) .values-photo-grid > :nth-child(4) { transition-delay: 340ms; }
:global(.reveal-visible) .values-photo-grid > :nth-child(5) { transition-delay: 400ms; }
:global(.reveal-visible) .values-contrast > :nth-child(1) { transition-delay: 460ms; }
:global(.reveal-visible) .values-contrast > :nth-child(2) { transition-delay: 540ms; }
:global(.reveal-visible) .values-points-header { transition-delay: 620ms; }
:global(.reveal-visible) .values-points > :nth-child(1) { transition-delay: 680ms; }
:global(.reveal-visible) .values-points > :nth-child(2) { transition-delay: 740ms; }
:global(.reveal-visible) .values-points > :nth-child(3) { transition-delay: 800ms; }
:global(.reveal-visible) .values-points > :nth-child(n + 4) { transition-delay: 860ms; }
}
</style>
@@ -26,6 +26,15 @@
};
};
type LifecycleStatus = 'active' | 'paused' | 'cancelled' | 'archived';
type Lifecycle = {
status: LifecycleStatus;
reason: string;
changedAt: string;
changedBy: string;
};
type AllClient = {
email: string;
fullName: string;
@@ -33,10 +42,22 @@
dogName: string;
dogBreed: string;
status: 'pending' | 'completed';
lifecycle: Lifecycle;
lastActivityAt: string;
welcomePackSentAt: string;
};
const lifecycleOptions: { value: LifecycleStatus; label: string; description: string }[] = [
{ value: 'active', label: 'Active', description: 'Currently a client. Included in outreach.' },
{ value: 'paused', label: 'Paused', description: 'Temporarily not walking. Still included in outreach.' },
{ value: 'cancelled', label: 'Cancelled', description: 'No longer a client. Excluded from outreach but kept on file.' },
{ value: 'archived', label: 'Archived', description: 'Long-term inactive. Hidden from outreach but kept for records.' },
];
function lifecycleLabel(status: LifecycleStatus): string {
return lifecycleOptions.find((o) => o.value === status)?.label ?? status;
}
type BirthdayClient = {
email: string;
fullName: string;
@@ -126,6 +147,118 @@
let confirmPreview = false;
let confirmBusy = false;
let confirmError = '';
let confirmPreviewHtml = '';
let confirmPreviewBusy = false;
let confirmPreviewToken = 0;
async function fetchConfirmPreview(ctx: ConfirmContext) {
const token = getToken();
if (!token) return;
const stamp = ++confirmPreviewToken;
confirmPreviewBusy = true;
try {
let url = '';
let body: Record<string, unknown> = {};
if (ctx.kind === 'welcome') {
url = '/api/owner/render-welcome-pack';
body = {
email: ctx.client.email,
serviceType: ctx.serviceType,
priceDetails: ctx.priceDetails,
startDate: ctx.startDate,
};
} else if (ctx.kind === 'birthday') {
url = '/api/owner/render-birthday-email';
body = { email: ctx.client.email };
} else {
// 'message' uses the live preview already rendered in the messaging tab.
if (stamp === confirmPreviewToken) confirmPreviewHtml = msgPreviewHtml || '';
return;
}
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => null);
if (stamp !== confirmPreviewToken) return;
if (res.ok && typeof data?.html === 'string') {
confirmPreviewHtml = data.html;
} else {
confirmPreviewHtml = '';
}
} catch {
if (stamp === confirmPreviewToken) confirmPreviewHtml = '';
} finally {
if (stamp === confirmPreviewToken) confirmPreviewBusy = false;
}
}
function openConfirmPreviewInNewTab() {
if (!confirmPreviewHtml) return;
const blob = new Blob([confirmPreviewHtml], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener,noreferrer');
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
$: if (confirmCtx) {
void fetchConfirmPreview(confirmCtx);
} else {
confirmPreviewHtml = '';
confirmPreviewBusy = false;
}
let statusCtx: AllClient | null = null;
let statusDraft: LifecycleStatus = 'active';
let statusReason = '';
let statusBusy = false;
let statusError = '';
function openStatusEditor(client: AllClient) {
statusCtx = client;
statusDraft = client.lifecycle?.status ?? 'active';
statusReason = client.lifecycle?.reason ?? '';
statusError = '';
}
function closeStatusEditor() {
if (statusBusy) return;
statusCtx = null;
statusError = '';
}
async function saveStatus() {
if (!statusCtx) return;
const token = getToken();
if (!token) {
statusError = 'Your session has expired. Please sign in again.';
return;
}
statusBusy = true;
statusError = '';
try {
const res = await fetch('/api/owner/client-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ email: statusCtx.email, status: statusDraft, reason: statusReason.trim() }),
});
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.detail?.message ?? data?.detail ?? 'Could not update status.');
const updated = data?.lifecycle as Lifecycle | undefined;
if (updated) {
allClients = allClients.map((c) =>
c.email === statusCtx!.email ? { ...c, lifecycle: updated } : c,
);
}
statusCtx = null;
} catch (error) {
statusError = error instanceof Error ? error.message : 'Could not update status.';
} finally {
statusBusy = false;
}
}
function getToken() {
return window.localStorage.getItem('gw_onboarding_session') ?? '';
@@ -825,6 +958,13 @@
<svelte:head>
<title>{header.title} | Goodwalk Admin</title>
<meta name="robots" content="noindex, nofollow" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
</svelte:head>
<main class="owner-page">
@@ -1067,9 +1207,17 @@
<div class="owner-cell">
<span class="owner-mobile-label">Status</span>
<span class:owner-status-complete={client.status === 'completed'} class="owner-status-pill">
{client.status === 'completed' ? 'Completed' : 'Pending'}
</span>
<div class="owner-status-stack">
<span class:owner-status-complete={client.status === 'completed'} class="owner-status-pill">
{client.status === 'completed' ? 'Completed' : 'Pending'}
</span>
<span class="owner-lifecycle-pill" data-lifecycle={client.lifecycle?.status ?? 'active'}>
{lifecycleLabel(client.lifecycle?.status ?? 'active')}
</span>
{#if client.lifecycle?.reason}
<small class="owner-lifecycle-reason" title={client.lifecycle.reason}>{client.lifecycle.reason}</small>
{/if}
</div>
</div>
<div class="owner-cell">
@@ -1079,12 +1227,16 @@
</div>
</div>
<div class="owner-cell owner-action-cell">
<div class="owner-cell owner-action-cell owner-action-stack">
<span class="owner-mobile-label">Action</span>
<button class="owner-secondary-btn" type="button" on:click={() => openEnquiry(client.email)}>
<Icon name="fas fa-file-lines" />
View enquiry
</button>
<button class="owner-secondary-btn" type="button" on:click={() => openStatusEditor(client)}>
<Icon name="fas fa-circle-half-stroke" />
Change status
</button>
</div>
</article>
{/each}
@@ -1473,8 +1625,8 @@
</section>
{#if confirmCtx}
<div class="owner-modal-backdrop" role="presentation" on:click={closeConfirm}>
<div class="owner-modal" role="dialog" aria-modal="true" aria-label="Confirm send" on:click|stopPropagation>
<div class="owner-modal-backdrop" role="presentation" on:click|self={closeConfirm}>
<div class="owner-modal" role="dialog" aria-modal="true" aria-label="Confirm send" tabindex="-1">
<div class="owner-modal-head">
<h2>
{#if confirmCtx.kind === 'welcome'}
@@ -1506,9 +1658,34 @@
{/if}
</p>
<div class="owner-modal-preview-shell">
{#if confirmPreviewHtml}
<iframe
class="owner-modal-preview-iframe"
title="Email preview"
sandbox=""
srcdoc={confirmPreviewHtml}
></iframe>
<div class="owner-modal-preview-actions">
<small>This is exactly how the email will look.</small>
<button type="button" class="owner-modal-preview-link" on:click={openConfirmPreviewInNewTab}>
<Icon name="fas fa-up-right-from-square" />
Open full preview
</button>
</div>
{:else}
<div class="owner-modal-preview-empty">
{confirmPreviewBusy ? 'Rendering preview…' : 'Preview unavailable.'}
</div>
{/if}
{#if confirmPreviewBusy && confirmPreviewHtml}
<span class="owner-modal-preview-busy">Refreshing…</span>
{/if}
</div>
<label class="owner-preview-toggle">
<input type="checkbox" bind:checked={confirmPreview} disabled={confirmBusy} />
<span>Send a preview to me ({ownerEmail}) instead</span>
<span>Also send a test copy to me ({ownerEmail}) instead of the client</span>
</label>
{#if confirmError}
@@ -1531,9 +1708,62 @@
</div>
{/if}
{#if statusCtx}
<div class="owner-modal-backdrop" role="presentation" on:click|self={closeStatusEditor}>
<div class="owner-modal" role="dialog" aria-modal="true" aria-label="Change client status" tabindex="-1">
<div class="owner-modal-head">
<h2>Change status for {statusCtx.fullName || statusCtx.email}</h2>
<button type="button" class="owner-modal-close" on:click={closeStatusEditor} aria-label="Close" disabled={statusBusy}>
<Icon name="fas fa-xmark" />
</button>
</div>
<p class="owner-modal-status">
Soft-change only. The client stays on file in case we want to reach out later
(e.g. a newsletter). Cancelled and archived clients are excluded from outreach.
</p>
<div class="status-options">
{#each lifecycleOptions as opt}
<label class:status-option-active={statusDraft === opt.value} class="status-option">
<input type="radio" name="lifecycle-status" value={opt.value} bind:group={statusDraft} disabled={statusBusy} />
<div>
<strong>{opt.label}</strong>
<small>{opt.description}</small>
</div>
</label>
{/each}
</div>
<label class="status-reason-field">
<span>Reason (optional)</span>
<textarea bind:value={statusReason} rows="3" maxlength="500" disabled={statusBusy}
placeholder="e.g. moved overseas, switched walkers, on hold until spring…"></textarea>
</label>
{#if statusCtx.lifecycle?.changedAt}
<p class="owner-modal-status">
Last changed {formatDateLabel(statusCtx.lifecycle.changedAt)}{statusCtx.lifecycle.changedBy ? ` by ${statusCtx.lifecycle.changedBy}` : ''}.
</p>
{/if}
{#if statusError}
<div class="owner-inline-error">{statusError}</div>
{/if}
<div class="owner-compose-actions">
<button class="owner-cancel-btn" type="button" on:click={closeStatusEditor} disabled={statusBusy}>Cancel</button>
<button class="owner-send-btn" type="button" on:click={saveStatus} disabled={statusBusy}>
{statusBusy ? 'Saving…' : 'Save status'}
</button>
</div>
</div>
</div>
{/if}
{#if enquiryOpen}
<div class="owner-modal-backdrop" role="presentation" on:click={closeEnquiry}>
<div class="owner-modal" role="dialog" aria-modal="true" aria-label="Client enquiry" on:click|stopPropagation>
<div class="owner-modal-backdrop" role="presentation" on:click|self={closeEnquiry}>
<div class="owner-modal" role="dialog" aria-modal="true" aria-label="Client enquiry" tabindex="-1">
<div class="owner-modal-head">
<h2>Website enquiry</h2>
<button type="button" class="owner-modal-close" on:click={closeEnquiry} aria-label="Close">
@@ -1585,6 +1815,18 @@
<style>
.owner-page {
/* Scope a clean Inter type system to the admin only — leaves the public
site's Unbounded / Readex Pro stack untouched. */
--admin-font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-head: var(--admin-font);
--font-body: var(--admin-font);
font-family: var(--admin-font);
font-feature-settings: 'cv11', 'ss01', 'ss03';
letter-spacing: -0.005em;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
min-height: 100vh;
background: #f7f6f1;
color: var(--gw-green);
@@ -1592,6 +1834,13 @@
flex-direction: column;
}
.owner-page :global(input),
.owner-page :global(textarea),
.owner-page :global(select),
.owner-page :global(button) {
font-family: inherit;
}
.owner-shell {
max-width: 1120px;
margin: 0 auto;
@@ -1719,29 +1968,8 @@
to { transform: rotate(360deg); }
}
.owner-hero {
padding: 16px 0 10px;
}
.owner-hero h1 {
margin: 0 0 6px;
color: rgba(17, 23, 27, 0.9);
font-family: var(--font-head);
font-size: clamp(22px, 3.4vw, 30px);
line-height: 1.1;
letter-spacing: -0.03em;
}
.owner-hero p {
margin: 0;
max-width: 62ch;
color: rgba(33, 48, 33, 0.66);
font-size: 14px;
line-height: 1.55;
}
.owner-content {
padding: 16px 0 112px;
padding: 16px 0 calc(108px + env(safe-area-inset-bottom, 0px));
}
.owner-summary-grid {
@@ -1801,7 +2029,6 @@
justify-content: flex-end;
}
.owner-section-head h2,
.owner-message-card h2 {
margin: 0;
color: #171b20;
@@ -1811,7 +2038,6 @@
letter-spacing: -0.02em;
}
.owner-section-head p,
.owner-message-card p {
margin: 6px 0 0;
color: rgba(33, 48, 33, 0.64);
@@ -1932,24 +2158,6 @@
overflow-wrap: anywhere;
}
.owner-chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.owner-chip {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 10px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.08);
color: #213021;
font-size: 12px;
font-weight: 700;
}
.owner-status-pill {
display: inline-flex;
align-items: center;
@@ -1971,6 +2179,122 @@
color: #213021;
}
.owner-status-stack {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.owner-lifecycle-pill {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 10px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: rgba(33, 48, 33, 0.08);
color: rgba(33, 48, 33, 0.72);
}
.owner-lifecycle-pill[data-lifecycle='active'] {
background: rgba(60, 140, 75, 0.16);
color: #1f5a2c;
}
.owner-lifecycle-pill[data-lifecycle='paused'] {
background: rgba(255, 209, 0, 0.22);
color: #6a4a00;
}
.owner-lifecycle-pill[data-lifecycle='cancelled'] {
background: rgba(190, 60, 60, 0.14);
color: #8a2a2a;
}
.owner-lifecycle-pill[data-lifecycle='archived'] {
background: rgba(33, 48, 33, 0.12);
color: rgba(33, 48, 33, 0.62);
}
.owner-lifecycle-reason {
color: rgba(33, 48, 33, 0.6);
font-size: 11px;
line-height: 1.35;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-options {
display: grid;
gap: 8px;
margin: 6px 0 10px;
}
.status-option {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(33, 48, 33, 0.12);
cursor: pointer;
background: rgba(255, 255, 255, 0.6);
}
.status-option input {
margin-top: 3px;
}
.status-option strong {
display: block;
font-size: 13px;
color: #171b20;
}
.status-option small {
display: block;
margin-top: 2px;
color: rgba(33, 48, 33, 0.62);
font-size: 12px;
line-height: 1.4;
}
.status-option-active {
border-color: rgba(33, 48, 33, 0.36);
background: rgba(255, 209, 0, 0.12);
}
.status-reason-field {
display: grid;
gap: 6px;
margin-bottom: 10px;
}
.status-reason-field span {
font-size: 12px;
font-weight: 700;
color: rgba(33, 48, 33, 0.72);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.status-reason-field textarea {
width: 100%;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(33, 48, 33, 0.16);
font: inherit;
font-size: 13px;
resize: vertical;
min-height: 70px;
}
.owner-open-btn,
.owner-send-btn,
.owner-cancel-btn,
@@ -2175,43 +2499,51 @@
.owner-bottom-nav {
position: fixed;
left: 12px;
right: 12px;
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
left: 10px;
right: 10px;
bottom: calc(10px + env(safe-area-inset-bottom, 0px));
z-index: 25;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 2px;
padding: 6px;
border-radius: 16px;
background: rgba(255, 253, 250, 0.95);
backdrop-filter: blur(12px);
border: 1px solid rgba(17, 20, 24, 0.08);
box-shadow: 0 6px 18px rgba(17, 20, 24, 0.08);
border-radius: 20px;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: saturate(180%) blur(18px);
-webkit-backdrop-filter: saturate(180%) blur(18px);
border: 1px solid rgba(17, 20, 24, 0.06);
box-shadow: 0 10px 28px rgba(17, 20, 24, 0.12), 0 1px 0 rgba(255, 255, 255, 0.6) inset;
}
.owner-bottom-tab {
min-height: 48px;
min-height: 54px;
border: 0;
border-radius: 12px;
border-radius: 14px;
background: transparent;
display: grid;
justify-items: center;
align-content: center;
gap: 3px;
color: rgba(33, 48, 33, 0.58);
gap: 4px;
color: rgba(33, 48, 33, 0.6);
font: inherit;
cursor: pointer;
transition: background 0.18s ease, color 0.18s ease, transform 0.18s ease;
-webkit-tap-highlight-color: transparent;
}
.owner-bottom-tab:active {
transform: scale(0.96);
}
.owner-bottom-tab span {
font-size: 10px;
font-size: 11px;
font-weight: 600;
line-height: 1.1;
letter-spacing: -0.005em;
}
.owner-bottom-tab :global(i) {
font-size: 13px;
font-size: 16px;
}
.owner-bottom-tab-active {
@@ -2219,8 +2551,11 @@
color: #fff;
}
.owner-bottom-tab-active span {
font-weight: 700;
}
@media (hover: hover) {
.owner-tab:hover,
.owner-open-btn:hover,
.owner-send-btn:hover,
.owner-cancel-btn:hover,
@@ -2398,10 +2733,10 @@
color: rgba(33, 48, 33, 0.4);
}
.rte-editor p { margin: 0 0 12px; }
.rte-editor ul,
.rte-editor ol { margin: 0 0 12px; padding-left: 22px; }
.rte-editor a { color: #c0392b; text-decoration: underline; }
.rte-editor :global(p) { margin: 0 0 12px; }
.rte-editor :global(ul),
.rte-editor :global(ol) { margin: 0 0 12px; padding-left: 22px; }
.rte-editor :global(a) { color: #c0392b; text-decoration: underline; }
.msg-form {
display: grid;
@@ -2563,25 +2898,6 @@
gap: 10px;
}
.owner-field textarea {
width: 100%;
min-height: 110px;
padding: 12px 15px;
border: 1px solid rgba(17, 20, 24, 0.1);
border-radius: 14px;
background: #fffdfa;
font: inherit;
color: #213021;
outline: none;
box-sizing: border-box;
resize: vertical;
}
.owner-field textarea:focus {
border-color: rgba(33, 48, 33, 0.42);
box-shadow: 0 0 0 4px rgba(255, 209, 0, 0.12);
}
.msg-recipient-modes {
display: flex;
flex-wrap: wrap;
@@ -2662,35 +2978,7 @@
}
}
.owner-footer-desktop {
display: none;
}
@media (min-width: 900px) {
.owner-footer-desktop {
display: block;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
}
.owner-footer-desktop :global(.ob-footer-inner) {
height: 38px;
padding: 0 28px;
}
.owner-footer-desktop :global(.ob-footer-back),
.owner-footer-desktop :global(.ob-footer-email),
.owner-footer-desktop :global(.ob-footer-logout) {
font-size: 11px;
}
.owner-footer-desktop :global(.ob-footer-logout) {
padding: 4px 10px;
}
.owner-content {
padding-bottom: 60px;
}
@@ -2703,24 +2991,97 @@
.owner-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(17, 20, 24, 0.45);
backdrop-filter: blur(2px);
background: rgba(17, 20, 24, 0.5);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
padding: 14px;
padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
padding-top: calc(14px + env(safe-area-inset-top, 0px));
z-index: 60;
}
.owner-modal {
width: 100%;
max-width: 480px;
max-height: 90vh;
max-width: 560px;
max-height: min(92vh, 800px);
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
background: #fff;
border-radius: 16px;
padding: 18px;
box-shadow: 0 24px 60px rgba(17, 20, 24, 0.25);
border-radius: 18px;
padding: 20px;
box-shadow: 0 24px 60px rgba(17, 20, 24, 0.28);
}
.owner-modal-preview-shell {
margin: 6px 0 14px;
border-radius: 14px;
border: 1px solid rgba(17, 20, 24, 0.08);
background: #f7f6f1;
overflow: hidden;
position: relative;
}
.owner-modal-preview-iframe {
display: block;
width: 100%;
height: 320px;
border: 0;
background: #fff;
}
.owner-modal-preview-busy {
position: absolute;
top: 8px;
right: 10px;
font-size: 11px;
font-weight: 600;
color: rgba(33, 48, 33, 0.6);
background: rgba(255, 255, 255, 0.85);
padding: 3px 8px;
border-radius: 999px;
}
.owner-modal-preview-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
color: rgba(33, 48, 33, 0.5);
font-size: 13px;
}
.owner-modal-preview-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
background: #fff;
border-top: 1px solid rgba(17, 20, 24, 0.06);
}
.owner-modal-preview-actions small {
color: rgba(33, 48, 33, 0.55);
font-size: 12px;
}
.owner-modal-preview-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(33, 48, 33, 0.16);
background: #fff;
color: #213021;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.owner-modal-head {
@@ -2832,21 +3193,56 @@
}
@media (max-width: 768px) {
.owner-page {
/* Prevent any element from breaking horizontal scroll on a phone. */
overflow-x: hidden;
}
.owner-shell {
padding-left: max(16px, env(safe-area-inset-left, 0px));
padding-right: max(16px, env(safe-area-inset-right, 0px));
}
.owner-topbar-inner {
min-height: 56px;
padding-top: 10px;
padding-bottom: 10px;
}
.owner-logo img {
height: 22px;
}
.owner-pill {
font-size: 10px;
padding: 4px 9px;
}
.owner-topbar-logout {
width: 36px;
height: 36px;
padding: 0;
justify-content: center;
border-radius: 999px;
}
.owner-summary-grid {
gap: 6px;
}
.owner-summary-pill {
padding: 8px 10px;
gap: 8px;
padding: 10px 12px;
gap: 10px;
border-radius: 14px;
}
.owner-summary-pill strong {
font-size: 16px;
font-size: 18px;
letter-spacing: -0.01em;
}
.owner-summary-pill span {
font-size: 10px;
font-size: 11px;
}
.owner-section-head {
@@ -2859,6 +3255,16 @@
grid-template-columns: 1fr;
}
.owner-table-row {
border-radius: 16px;
padding: 14px;
gap: 10px;
}
.owner-cell h3 {
font-size: 16px;
}
.owner-pagination {
display: flex;
justify-content: flex-end;
@@ -2867,19 +3273,45 @@
}
.owner-pagination .owner-page-btn {
min-height: 34px;
padding: 0 12px;
font-size: 12px;
min-height: 40px;
padding: 0 14px;
font-size: 13px;
}
.owner-pagination .owner-page-indicator {
font-size: 12px;
font-size: 13px;
}
.owner-enquiry-list > div {
grid-template-columns: 1fr;
gap: 2px;
}
.owner-modal {
max-width: 100%;
max-height: calc(100dvh - 90px - env(safe-area-inset-bottom, 0px));
border-radius: 20px 20px 16px 16px;
padding: 18px 16px;
}
.owner-modal-head h2 {
font-size: 17px;
}
.owner-modal-close {
width: 36px;
height: 36px;
}
.owner-modal-preview-iframe {
height: 260px;
}
.owner-send-btn,
.owner-cancel-btn {
min-height: 46px;
font-size: 14px;
}
}
/* ── Activity tab ── */
+18 -3
View File
@@ -43,6 +43,23 @@ export function buildAreaServed(locations = locationPages) {
}));
}
export const goodwalkProviderNode = {
'@type': ['LocalBusiness', 'PetCareService'],
'@id': `${siteUrl}/#business`,
name: 'Goodwalk',
url: siteUrl,
logo: `${siteUrl}/images/goodwalk-auckland-dog-walking-logo.webp`,
image: `${siteUrl}/images/goodwalk-auckland-dog-walking-logo.webp`,
email: 'info@goodwalk.co.nz',
telephone: '+64226421011',
address: {
'@type': 'PostalAddress',
addressLocality: 'Auckland Central',
addressRegion: 'Auckland',
addressCountry: 'NZ'
}
} as const;
function getLocationSeoImage(location: LocationPageContent) {
return location.seo?.image ?? location.parks.find((park) => park.image?.src)?.image?.src ?? defaultLocationImage;
}
@@ -81,9 +98,7 @@ export function buildLocationSeo(location: LocationPageContent) {
name: serviceName,
description,
serviceType,
provider: {
'@id': `${siteUrl}/#business`
},
provider: goodwalkProviderNode,
areaServed: {
'@type': 'Place',
name: `${location.suburb}, Auckland, New Zealand`
+5
View File
@@ -25,6 +25,11 @@ img {
display: block;
max-width: 100%;
height: auto;
/* Hide alt text flash before pixels paint. Alt is still exposed to
screen readers; this only suppresses the visual text on the
broken-image placeholder shown by some browsers mid-load. */
color: transparent;
font-size: 0;
}
a {
+1 -1
View File
@@ -55,7 +55,7 @@ header {
@media (min-width: 769px) {
.nav-ribbon::after {
animation: nav-ribbon-shine 8s ease-in-out 2.8s infinite;
animation: nav-ribbon-shine 8s ease-in-out 2.8s 2;
}
}
+202
View File
@@ -0,0 +1,202 @@
/*
Motion system
Reusable, opt-in animation utilities. One easing family, one set of
distances, one set of durations. Designed to pair with the existing
`use:reveal` action: apply the action on a parent section, then add
motion utilities to inner elements.
Naming
.fade-up opacity + translateY
.fade-in opacity only
.scale-soft opacity + subtle scale-in (0.985 1)
.stagger-children cascades children at --motion-stagger-step
.hover-lift elevates on hover (hover-capable devices only)
.cta-shimmer slow, subtle sheen across a CTA (one-off, not loop)
.subtle-glow soft, breathing focus halo for accent surfaces
.image-parallax gentle drift; CSS scroll-driven where supported
All motion is gated behind prefers-reduced-motion: no-preference.
*/
@media (prefers-reduced-motion: no-preference) {
/* Reveal utilities
These start in an "off" state and animate when an ancestor carries
`.reveal-visible` (set by the existing reveal action). That keeps
trigger logic centralized no extra observers per element. */
.fade-up,
.fade-in,
.scale-soft {
opacity: 0;
transition:
opacity var(--motion-duration-md) var(--ease-out-quiet),
transform var(--motion-duration-md) var(--ease-out-quiet);
will-change: opacity, transform;
}
.fade-up {
transform: translate3d(0, var(--motion-distance-md), 0);
}
.scale-soft {
transform: scale(0.985);
transform-origin: center;
}
/* Reveal trigger: when a parent reveal block becomes visible, or the
element itself carries `.is-in` (for non-reveal contexts). */
.reveal-visible .fade-up,
.reveal-visible .fade-in,
.reveal-visible .scale-soft,
.fade-up.is-in,
.fade-in.is-in,
.scale-soft.is-in {
opacity: 1;
transform: none;
}
/* Once the animation has played, drop will-change to free the layer. */
.reveal-visible .fade-up,
.reveal-visible .fade-in,
.reveal-visible .scale-soft {
will-change: auto;
}
/* Stagger
Direct-child cascade. Up to 8 children covered explicitly; beyond
that, children inherit the 8th delay (graceful fallback for grids). */
.stagger-children > * {
transition-delay: 0ms;
}
.stagger-children > :nth-child(1) { transition-delay: calc(var(--motion-stagger-step) * 0); }
.stagger-children > :nth-child(2) { transition-delay: calc(var(--motion-stagger-step) * 1); }
.stagger-children > :nth-child(3) { transition-delay: calc(var(--motion-stagger-step) * 2); }
.stagger-children > :nth-child(4) { transition-delay: calc(var(--motion-stagger-step) * 3); }
.stagger-children > :nth-child(5) { transition-delay: calc(var(--motion-stagger-step) * 4); }
.stagger-children > :nth-child(6) { transition-delay: calc(var(--motion-stagger-step) * 5); }
.stagger-children > :nth-child(7) { transition-delay: calc(var(--motion-stagger-step) * 6); }
.stagger-children > :nth-child(n + 8) { transition-delay: calc(var(--motion-stagger-step) * 7); }
/* Tighter cascade for dense grids (8+ cells). Opt-in. */
.stagger-children.stagger-tight > * {
--motion-stagger-step: 45ms;
}
/* Hover lift
Quiet 2px elevation. Only on hover-capable devices so touch users
don't get a sticky transformed state after tap. */
.hover-lift {
transition:
transform var(--motion-duration-sm) var(--ease-out-soft),
box-shadow var(--motion-duration-sm) var(--ease-out-soft);
}
@media (hover: hover) {
.hover-lift:hover {
transform: translateY(-2px);
}
}
/* Focus-visible matches hover for keyboard parity. */
.hover-lift:focus-visible {
transform: translateY(-2px);
outline: none;
}
/* CTA shimmer
A single, soft sheen sweep that fires once on hover. Quiet, not a
looping ad-banner. Requires the host to be position: relative and
overflow: hidden, which most pill buttons already are. */
.cta-shimmer {
position: relative;
overflow: hidden;
isolation: isolate;
}
.cta-shimmer::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
115deg,
transparent 30%,
rgba(255, 255, 255, 0.32) 50%,
transparent 70%
);
transform: translateX(-110%);
transition: transform var(--motion-duration-lg) var(--ease-in-out-soft);
pointer-events: none;
z-index: 1;
}
@media (hover: hover) {
.cta-shimmer:hover::after {
transform: translateX(110%);
}
}
/* Subtle glow
A slow, breathing accent halo. Calibrated to be barely perceptible
the kind of thing you notice only after watching for a few seconds. */
.subtle-glow {
animation: motion-subtle-glow 5.2s var(--ease-in-out-soft) infinite;
}
@keyframes motion-subtle-glow {
0%, 100% {
box-shadow: 0 0 0 0 rgba(255, 209, 0, 0);
}
50% {
box-shadow: 0 0 0 6px rgba(255, 209, 0, 0.08);
}
}
/* Image parallax
Gentle vertical drift on scroll. Uses CSS scroll-driven animations
where supported (Chrome 115+, Edge 115+). Older browsers get a still
image perfectly fine fallback, no jank, no JS. */
@supports (animation-timeline: view()) {
.image-parallax {
animation: motion-image-parallax linear both;
animation-timeline: view();
animation-range: cover 0% cover 100%;
will-change: transform;
}
@keyframes motion-image-parallax {
from { transform: translate3d(0, -3%, 0); }
to { transform: translate3d(0, 3%, 0); }
}
}
}
/* Reduced motion
Strip every transition and animation that this system introduces. The
reveal utilities collapse to their visible state immediately so layout
stays correct. */
@media (prefers-reduced-motion: reduce) {
.fade-up,
.fade-in,
.scale-soft {
opacity: 1;
transform: none;
transition: none;
}
.stagger-children > * {
transition-delay: 0 !important;
}
.hover-lift,
.cta-shimmer::after,
.subtle-glow,
.image-parallax {
transition: none !important;
animation: none !important;
transform: none !important;
}
.cta-shimmer::after {
display: none;
}
}
+59
View File
@@ -761,12 +761,71 @@
}
}
/* 14" laptop range: dog still clips the centered headline at the existing
54% / -200px settings. Shrink the footprint and pull it further left so
the mascot fully clears the text column. */
@media (min-width: 1024px) and (max-width: 1439px) {
.hero-img img {
width: 46%;
transform: translateX(-260px);
}
}
@media (min-width: 1200px) {
body:has(#hero) .hero-inner {
padding-bottom: 120px;
}
}
/* Laptop screens (1314"): the hero is otherwise capped at 680px, which
leaves the text crowding the top of the section. Give it more room so
the bottom-anchored text sits lower in the viewport. */
@media (min-width: 1024px) and (max-width: 1439px) {
#hero {
min-height: 92svh;
max-height: 920px;
}
}
/* 13/14" laptop polish
The default type and spacing scales were tuned on a large external
monitor their caps land at sizes that feel oversized on the shorter
720900px viewports typical of 1314" laptops. Pull the caps down so
headings, hero text, and section rhythm sit comfortably above the fold
without changing the visual identity. Tokens flow through every section
that reads from them. */
@media (min-width: 1024px) and (max-width: 1439px) {
:root {
/* Type scale — trim caps ~15% so headings stop dominating the viewport */
--text-display: clamp(38px, 3.8vw, 46px);
--text-h1: clamp(30px, 3.1vw, 38px);
--text-h2: clamp(24px, 2.4vw, 30px);
--text-h3: 20px;
/* Section rhythm keep generous breathing room but stop sections
eating ~60% of the viewport on a single scroll step */
--space-section-featured-y: clamp(var(--space-11), 6.2vw, var(--space-13));
--space-section-support-y: clamp(var(--space-10), 5.4vw, var(--space-12));
--space-section-form-y: clamp(var(--space-10), 5.4vw, var(--space-12));
--space-section-page-y: clamp(var(--space-10), 5vw, var(--space-12));
--space-hero-inner-bottom: clamp(var(--space-6), 3.6vw, var(--space-8));
}
/* Hero heading reads --text-display (now 46px max); explicitly tune
line-height and margin so the two-line headline stays tight. */
.hero-text h1 {
line-height: 1.04;
margin-bottom: 20px;
}
/* Subtitle: drop a hair so the lead doesn't compete with the heading */
.hero-subtitle,
.hero-subtitle-desktop {
font-size: 16px;
line-height: 1.5;
}
}
/* Ultrawide (1800px)
Only add rules here that are genuinely ultrawide-specific.
Normal desktop, tablet, and mobile breakpoints are handled above. */
+15
View File
@@ -152,6 +152,21 @@
--motion-reveal-opacity: 0.3s ease;
--motion-reveal-transform: 0.45s cubic-bezier(0.2, 0.8, 0.2, 1);
/* Motion system shared timing, easing, and distance tokens.
A single curve family (refined ease-out, gentle ease-in-out) keeps
the whole site speaking one motion language. */
--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
--ease-out-quiet: cubic-bezier(0.2, 0.8, 0.2, 1);
--ease-in-out-soft: cubic-bezier(0.45, 0, 0.2, 1);
--motion-duration-xs: 180ms;
--motion-duration-sm: 260ms;
--motion-duration-md: 420ms;
--motion-duration-lg: 620ms;
--motion-distance-xs: 6px;
--motion-distance-sm: 12px;
--motion-distance-md: 18px;
--motion-stagger-step: 70ms;
/* Layout */
--max-w: 1280px;
--space-container-x: clamp(var(--space-6), 4vw, var(--space-9));
+1
View File
@@ -28,6 +28,7 @@
import '$lib/styles/buttons.css';
import '$lib/styles/forms.css';
import '$lib/styles/sections.css';
import '$lib/styles/motion.css';
import '$lib/styles/responsive.css';
const mobileCtaButtonEnabled = isMobileCtaButtonEnabled();
+34 -39
View File
@@ -16,7 +16,7 @@
import { privacyPolicyContent } from '$lib/content/privacy-policy';
import { puppyVisitsContent } from '$lib/content/puppy-visits';
import { termsAndConditionsContent } from '$lib/content/terms-and-conditions';
import { buildAreaServed, buildBreadcrumb, absoluteUrl } from '$lib/seo';
import { buildAreaServed, buildBreadcrumb, absoluteUrl, goodwalkProviderNode } from '$lib/seo';
import type { PageData } from './$types';
export let data: PageData;
@@ -47,15 +47,31 @@
};
}
const sharedServiceRating = {
'@type': 'AggregateRating',
ratingValue: '5.0',
bestRating: '5',
worstRating: '1',
reviewCount: '30'
};
function buildServiceNode(base: {
name: string;
description: string;
serviceType: string;
image: string;
url: string;
offers: ReturnType<typeof aggregateOfferSchema>;
}) {
const node: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': 'Service',
name: base.name,
description: base.description,
serviceType: base.serviceType,
provider: goodwalkProviderNode,
areaServed,
image: base.image,
url: base.url
};
if (base.offers) {
node.offers = base.offers;
}
return node;
}
const expertProvider = { '@id': `${siteUrl}/#alessandra` };
const areaServed = buildAreaServed();
let seoImage = defaultSeoImage;
@@ -86,21 +102,14 @@
seoImage = packWalksContent.hero.imageUrl;
seoImageAlt = packWalksContent.hero.imageAlt;
pageStructuredData = [
{
'@context': 'https://schema.org',
'@type': 'Service',
buildServiceNode({
name: 'Goodwalk Tiny Gang Pack Walks',
description: data.page.description,
serviceType: 'Pack walks for small and medium dogs',
provider: { '@id': 'https://www.goodwalk.co.nz/#business' },
areaServed,
image: absoluteUrl(seoImage),
url: `${siteUrl}${data.page.canonicalPath}`,
offers: aggregateOfferSchema(packWalksContent.pricing.plans),
aggregateRating: sharedServiceRating,
author: expertProvider,
dateModified: packWalksContent.lastUpdated
},
offers: aggregateOfferSchema(packWalksContent.pricing.plans)
}),
buildBreadcrumb([
{ name: 'Home', url: siteUrl },
{ name: 'Tiny Gang Pack Walks', path: data.page.canonicalPath }
@@ -111,21 +120,14 @@
seoImage = dogWalkingContent.hero.imageUrl;
seoImageAlt = dogWalkingContent.hero.imageAlt;
pageStructuredData = [
{
'@context': 'https://schema.org',
'@type': 'Service',
buildServiceNode({
name: 'Goodwalk Solo Dog Walks',
description: data.page.description,
serviceType: 'One-on-one dog walking',
provider: { '@id': 'https://www.goodwalk.co.nz/#business' },
areaServed,
image: absoluteUrl(seoImage),
url: `${siteUrl}${data.page.canonicalPath}`,
offers: aggregateOfferSchema(dogWalkingContent.pricing.plans),
aggregateRating: sharedServiceRating,
author: expertProvider,
dateModified: dogWalkingContent.lastUpdated
},
offers: aggregateOfferSchema(dogWalkingContent.pricing.plans)
}),
buildBreadcrumb([
{ name: 'Home', url: siteUrl },
{ name: 'Solo Walks', path: data.page.canonicalPath }
@@ -136,21 +138,14 @@
seoImage = puppyVisitsContent.hero.imageUrl;
seoImageAlt = puppyVisitsContent.hero.imageAlt;
pageStructuredData = [
{
'@context': 'https://schema.org',
'@type': 'Service',
buildServiceNode({
name: 'Goodwalk Puppy Visits',
description: data.page.description,
serviceType: 'In-home puppy visits',
provider: { '@id': 'https://www.goodwalk.co.nz/#business' },
areaServed,
image: absoluteUrl(seoImage),
url: `${siteUrl}${data.page.canonicalPath}`,
offers: aggregateOfferSchema(puppyVisitsContent.pricing.plans),
aggregateRating: sharedServiceRating,
author: expertProvider,
dateModified: puppyVisitsContent.lastUpdated
},
offers: aggregateOfferSchema(puppyVisitsContent.pricing.plans)
}),
buildBreadcrumb([
{ name: 'Home', url: siteUrl },
{ name: 'Puppy Visits', path: data.page.canonicalPath }
+5 -10
View File
@@ -17,7 +17,7 @@ describe('static slug route page', () => {
['our-pricing', ourPricingContent.subtitle ?? ourPricingContent.title],
['about', aboutPageContent.sections[0].title],
['testimonials', 'Client Testimonials'],
['contact-us', "Let's meet!"],
['contact-us', 'Contact Us'],
['terms-and-conditions', '1. Application of Terms'],
['privacy-policy', 'How we collect your information']
] as const)('renders the %s page branch', (slug, expectedText) => {
@@ -49,26 +49,21 @@ describe('static slug route page', () => {
data: createStaticRouteData(slug)
});
expect(screen.queryByText("Let's meet!")).not.toBeInTheDocument();
expect(screen.queryByText(/Tell us about your dog/i)).not.toBeInTheDocument();
expect(screen.queryByText('What our clients say')).not.toBeInTheDocument();
}
);
it('shows the general enquiry option on the contact page only', () => {
const { rerender } = render(SlugPage, {
it('does not surface the general-enquiry option even when the flag is on', () => {
render(SlugPage, {
data: {
...createStaticRouteData('contact-us'),
generalEnquiryEnabled: true
}
});
expect(screen.getByRole('button', { name: /General enquiry/i })).toBeInTheDocument();
rerender({
data: createStaticRouteData('pack-walks')
});
expect(screen.queryByRole('button', { name: /General enquiry/i })).not.toBeInTheDocument();
expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument();
});
it('renders the shared FAQ section on the contact page', () => {
+3 -1
View File
@@ -21,7 +21,9 @@ describe('home page route', () => {
const booking = document.getElementById('newlead');
expect(screen.getAllByText(homepageContent.hero.highlight).length).toBeGreaterThan(0);
expect(screen.getByText(homepageContent.intro.text)).toBeInTheDocument();
const introHeadlineText = homepageContent.intro.text.replace(/\.$/, '');
const introHeading = document.querySelector('#intro .intro-headline');
expect(introHeading?.textContent?.replace(/\s+/g, ' ').trim()).toBe(introHeadlineText);
expect(screen.getByText('Calmer dogs. Clearer routines. Less worry.')).toBeInTheDocument();
expect(screen.getByText(homepageContent.howItWorks.title)).toBeInTheDocument();
expect(screen.getByText(homepageContent.info.title)).toBeInTheDocument();
+1
View File
@@ -8,6 +8,7 @@ export const GET: RequestHandler = () => {
'Disallow: /contract',
'Disallow: /meet-greet-v2',
'Disallow: /variants-contact-form',
'Disallow: /variants/',
'',
'# AI crawlers — explicitly permitted',
'User-agent: GPTBot',
@@ -0,0 +1,881 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
const instagramHref = 'https://www.instagram.com/goodwalk.nz/';
// Thumbnails for the polaroid stack + phone mosaic come from the existing
// imagery in /static/images so this page works without new uploads.
const gridImages = [
'/images/goodwalk-tiny-gang-pack-walk-auckland.webp',
'/images/goodwalk-tiny-gang-mt-albert-park-auckland.webp',
'/images/goodwalk-tiny-gang-finishing-walk-suv-auckland.webp',
'/images/goodwalk-client-dogs-mt-cecila-auckland.webp',
'/images/goodwalk-tiny-gang-pack-walk-small-dogs-auckland.webp',
'/images/goodwalk-dogs-group-outing-auckland.webp',
'/images/archie-goodwalk-dog-walking-review-auckland.webp',
'/images/monty-goodwalk-dog-walking-review-auckland.webp',
'/images/otis-goodwalk-dog-walking-review-auckland.webp'
];
const variants = [
{
id: 'editorial',
index: '01',
label: 'Photo-led editorial',
kicker: 'Magazine feel — image does the work, type stays quiet'
},
{
id: 'phone-mosaic',
index: '02',
label: 'Phone mosaic',
kicker: 'Native Instagram preview — see exactly what youll get'
},
{
id: 'quiet-split',
index: '03',
label: 'Quiet split',
kicker: 'Apple-style restraint — no yellow background, no dog cutout'
}
];
</script>
<svelte:head>
<title>Instagram CTA — variants</title>
<meta name="robots" content="noindex, nofollow" />
</svelte:head>
<main class="variants-page">
<header class="variants-header">
<span class="variants-eyebrow">Variants</span>
<h1>Follow-on-Instagram CTA</h1>
<p>
Three reference takes on the existing yellow banner. All link to <code>@goodwalk.nz</code>.
No copy, branding, or palette changes — same content, three different ways to frame it.
</p>
<nav class="variants-nav" aria-label="Jump to variant">
{#each variants as variant}
<a href={`#${variant.id}`} class="variants-nav-chip">
<span class="variants-nav-index">{variant.index}</span>
<span class="variants-nav-label">{variant.label}</span>
</a>
{/each}
</nav>
</header>
<!-- ─── Variant 01: Photo-led editorial ─────────────────────────────────── -->
<section id="editorial" class="variant-showcase">
<div class="variant-meta">
<span class="variant-index">01</span>
<div>
<h2>Photo-led editorial</h2>
<p>Full-bleed dog photo. Type sits in a dark scrim on the left. A small Instagram source pill anchors the corner. Yellow CTA the only saturated element.</p>
</div>
</div>
<div class="variant-stage variant-stage-editorial">
<aside class="ig-editorial" aria-label="Follow Goodwalk on Instagram">
<div class="ig-editorial-photo">
<img
src="/images/goodwalk-tiny-gang-pack-walk-small-dogs-auckland.webp"
alt=""
loading="lazy"
decoding="async"
/>
</div>
<div class="ig-editorial-scrim">
<div class="ig-editorial-copy">
<span class="ig-editorial-source">
<Icon name="fab fa-instagram" />
<span>@goodwalk.nz</span>
</span>
<h2>Follow the Tiny Gang on Instagram.</h2>
<p>Daily walks, real dogs, no filter. Park runs, pack moments, and the kind of updates you'd actually want to scroll.</p>
<a href={instagramHref} target="_blank" rel="noopener" class="ig-editorial-cta">
<Icon name="fab fa-instagram" />
<span>Follow @goodwalk.nz</span>
<Icon name="fas fa-arrow-right" className="ig-editorial-arrow" />
</a>
</div>
</div>
</aside>
</div>
</section>
<!-- ─── Variant 02: Phone mosaic ────────────────────────────────────────── -->
<section id="phone-mosaic" class="variant-showcase">
<div class="variant-meta">
<span class="variant-index">02</span>
<div>
<h2>Phone mosaic</h2>
<p>Goodwalk green panel, copy left. Right side carries a subtle phone frame showing a 3×3 Instagram grid. Visitors see the product before they tap.</p>
</div>
</div>
<div class="variant-stage variant-stage-mosaic">
<aside class="ig-mosaic" aria-label="Follow Goodwalk on Instagram">
<div class="ig-mosaic-copy">
<span class="ig-mosaic-eyebrow">
<Icon name="fab fa-instagram" />
<span>@goodwalk.nz</span>
</span>
<h2>Follow the Tiny Gang on Instagram.</h2>
<p>Pack walks, puppy visits, and the dogs your dog would call friends. Updated most days.</p>
<a href={instagramHref} target="_blank" rel="noopener" class="btn btn-yellow ig-mosaic-cta">
<Icon name="fab fa-instagram" />
<span>Follow on Instagram</span>
</a>
<div class="ig-mosaic-meta" aria-label="Follower stats">
<div>
<strong>30+</strong>
<span>five-star reviews</span>
</div>
<div>
<strong>Daily</strong>
<span>tiny-gang updates</span>
</div>
</div>
</div>
<div class="ig-mosaic-phone" aria-hidden="true">
<div class="ig-mosaic-phone-frame">
<div class="ig-mosaic-phone-header">
<Icon name="fab fa-instagram" />
<span>goodwalk.nz</span>
<span class="ig-mosaic-phone-dot" aria-hidden="true"></span>
<span class="ig-mosaic-phone-follow">Following</span>
</div>
<div class="ig-mosaic-phone-grid">
{#each gridImages as src}
<div class="ig-mosaic-phone-cell">
<img {src} alt="" loading="lazy" decoding="async" />
</div>
{/each}
</div>
</div>
</div>
</aside>
</div>
</section>
<!-- ─── Variant 03: Quiet split ─────────────────────────────────────────── -->
<section id="quiet-split" class="variant-showcase">
<div class="variant-meta">
<span class="variant-index">03</span>
<div>
<h2>Quiet split</h2>
<p>No yellow page background. The section sits on off-white. One editorial photo, type at the leading edge, CTA understated. The page exhales here instead of shouting.</p>
</div>
</div>
<div class="variant-stage variant-stage-quiet">
<aside class="ig-quiet" aria-label="Follow Goodwalk on Instagram">
<div class="ig-quiet-copy">
<span class="ig-quiet-eyebrow">Daily on Instagram</span>
<h2>The Tiny Gang, day&nbsp;by&nbsp;day.</h2>
<p>
A small window into our walks. Park favourites, new pack pairings, and the
quieter moments owners love coming home to.
</p>
<a href={instagramHref} target="_blank" rel="noopener" class="ig-quiet-cta">
<span class="ig-quiet-cta-mark" aria-hidden="true">
<Icon name="fab fa-instagram" />
</span>
<span class="ig-quiet-cta-text">
<span class="ig-quiet-cta-handle">@goodwalk.nz</span>
<span class="ig-quiet-cta-action">
Follow
<Icon name="fas fa-arrow-right" />
</span>
</span>
</a>
</div>
<div class="ig-quiet-media">
<img
src="/images/goodwalk-tiny-gang-mt-albert-park-auckland.webp"
alt=""
loading="lazy"
decoding="async"
/>
</div>
</aside>
</div>
</section>
</main>
<style>
.variants-page {
background: var(--off-white);
min-height: 100vh;
padding-bottom: 96px;
}
/* ── Page header ─────────────────────────────────────────────────────── */
.variants-header {
max-width: 880px;
margin: 0 auto;
padding: 96px 24px 48px;
}
.variants-eyebrow {
display: inline-block;
margin-bottom: 16px;
color: var(--gw-green);
font-family: var(--font-head);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.variants-header h1 {
margin: 0 0 18px;
font-family: var(--font-head);
font-size: clamp(34px, 4vw, 48px);
font-weight: 800;
letter-spacing: -0.025em;
line-height: 1.05;
color: var(--text-heading);
}
.variants-header p {
margin: 0;
max-width: 60ch;
color: var(--text-muted);
font-size: 16px;
line-height: 1.65;
}
.variants-header code {
padding: 2px 6px;
border-radius: 6px;
background: rgba(33, 48, 33, 0.08);
color: var(--gw-green);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
}
.variants-nav {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 28px;
}
.variants-nav-chip {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 9px 16px 9px 12px;
border-radius: 999px;
background: #fff;
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.08), 0 6px 16px rgba(17, 20, 24, 0.04);
color: var(--gw-green);
font-family: var(--font-head);
font-size: 13px;
font-weight: 700;
text-decoration: none;
transition: transform 0.18s ease, box-shadow 0.22s ease;
}
.variants-nav-chip:hover {
transform: translateY(-1px);
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.1), 0 10px 22px rgba(17, 20, 24, 0.07);
}
.variants-nav-index {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--yellow);
color: var(--gw-green);
font-size: 11px;
font-weight: 800;
}
/* ── Each variant block ──────────────────────────────────────────────── */
.variant-showcase {
max-width: 1280px;
margin: 0 auto;
padding: 72px 24px 0;
}
.variant-meta {
display: flex;
align-items: flex-start;
gap: 18px;
margin-bottom: 28px;
max-width: 760px;
}
.variant-index {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--gw-green);
color: var(--yellow);
font-family: var(--font-head);
font-size: 13px;
font-weight: 800;
}
.variant-meta h2 {
margin: 0 0 6px;
font-family: var(--font-head);
font-size: 22px;
font-weight: 700;
color: var(--text-heading);
letter-spacing: -0.015em;
}
.variant-meta p {
margin: 0;
color: var(--text-muted);
font-size: 14px;
line-height: 1.55;
}
/* The "stage" mimics where the section sits on the live page — page
surface around it, so each variant reads in context. */
.variant-stage {
padding: 56px clamp(24px, 4vw, 64px);
border-radius: 32px;
}
.variant-stage-editorial {
background: #f4efe5;
}
.variant-stage-mosaic {
background: var(--yellow);
}
.variant-stage-quiet {
background: var(--off-white);
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.04);
}
/* ═══════════════════════════════════════════════════════════════════════
Variant 01 — Photo-led editorial
═══════════════════════════════════════════════════════════════════════ */
.ig-editorial {
position: relative;
overflow: hidden;
border-radius: 28px;
aspect-ratio: 2.6 / 1;
min-height: 280px;
box-shadow: 0 30px 70px rgba(17, 20, 24, 0.16);
}
.ig-editorial-photo {
position: absolute;
inset: 0;
z-index: 0;
}
.ig-editorial-photo img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 60% 40%;
transform: scale(1.02);
transition: transform 0.8s cubic-bezier(0.22, 1, 0.36, 1);
}
.ig-editorial:hover .ig-editorial-photo img {
transform: scale(1.06);
}
.ig-editorial-scrim {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
background: linear-gradient(
100deg,
rgba(15, 24, 15, 0.86) 0%,
rgba(15, 24, 15, 0.74) 36%,
rgba(15, 24, 15, 0.18) 70%,
rgba(15, 24, 15, 0) 100%
);
padding: 48px clamp(28px, 4vw, 56px);
}
.ig-editorial-copy {
max-width: 480px;
color: #fff;
}
.ig-editorial-source {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px 6px 10px;
margin-bottom: 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
color: #fff;
font-family: var(--font-head);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
}
.ig-editorial-source :global(.icon) {
font-size: 13px;
}
.ig-editorial-copy h2 {
margin: 0 0 12px;
font-family: var(--font-head);
font-size: clamp(26px, 3vw, 36px);
font-weight: 800;
letter-spacing: -0.025em;
line-height: 1.08;
color: #fff;
}
.ig-editorial-copy p {
margin: 0 0 26px;
color: rgba(255, 255, 255, 0.78);
font-size: 15px;
line-height: 1.55;
max-width: 36ch;
}
.ig-editorial-cta {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 13px 22px;
border-radius: 999px;
background: var(--yellow);
color: var(--gw-green);
font-family: var(--font-head);
font-size: 14px;
font-weight: 800;
text-decoration: none;
box-shadow: 0 12px 24px rgba(255, 209, 0, 0.32);
transition: transform 0.22s ease, box-shadow 0.22s ease;
}
.ig-editorial-cta:hover {
transform: translateY(-2px);
box-shadow: 0 18px 32px rgba(255, 209, 0, 0.38);
}
.ig-editorial-cta :global(.ig-editorial-arrow) {
transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1);
}
.ig-editorial-cta:hover :global(.ig-editorial-arrow) {
transform: translateX(3px);
}
/* ═══════════════════════════════════════════════════════════════════════
Variant 02 — Phone mosaic
═══════════════════════════════════════════════════════════════════════ */
.ig-mosaic {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 0.9fr);
align-items: center;
gap: clamp(24px, 4vw, 56px);
padding: clamp(36px, 5vw, 64px) clamp(28px, 4vw, 56px);
border-radius: 32px;
background:
radial-gradient(circle at 100% 0%, rgba(255, 209, 0, 0.16), transparent 38%),
var(--gw-green);
color: #fff;
overflow: hidden;
}
.ig-mosaic-copy {
max-width: 460px;
}
.ig-mosaic-eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 14px 7px 11px;
margin-bottom: 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: var(--yellow);
font-family: var(--font-head);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
}
.ig-mosaic h2 {
margin: 0 0 14px;
font-family: var(--font-head);
font-size: clamp(28px, 3vw, 38px);
font-weight: 800;
letter-spacing: -0.025em;
line-height: 1.06;
}
.ig-mosaic-copy p {
margin: 0 0 28px;
color: rgba(255, 255, 255, 0.78);
font-size: 15px;
line-height: 1.55;
max-width: 38ch;
}
.ig-mosaic-cta {
gap: 9px;
font-weight: 800;
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.18);
}
.ig-mosaic-meta {
display: flex;
gap: 32px;
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid rgba(255, 255, 255, 0.14);
}
.ig-mosaic-meta div {
display: flex;
flex-direction: column;
gap: 2px;
}
.ig-mosaic-meta strong {
color: var(--yellow);
font-family: var(--font-head);
font-size: 18px;
font-weight: 800;
letter-spacing: -0.01em;
}
.ig-mosaic-meta span {
color: rgba(255, 255, 255, 0.7);
font-size: 12px;
line-height: 1.2;
}
.ig-mosaic-phone {
position: relative;
display: flex;
justify-content: center;
align-items: center;
aspect-ratio: 1 / 1;
min-height: 320px;
}
.ig-mosaic-phone-frame {
width: min(100%, 320px);
padding: 14px;
border-radius: 26px;
background: linear-gradient(180deg, #fafaf7, #ecebe5);
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.1),
0 28px 60px rgba(0, 0, 0, 0.32),
0 4px 10px rgba(0, 0, 0, 0.18);
transform: rotate(-3deg);
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.ig-mosaic:hover .ig-mosaic-phone-frame {
transform: rotate(-1.5deg) translateY(-4px);
}
.ig-mosaic-phone-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 4px 14px;
color: var(--text-heading);
font-family: var(--font-head);
font-size: 13px;
font-weight: 700;
}
.ig-mosaic-phone-header :global(.icon) {
color: var(--text-heading);
font-size: 15px;
}
.ig-mosaic-phone-dot {
color: var(--text-muted);
font-size: 14px;
line-height: 1;
}
.ig-mosaic-phone-follow {
color: var(--gw-green);
font-size: 12px;
font-weight: 800;
}
.ig-mosaic-phone-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 3px;
border-radius: 6px;
overflow: hidden;
}
.ig-mosaic-phone-cell {
aspect-ratio: 1 / 1;
overflow: hidden;
background: rgba(33, 48, 33, 0.08);
}
.ig-mosaic-phone-cell img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* ═══════════════════════════════════════════════════════════════════════
Variant 03 — Quiet split
═══════════════════════════════════════════════════════════════════════ */
.ig-quiet {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: center;
gap: clamp(32px, 5vw, 72px);
padding: clamp(28px, 3vw, 40px) 0;
}
.ig-quiet-copy {
max-width: 440px;
}
.ig-quiet-eyebrow {
display: inline-block;
margin-bottom: 16px;
color: var(--gw-green);
font-family: var(--font-head);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.ig-quiet h2 {
margin: 0 0 18px;
font-family: var(--font-head);
font-size: clamp(28px, 3.2vw, 40px);
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.04;
color: var(--text-heading);
}
.ig-quiet-copy p {
margin: 0 0 32px;
color: var(--text-muted);
font-size: 16px;
line-height: 1.6;
max-width: 36ch;
}
.ig-quiet-cta {
display: inline-flex;
align-items: stretch;
gap: 14px;
padding: 8px 8px 8px 12px;
border-radius: 18px;
background: #fff;
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.08), 0 18px 32px rgba(17, 20, 24, 0.06);
color: var(--text-heading);
text-decoration: none;
transition: transform 0.22s ease, box-shadow 0.22s ease;
}
.ig-quiet-cta:hover {
transform: translateY(-2px);
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.1), 0 24px 40px rgba(17, 20, 24, 0.1);
}
.ig-quiet-cta-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 12px;
background: var(--gw-green);
color: var(--yellow);
font-size: 20px;
}
.ig-quiet-cta-text {
display: flex;
flex-direction: column;
justify-content: center;
padding-right: 16px;
}
.ig-quiet-cta-handle {
font-family: var(--font-head);
font-size: 14px;
font-weight: 800;
color: var(--text-heading);
line-height: 1.1;
letter-spacing: -0.01em;
}
.ig-quiet-cta-action {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
line-height: 1;
margin-top: 4px;
}
.ig-quiet-cta-action :global(.icon) {
font-size: 10px;
transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1);
}
.ig-quiet-cta:hover .ig-quiet-cta-action :global(.icon) {
transform: translateX(3px);
}
.ig-quiet-media {
position: relative;
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 24px;
box-shadow: 0 22px 50px rgba(17, 20, 24, 0.12);
}
.ig-quiet-media img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
}
.ig-quiet:hover .ig-quiet-media img {
transform: scale(1.04);
}
/* ── Mobile ──────────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.variants-header {
padding: 64px 20px 32px;
}
.variant-showcase {
padding: 48px 16px 0;
}
.variant-stage {
padding: 28px 16px;
}
.ig-editorial {
aspect-ratio: auto;
min-height: 0;
}
.ig-editorial-photo {
position: relative;
aspect-ratio: 4 / 3;
}
.ig-editorial-scrim {
position: relative;
inset: auto;
background: var(--gw-green);
padding: 28px 22px;
}
.ig-mosaic {
grid-template-columns: 1fr;
padding: 32px 22px;
gap: 24px;
text-align: center;
}
.ig-mosaic-copy {
margin: 0 auto;
}
.ig-mosaic-eyebrow,
.ig-mosaic-cta {
margin-left: auto;
margin-right: auto;
}
.ig-mosaic-meta {
justify-content: center;
}
.ig-mosaic-phone {
min-height: 0;
aspect-ratio: auto;
}
.ig-mosaic-phone-frame {
width: min(100%, 280px);
transform: rotate(-2deg);
}
.ig-quiet {
grid-template-columns: 1fr;
gap: 24px;
padding: 0;
}
.ig-quiet-copy {
order: 2;
margin: 0 auto;
text-align: center;
}
.ig-quiet-copy p {
margin-left: auto;
margin-right: auto;
}
.ig-quiet-cta {
margin: 0 auto;
}
.ig-quiet-media {
order: 1;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.ig-editorial-photo img,
.ig-mosaic-phone-frame,
.ig-quiet-media img,
.ig-editorial-cta,
.ig-quiet-cta {
transition: none;
transform: none;
}
}
</style>