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
+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 ── */