Content Rewrite

This commit is contained in:
2026-05-07 21:47:42 +12:00
parent 0d86f450ec
commit a90dfb7c66
47 changed files with 1352 additions and 527 deletions
+3 -3
View File
@@ -18,9 +18,9 @@
<h1>Contact Us</h1>
<p class="booking-page-sub">
{#if allowGeneralEnquiry}
Fill in the form below to book a Meet &amp; Greet or send a general enquiry.
Book a Meet &amp; Greet or send a general enquiry. Well come back within 24 hours.
{:else}
Fill in the form below and we'll be in touch to arrange a free introduction.
Tell us a little about your dog and well be in touch within 24 hours to arrange a free Meet &amp; Greet.
{/if}
</p>
<div class="booking-page-contact">
@@ -46,7 +46,7 @@
}
.booking-page-hero {
background: var(--green);
background: var(--gw-green);
color: #fff;
padding: 64px 0 72px;
}
+216 -93
View File
@@ -11,7 +11,38 @@
type EnquiryType = 'booking' | 'general';
const visitStartedStorageKey = 'goodwalk_visit_started_at';
const journeyStorageKey = 'goodwalk_journey';
const requestedServiceStorageKey = 'goodwalk_requested_service';
const maxJourneyEntries = 8;
const servicePrompts: Record<
string,
{
intro: string;
messageLabel: string;
messagePlaceholder: string;
}
> = {
'Pack Walks': {
intro:
'Tell us about your dog, your area, and how they feel around other dogs so we can see if Pack Walks are the right fit.',
messageLabel: 'Pack Walks fit',
messagePlaceholder:
'How old is your dog, how do they feel in groups, and is there anything about confidence, recall, or social behaviour we should know?'
},
'1:1 Walks': {
intro:
'Tell us about your dog, your area, and what you want from one-on-one walks so we can plan the right routine.',
messageLabel: '1:1 walk needs',
messagePlaceholder:
'Tell us about your dogs size, pace, leash manners, confidence, and anything else that would help us tailor a one-on-one walk.'
},
'Puppy Visits': {
intro:
'Tell us about your puppy, your area, and the kind of support you need at home so we can plan the right visit.',
messageLabel: 'Puppy visit details',
messagePlaceholder:
'Tell us your puppys age, routine, toilet needs, feeding schedule, and anything important we should know before visiting.'
}
};
let step = 1;
$: headingParts = splitBookingTitle(booking.title);
@@ -76,7 +107,9 @@
const defaultGeneralSubtitle =
'Almost there — just your contact details so we can reply properly to your message.';
$: dogIntro = booking.dogIntro?.trim() || defaultDogIntro;
$: primarySelectedService = selectedServices[0] ?? '';
$: activeServicePrompt = servicePrompts[primarySelectedService];
$: dogIntro = activeServicePrompt?.intro || booking.dogIntro?.trim() || defaultDogIntro;
$: generalIntro = booking.generalIntro?.trim() || defaultGeneralIntro;
$: hasServices = booking.serviceOptions.length > 0;
$: if (!allowGeneralEnquiry && enquiryType === 'general') {
@@ -90,6 +123,16 @@
$: dogStepLabel = booking.dogStepLabel?.trim() || 'Your dog';
$: detailsStepLabel = isGeneralEnquiry ? 'Your enquiry' : dogStepLabel;
$: detailsStepIntro = isGeneralEnquiry ? generalIntro : dogIntro;
$: bookingEyebrow = isGeneralEnquiry ? 'Friendly contact' : primarySelectedService || 'Free Meet & Greet';
$: bookingIntro = isGeneralEnquiry
? 'Send us the details and well point you in the right direction.'
: 'Tell us a little about your dog first. Well come back within 24 hours with the right next step.';
$: detailsMessageLabel = isGeneralEnquiry
? 'Your Message'
: activeServicePrompt?.messageLabel || 'About Your Dog';
$: detailsMessagePlaceholder = isGeneralEnquiry
? 'Tell us if this is feedback, a complaint, a business enquiry, or anything else we should know.'
: activeServicePrompt?.messagePlaceholder || 'Describe your pet, any special needs, or anything we should know.';
$: successPetName = petName.trim() || 'your dog';
onMount(() => {
@@ -98,6 +141,19 @@
pageEnteredAt = now;
visitStartedAt = readOrCreateVisitStartedAt(now);
journey = updateJourneySnapshot(window.location.pathname, window.location.search);
applyRequestedService();
const handleRequestedService = (event: Event) => {
const customEvent = event as CustomEvent<{ service?: string }>;
applyRequestedService(customEvent.detail?.service?.trim());
};
window.addEventListener('goodwalk:service-selected', handleRequestedService as EventListener);
return () => {
window.removeEventListener('goodwalk:service-selected', handleRequestedService as EventListener);
};
});
function splitBookingTitle(title: string) {
@@ -169,11 +225,35 @@
errors = {};
}
function applyRequestedService(service?: string) {
const requestedService =
service ||
(() => {
try {
return window.sessionStorage.getItem(requestedServiceStorageKey)?.trim() || '';
} catch {
return '';
}
})();
if (!requestedService || !booking.serviceOptions.includes(requestedService)) {
return;
}
selectedServices = [requestedService];
try {
window.sessionStorage.removeItem(requestedServiceStorageKey);
} catch {
// Ignore storage cleanup failures.
}
}
function toggleService(service: string, checked: boolean) {
noteInteraction();
if (checked) {
selectedServices = [...selectedServices, service];
selectedServices = [service, ...selectedServices.filter((item) => item !== service)];
return;
}
@@ -342,10 +422,22 @@
{/if}
<div class="booking-header">
<span class="booking-eyebrow">{bookingEyebrow}</span>
<h2 class="booking-title">
<span class="booking-title-plain">{headingParts.plain}</span>{' '}
<span class="booking-title-highlight">{headingParts.highlight}</span>
</h2>
<p class="booking-intro">{bookingIntro}</p>
<div class="booking-trust-row" aria-label="Booking highlights">
<span class="booking-trust-chip">
<Icon name="fas fa-comment-dots" />
Reply within 24 hours
</span>
<span class="booking-trust-chip">
<Icon name="fas fa-paw" />
Free, no-obligation Meet &amp; Greet
</span>
</div>
<div class="booking-stepper" aria-label="Booking form steps">
<button
@@ -438,113 +530,138 @@
{/if}
{#if !isGeneralEnquiry}
<div class="booking-field-card" class:booking-field-card-invalid={errors.petName}>
<label for="petName">
<Icon name="fas fa-dog" />&nbsp;Dog's Name <span class="booking-required">*</span>
</label>
<input
bind:this={petNameInput}
bind:value={petName}
type="text"
id="petName"
name="petName"
required
placeholder="Your dog's name"
class:input-invalid={errors.petName}
on:input={() => clearError('petName')}
/>
{#if errors.petName}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.petName}
</p>
{/if}
</div>
<div class="booking-field-card booking-field-card-group booking-field-card-full">
<div class="booking-field-group booking-field-group-dog">
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.petName}>
<label for="petName">
<Icon name="fas fa-dog" />&nbsp;Dog's Name <span class="booking-required">*</span>
</label>
<input
bind:this={petNameInput}
bind:value={petName}
type="text"
id="petName"
name="petName"
required
placeholder="Your dog's name"
class:input-invalid={errors.petName}
on:input={() => clearError('petName')}
/>
{#if errors.petName}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.petName}
</p>
{/if}
</div>
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.location}>
<label for="location">
<Icon name="fas fa-location-dot" />&nbsp;Location <span class="booking-required">*</span>
</label>
<input
bind:this={locationInput}
bind:value={location}
type="text"
id="location"
name="location"
required
placeholder="Suburb, street..."
class:input-invalid={errors.location}
on:input={() => clearError('location')}
/>
{#if errors.location}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.location}
</p>
{/if}
</div>
<div
class="booking-field-stack booking-field-stack-full"
class:booking-field-stack-invalid={errors.message}
>
<label for="message">
<Icon name="fas fa-comment" />&nbsp;{detailsMessageLabel}
</label>
<textarea
bind:value={message}
id="message"
name="message"
rows="4"
placeholder={detailsMessagePlaceholder}
class:input-invalid={errors.message}
on:input={() => clearError('message')}
></textarea>
{#if errors.message}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.message}
</p>
{/if}
</div>
{#if hasServices}
<div class="booking-field-stack booking-field-stack-full">
<span class="booking-service-label"><Icon name="fas fa-paw" />&nbsp;Services</span>
<div class="booking-service-options">
{#each booking.serviceOptions as service}
<label class="booking-check-option">
<input
type="checkbox"
name="services"
value={service}
checked={selectedServices.includes(service)}
on:change={(event) =>
toggleService(service, (event.currentTarget as HTMLInputElement).checked)}
/>
<span class="booking-check-box" aria-hidden="true"></span>
<span>{service}</span>
</label>
{/each}
</div>
</div>
{/if}
</div>
</div>
{/if}
{#if isGeneralEnquiry}
<div
class="booking-field-card booking-field-card-wide"
class:booking-field-card-invalid={errors.location}
class="booking-field-card booking-field-card-full"
class:booking-field-card-invalid={errors.message}
>
<label for="location">
<Icon name="fas fa-location-dot" />&nbsp;Location <span class="booking-required">*</span>
<label for="message">
<Icon name="fas fa-comment" />&nbsp;{detailsMessageLabel}
<span class="booking-required">*</span>
</label>
<input
bind:this={locationInput}
bind:value={location}
type="text"
id="location"
name="location"
required
placeholder="Suburb, street..."
class:input-invalid={errors.location}
on:input={() => clearError('location')}
/>
{#if errors.location}
<textarea
bind:value={message}
id="message"
name="message"
rows="4"
placeholder={detailsMessagePlaceholder}
class:input-invalid={errors.message}
on:input={() => clearError('message')}
></textarea>
{#if errors.message}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.location}
{errors.message}
</p>
{/if}
</div>
{/if}
<div
class="booking-field-card booking-field-card-full"
class:booking-field-card-invalid={errors.message}
>
<label for="message">
<Icon name="fas fa-comment" />&nbsp;{isGeneralEnquiry ? 'Your Message' : 'About Your Dog'}
{#if isGeneralEnquiry}<span class="booking-required">*</span>{/if}
</label>
<textarea
bind:value={message}
id="message"
name="message"
rows="4"
placeholder={isGeneralEnquiry
? 'Tell us if this is feedback, a complaint, a business enquiry, or anything else we should know.'
: 'Describe your pet, any special needs, or anything we should know.'}
class:input-invalid={errors.message}
on:input={() => clearError('message')}
></textarea>
{#if errors.message}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.message}
</p>
{/if}
</div>
</div>
{#if hasServices && !isGeneralEnquiry}
<div class="booking-service-row">
<span class="booking-service-label"><Icon name="fas fa-paw" />&nbsp;Services</span>
<div class="booking-service-options">
{#each booking.serviceOptions as service}
<label class="booking-check-option">
<input
type="checkbox"
name="services"
value={service}
checked={selectedServices.includes(service)}
on:change={(event) =>
toggleService(service, (event.currentTarget as HTMLInputElement).checked)}
/>
<span class="booking-check-box" aria-hidden="true"></span>
<span>{service}</span>
</label>
{/each}
</div>
</div>
{/if}
</div>
<div class="booking-actions booking-actions-next">
<button type="button" class="btn btn-yellow booking-next-button" on:click={goToOwnerStep}>
{ownerStepLabel}
Next: {ownerStepLabel.toLowerCase()}
<Icon name="fas fa-arrow-right" />
</button>
<p class="booking-next-note">Response from us within 24 hours</p>
<p class="booking-next-note">No payment, no pressure, just the right starting point for your dog.</p>
</div>
{:else}
<input type="hidden" name="fullName" value={fullName} />
@@ -652,7 +769,13 @@
Back
</button>
<button type="submit" class="btn btn-yellow booking-submit-button" disabled={submitting}>
{#if submitting}Sending…{:else}Send <Icon name="fas fa-arrow-right" />{/if}
{#if submitting}
Sending…
{:else if isGeneralEnquiry}
Send enquiry <Icon name="fas fa-arrow-right" />
{:else}
Request Meet &amp; Greet <Icon name="fas fa-arrow-right" />
{/if}
</button>
</div>
{/if}
+6 -6
View File
@@ -141,7 +141,7 @@
margin: 0 0 12px;
font-size: 24px;
font-weight: 700;
color: #213021;
color: var(--gw-green);
line-height: 1.25;
}
@@ -162,7 +162,7 @@
border-radius: 14px;
background: #f7f6f1;
border: 1px solid #ebe9df;
color: #213021;
color: var(--gw-green);
text-decoration: none;
transition:
background 0.15s ease,
@@ -187,7 +187,7 @@
.modal-email-address {
font-size: 17px;
font-weight: 600;
color: #213021;
color: var(--gw-green);
word-break: break-all;
}
@@ -226,7 +226,7 @@
}
.modal-btn-primary {
background: #213021;
background: var(--gw-green);
color: #ffd100;
}
@@ -237,13 +237,13 @@
.modal-btn-secondary {
background: transparent;
color: #213021;
color: var(--gw-green);
border: 1px solid #d4d2c6;
}
.modal-btn-secondary:hover {
background: #f2f2f0;
border-color: #213021;
border-color: var(--gw-green);
}
@keyframes backdrop-in {
+24 -2
View File
@@ -25,6 +25,14 @@
connector: ''
};
}
function linkTarget(external?: boolean) {
return external ? '_blank' : undefined;
}
function linkRel(external?: boolean) {
return external ? 'noopener' : undefined;
}
</script>
<section id="hero">
@@ -71,8 +79,22 @@
{/if}
<div class="hero-buttons">
<a href={hero.primaryCta.href} class="btn btn-yellow">{hero.primaryCta.label}</a>
<a href={hero.secondaryCta.href} class="btn btn-outline">{hero.secondaryCta.label}</a>
<a
href={hero.primaryCta.href}
target={linkTarget(hero.primaryCta.external)}
rel={linkRel(hero.primaryCta.external)}
class="btn btn-yellow"
>
{hero.primaryCta.label}
</a>
<a
href={hero.secondaryCta.href}
target={linkTarget(hero.secondaryCta.external)}
rel={linkRel(hero.secondaryCta.external)}
class="btn btn-outline"
>
{hero.secondaryCta.label}
</a>
</div>
</div>
+135 -129
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { reveal } from '$lib/actions/reveal';
import Icon from '$lib/components/Icon.svelte';
import type { HowItWorksContent } from '$lib/types';
export let content: HowItWorksContent;
@@ -17,28 +16,22 @@
<div class="how-it-works-flow" aria-label="How it works">
{#each content.steps as step, index}
<article class="how-it-works-step">
<div class="how-it-works-badge" aria-hidden="true">
<span class="how-it-works-count">0{index + 1}</span>
<article class:how-it-works-step-payoff={index === content.steps.length - 1} class="how-it-works-step">
<div class="how-it-works-rail-node" aria-hidden="true">
<span class="how-it-works-rail-dot"></span>
</div>
{#if step.icon}
<div class="how-it-works-icon-bubble">
<Icon name={step.icon} className="how-it-works-icon" />
</div>
{/if}
<h3>{step.title}</h3>
<p>{step.body}</p>
<div class="how-it-works-step-top">
<span class="how-it-works-count">{`0${index + 1}`}</span>
<span class="how-it-works-phase">{step.phase || `Step ${index + 1}`}</span>
</div>
<div class="how-it-works-copy">
<h3>{step.title}</h3>
{#if step.benefit}
<p class="how-it-works-benefit">{step.benefit}</p>
{/if}
</div>
<p class="how-it-works-body">{step.body}</p>
</article>
{#if index < content.steps.length - 1}
<div class="how-it-works-connector" aria-hidden="true">
<span class="how-it-works-connector-line"></span>
<div class="how-it-works-connector-bubble">
<Icon name="fas fa-paw" className="how-it-works-connector-icon" />
</div>
<span class="how-it-works-connector-line"></span>
</div>
{/if}
{/each}
</div>
</div>
@@ -84,108 +77,113 @@
.how-it-works-flow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
gap: 18px;
position: relative;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 28px;
align-items: stretch;
margin-top: 34px;
margin-top: 40px;
}
.how-it-works-flow::before {
content: '';
position: absolute;
left: 18px;
right: 18px;
top: 22px;
height: 1px;
background: linear-gradient(
90deg,
rgba(33, 48, 33, 0.12) 0%,
rgba(33, 48, 33, 0.28) 50%,
rgba(33, 48, 33, 0.12) 100%
);
}
.how-it-works-step {
position: relative;
padding: 26px 24px;
border-radius: 28px;
background:
radial-gradient(circle at top center, rgba(255, 209, 0, 0.18), transparent 36%),
linear-gradient(180deg, #fffaf0 0%, #f8f4ea 100%);
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.05),
0 14px 28px rgba(17, 20, 24, 0.04);
text-align: center;
display: flex;
flex-direction: column;
padding: 0 18px 0 0;
text-align: left;
}
.how-it-works-badge {
display: inline-flex;
.how-it-works-step-payoff {
transform: translateY(-4px);
}
.how-it-works-rail-node {
position: relative;
display: flex;
align-items: center;
margin-bottom: 14px;
padding: 8px 12px;
border-radius: 999px;
background: #fff;
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.06);
min-height: 44px;
margin-bottom: 24px;
}
.how-it-works-rail-dot {
display: inline-flex;
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--gw-green);
box-shadow:
0 0 0 7px #fff,
0 0 0 8px rgba(33, 48, 33, 0.12);
}
.how-it-works-step-top {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 12px;
}
.how-it-works-count {
color: var(--green);
color: rgba(33, 48, 33, 0.3);
font-family: var(--font-head);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
font-size: 36px;
font-weight: 800;
line-height: 0.9;
}
.how-it-works-icon-bubble {
display: inline-flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
margin: 0 auto 16px;
border-radius: 50%;
background: var(--green);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
0 16px 28px rgba(33, 48, 33, 0.16);
.how-it-works-phase {
color: var(--gw-green);
font-family: var(--font-head);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
:global(.how-it-works-icon.icon) {
color: #fff;
font-size: 24px;
.how-it-works-copy {
padding: 22px 22px 20px;
border-radius: 24px;
background:
linear-gradient(180deg, rgba(255, 250, 240, 0.92) 0%, rgba(248, 244, 234, 0.92) 100%);
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
}
.how-it-works-step h3 {
margin: 0 0 10px;
margin: 0;
font-size: 20px;
line-height: 1.18;
}
.how-it-works-step p {
margin: 0;
.how-it-works-benefit {
margin: 10px 0 0;
color: #6b5830;
font-size: 14px;
font-weight: 700;
line-height: 1.35;
}
.how-it-works-body {
margin: 14px 0 0;
padding: 0 4px 0 22px;
color: #4c5056;
font-size: 15px;
line-height: 1.6;
}
.how-it-works-connector {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
min-width: 90px;
}
.how-it-works-connector-line {
width: 24px;
height: 2px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.22);
}
.how-it-works-connector-bubble {
display: inline-flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(180deg, #254129 0%, #213021 100%);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
0 14px 24px rgba(33, 48, 33, 0.14);
}
:global(.how-it-works-connector-icon.icon) {
color: var(--green);
color: #ffd54a;
font-size: 16px;
}
@media (max-width: 768px) {
#how-it-works {
padding-top: 6px;
@@ -202,58 +200,66 @@
.how-it-works-flow {
grid-template-columns: 1fr;
gap: 14px;
gap: 24px;
margin-top: 26px;
}
.how-it-works-flow::before {
left: 5px;
right: auto;
top: 22px;
bottom: 22px;
width: 1px;
height: auto;
background: linear-gradient(
180deg,
rgba(33, 48, 33, 0.12) 0%,
rgba(33, 48, 33, 0.28) 50%,
rgba(33, 48, 33, 0.12) 100%
);
}
.how-it-works-step {
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-areas:
'icon title badge'
'body body body';
column-gap: 14px;
row-gap: 10px;
padding: 20px 18px;
text-align: left;
padding: 0 0 0 28px;
}
.how-it-works-badge {
grid-area: badge;
justify-self: end;
align-self: start;
margin-bottom: 0;
padding: 7px 10px;
.how-it-works-step-payoff {
transform: none;
}
.how-it-works-icon-bubble {
grid-area: icon;
width: 64px;
height: 64px;
margin: 0;
align-self: start;
.how-it-works-rail-node {
position: absolute;
left: 0;
top: 0;
min-height: 0;
margin: 18px 0 0;
}
.how-it-works-rail-dot {
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
0 12px 22px rgba(33, 48, 33, 0.14);
0 0 0 5px #fff,
0 0 0 6px rgba(33, 48, 33, 0.12);
}
.how-it-works-step-top {
margin-bottom: 10px;
}
.how-it-works-step h3 {
grid-area: title;
align-self: center;
margin: 0;
font-size: 18px;
line-height: 1.2;
}
.how-it-works-step p {
grid-area: body;
.how-it-works-benefit {
margin-top: 8px;
font-size: 13px;
}
.how-it-works-body {
margin-top: 2px;
padding: 14px 2px 0 18px;
font-size: 14px;
line-height: 1.55;
}
.how-it-works-connector {
display: none;
}
}
</style>
+3 -3
View File
@@ -78,7 +78,7 @@
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.06),
0 10px 24px rgba(17, 20, 24, 0.04);
color: #213021;
color: var(--gw-green);
font-size: 14px;
font-weight: 600;
line-height: 1.2;
@@ -103,7 +103,7 @@
.info-nearby-kicker {
display: inline-block;
color: var(--green);
color: var(--gw-green);
font-family: var(--font-head);
font-size: 12px;
font-weight: 700;
@@ -119,7 +119,7 @@
min-height: 48px;
padding: 12px 18px;
border-radius: 999px;
background: var(--green);
background: var(--gw-green);
color: #fff;
font-family: var(--font-head);
font-size: 14px;
+7 -7
View File
@@ -4,7 +4,7 @@
export let instagram: HomePageContent['instagram'];
const dogCutoutSrc = '/images/smiling-dogs-instagram-cta.png';
const dogCutoutSrc = '/images/dog-cutout.png';
</script>
<aside id="instagram" aria-label="Follow Goodwalk on Instagram">
@@ -29,7 +29,7 @@
<style>
#instagram {
overflow: visible;
overflow: hidden;
padding-bottom: 40px;
}
@@ -89,12 +89,12 @@
.instagram-dog-wrap {
position: absolute;
right: 24px;
bottom: 0;
right: -56px;
bottom: -14px;
display: flex;
align-items: flex-end;
justify-content: center;
width: 280px;
width: 380px;
pointer-events: none;
z-index: 0;
}
@@ -149,8 +149,8 @@
.instagram-dog-wrap {
left: 50%;
right: auto;
bottom: -80px;
width: min(260px, calc(100% - 40px));
bottom: -96px;
width: min(300px, calc(100% - 32px));
transform: translateX(-50%);
}
+2 -2
View File
@@ -101,7 +101,7 @@
.legal-section h2 {
margin: 0 0 16px;
padding-left: 14px;
border-left: 3px solid var(--green);
border-left: 3px solid var(--gw-green);
font-family: var(--font-head);
font-size: clamp(14px, 1.4vw, 17px);
line-height: 1.3;
@@ -140,7 +140,7 @@
content: '';
position: absolute;
left: 0;
color: var(--green);
color: var(--gw-green);
font-size: 14px;
line-height: 1.9;
}
+32 -8
View File
@@ -12,6 +12,26 @@
const scrollDepthThreshold = 0.65;
const desktopPromptMediaQuery = '(min-width: 769px)';
function numericPrice(price: string) {
const value = Number(price.replace(/[^0-9.]/g, ''));
return Number.isFinite(value) ? value : Number.POSITIVE_INFINITY;
}
function decoratePlans<T extends { price: string }>(plans: T[]) {
const sorted = [...plans]
.map((plan, index) => ({ plan, index, value: numericPrice(plan.price) }))
.sort((a, b) => a.value - b.value || a.index - b.index);
const cheapestIndex = sorted[0]?.index ?? -1;
const mobileOrder = new Map(sorted.map((entry, order) => [entry.index, order]));
return plans.map((plan, index) => ({
...plan,
isPopular: index === cheapestIndex,
mobileOrder: mobileOrder.get(index) ?? index
}));
}
let showMeetGreetPrompt = false;
let dismissMeetGreetPrompt = false;
let bookingInView = false;
@@ -147,9 +167,13 @@
</div>
<div class:pricing-plan-grid-three={section.plans.length === 3} class="pricing-plan-grid">
{#each section.plans as plan}
<article class:pricing-plan-popular={plan.popular} class="pricing-plan-card">
{#if plan.popular}
{#each decoratePlans(section.plans) as plan}
<article
class:pricing-plan-popular={plan.isPopular}
class="pricing-plan-card"
style={`--mobile-order:${plan.mobileOrder};`}
>
{#if plan.isPopular}
<span class="pricing-plan-ribbon">Popular</span>
{/if}
@@ -226,7 +250,7 @@
}
.pricing-page-hero {
background: var(--green);
background: var(--gw-green);
padding: 56px 0 64px;
text-align: center;
}
@@ -320,7 +344,7 @@
height: 56px;
margin-bottom: 16px;
border-radius: 16px;
background: var(--green);
background: var(--gw-green);
color: #fff;
font-size: 22px;
}
@@ -469,7 +493,7 @@
padding: 6px 10px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.08);
color: var(--green);
color: var(--gw-green);
font-family: var(--font-head);
font-size: 12px;
letter-spacing: 0.04em;
@@ -635,7 +659,7 @@
}
.pricing-plan-popular {
order: -1;
order: var(--mobile-order, 0);
}
.pricing-plan-card {
@@ -672,7 +696,7 @@
align-items: center;
gap: 8px;
margin-bottom: 10px;
color: var(--green);
color: var(--gw-green);
font-family: var(--font-head);
font-size: 12px;
font-weight: 700;
+10 -8
View File
@@ -28,14 +28,16 @@
</div>
<div class="promise-img">
<img
src={promise.imageUrl}
alt={promise.imageAlt}
width={promiseImage?.width}
height={promiseImage?.height}
loading="lazy"
decoding="async"
/>
<div class="promise-img-frame">
<img
src={promise.imageUrl}
alt={promise.imageAlt}
width={promiseImage?.width}
height={promiseImage?.height}
loading="lazy"
decoding="async"
/>
</div>
</div>
</div>
</section>
+160 -35
View File
@@ -10,9 +10,35 @@
export let pageContent: ServicePageContent;
export let currentPath = '';
function numericPrice(price: string) {
const value = Number(price.replace(/[^0-9.]/g, ''));
return Number.isFinite(value) ? value : Number.POSITIVE_INFINITY;
}
function decoratePlans<T extends { price: string }>(plans: T[]) {
const sorted = [...plans]
.map((plan, index) => ({ plan, index, value: numericPrice(plan.price) }))
.sort((a, b) => a.value - b.value || a.index - b.index);
const cheapestIndex = sorted[0]?.index ?? -1;
const mobileOrder = new Map(sorted.map((entry, order) => [entry.index, order]));
return plans.map((plan, index) => ({
...plan,
isPopular: index === cheapestIndex,
mobileOrder: mobileOrder.get(index) ?? index
}));
}
$: heroImage = getImageMetadata(pageContent.hero.imageUrl);
$: highlightImage = pageContent.highlight ? getImageMetadata(pageContent.highlight.imageUrl) : null;
$: highlightCollageImages =
pageContent.highlight?.collageImages?.map((image) => ({
...image,
meta: getImageMetadata(image.imageUrl)
})) ?? [];
$: relatedServices = content.services.filter((s) => s.href && s.href !== currentPath);
$: pricingPlans = decoratePlans(pageContent.pricing.plans);
$: relatedCards = [
...relatedServices.map((s) => ({
@@ -68,16 +94,33 @@
</div>
<div class="service-inner">
<div class="service-highlight-image">
<img
src={pageContent.highlight.imageUrl}
alt={pageContent.highlight.imageAlt}
width={highlightImage?.width}
height={highlightImage?.height}
loading="lazy"
decoding="async"
/>
</div>
{#if highlightCollageImages.length}
<div class="service-highlight-collage" aria-label={pageContent.highlight.title}>
{#each highlightCollageImages as image, index}
<figure class={`service-collage-card service-collage-card-${index + 1}`}>
<img
src={image.imageUrl}
alt={image.imageAlt}
width={image.meta?.width}
height={image.meta?.height}
loading="lazy"
decoding="async"
/>
</figure>
{/each}
</div>
{:else}
<div class="service-highlight-image">
<img
src={pageContent.highlight.imageUrl}
alt={pageContent.highlight.imageAlt}
width={highlightImage?.width}
height={highlightImage?.height}
loading="lazy"
decoding="async"
/>
</div>
{/if}
</div>
</section>
{/if}
@@ -92,9 +135,13 @@
</div>
<div class:service-plan-grid-three={pageContent.pricing.plans.length === 3} class="service-plan-grid">
{#each pageContent.pricing.plans as plan}
<article class:service-plan-popular={plan.popular} class="service-plan-card">
{#if plan.popular}
{#each pricingPlans as plan}
<article
class:service-plan-popular={plan.isPopular}
class="service-plan-card"
style={`--mobile-order:${plan.mobileOrder};`}
>
{#if plan.isPopular}
<span class="service-plan-ribbon">Popular</span>
{/if}
@@ -263,11 +310,11 @@
}
.service-related-tint-1 {
--card-accent: var(--green);
--card-accent: var(--gw-green);
}
.service-related-tint-1 .service-related-icon {
background: #dce6dc;
color: var(--green);
color: var(--gw-green);
}
.service-related-tint-2 {
@@ -275,7 +322,7 @@
}
.service-related-tint-2 .service-related-icon {
background: #efe4d1;
color: var(--green);
color: var(--gw-green);
}
.service-related-tint-3 {
@@ -305,7 +352,7 @@
height: 52px;
border-radius: 50%;
background: #efe4d1;
color: var(--green);
color: var(--gw-green);
font-size: 18px;
margin-bottom: 8px;
}
@@ -334,7 +381,7 @@
.service-related-price {
font-family: var(--font-head);
font-weight: 700;
color: var(--green);
color: var(--gw-green);
font-size: 14px;
}
@@ -352,7 +399,7 @@
.service-related-link {
margin-top: auto;
padding-top: 6px;
color: var(--green);
color: var(--gw-green);
font-family: var(--font-head);
font-weight: 700;
font-size: 14px;
@@ -386,18 +433,69 @@
line-height: 1.7;
}
.service-hero-media,
.service-highlight-image {
position: relative;
overflow: hidden;
border-radius: 28px;
background: #f4efe7;
box-shadow: 0 16px 40px rgba(17, 20, 24, 0.08);
}
.service-hero-media {
aspect-ratio: 4 / 3;
}
.service-highlight-image {
aspect-ratio: 4 / 3;
max-width: 900px;
margin: 0 auto;
}
.service-highlight-collage {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
max-width: 780px;
margin: 0 auto;
align-items: stretch;
}
.service-collage-card {
position: relative;
overflow: hidden;
border-radius: 28px;
background: #f4efe7;
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.05),
0 16px 40px rgba(17, 20, 24, 0.08);
}
.service-collage-card img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.service-collage-card-1 {
min-height: 250px;
}
.service-collage-card-2 {
min-height: 250px;
}
.service-collage-card-3 {
min-height: 250px;
}
.service-hero-media img,
.service-highlight-image img {
display: block;
width: 100%;
border-radius: 28px;
height: 100%;
object-fit: cover;
box-shadow: 0 16px 40px rgba(17, 20, 24, 0.08);
}
.service-hero-media img {
aspect-ratio: 4 / 3;
height: auto;
}
.service-highlight {
@@ -418,10 +516,6 @@
margin: 0 0 16px;
}
.service-highlight-image img {
max-height: 620px;
}
.service-pricing,
.service-benefits {
padding: 0 0 96px;
@@ -460,6 +554,13 @@
border-color 0.22s ease;
}
.service-plan-card {
display: flex;
flex-direction: column;
align-items: stretch;
height: 100%;
}
.service-plan-popular {
border: 2px solid var(--yellow);
}
@@ -521,7 +622,7 @@
font-family: var(--font-head);
font-size: 44px;
line-height: 1;
color: var(--green);
color: var(--gw-green);
}
.service-plan-period {
@@ -537,6 +638,7 @@
margin: 24px 0 0;
padding: 0;
list-style: none;
flex: 1 1 auto;
}
.service-plan-features li {
@@ -583,7 +685,7 @@
padding: 8px 16px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.06);
color: var(--green);
color: var(--gw-green);
font-size: 14px;
font-weight: 600;
}
@@ -662,7 +764,7 @@
.service-extra-price {
font-family: var(--font-head);
font-weight: 700;
color: var(--green);
color: var(--gw-green);
white-space: nowrap;
}
@@ -680,7 +782,7 @@
height: 52px;
border-radius: 50%;
background: #efe4d1;
color: var(--green);
color: var(--gw-green);
font-size: 18px;
margin-bottom: 18px;
}
@@ -702,6 +804,16 @@
.service-hero-grid {
align-items: start;
}
.service-highlight-collage {
max-width: 720px;
}
.service-collage-card-2,
.service-collage-card-3,
.service-collage-card-1 {
min-height: 220px;
}
}
@media (max-width: 768px) {
@@ -723,7 +835,7 @@
}
.service-plan-popular {
order: -1;
order: var(--mobile-order, 0);
}
.service-highlight,
@@ -748,5 +860,18 @@
margin: 18px auto 0;
font-family: var(--font-head);
}
.service-highlight-collage {
grid-template-columns: 1fr;
gap: 12px;
max-width: 420px;
}
.service-collage-card-1,
.service-collage-card-2,
.service-collage-card-3 {
min-height: 220px;
transform: none;
}
}
</style>
+71 -4
View File
@@ -5,12 +5,35 @@
export let services: IconCard[];
export let heading = 'What we do';
export let intro =
'Choose the walk style that fits your dog best, then book a free Meet & Greet when you are ready.';
const requestedServiceStorageKey = 'goodwalk_requested_service';
function bookingHref() {
return '#newlead';
}
function primeBookingService(serviceTitle: string) {
try {
window.sessionStorage.setItem(requestedServiceStorageKey, serviceTitle);
} catch {
// Ignore storage failures and continue with the link target.
}
window.dispatchEvent(
new CustomEvent('goodwalk:service-selected', {
detail: { service: serviceTitle }
})
);
}
</script>
<section id="services" use:reveal={{ delay: 20 }} class="reveal-block">
<div class="services-inner">
<h2 class="section-heading">{heading}</h2>
<p class="services-intro">{intro}</p>
<div class="services-grid">
{#each services as service}
@@ -26,10 +49,16 @@
{/if}
{#if service.href}
<a href={service.href} class="btn btn-green">
<span>See {service.title} pricing</span>
<Icon name="fas fa-arrow-right" />
</a>
<div class="service-card-actions">
<a href={bookingHref()} class="btn btn-green" on:click={() => primeBookingService(service.title)}>
<span>Book {service.title}</span>
<Icon name="fas fa-arrow-right" />
</a>
<a href={service.href} class="service-card-link">
View details &amp; pricing
</a>
</div>
{/if}
</div>
{/each}
@@ -81,4 +110,42 @@
transform: translateY(0);
}
}
.services-intro {
max-width: 700px;
margin: 18px auto 0;
text-align: center;
color: #4c5056;
font-size: 17px;
line-height: 1.65;
}
.service-card-actions {
display: grid;
gap: 12px;
margin-top: 18px;
}
.service-card-actions :global(.btn) {
width: 100%;
justify-content: center;
}
.service-card-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
color: var(--gw-green);
font-size: 14px;
font-weight: 700;
text-decoration: none;
}
@media (hover: hover) {
.service-card-link:hover {
text-decoration: underline;
text-underline-offset: 0.18em;
}
}
</style>
+5 -4
View File
@@ -7,6 +7,7 @@
export let email: string;
export let enquiryType: 'booking' | 'general' = 'booking';
export let onClose: () => void;
const gwGreenHex = '#213021';
$: isGeneralEnquiry = enquiryType === 'general';
@@ -20,7 +21,7 @@
angle: 60,
spread: 65,
origin: { x: 0, y: 0.75 },
colors: ['#FFD100', '#213021', '#ffffff', '#7aaa7a', '#ffeaa0'],
colors: ['#FFD100', gwGreenHex, '#ffffff', '#7aaa7a', '#ffeaa0'],
gravity: 0.9,
scalar: 1.1,
});
@@ -29,7 +30,7 @@
angle: 120,
spread: 65,
origin: { x: 1, y: 0.75 },
colors: ['#FFD100', '#213021', '#ffffff', '#7aaa7a', '#ffeaa0'],
colors: ['#FFD100', gwGreenHex, '#ffffff', '#7aaa7a', '#ffeaa0'],
gravity: 0.9,
scalar: 1.1,
});
@@ -157,7 +158,7 @@
margin: 0 0 14px;
font-size: 26px;
font-weight: 700;
color: #213021;
color: var(--gw-green);
line-height: 1.2;
}
@@ -186,7 +187,7 @@
.modal-btn {
display: inline-block;
padding: 14px 36px;
background: #213021;
background: var(--gw-green);
color: #FFD100;
font-size: 15px;
font-weight: 600;
+31 -6
View File
@@ -6,8 +6,9 @@
import type { TestimonialContent } from '$lib/types';
export let testimonials: TestimonialContent[];
export let heading = 'Why people choose us!';
export let blurb = 'Busy parents get peace of mind. Dogs come home tired and happy. See why 30+ Auckland families trust the Tiny Gang — follow along on Instagram for daily adventures, wagging tails and the odd zoomie.';
export let eyebrow = '30+ five-star reviews';
export let heading = 'Proof your dog is in good hands';
export let blurb = 'Peace of mind for busy Auckland dog owners. Happier dogs, smoother routines, and a team owners trust with the important stuff.';
export let instagramHref = 'https://www.instagram.com/goodwalk.nz/';
export let instagramLabel = 'goodwalk.nz';
@@ -30,7 +31,7 @@
},
Ross: {
reviewer: 'Ross',
detail: "Otis's Dad",
detail: "Otis's dad",
quote:
'Truly the best dog walker in Auckland! I feel so lucky to have found Aless and my little terrier Otis absolutely adores her. He enjoys his regular weekly walks and always comes back happy & tired. Love the updates on social media so I can see how my dog is enjoying his day! Aless makes logistics so easy too. Highly highly recommend, theres a reason she has 5 stars!',
imageUrl: '/images/otis-auckland-dog-walking-review.png'
@@ -123,6 +124,7 @@
<section id="testimonials" use:reveal={{ delay: 40 }} class="reveal-block">
<div class="testimonials-inner">
<span class="testimonials-eyebrow">{eyebrow}</span>
<h2 class="section-heading">{heading}</h2>
<div class="testimonials-intro">
<p>{blurb}</p>
@@ -244,6 +246,21 @@
</section>
<style>
.testimonials-eyebrow {
display: block;
width: fit-content;
margin: 0 auto 10px;
padding: 7px 12px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.08);
color: var(--gw-green);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
}
.testimonials-intro {
max-width: 760px;
margin: 18px auto 0;
@@ -266,7 +283,7 @@
padding: 10px 16px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.06);
color: var(--green);
color: var(--gw-green);
font-weight: 700;
text-decoration: none;
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.06);
@@ -297,9 +314,16 @@
.testimonials-carousel {
position: relative;
margin-top: 48px;
padding: 0 38px;
}
@media (max-width: 768px) {
.testimonials-eyebrow {
margin-bottom: 8px;
padding: 6px 10px;
font-size: 11px;
}
.testimonials-intro {
margin-top: 14px;
}
@@ -555,11 +579,11 @@
}
.testimonial-arrow-left {
left: -38px;
left: 0;
}
.testimonial-arrow-right {
right: -38px;
right: 0;
}
@media (max-width: 1024px) {
@@ -588,6 +612,7 @@
@media (max-width: 767px) {
.testimonials-carousel {
margin-top: 32px;
padding: 0;
}
.testimonial-stage {
+45 -1
View File
@@ -21,7 +21,11 @@
<section id="values">
<div class="values-inner">
<h2 class="section-heading">Where dogs come first</h2>
<span class="values-eyebrow">Why owners stay</span>
<h2 class="section-heading">Calmer dogs. Clearer routines. Less worry.</h2>
<p class="values-intro">
Everything is designed to make life easier for busy Auckland dog owners and safer, happier for the dogs in our care.
</p>
<div class="values-grid">
{#each orderedValues as value}
@@ -34,3 +38,43 @@
</div>
</div>
</section>
<style>
.values-eyebrow {
display: block;
width: fit-content;
margin: 0 auto 10px;
padding: 7px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: var(--yellow);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.values-intro {
max-width: 760px;
margin: 18px auto 0;
text-align: center;
color: rgba(255, 255, 255, 0.82);
font-size: 17px;
line-height: 1.65;
}
@media (max-width: 768px) {
.values-eyebrow {
margin-bottom: 8px;
padding: 6px 10px;
font-size: 11px;
}
.values-intro {
margin-top: 14px;
font-size: 15px;
line-height: 1.55;
}
}
</style>