v4.0.0.2
This commit is contained in:
@@ -28,8 +28,12 @@ describe('reveal action', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('toggles visibility as the element enters and leaves the viewport', () => {
|
||||
it('reveals the element when the observer reports intersection', () => {
|
||||
vi.stubGlobal('IntersectionObserver', TestIntersectionObserver);
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
||||
matches: false,
|
||||
media: '(prefers-reduced-motion: reduce)',
|
||||
@@ -67,9 +71,7 @@ describe('reveal action', () => {
|
||||
|
||||
observer.trigger(node, true);
|
||||
expect(node.classList.contains('reveal-visible')).toBe(true);
|
||||
|
||||
observer.trigger(node, false);
|
||||
expect(node.classList.contains('reveal-visible')).toBe(false);
|
||||
expect(observer.unobserve).toHaveBeenCalledWith(node);
|
||||
|
||||
action.destroy();
|
||||
expect(observer.disconnect).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -34,6 +34,17 @@ export function reveal(node: HTMLElement, options: RevealOptions = {}) {
|
||||
let observer: IntersectionObserver | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
// Failsafe: if hydration mismatch or any other edge case prevents the rAF
|
||||
// check and IntersectionObserver from running, force visibility after a
|
||||
// short delay so content never stays stuck at opacity 0.
|
||||
const failsafe = window.setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
if (!node.classList.contains('reveal-visible')) {
|
||||
node.classList.add('reveal-visible');
|
||||
observer?.disconnect();
|
||||
}
|
||||
}, 1200);
|
||||
|
||||
// Defer the layout-reading initial check + observer setup to the next
|
||||
// frame. With 20+ `use:reveal` instances mounting in a row, calling
|
||||
// getBoundingClientRect() synchronously after a class mutation forces
|
||||
@@ -72,6 +83,7 @@ export function reveal(node: HTMLElement, options: RevealOptions = {}) {
|
||||
return {
|
||||
destroy() {
|
||||
cancelled = true;
|
||||
window.clearTimeout(failsafe);
|
||||
observer?.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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 & Greet.
|
||||
</p>
|
||||
|
||||
<a class="btn btn-yellow btn-mobile-center service-plan-mobile-cta" href="#newlead">Book a Meet & Greet</a>
|
||||
<a class="btn btn-yellow btn-mobile-center service-plan-mobile-cta cta-shimmer" href="#newlead">Book a Meet & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -84,15 +84,15 @@
|
||||
.map(({ value }) => value);
|
||||
</script>
|
||||
|
||||
<section id="values" use:reveal={{ delay: 30 }} class="reveal-block">
|
||||
<section id="values" use:reveal={{ delay: 30, distance: 0 }} class="reveal-block">
|
||||
<div class="values-inner">
|
||||
<div class="section-header">
|
||||
<div class="section-header fade-up">
|
||||
<h2 class="section-heading">Calmer dogs. Calmer evenings.</h2>
|
||||
</div>
|
||||
|
||||
<div class="values-photo-grid" aria-label="Goodwalk client dogs">
|
||||
<div class="values-photo-grid stagger-children stagger-tight" aria-label="Goodwalk client dogs">
|
||||
{#each clientPhotoCards as photo, index}
|
||||
<figure class:values-photo-card-featured={index === 0} class="values-photo-card">
|
||||
<figure class:values-photo-card-featured={index === 0} class="values-photo-card fade-up">
|
||||
{#if photo.enhanced}
|
||||
<enhanced:img
|
||||
class="values-photo-image"
|
||||
@@ -120,9 +120,9 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="values-bento values-contrast">
|
||||
<div class="values-bento values-contrast stagger-children">
|
||||
{#each stakes as stake, index}
|
||||
<article class:values-contrast-cell-good={index === 1} class="values-contrast-cell">
|
||||
<article class:values-contrast-cell-good={index === 1} class="values-contrast-cell fade-up">
|
||||
<div class="values-contrast-head">
|
||||
<span class:values-contrast-label-good={index === 1} class="values-contrast-label">
|
||||
{stake.label}
|
||||
@@ -149,13 +149,13 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="values-points-header">
|
||||
<div class="values-points-header fade-up">
|
||||
<h3 class="values-points-title">Things we don't treat as extras</h3>
|
||||
</div>
|
||||
|
||||
<div class="values-bento values-points">
|
||||
<div class="values-bento values-points stagger-children">
|
||||
{#each orderedValues as value}
|
||||
<div class="values-point">
|
||||
<div class="values-point fade-up">
|
||||
<div class="values-point-icon">
|
||||
<Icon name={value.icon} className="values-point-glyph" />
|
||||
</div>
|
||||
@@ -627,4 +627,25 @@
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
/* Tier choreography — header, then photo gallery cascades tight,
|
||||
contrast cells settle, then the bottom-row "extras" cascade. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:global(.reveal-visible) .values-inner > .section-header { transition-delay: 40ms; }
|
||||
|
||||
:global(.reveal-visible) .values-photo-grid > :nth-child(1) { transition-delay: 160ms; }
|
||||
:global(.reveal-visible) .values-photo-grid > :nth-child(2) { transition-delay: 220ms; }
|
||||
:global(.reveal-visible) .values-photo-grid > :nth-child(3) { transition-delay: 280ms; }
|
||||
:global(.reveal-visible) .values-photo-grid > :nth-child(4) { transition-delay: 340ms; }
|
||||
:global(.reveal-visible) .values-photo-grid > :nth-child(5) { transition-delay: 400ms; }
|
||||
|
||||
:global(.reveal-visible) .values-contrast > :nth-child(1) { transition-delay: 460ms; }
|
||||
:global(.reveal-visible) .values-contrast > :nth-child(2) { transition-delay: 540ms; }
|
||||
|
||||
:global(.reveal-visible) .values-points-header { transition-delay: 620ms; }
|
||||
:global(.reveal-visible) .values-points > :nth-child(1) { transition-delay: 680ms; }
|
||||
:global(.reveal-visible) .values-points > :nth-child(2) { transition-delay: 740ms; }
|
||||
:global(.reveal-visible) .values-points > :nth-child(3) { transition-delay: 800ms; }
|
||||
:global(.reveal-visible) .values-points > :nth-child(n + 4) { transition-delay: 860ms; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,6 +26,15 @@
|
||||
};
|
||||
};
|
||||
|
||||
type LifecycleStatus = 'active' | 'paused' | 'cancelled' | 'archived';
|
||||
|
||||
type Lifecycle = {
|
||||
status: LifecycleStatus;
|
||||
reason: string;
|
||||
changedAt: string;
|
||||
changedBy: string;
|
||||
};
|
||||
|
||||
type AllClient = {
|
||||
email: string;
|
||||
fullName: string;
|
||||
@@ -33,10 +42,22 @@
|
||||
dogName: string;
|
||||
dogBreed: string;
|
||||
status: 'pending' | 'completed';
|
||||
lifecycle: Lifecycle;
|
||||
lastActivityAt: string;
|
||||
welcomePackSentAt: string;
|
||||
};
|
||||
|
||||
const lifecycleOptions: { value: LifecycleStatus; label: string; description: string }[] = [
|
||||
{ value: 'active', label: 'Active', description: 'Currently a client. Included in outreach.' },
|
||||
{ value: 'paused', label: 'Paused', description: 'Temporarily not walking. Still included in outreach.' },
|
||||
{ value: 'cancelled', label: 'Cancelled', description: 'No longer a client. Excluded from outreach but kept on file.' },
|
||||
{ value: 'archived', label: 'Archived', description: 'Long-term inactive. Hidden from outreach but kept for records.' },
|
||||
];
|
||||
|
||||
function lifecycleLabel(status: LifecycleStatus): string {
|
||||
return lifecycleOptions.find((o) => o.value === status)?.label ?? status;
|
||||
}
|
||||
|
||||
type BirthdayClient = {
|
||||
email: string;
|
||||
fullName: string;
|
||||
@@ -126,6 +147,118 @@
|
||||
let confirmPreview = false;
|
||||
let confirmBusy = false;
|
||||
let confirmError = '';
|
||||
let confirmPreviewHtml = '';
|
||||
let confirmPreviewBusy = false;
|
||||
let confirmPreviewToken = 0;
|
||||
|
||||
async function fetchConfirmPreview(ctx: ConfirmContext) {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
const stamp = ++confirmPreviewToken;
|
||||
confirmPreviewBusy = true;
|
||||
try {
|
||||
let url = '';
|
||||
let body: Record<string, unknown> = {};
|
||||
if (ctx.kind === 'welcome') {
|
||||
url = '/api/owner/render-welcome-pack';
|
||||
body = {
|
||||
email: ctx.client.email,
|
||||
serviceType: ctx.serviceType,
|
||||
priceDetails: ctx.priceDetails,
|
||||
startDate: ctx.startDate,
|
||||
};
|
||||
} else if (ctx.kind === 'birthday') {
|
||||
url = '/api/owner/render-birthday-email';
|
||||
body = { email: ctx.client.email };
|
||||
} else {
|
||||
// 'message' uses the live preview already rendered in the messaging tab.
|
||||
if (stamp === confirmPreviewToken) confirmPreviewHtml = msgPreviewHtml || '';
|
||||
return;
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (stamp !== confirmPreviewToken) return;
|
||||
if (res.ok && typeof data?.html === 'string') {
|
||||
confirmPreviewHtml = data.html;
|
||||
} else {
|
||||
confirmPreviewHtml = '';
|
||||
}
|
||||
} catch {
|
||||
if (stamp === confirmPreviewToken) confirmPreviewHtml = '';
|
||||
} finally {
|
||||
if (stamp === confirmPreviewToken) confirmPreviewBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openConfirmPreviewInNewTab() {
|
||||
if (!confirmPreviewHtml) return;
|
||||
const blob = new Blob([confirmPreviewHtml], { type: 'text/html' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
$: if (confirmCtx) {
|
||||
void fetchConfirmPreview(confirmCtx);
|
||||
} else {
|
||||
confirmPreviewHtml = '';
|
||||
confirmPreviewBusy = false;
|
||||
}
|
||||
|
||||
let statusCtx: AllClient | null = null;
|
||||
let statusDraft: LifecycleStatus = 'active';
|
||||
let statusReason = '';
|
||||
let statusBusy = false;
|
||||
let statusError = '';
|
||||
|
||||
function openStatusEditor(client: AllClient) {
|
||||
statusCtx = client;
|
||||
statusDraft = client.lifecycle?.status ?? 'active';
|
||||
statusReason = client.lifecycle?.reason ?? '';
|
||||
statusError = '';
|
||||
}
|
||||
|
||||
function closeStatusEditor() {
|
||||
if (statusBusy) return;
|
||||
statusCtx = null;
|
||||
statusError = '';
|
||||
}
|
||||
|
||||
async function saveStatus() {
|
||||
if (!statusCtx) return;
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
statusError = 'Your session has expired. Please sign in again.';
|
||||
return;
|
||||
}
|
||||
statusBusy = true;
|
||||
statusError = '';
|
||||
try {
|
||||
const res = await fetch('/api/owner/client-status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ email: statusCtx.email, status: statusDraft, reason: statusReason.trim() }),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) throw new Error(data?.detail?.message ?? data?.detail ?? 'Could not update status.');
|
||||
|
||||
const updated = data?.lifecycle as Lifecycle | undefined;
|
||||
if (updated) {
|
||||
allClients = allClients.map((c) =>
|
||||
c.email === statusCtx!.email ? { ...c, lifecycle: updated } : c,
|
||||
);
|
||||
}
|
||||
statusCtx = null;
|
||||
} catch (error) {
|
||||
statusError = error instanceof Error ? error.message : 'Could not update status.';
|
||||
} finally {
|
||||
statusBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
return window.localStorage.getItem('gw_onboarding_session') ?? '';
|
||||
@@ -825,6 +958,13 @@
|
||||
<svelte:head>
|
||||
<title>{header.title} | Goodwalk Admin</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<main class="owner-page">
|
||||
@@ -1067,9 +1207,17 @@
|
||||
|
||||
<div class="owner-cell">
|
||||
<span class="owner-mobile-label">Status</span>
|
||||
<span class:owner-status-complete={client.status === 'completed'} class="owner-status-pill">
|
||||
{client.status === 'completed' ? 'Completed' : 'Pending'}
|
||||
</span>
|
||||
<div class="owner-status-stack">
|
||||
<span class:owner-status-complete={client.status === 'completed'} class="owner-status-pill">
|
||||
{client.status === 'completed' ? 'Completed' : 'Pending'}
|
||||
</span>
|
||||
<span class="owner-lifecycle-pill" data-lifecycle={client.lifecycle?.status ?? 'active'}>
|
||||
{lifecycleLabel(client.lifecycle?.status ?? 'active')}
|
||||
</span>
|
||||
{#if client.lifecycle?.reason}
|
||||
<small class="owner-lifecycle-reason" title={client.lifecycle.reason}>{client.lifecycle.reason}</small>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="owner-cell">
|
||||
@@ -1079,12 +1227,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="owner-cell owner-action-cell">
|
||||
<div class="owner-cell owner-action-cell owner-action-stack">
|
||||
<span class="owner-mobile-label">Action</span>
|
||||
<button class="owner-secondary-btn" type="button" on:click={() => openEnquiry(client.email)}>
|
||||
<Icon name="fas fa-file-lines" />
|
||||
View enquiry
|
||||
</button>
|
||||
<button class="owner-secondary-btn" type="button" on:click={() => openStatusEditor(client)}>
|
||||
<Icon name="fas fa-circle-half-stroke" />
|
||||
Change status
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
@@ -1473,8 +1625,8 @@
|
||||
</section>
|
||||
|
||||
{#if confirmCtx}
|
||||
<div class="owner-modal-backdrop" role="presentation" on:click={closeConfirm}>
|
||||
<div class="owner-modal" role="dialog" aria-modal="true" aria-label="Confirm send" on:click|stopPropagation>
|
||||
<div class="owner-modal-backdrop" role="presentation" on:click|self={closeConfirm}>
|
||||
<div class="owner-modal" role="dialog" aria-modal="true" aria-label="Confirm send" tabindex="-1">
|
||||
<div class="owner-modal-head">
|
||||
<h2>
|
||||
{#if confirmCtx.kind === 'welcome'}
|
||||
@@ -1506,9 +1658,34 @@
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<div class="owner-modal-preview-shell">
|
||||
{#if confirmPreviewHtml}
|
||||
<iframe
|
||||
class="owner-modal-preview-iframe"
|
||||
title="Email preview"
|
||||
sandbox=""
|
||||
srcdoc={confirmPreviewHtml}
|
||||
></iframe>
|
||||
<div class="owner-modal-preview-actions">
|
||||
<small>This is exactly how the email will look.</small>
|
||||
<button type="button" class="owner-modal-preview-link" on:click={openConfirmPreviewInNewTab}>
|
||||
<Icon name="fas fa-up-right-from-square" />
|
||||
Open full preview
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="owner-modal-preview-empty">
|
||||
{confirmPreviewBusy ? 'Rendering preview…' : 'Preview unavailable.'}
|
||||
</div>
|
||||
{/if}
|
||||
{#if confirmPreviewBusy && confirmPreviewHtml}
|
||||
<span class="owner-modal-preview-busy">Refreshing…</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<label class="owner-preview-toggle">
|
||||
<input type="checkbox" bind:checked={confirmPreview} disabled={confirmBusy} />
|
||||
<span>Send a preview to me ({ownerEmail}) instead</span>
|
||||
<span>Also send a test copy to me ({ownerEmail}) instead of the client</span>
|
||||
</label>
|
||||
|
||||
{#if confirmError}
|
||||
@@ -1531,9 +1708,62 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if statusCtx}
|
||||
<div class="owner-modal-backdrop" role="presentation" on:click|self={closeStatusEditor}>
|
||||
<div class="owner-modal" role="dialog" aria-modal="true" aria-label="Change client status" tabindex="-1">
|
||||
<div class="owner-modal-head">
|
||||
<h2>Change status for {statusCtx.fullName || statusCtx.email}</h2>
|
||||
<button type="button" class="owner-modal-close" on:click={closeStatusEditor} aria-label="Close" disabled={statusBusy}>
|
||||
<Icon name="fas fa-xmark" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="owner-modal-status">
|
||||
Soft-change only. The client stays on file in case we want to reach out later
|
||||
(e.g. a newsletter). Cancelled and archived clients are excluded from outreach.
|
||||
</p>
|
||||
|
||||
<div class="status-options">
|
||||
{#each lifecycleOptions as opt}
|
||||
<label class:status-option-active={statusDraft === opt.value} class="status-option">
|
||||
<input type="radio" name="lifecycle-status" value={opt.value} bind:group={statusDraft} disabled={statusBusy} />
|
||||
<div>
|
||||
<strong>{opt.label}</strong>
|
||||
<small>{opt.description}</small>
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<label class="status-reason-field">
|
||||
<span>Reason (optional)</span>
|
||||
<textarea bind:value={statusReason} rows="3" maxlength="500" disabled={statusBusy}
|
||||
placeholder="e.g. moved overseas, switched walkers, on hold until spring…"></textarea>
|
||||
</label>
|
||||
|
||||
{#if statusCtx.lifecycle?.changedAt}
|
||||
<p class="owner-modal-status">
|
||||
Last changed {formatDateLabel(statusCtx.lifecycle.changedAt)}{statusCtx.lifecycle.changedBy ? ` by ${statusCtx.lifecycle.changedBy}` : ''}.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if statusError}
|
||||
<div class="owner-inline-error">{statusError}</div>
|
||||
{/if}
|
||||
|
||||
<div class="owner-compose-actions">
|
||||
<button class="owner-cancel-btn" type="button" on:click={closeStatusEditor} disabled={statusBusy}>Cancel</button>
|
||||
<button class="owner-send-btn" type="button" on:click={saveStatus} disabled={statusBusy}>
|
||||
{statusBusy ? 'Saving…' : 'Save status'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if enquiryOpen}
|
||||
<div class="owner-modal-backdrop" role="presentation" on:click={closeEnquiry}>
|
||||
<div class="owner-modal" role="dialog" aria-modal="true" aria-label="Client enquiry" on:click|stopPropagation>
|
||||
<div class="owner-modal-backdrop" role="presentation" on:click|self={closeEnquiry}>
|
||||
<div class="owner-modal" role="dialog" aria-modal="true" aria-label="Client enquiry" tabindex="-1">
|
||||
<div class="owner-modal-head">
|
||||
<h2>Website enquiry</h2>
|
||||
<button type="button" class="owner-modal-close" on:click={closeEnquiry} aria-label="Close">
|
||||
@@ -1585,6 +1815,18 @@
|
||||
|
||||
<style>
|
||||
.owner-page {
|
||||
/* Scope a clean Inter type system to the admin only — leaves the public
|
||||
site's Unbounded / Readex Pro stack untouched. */
|
||||
--admin-font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
--font-head: var(--admin-font);
|
||||
--font-body: var(--admin-font);
|
||||
font-family: var(--admin-font);
|
||||
font-feature-settings: 'cv11', 'ss01', 'ss03';
|
||||
letter-spacing: -0.005em;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
min-height: 100vh;
|
||||
background: #f7f6f1;
|
||||
color: var(--gw-green);
|
||||
@@ -1592,6 +1834,13 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.owner-page :global(input),
|
||||
.owner-page :global(textarea),
|
||||
.owner-page :global(select),
|
||||
.owner-page :global(button) {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.owner-shell {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
@@ -1719,29 +1968,8 @@
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.owner-hero {
|
||||
padding: 16px 0 10px;
|
||||
}
|
||||
|
||||
.owner-hero h1 {
|
||||
margin: 0 0 6px;
|
||||
color: rgba(17, 23, 27, 0.9);
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(22px, 3.4vw, 30px);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.owner-hero p {
|
||||
margin: 0;
|
||||
max-width: 62ch;
|
||||
color: rgba(33, 48, 33, 0.66);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.owner-content {
|
||||
padding: 16px 0 112px;
|
||||
padding: 16px 0 calc(108px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.owner-summary-grid {
|
||||
@@ -1801,7 +2029,6 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.owner-section-head h2,
|
||||
.owner-message-card h2 {
|
||||
margin: 0;
|
||||
color: #171b20;
|
||||
@@ -1811,7 +2038,6 @@
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.owner-section-head p,
|
||||
.owner-message-card p {
|
||||
margin: 6px 0 0;
|
||||
color: rgba(33, 48, 33, 0.64);
|
||||
@@ -1932,24 +2158,6 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.owner-chip-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.owner-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(33, 48, 33, 0.08);
|
||||
color: #213021;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.owner-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1971,6 +2179,122 @@
|
||||
color: #213021;
|
||||
}
|
||||
|
||||
.owner-status-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.owner-lifecycle-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(33, 48, 33, 0.08);
|
||||
color: rgba(33, 48, 33, 0.72);
|
||||
}
|
||||
|
||||
.owner-lifecycle-pill[data-lifecycle='active'] {
|
||||
background: rgba(60, 140, 75, 0.16);
|
||||
color: #1f5a2c;
|
||||
}
|
||||
|
||||
.owner-lifecycle-pill[data-lifecycle='paused'] {
|
||||
background: rgba(255, 209, 0, 0.22);
|
||||
color: #6a4a00;
|
||||
}
|
||||
|
||||
.owner-lifecycle-pill[data-lifecycle='cancelled'] {
|
||||
background: rgba(190, 60, 60, 0.14);
|
||||
color: #8a2a2a;
|
||||
}
|
||||
|
||||
.owner-lifecycle-pill[data-lifecycle='archived'] {
|
||||
background: rgba(33, 48, 33, 0.12);
|
||||
color: rgba(33, 48, 33, 0.62);
|
||||
}
|
||||
|
||||
.owner-lifecycle-reason {
|
||||
color: rgba(33, 48, 33, 0.6);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-options {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 6px 0 10px;
|
||||
}
|
||||
|
||||
.status-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(33, 48, 33, 0.12);
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.status-option input {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.status-option strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #171b20;
|
||||
}
|
||||
|
||||
.status-option small {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: rgba(33, 48, 33, 0.62);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.status-option-active {
|
||||
border-color: rgba(33, 48, 33, 0.36);
|
||||
background: rgba(255, 209, 0, 0.12);
|
||||
}
|
||||
|
||||
.status-reason-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.status-reason-field span {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: rgba(33, 48, 33, 0.72);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-reason-field textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(33, 48, 33, 0.16);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.owner-open-btn,
|
||||
.owner-send-btn,
|
||||
.owner-cancel-btn,
|
||||
@@ -2175,43 +2499,51 @@
|
||||
|
||||
.owner-bottom-nav {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 25;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 253, 250, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(17, 20, 24, 0.08);
|
||||
box-shadow: 0 6px 18px rgba(17, 20, 24, 0.08);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: saturate(180%) blur(18px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(18px);
|
||||
border: 1px solid rgba(17, 20, 24, 0.06);
|
||||
box-shadow: 0 10px 28px rgba(17, 20, 24, 0.12), 0 1px 0 rgba(255, 255, 255, 0.6) inset;
|
||||
}
|
||||
|
||||
.owner-bottom-tab {
|
||||
min-height: 48px;
|
||||
min-height: 54px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-content: center;
|
||||
gap: 3px;
|
||||
color: rgba(33, 48, 33, 0.58);
|
||||
gap: 4px;
|
||||
color: rgba(33, 48, 33, 0.6);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, color 0.18s ease, transform 0.18s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.owner-bottom-tab:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.owner-bottom-tab span {
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
.owner-bottom-tab :global(i) {
|
||||
font-size: 13px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.owner-bottom-tab-active {
|
||||
@@ -2219,8 +2551,11 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.owner-bottom-tab-active span {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.owner-tab:hover,
|
||||
.owner-open-btn:hover,
|
||||
.owner-send-btn:hover,
|
||||
.owner-cancel-btn:hover,
|
||||
@@ -2398,10 +2733,10 @@
|
||||
color: rgba(33, 48, 33, 0.4);
|
||||
}
|
||||
|
||||
.rte-editor p { margin: 0 0 12px; }
|
||||
.rte-editor ul,
|
||||
.rte-editor ol { margin: 0 0 12px; padding-left: 22px; }
|
||||
.rte-editor a { color: #c0392b; text-decoration: underline; }
|
||||
.rte-editor :global(p) { margin: 0 0 12px; }
|
||||
.rte-editor :global(ul),
|
||||
.rte-editor :global(ol) { margin: 0 0 12px; padding-left: 22px; }
|
||||
.rte-editor :global(a) { color: #c0392b; text-decoration: underline; }
|
||||
|
||||
.msg-form {
|
||||
display: grid;
|
||||
@@ -2563,25 +2898,6 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.owner-field textarea {
|
||||
width: 100%;
|
||||
min-height: 110px;
|
||||
padding: 12px 15px;
|
||||
border: 1px solid rgba(17, 20, 24, 0.1);
|
||||
border-radius: 14px;
|
||||
background: #fffdfa;
|
||||
font: inherit;
|
||||
color: #213021;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.owner-field textarea:focus {
|
||||
border-color: rgba(33, 48, 33, 0.42);
|
||||
box-shadow: 0 0 0 4px rgba(255, 209, 0, 0.12);
|
||||
}
|
||||
|
||||
.msg-recipient-modes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -2662,35 +2978,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.owner-footer-desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.owner-footer-desktop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.owner-footer-desktop :global(.ob-footer-inner) {
|
||||
height: 38px;
|
||||
padding: 0 28px;
|
||||
}
|
||||
|
||||
.owner-footer-desktop :global(.ob-footer-back),
|
||||
.owner-footer-desktop :global(.ob-footer-email),
|
||||
.owner-footer-desktop :global(.ob-footer-logout) {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.owner-footer-desktop :global(.ob-footer-logout) {
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.owner-content {
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
@@ -2703,24 +2991,97 @@
|
||||
.owner-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(17, 20, 24, 0.45);
|
||||
backdrop-filter: blur(2px);
|
||||
background: rgba(17, 20, 24, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
padding: 14px;
|
||||
padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
|
||||
padding-top: calc(14px + env(safe-area-inset-top, 0px));
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.owner-modal {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
max-width: 560px;
|
||||
max-height: min(92vh, 800px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
box-shadow: 0 24px 60px rgba(17, 20, 24, 0.25);
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 24px 60px rgba(17, 20, 24, 0.28);
|
||||
}
|
||||
|
||||
.owner-modal-preview-shell {
|
||||
margin: 6px 0 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(17, 20, 24, 0.08);
|
||||
background: #f7f6f1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.owner-modal-preview-iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.owner-modal-preview-busy {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgba(33, 48, 33, 0.6);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.owner-modal-preview-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
color: rgba(33, 48, 33, 0.5);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.owner-modal-preview-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: #fff;
|
||||
border-top: 1px solid rgba(17, 20, 24, 0.06);
|
||||
}
|
||||
|
||||
.owner-modal-preview-actions small {
|
||||
color: rgba(33, 48, 33, 0.55);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.owner-modal-preview-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(33, 48, 33, 0.16);
|
||||
background: #fff;
|
||||
color: #213021;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.owner-modal-head {
|
||||
@@ -2832,21 +3193,56 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.owner-page {
|
||||
/* Prevent any element from breaking horizontal scroll on a phone. */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.owner-shell {
|
||||
padding-left: max(16px, env(safe-area-inset-left, 0px));
|
||||
padding-right: max(16px, env(safe-area-inset-right, 0px));
|
||||
}
|
||||
|
||||
.owner-topbar-inner {
|
||||
min-height: 56px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.owner-logo img {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.owner-pill {
|
||||
font-size: 10px;
|
||||
padding: 4px 9px;
|
||||
}
|
||||
|
||||
.owner-topbar-logout {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.owner-summary-grid {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.owner-summary-pill {
|
||||
padding: 8px 10px;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
gap: 10px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.owner-summary-pill strong {
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.owner-summary-pill span {
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.owner-section-head {
|
||||
@@ -2859,6 +3255,16 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.owner-table-row {
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.owner-cell h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.owner-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -2867,19 +3273,45 @@
|
||||
}
|
||||
|
||||
.owner-pagination .owner-page-btn {
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.owner-pagination .owner-page-indicator {
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.owner-enquiry-list > div {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.owner-modal {
|
||||
max-width: 100%;
|
||||
max-height: calc(100dvh - 90px - env(safe-area-inset-bottom, 0px));
|
||||
border-radius: 20px 20px 16px 16px;
|
||||
padding: 18px 16px;
|
||||
}
|
||||
|
||||
.owner-modal-head h2 {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.owner-modal-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.owner-modal-preview-iframe {
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
.owner-send-btn,
|
||||
.owner-cancel-btn {
|
||||
min-height: 46px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Activity tab ── */
|
||||
|
||||
+18
-3
@@ -43,6 +43,23 @@ export function buildAreaServed(locations = locationPages) {
|
||||
}));
|
||||
}
|
||||
|
||||
export const goodwalkProviderNode = {
|
||||
'@type': ['LocalBusiness', 'PetCareService'],
|
||||
'@id': `${siteUrl}/#business`,
|
||||
name: 'Goodwalk',
|
||||
url: siteUrl,
|
||||
logo: `${siteUrl}/images/goodwalk-auckland-dog-walking-logo.webp`,
|
||||
image: `${siteUrl}/images/goodwalk-auckland-dog-walking-logo.webp`,
|
||||
email: 'info@goodwalk.co.nz',
|
||||
telephone: '+64226421011',
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: 'Auckland Central',
|
||||
addressRegion: 'Auckland',
|
||||
addressCountry: 'NZ'
|
||||
}
|
||||
} as const;
|
||||
|
||||
function getLocationSeoImage(location: LocationPageContent) {
|
||||
return location.seo?.image ?? location.parks.find((park) => park.image?.src)?.image?.src ?? defaultLocationImage;
|
||||
}
|
||||
@@ -81,9 +98,7 @@ export function buildLocationSeo(location: LocationPageContent) {
|
||||
name: serviceName,
|
||||
description,
|
||||
serviceType,
|
||||
provider: {
|
||||
'@id': `${siteUrl}/#business`
|
||||
},
|
||||
provider: goodwalkProviderNode,
|
||||
areaServed: {
|
||||
'@type': 'Place',
|
||||
name: `${location.suburb}, Auckland, New Zealand`
|
||||
|
||||
@@ -25,6 +25,11 @@ img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
/* Hide alt text flash before pixels paint. Alt is still exposed to
|
||||
screen readers; this only suppresses the visual text on the
|
||||
broken-image placeholder shown by some browsers mid-load. */
|
||||
color: transparent;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
|
||||
@@ -55,7 +55,7 @@ header {
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.nav-ribbon::after {
|
||||
animation: nav-ribbon-shine 8s ease-in-out 2.8s infinite;
|
||||
animation: nav-ribbon-shine 8s ease-in-out 2.8s 2;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
Motion system
|
||||
|
||||
Reusable, opt-in animation utilities. One easing family, one set of
|
||||
distances, one set of durations. Designed to pair with the existing
|
||||
`use:reveal` action: apply the action on a parent section, then add
|
||||
motion utilities to inner elements.
|
||||
|
||||
Naming
|
||||
.fade-up opacity + translateY
|
||||
.fade-in opacity only
|
||||
.scale-soft opacity + subtle scale-in (0.985 → 1)
|
||||
.stagger-children cascades children at --motion-stagger-step
|
||||
.hover-lift elevates on hover (hover-capable devices only)
|
||||
.cta-shimmer slow, subtle sheen across a CTA (one-off, not loop)
|
||||
.subtle-glow soft, breathing focus halo for accent surfaces
|
||||
.image-parallax gentle drift; CSS scroll-driven where supported
|
||||
|
||||
All motion is gated behind prefers-reduced-motion: no-preference.
|
||||
───────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
/* ── Reveal utilities ─────────────────────────────────────────────────
|
||||
These start in an "off" state and animate when an ancestor carries
|
||||
`.reveal-visible` (set by the existing reveal action). That keeps
|
||||
trigger logic centralized — no extra observers per element. */
|
||||
.fade-up,
|
||||
.fade-in,
|
||||
.scale-soft {
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--motion-duration-md) var(--ease-out-quiet),
|
||||
transform var(--motion-duration-md) var(--ease-out-quiet);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.fade-up {
|
||||
transform: translate3d(0, var(--motion-distance-md), 0);
|
||||
}
|
||||
|
||||
.scale-soft {
|
||||
transform: scale(0.985);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
/* Reveal trigger: when a parent reveal block becomes visible, or the
|
||||
element itself carries `.is-in` (for non-reveal contexts). */
|
||||
.reveal-visible .fade-up,
|
||||
.reveal-visible .fade-in,
|
||||
.reveal-visible .scale-soft,
|
||||
.fade-up.is-in,
|
||||
.fade-in.is-in,
|
||||
.scale-soft.is-in {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Once the animation has played, drop will-change to free the layer. */
|
||||
.reveal-visible .fade-up,
|
||||
.reveal-visible .fade-in,
|
||||
.reveal-visible .scale-soft {
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
/* ── Stagger ──────────────────────────────────────────────────────────
|
||||
Direct-child cascade. Up to 8 children covered explicitly; beyond
|
||||
that, children inherit the 8th delay (graceful fallback for grids). */
|
||||
.stagger-children > * {
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
.stagger-children > :nth-child(1) { transition-delay: calc(var(--motion-stagger-step) * 0); }
|
||||
.stagger-children > :nth-child(2) { transition-delay: calc(var(--motion-stagger-step) * 1); }
|
||||
.stagger-children > :nth-child(3) { transition-delay: calc(var(--motion-stagger-step) * 2); }
|
||||
.stagger-children > :nth-child(4) { transition-delay: calc(var(--motion-stagger-step) * 3); }
|
||||
.stagger-children > :nth-child(5) { transition-delay: calc(var(--motion-stagger-step) * 4); }
|
||||
.stagger-children > :nth-child(6) { transition-delay: calc(var(--motion-stagger-step) * 5); }
|
||||
.stagger-children > :nth-child(7) { transition-delay: calc(var(--motion-stagger-step) * 6); }
|
||||
.stagger-children > :nth-child(n + 8) { transition-delay: calc(var(--motion-stagger-step) * 7); }
|
||||
|
||||
/* Tighter cascade for dense grids (8+ cells). Opt-in. */
|
||||
.stagger-children.stagger-tight > * {
|
||||
--motion-stagger-step: 45ms;
|
||||
}
|
||||
|
||||
/* ── Hover lift ───────────────────────────────────────────────────────
|
||||
Quiet 2px elevation. Only on hover-capable devices so touch users
|
||||
don't get a sticky transformed state after tap. */
|
||||
.hover-lift {
|
||||
transition:
|
||||
transform var(--motion-duration-sm) var(--ease-out-soft),
|
||||
box-shadow var(--motion-duration-sm) var(--ease-out-soft);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus-visible matches hover for keyboard parity. */
|
||||
.hover-lift:focus-visible {
|
||||
transform: translateY(-2px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ── CTA shimmer ──────────────────────────────────────────────────────
|
||||
A single, soft sheen sweep that fires once on hover. Quiet, not a
|
||||
looping ad-banner. Requires the host to be position: relative and
|
||||
overflow: hidden, which most pill buttons already are. */
|
||||
.cta-shimmer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.cta-shimmer::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
115deg,
|
||||
transparent 30%,
|
||||
rgba(255, 255, 255, 0.32) 50%,
|
||||
transparent 70%
|
||||
);
|
||||
transform: translateX(-110%);
|
||||
transition: transform var(--motion-duration-lg) var(--ease-in-out-soft);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.cta-shimmer:hover::after {
|
||||
transform: translateX(110%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Subtle glow ──────────────────────────────────────────────────────
|
||||
A slow, breathing accent halo. Calibrated to be barely perceptible —
|
||||
the kind of thing you notice only after watching for a few seconds. */
|
||||
.subtle-glow {
|
||||
animation: motion-subtle-glow 5.2s var(--ease-in-out-soft) infinite;
|
||||
}
|
||||
|
||||
@keyframes motion-subtle-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(255, 209, 0, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px rgba(255, 209, 0, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Image parallax ───────────────────────────────────────────────────
|
||||
Gentle vertical drift on scroll. Uses CSS scroll-driven animations
|
||||
where supported (Chrome 115+, Edge 115+). Older browsers get a still
|
||||
image — perfectly fine fallback, no jank, no JS. */
|
||||
@supports (animation-timeline: view()) {
|
||||
.image-parallax {
|
||||
animation: motion-image-parallax linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: cover 0% cover 100%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes motion-image-parallax {
|
||||
from { transform: translate3d(0, -3%, 0); }
|
||||
to { transform: translate3d(0, 3%, 0); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reduced motion ─────────────────────────────────────────────────────
|
||||
Strip every transition and animation that this system introduces. The
|
||||
reveal utilities collapse to their visible state immediately so layout
|
||||
stays correct. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fade-up,
|
||||
.fade-in,
|
||||
.scale-soft {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.stagger-children > * {
|
||||
transition-delay: 0 !important;
|
||||
}
|
||||
|
||||
.hover-lift,
|
||||
.cta-shimmer::after,
|
||||
.subtle-glow,
|
||||
.image-parallax {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.cta-shimmer::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -761,12 +761,71 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 14" laptop range: dog still clips the centered headline at the existing
|
||||
54% / -200px settings. Shrink the footprint and pull it further left so
|
||||
the mascot fully clears the text column. */
|
||||
@media (min-width: 1024px) and (max-width: 1439px) {
|
||||
.hero-img img {
|
||||
width: 46%;
|
||||
transform: translateX(-260px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
body:has(#hero) .hero-inner {
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Laptop screens (13–14"): the hero is otherwise capped at 680px, which
|
||||
leaves the text crowding the top of the section. Give it more room so
|
||||
the bottom-anchored text sits lower in the viewport. */
|
||||
@media (min-width: 1024px) and (max-width: 1439px) {
|
||||
#hero {
|
||||
min-height: 92svh;
|
||||
max-height: 920px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 13/14" laptop polish ───────────────────────────────────────────────────
|
||||
The default type and spacing scales were tuned on a large external
|
||||
monitor — their caps land at sizes that feel oversized on the shorter
|
||||
720–900px viewports typical of 13–14" laptops. Pull the caps down so
|
||||
headings, hero text, and section rhythm sit comfortably above the fold
|
||||
without changing the visual identity. Tokens flow through every section
|
||||
that reads from them. ── */
|
||||
@media (min-width: 1024px) and (max-width: 1439px) {
|
||||
:root {
|
||||
/* Type scale — trim caps ~15% so headings stop dominating the viewport */
|
||||
--text-display: clamp(38px, 3.8vw, 46px);
|
||||
--text-h1: clamp(30px, 3.1vw, 38px);
|
||||
--text-h2: clamp(24px, 2.4vw, 30px);
|
||||
--text-h3: 20px;
|
||||
|
||||
/* Section rhythm — keep generous breathing room but stop sections
|
||||
eating ~60% of the viewport on a single scroll step */
|
||||
--space-section-featured-y: clamp(var(--space-11), 6.2vw, var(--space-13));
|
||||
--space-section-support-y: clamp(var(--space-10), 5.4vw, var(--space-12));
|
||||
--space-section-form-y: clamp(var(--space-10), 5.4vw, var(--space-12));
|
||||
--space-section-page-y: clamp(var(--space-10), 5vw, var(--space-12));
|
||||
--space-hero-inner-bottom: clamp(var(--space-6), 3.6vw, var(--space-8));
|
||||
}
|
||||
|
||||
/* Hero heading reads --text-display (now 46px max); explicitly tune
|
||||
line-height and margin so the two-line headline stays tight. */
|
||||
.hero-text h1 {
|
||||
line-height: 1.04;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Subtitle: drop a hair so the lead doesn't compete with the heading */
|
||||
.hero-subtitle,
|
||||
.hero-subtitle-desktop {
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Ultrawide (≥1800px) ────────────────────────────────────────────────────
|
||||
Only add rules here that are genuinely ultrawide-specific.
|
||||
Normal desktop, tablet, and mobile breakpoints are handled above. ── */
|
||||
|
||||
@@ -152,6 +152,21 @@
|
||||
--motion-reveal-opacity: 0.3s ease;
|
||||
--motion-reveal-transform: 0.45s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
|
||||
/* Motion system — shared timing, easing, and distance tokens.
|
||||
A single curve family (refined ease-out, gentle ease-in-out) keeps
|
||||
the whole site speaking one motion language. */
|
||||
--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-out-quiet: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
--ease-in-out-soft: cubic-bezier(0.45, 0, 0.2, 1);
|
||||
--motion-duration-xs: 180ms;
|
||||
--motion-duration-sm: 260ms;
|
||||
--motion-duration-md: 420ms;
|
||||
--motion-duration-lg: 620ms;
|
||||
--motion-distance-xs: 6px;
|
||||
--motion-distance-sm: 12px;
|
||||
--motion-distance-md: 18px;
|
||||
--motion-stagger-step: 70ms;
|
||||
|
||||
/* Layout */
|
||||
--max-w: 1280px;
|
||||
--space-container-x: clamp(var(--space-6), 4vw, var(--space-9));
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
import '$lib/styles/buttons.css';
|
||||
import '$lib/styles/forms.css';
|
||||
import '$lib/styles/sections.css';
|
||||
import '$lib/styles/motion.css';
|
||||
import '$lib/styles/responsive.css';
|
||||
|
||||
const mobileCtaButtonEnabled = isMobileCtaButtonEnabled();
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { privacyPolicyContent } from '$lib/content/privacy-policy';
|
||||
import { puppyVisitsContent } from '$lib/content/puppy-visits';
|
||||
import { termsAndConditionsContent } from '$lib/content/terms-and-conditions';
|
||||
import { buildAreaServed, buildBreadcrumb, absoluteUrl } from '$lib/seo';
|
||||
import { buildAreaServed, buildBreadcrumb, absoluteUrl, goodwalkProviderNode } from '$lib/seo';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
@@ -47,15 +47,31 @@
|
||||
};
|
||||
}
|
||||
|
||||
const sharedServiceRating = {
|
||||
'@type': 'AggregateRating',
|
||||
ratingValue: '5.0',
|
||||
bestRating: '5',
|
||||
worstRating: '1',
|
||||
reviewCount: '30'
|
||||
};
|
||||
function buildServiceNode(base: {
|
||||
name: string;
|
||||
description: string;
|
||||
serviceType: string;
|
||||
image: string;
|
||||
url: string;
|
||||
offers: ReturnType<typeof aggregateOfferSchema>;
|
||||
}) {
|
||||
const node: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Service',
|
||||
name: base.name,
|
||||
description: base.description,
|
||||
serviceType: base.serviceType,
|
||||
provider: goodwalkProviderNode,
|
||||
areaServed,
|
||||
image: base.image,
|
||||
url: base.url
|
||||
};
|
||||
if (base.offers) {
|
||||
node.offers = base.offers;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
const expertProvider = { '@id': `${siteUrl}/#alessandra` };
|
||||
const areaServed = buildAreaServed();
|
||||
|
||||
let seoImage = defaultSeoImage;
|
||||
@@ -86,21 +102,14 @@
|
||||
seoImage = packWalksContent.hero.imageUrl;
|
||||
seoImageAlt = packWalksContent.hero.imageAlt;
|
||||
pageStructuredData = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Service',
|
||||
buildServiceNode({
|
||||
name: 'Goodwalk Tiny Gang Pack Walks',
|
||||
description: data.page.description,
|
||||
serviceType: 'Pack walks for small and medium dogs',
|
||||
provider: { '@id': 'https://www.goodwalk.co.nz/#business' },
|
||||
areaServed,
|
||||
image: absoluteUrl(seoImage),
|
||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||
offers: aggregateOfferSchema(packWalksContent.pricing.plans),
|
||||
aggregateRating: sharedServiceRating,
|
||||
author: expertProvider,
|
||||
dateModified: packWalksContent.lastUpdated
|
||||
},
|
||||
offers: aggregateOfferSchema(packWalksContent.pricing.plans)
|
||||
}),
|
||||
buildBreadcrumb([
|
||||
{ name: 'Home', url: siteUrl },
|
||||
{ name: 'Tiny Gang Pack Walks', path: data.page.canonicalPath }
|
||||
@@ -111,21 +120,14 @@
|
||||
seoImage = dogWalkingContent.hero.imageUrl;
|
||||
seoImageAlt = dogWalkingContent.hero.imageAlt;
|
||||
pageStructuredData = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Service',
|
||||
buildServiceNode({
|
||||
name: 'Goodwalk Solo Dog Walks',
|
||||
description: data.page.description,
|
||||
serviceType: 'One-on-one dog walking',
|
||||
provider: { '@id': 'https://www.goodwalk.co.nz/#business' },
|
||||
areaServed,
|
||||
image: absoluteUrl(seoImage),
|
||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||
offers: aggregateOfferSchema(dogWalkingContent.pricing.plans),
|
||||
aggregateRating: sharedServiceRating,
|
||||
author: expertProvider,
|
||||
dateModified: dogWalkingContent.lastUpdated
|
||||
},
|
||||
offers: aggregateOfferSchema(dogWalkingContent.pricing.plans)
|
||||
}),
|
||||
buildBreadcrumb([
|
||||
{ name: 'Home', url: siteUrl },
|
||||
{ name: 'Solo Walks', path: data.page.canonicalPath }
|
||||
@@ -136,21 +138,14 @@
|
||||
seoImage = puppyVisitsContent.hero.imageUrl;
|
||||
seoImageAlt = puppyVisitsContent.hero.imageAlt;
|
||||
pageStructuredData = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Service',
|
||||
buildServiceNode({
|
||||
name: 'Goodwalk Puppy Visits',
|
||||
description: data.page.description,
|
||||
serviceType: 'In-home puppy visits',
|
||||
provider: { '@id': 'https://www.goodwalk.co.nz/#business' },
|
||||
areaServed,
|
||||
image: absoluteUrl(seoImage),
|
||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||
offers: aggregateOfferSchema(puppyVisitsContent.pricing.plans),
|
||||
aggregateRating: sharedServiceRating,
|
||||
author: expertProvider,
|
||||
dateModified: puppyVisitsContent.lastUpdated
|
||||
},
|
||||
offers: aggregateOfferSchema(puppyVisitsContent.pricing.plans)
|
||||
}),
|
||||
buildBreadcrumb([
|
||||
{ name: 'Home', url: siteUrl },
|
||||
{ name: 'Puppy Visits', path: data.page.canonicalPath }
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('static slug route page', () => {
|
||||
['our-pricing', ourPricingContent.subtitle ?? ourPricingContent.title],
|
||||
['about', aboutPageContent.sections[0].title],
|
||||
['testimonials', 'Client Testimonials'],
|
||||
['contact-us', "Let's meet!"],
|
||||
['contact-us', 'Contact Us'],
|
||||
['terms-and-conditions', '1. Application of Terms'],
|
||||
['privacy-policy', 'How we collect your information']
|
||||
] as const)('renders the %s page branch', (slug, expectedText) => {
|
||||
@@ -49,26 +49,21 @@ describe('static slug route page', () => {
|
||||
data: createStaticRouteData(slug)
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Let's meet!")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Tell us about your dog/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('What our clients say')).not.toBeInTheDocument();
|
||||
}
|
||||
);
|
||||
|
||||
it('shows the general enquiry option on the contact page only', () => {
|
||||
const { rerender } = render(SlugPage, {
|
||||
it('does not surface the general-enquiry option even when the flag is on', () => {
|
||||
render(SlugPage, {
|
||||
data: {
|
||||
...createStaticRouteData('contact-us'),
|
||||
generalEnquiryEnabled: true
|
||||
}
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: /General enquiry/i })).toBeInTheDocument();
|
||||
|
||||
rerender({
|
||||
data: createStaticRouteData('pack-walks')
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: /General enquiry/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the shared FAQ section on the contact page', () => {
|
||||
|
||||
@@ -21,7 +21,9 @@ describe('home page route', () => {
|
||||
const booking = document.getElementById('newlead');
|
||||
|
||||
expect(screen.getAllByText(homepageContent.hero.highlight).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(homepageContent.intro.text)).toBeInTheDocument();
|
||||
const introHeadlineText = homepageContent.intro.text.replace(/\.$/, '');
|
||||
const introHeading = document.querySelector('#intro .intro-headline');
|
||||
expect(introHeading?.textContent?.replace(/\s+/g, ' ').trim()).toBe(introHeadlineText);
|
||||
expect(screen.getByText('Calmer dogs. Clearer routines. Less worry.')).toBeInTheDocument();
|
||||
expect(screen.getByText(homepageContent.howItWorks.title)).toBeInTheDocument();
|
||||
expect(screen.getByText(homepageContent.info.title)).toBeInTheDocument();
|
||||
|
||||
@@ -8,6 +8,7 @@ export const GET: RequestHandler = () => {
|
||||
'Disallow: /contract',
|
||||
'Disallow: /meet-greet-v2',
|
||||
'Disallow: /variants-contact-form',
|
||||
'Disallow: /variants/',
|
||||
'',
|
||||
'# AI crawlers — explicitly permitted',
|
||||
'User-agent: GPTBot',
|
||||
|
||||
@@ -0,0 +1,881 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
const instagramHref = 'https://www.instagram.com/goodwalk.nz/';
|
||||
|
||||
// Thumbnails for the polaroid stack + phone mosaic come from the existing
|
||||
// imagery in /static/images so this page works without new uploads.
|
||||
const gridImages = [
|
||||
'/images/goodwalk-tiny-gang-pack-walk-auckland.webp',
|
||||
'/images/goodwalk-tiny-gang-mt-albert-park-auckland.webp',
|
||||
'/images/goodwalk-tiny-gang-finishing-walk-suv-auckland.webp',
|
||||
'/images/goodwalk-client-dogs-mt-cecila-auckland.webp',
|
||||
'/images/goodwalk-tiny-gang-pack-walk-small-dogs-auckland.webp',
|
||||
'/images/goodwalk-dogs-group-outing-auckland.webp',
|
||||
'/images/archie-goodwalk-dog-walking-review-auckland.webp',
|
||||
'/images/monty-goodwalk-dog-walking-review-auckland.webp',
|
||||
'/images/otis-goodwalk-dog-walking-review-auckland.webp'
|
||||
];
|
||||
|
||||
const variants = [
|
||||
{
|
||||
id: 'editorial',
|
||||
index: '01',
|
||||
label: 'Photo-led editorial',
|
||||
kicker: 'Magazine feel — image does the work, type stays quiet'
|
||||
},
|
||||
{
|
||||
id: 'phone-mosaic',
|
||||
index: '02',
|
||||
label: 'Phone mosaic',
|
||||
kicker: 'Native Instagram preview — see exactly what you’ll get'
|
||||
},
|
||||
{
|
||||
id: 'quiet-split',
|
||||
index: '03',
|
||||
label: 'Quiet split',
|
||||
kicker: 'Apple-style restraint — no yellow background, no dog cutout'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Instagram CTA — variants</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
<main class="variants-page">
|
||||
<header class="variants-header">
|
||||
<span class="variants-eyebrow">Variants</span>
|
||||
<h1>Follow-on-Instagram CTA</h1>
|
||||
<p>
|
||||
Three reference takes on the existing yellow banner. All link to <code>@goodwalk.nz</code>.
|
||||
No copy, branding, or palette changes — same content, three different ways to frame it.
|
||||
</p>
|
||||
|
||||
<nav class="variants-nav" aria-label="Jump to variant">
|
||||
{#each variants as variant}
|
||||
<a href={`#${variant.id}`} class="variants-nav-chip">
|
||||
<span class="variants-nav-index">{variant.index}</span>
|
||||
<span class="variants-nav-label">{variant.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- ─── Variant 01: Photo-led editorial ─────────────────────────────────── -->
|
||||
<section id="editorial" class="variant-showcase">
|
||||
<div class="variant-meta">
|
||||
<span class="variant-index">01</span>
|
||||
<div>
|
||||
<h2>Photo-led editorial</h2>
|
||||
<p>Full-bleed dog photo. Type sits in a dark scrim on the left. A small Instagram source pill anchors the corner. Yellow CTA the only saturated element.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="variant-stage variant-stage-editorial">
|
||||
<aside class="ig-editorial" aria-label="Follow Goodwalk on Instagram">
|
||||
<div class="ig-editorial-photo">
|
||||
<img
|
||||
src="/images/goodwalk-tiny-gang-pack-walk-small-dogs-auckland.webp"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ig-editorial-scrim">
|
||||
<div class="ig-editorial-copy">
|
||||
<span class="ig-editorial-source">
|
||||
<Icon name="fab fa-instagram" />
|
||||
<span>@goodwalk.nz</span>
|
||||
</span>
|
||||
<h2>Follow the Tiny Gang on Instagram.</h2>
|
||||
<p>Daily walks, real dogs, no filter. Park runs, pack moments, and the kind of updates you'd actually want to scroll.</p>
|
||||
|
||||
<a href={instagramHref} target="_blank" rel="noopener" class="ig-editorial-cta">
|
||||
<Icon name="fab fa-instagram" />
|
||||
<span>Follow @goodwalk.nz</span>
|
||||
<Icon name="fas fa-arrow-right" className="ig-editorial-arrow" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── Variant 02: Phone mosaic ────────────────────────────────────────── -->
|
||||
<section id="phone-mosaic" class="variant-showcase">
|
||||
<div class="variant-meta">
|
||||
<span class="variant-index">02</span>
|
||||
<div>
|
||||
<h2>Phone mosaic</h2>
|
||||
<p>Goodwalk green panel, copy left. Right side carries a subtle phone frame showing a 3×3 Instagram grid. Visitors see the product before they tap.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="variant-stage variant-stage-mosaic">
|
||||
<aside class="ig-mosaic" aria-label="Follow Goodwalk on Instagram">
|
||||
<div class="ig-mosaic-copy">
|
||||
<span class="ig-mosaic-eyebrow">
|
||||
<Icon name="fab fa-instagram" />
|
||||
<span>@goodwalk.nz</span>
|
||||
</span>
|
||||
<h2>Follow the Tiny Gang on Instagram.</h2>
|
||||
<p>Pack walks, puppy visits, and the dogs your dog would call friends. Updated most days.</p>
|
||||
|
||||
<a href={instagramHref} target="_blank" rel="noopener" class="btn btn-yellow ig-mosaic-cta">
|
||||
<Icon name="fab fa-instagram" />
|
||||
<span>Follow on Instagram</span>
|
||||
</a>
|
||||
|
||||
<div class="ig-mosaic-meta" aria-label="Follower stats">
|
||||
<div>
|
||||
<strong>30+</strong>
|
||||
<span>five-star reviews</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Daily</strong>
|
||||
<span>tiny-gang updates</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ig-mosaic-phone" aria-hidden="true">
|
||||
<div class="ig-mosaic-phone-frame">
|
||||
<div class="ig-mosaic-phone-header">
|
||||
<Icon name="fab fa-instagram" />
|
||||
<span>goodwalk.nz</span>
|
||||
<span class="ig-mosaic-phone-dot" aria-hidden="true">•</span>
|
||||
<span class="ig-mosaic-phone-follow">Following</span>
|
||||
</div>
|
||||
<div class="ig-mosaic-phone-grid">
|
||||
{#each gridImages as src}
|
||||
<div class="ig-mosaic-phone-cell">
|
||||
<img {src} alt="" loading="lazy" decoding="async" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── Variant 03: Quiet split ─────────────────────────────────────────── -->
|
||||
<section id="quiet-split" class="variant-showcase">
|
||||
<div class="variant-meta">
|
||||
<span class="variant-index">03</span>
|
||||
<div>
|
||||
<h2>Quiet split</h2>
|
||||
<p>No yellow page background. The section sits on off-white. One editorial photo, type at the leading edge, CTA understated. The page exhales here instead of shouting.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="variant-stage variant-stage-quiet">
|
||||
<aside class="ig-quiet" aria-label="Follow Goodwalk on Instagram">
|
||||
<div class="ig-quiet-copy">
|
||||
<span class="ig-quiet-eyebrow">Daily on Instagram</span>
|
||||
<h2>The Tiny Gang, day by day.</h2>
|
||||
<p>
|
||||
A small window into our walks. Park favourites, new pack pairings, and the
|
||||
quieter moments owners love coming home to.
|
||||
</p>
|
||||
|
||||
<a href={instagramHref} target="_blank" rel="noopener" class="ig-quiet-cta">
|
||||
<span class="ig-quiet-cta-mark" aria-hidden="true">
|
||||
<Icon name="fab fa-instagram" />
|
||||
</span>
|
||||
<span class="ig-quiet-cta-text">
|
||||
<span class="ig-quiet-cta-handle">@goodwalk.nz</span>
|
||||
<span class="ig-quiet-cta-action">
|
||||
Follow
|
||||
<Icon name="fas fa-arrow-right" />
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="ig-quiet-media">
|
||||
<img
|
||||
src="/images/goodwalk-tiny-gang-mt-albert-park-auckland.webp"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.variants-page {
|
||||
background: var(--off-white);
|
||||
min-height: 100vh;
|
||||
padding-bottom: 96px;
|
||||
}
|
||||
|
||||
/* ── Page header ─────────────────────────────────────────────────────── */
|
||||
.variants-header {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 96px 24px 48px;
|
||||
}
|
||||
|
||||
.variants-eyebrow {
|
||||
display: inline-block;
|
||||
margin-bottom: 16px;
|
||||
color: var(--gw-green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.variants-header h1 {
|
||||
margin: 0 0 18px;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(34px, 4vw, 48px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.05;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.variants-header p {
|
||||
margin: 0;
|
||||
max-width: 60ch;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.variants-header code {
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
background: rgba(33, 48, 33, 0.08);
|
||||
color: var(--gw-green);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.variants-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.variants-nav-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 16px 9px 12px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.08), 0 6px 16px rgba(17, 20, 24, 0.04);
|
||||
color: var(--gw-green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: transform 0.18s ease, box-shadow 0.22s ease;
|
||||
}
|
||||
|
||||
.variants-nav-chip:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.1), 0 10px 22px rgba(17, 20, 24, 0.07);
|
||||
}
|
||||
|
||||
.variants-nav-index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--yellow);
|
||||
color: var(--gw-green);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* ── Each variant block ──────────────────────────────────────────────── */
|
||||
.variant-showcase {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 72px 24px 0;
|
||||
}
|
||||
|
||||
.variant-meta {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
margin-bottom: 28px;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.variant-index {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--gw-green);
|
||||
color: var(--yellow);
|
||||
font-family: var(--font-head);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.variant-meta h2 {
|
||||
margin: 0 0 6px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-heading);
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.variant-meta p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* The "stage" mimics where the section sits on the live page — page
|
||||
surface around it, so each variant reads in context. */
|
||||
.variant-stage {
|
||||
padding: 56px clamp(24px, 4vw, 64px);
|
||||
border-radius: 32px;
|
||||
}
|
||||
|
||||
.variant-stage-editorial {
|
||||
background: #f4efe5;
|
||||
}
|
||||
|
||||
.variant-stage-mosaic {
|
||||
background: var(--yellow);
|
||||
}
|
||||
|
||||
.variant-stage-quiet {
|
||||
background: var(--off-white);
|
||||
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.04);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
Variant 01 — Photo-led editorial
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
.ig-editorial {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 28px;
|
||||
aspect-ratio: 2.6 / 1;
|
||||
min-height: 280px;
|
||||
box-shadow: 0 30px 70px rgba(17, 20, 24, 0.16);
|
||||
}
|
||||
|
||||
.ig-editorial-photo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.ig-editorial-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 60% 40%;
|
||||
transform: scale(1.02);
|
||||
transition: transform 0.8s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.ig-editorial:hover .ig-editorial-photo img {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.ig-editorial-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
rgba(15, 24, 15, 0.86) 0%,
|
||||
rgba(15, 24, 15, 0.74) 36%,
|
||||
rgba(15, 24, 15, 0.18) 70%,
|
||||
rgba(15, 24, 15, 0) 100%
|
||||
);
|
||||
padding: 48px clamp(28px, 4vw, 56px);
|
||||
}
|
||||
|
||||
.ig-editorial-copy {
|
||||
max-width: 480px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ig-editorial-source {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px 6px 10px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
color: #fff;
|
||||
font-family: var(--font-head);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.ig-editorial-source :global(.icon) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ig-editorial-copy h2 {
|
||||
margin: 0 0 12px;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(26px, 3vw, 36px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.08;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ig-editorial-copy p {
|
||||
margin: 0 0 26px;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
max-width: 36ch;
|
||||
}
|
||||
|
||||
.ig-editorial-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 13px 22px;
|
||||
border-radius: 999px;
|
||||
background: var(--yellow);
|
||||
color: var(--gw-green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 12px 24px rgba(255, 209, 0, 0.32);
|
||||
transition: transform 0.22s ease, box-shadow 0.22s ease;
|
||||
}
|
||||
|
||||
.ig-editorial-cta:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 18px 32px rgba(255, 209, 0, 0.38);
|
||||
}
|
||||
|
||||
.ig-editorial-cta :global(.ig-editorial-arrow) {
|
||||
transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.ig-editorial-cta:hover :global(.ig-editorial-arrow) {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
Variant 02 — Phone mosaic
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
.ig-mosaic {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 0.9fr);
|
||||
align-items: center;
|
||||
gap: clamp(24px, 4vw, 56px);
|
||||
padding: clamp(36px, 5vw, 64px) clamp(28px, 4vw, 56px);
|
||||
border-radius: 32px;
|
||||
background:
|
||||
radial-gradient(circle at 100% 0%, rgba(255, 209, 0, 0.16), transparent 38%),
|
||||
var(--gw-green);
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ig-mosaic-copy {
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.ig-mosaic-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 14px 7px 11px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: var(--yellow);
|
||||
font-family: var(--font-head);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.ig-mosaic h2 {
|
||||
margin: 0 0 14px;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(28px, 3vw, 38px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.06;
|
||||
}
|
||||
|
||||
.ig-mosaic-copy p {
|
||||
margin: 0 0 28px;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
max-width: 38ch;
|
||||
}
|
||||
|
||||
.ig-mosaic-cta {
|
||||
gap: 9px;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.ig-mosaic-meta {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
margin-top: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.ig-mosaic-meta div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ig-mosaic-meta strong {
|
||||
color: var(--yellow);
|
||||
font-family: var(--font-head);
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.ig-mosaic-meta span {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ig-mosaic-phone {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
aspect-ratio: 1 / 1;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.ig-mosaic-phone-frame {
|
||||
width: min(100%, 320px);
|
||||
padding: 14px;
|
||||
border-radius: 26px;
|
||||
background: linear-gradient(180deg, #fafaf7, #ecebe5);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(17, 20, 24, 0.1),
|
||||
0 28px 60px rgba(0, 0, 0, 0.32),
|
||||
0 4px 10px rgba(0, 0, 0, 0.18);
|
||||
transform: rotate(-3deg);
|
||||
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.ig-mosaic:hover .ig-mosaic-phone-frame {
|
||||
transform: rotate(-1.5deg) translateY(-4px);
|
||||
}
|
||||
|
||||
.ig-mosaic-phone-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 4px 14px;
|
||||
color: var(--text-heading);
|
||||
font-family: var(--font-head);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ig-mosaic-phone-header :global(.icon) {
|
||||
color: var(--text-heading);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.ig-mosaic-phone-dot {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ig-mosaic-phone-follow {
|
||||
color: var(--gw-green);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.ig-mosaic-phone-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 3px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ig-mosaic-phone-cell {
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
background: rgba(33, 48, 33, 0.08);
|
||||
}
|
||||
|
||||
.ig-mosaic-phone-cell img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
Variant 03 — Quiet split
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
.ig-quiet {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: clamp(32px, 5vw, 72px);
|
||||
padding: clamp(28px, 3vw, 40px) 0;
|
||||
}
|
||||
|
||||
.ig-quiet-copy {
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.ig-quiet-eyebrow {
|
||||
display: inline-block;
|
||||
margin-bottom: 16px;
|
||||
color: var(--gw-green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ig-quiet h2 {
|
||||
margin: 0 0 18px;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(28px, 3.2vw, 40px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.04;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.ig-quiet-copy p {
|
||||
margin: 0 0 32px;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
max-width: 36ch;
|
||||
}
|
||||
|
||||
.ig-quiet-cta {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
gap: 14px;
|
||||
padding: 8px 8px 8px 12px;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.08), 0 18px 32px rgba(17, 20, 24, 0.06);
|
||||
color: var(--text-heading);
|
||||
text-decoration: none;
|
||||
transition: transform 0.22s ease, box-shadow 0.22s ease;
|
||||
}
|
||||
|
||||
.ig-quiet-cta:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.1), 0 24px 40px rgba(17, 20, 24, 0.1);
|
||||
}
|
||||
|
||||
.ig-quiet-cta-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: var(--gw-green);
|
||||
color: var(--yellow);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.ig-quiet-cta-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.ig-quiet-cta-handle {
|
||||
font-family: var(--font-head);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: var(--text-heading);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.ig-quiet-cta-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ig-quiet-cta-action :global(.icon) {
|
||||
font-size: 10px;
|
||||
transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.ig-quiet-cta:hover .ig-quiet-cta-action :global(.icon) {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.ig-quiet-media {
|
||||
position: relative;
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 22px 50px rgba(17, 20, 24, 0.12);
|
||||
}
|
||||
|
||||
.ig-quiet-media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.ig-quiet:hover .ig-quiet-media img {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
/* ── Mobile ──────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.variants-header {
|
||||
padding: 64px 20px 32px;
|
||||
}
|
||||
|
||||
.variant-showcase {
|
||||
padding: 48px 16px 0;
|
||||
}
|
||||
|
||||
.variant-stage {
|
||||
padding: 28px 16px;
|
||||
}
|
||||
|
||||
.ig-editorial {
|
||||
aspect-ratio: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ig-editorial-photo {
|
||||
position: relative;
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.ig-editorial-scrim {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
background: var(--gw-green);
|
||||
padding: 28px 22px;
|
||||
}
|
||||
|
||||
.ig-mosaic {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 32px 22px;
|
||||
gap: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ig-mosaic-copy {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.ig-mosaic-eyebrow,
|
||||
.ig-mosaic-cta {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.ig-mosaic-meta {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ig-mosaic-phone {
|
||||
min-height: 0;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.ig-mosaic-phone-frame {
|
||||
width: min(100%, 280px);
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
.ig-quiet {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ig-quiet-copy {
|
||||
order: 2;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ig-quiet-copy p {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.ig-quiet-cta {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.ig-quiet-media {
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ig-editorial-photo img,
|
||||
.ig-mosaic-phone-frame,
|
||||
.ig-quiet-media img,
|
||||
.ig-editorial-cta,
|
||||
.ig-quiet-cta {
|
||||
transition: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user