feat: add landing page components including hero, features, problem, and footer
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m23s

- Introduced LandingHero component with logo, tagline, and action buttons.
- Created LandingFeatures component to showcase chore system benefits.
- Developed LandingProblem component explaining the importance of a structured chore system.
- Implemented LandingFooter for navigation and copyright information.
- Added LandingPage to assemble all components and manage navigation.
- Included unit tests for LandingHero component to ensure functionality.
This commit is contained in:
2026-03-04 16:21:26 -05:00
parent 82ac820c67
commit c922e1180d
19 changed files with 965 additions and 43 deletions

125
.github/specs/feat-landing-page.md vendored Normal file
View File

@@ -0,0 +1,125 @@
# Feature: Landing page with details
## Overview
When an unauthenticated user visits the web site, they will be sent to this landing page. The page will contain a hero with various images. A sign up/sign in component. On scrolling, various components will show describing the application features and functionality.
**Goal:** New users are brought to the landing page to learn more about the app.
**User Story:**
As a new user, I should be presented with the landing page that tells me about the app.
**Rules:**
.github/copilot-instructions.md
You know how my app works at a high level
**Discussions:**
- I want each part of the landing page as different components (rows)
- Should these components go into their own directory (components/landing)
1. Row 1
- My logo is resources\logo\full_logo.png
- I plan to have my logo in the center near the top of the landing page
- I'd like to have my hero as resources\logo\hero.png
- I'd like to have my current sign in / sign up logic copied to a new component, but with this hero, it should be modern looking - semi-transparent glassmorphism box
- Should I have sign in or sign up? or compact both together
2. Row 2
- This should describe the problem - getting kids to do chores consistently - Describe how a system benefits:
1. Develops Executive Function: Managing a "to-do list" teaches kids how to prioritize tasks, manage their time, and follow multi-step directions—skills that translate directly to schoolwork and future careers.
2. Fosters a "Can-Do" Attitude: Completing a task provides a tangible sense of mastery. This builds genuine self-esteem because its based on actual competence rather than just empty praise.
3. Encourages Accountability: A system moves chores from "Mom/Dad is nagging me" to "This is my responsibility." It teaches that their actions (or lack thereof) have a direct impact on the people they live with.
4. Normalizes Life Skills: By the time they head out on their own, "adulting" won't feel like a mountain to climb. Cooking, cleaning, and organizing will be second nature rather than a stressful learning curve.
5. Promotes Family Belonging: Contributing to the home makes a child feel like a teammate rather than a guest. It reinforces the idea that a family is a unit where everyone supports one another.
3. Row 3
- This should describe how my app helps to solve the problem by creating the system
1. Chores can be created with customizable points that children have fun collecting.
2. Customized reward offer insentives for a child to set a goal. (saving points, etc)
3. Easy interface for children to see which chores need to be done and what rewards they can afford.
4. Parental controls (hidden) behind a pin that offer powerful tools for managing the system
- In this component we'll show some screen shots of the interface.
4. Row 4
- I'm not sure what else I should add for now
- How should I handle colors - should I follow my current theme in colors.css or should I adopt my logo / hero colors?
- Should my landing page be more modern and sleek with animations? What kind?
- Considerations for mobile should also be handled (whether in portrait or landscape mode)
- ***
## Data Model Changes
### Backend Model
### Frontend Model
---
## Backend Implementation
## Backend Tests
- [ ]
---
## Frontend Implementation
- [x] Copy `resources/logo/full_logo.png` and `hero.png` to `frontend/vue-app/public/images/`
- [x] Add landing CSS variables to `colors.css`
- [x] Create `src/components/landing/LandingPage.vue` — orchestrator with sticky nav
- [x] Create `src/components/landing/LandingHero.vue` — hero section with logo, tagline, glassmorphism CTA card
- [x] Create `src/components/landing/LandingProblem.vue` — 5-benefit problem section
- [x] Create `src/components/landing/LandingFeatures.vue` — 4-feature alternating layout with screenshot placeholders
- [x] Create `src/components/landing/LandingFooter.vue` — dark footer with links
- [x] Update router: add `/` → LandingPage route (meta: isPublic), update auth guard
## Frontend Tests
- [x] Update `authGuard.spec.ts`: fix redirect assertions (`/auth``/`) and add 3 landing-route guard tests
- [x] Create `src/components/landing/__tests__/LandingHero.spec.ts`: renders logo, tagline, buttons; CTA clicks push correct routes
---
## Future Considerations
- Eventually add a pricing section (component)
---
## Acceptance Criteria (Definition of Done)
### Backend
- [ ]
### Frontend
- [x] Unauthenticated user visiting `/` sees the full landing page
- [x] Sign In CTA routes to `/auth/login`
- [x] Get Started CTA routes to `/auth/signup`
- [x] Logged-in user visiting `/` is redirected to `/child` or `/parent`
- [x] Unauthenticated user visiting any protected route is redirected to `/` (landing)
- [x] All 4 rows render: Hero, Problem, Features, Footer
- [x] Sticky nav appears on scroll
- [x] Mobile: hero buttons stack vertically, grid goes single-column
- [x] All colors use `colors.css` variables only
---
## Bugs
### BUG-001 — Landing page instantly redirects to `/auth` on first visit
**Status:** Fixed
**Description:**
Visiting `/` as an unauthenticated user caused a visible flash of the landing page followed by an immediate hard redirect to `/auth`, making the landing page effectively unreachable.
**Root Cause:**
`App.vue` calls `checkAuth()` on mount, which hits `/api/auth/me`. For an unauthenticated user this returns a `401`. The fetch interceptor in `api.ts` (`handleUnauthorizedResponse`) then attempted a token refresh, which also failed, and finally called `window.location.assign('/auth')`. The interceptor only exempted paths already starting with `/auth` — not the landing page at `/`.
**Solution:**
- [`frontend/vue-app/src/common/api.ts`](../frontend/vue-app/src/common/api.ts) — Added `if (window.location.pathname === '/') return` inside `handleUnauthorizedResponse()` so the unauthorized interceptor does not forcibly redirect away from the public landing page.
- [`frontend/vue-app/src/stores/auth.ts`](../frontend/vue-app/src/stores/auth.ts) — Updated the cross-tab logout storage event handler to redirect to `/` instead of `/auth/login`, and skip the redirect entirely if already on `/`.

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ backend/test_data/db/tasks.json
backend/test_data/db/users.json backend/test_data/db/users.json
logs/account_deletion.log logs/account_deletion.log
backend/test_data/db/tracking_events.json backend/test_data/db/tracking_events.json
resources/

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -183,4 +183,24 @@
--create-btn-border: #2563eb; --create-btn-border: #2563eb;
--create-btn-hover-bg: #2563eb; --create-btn-hover-bg: #2563eb;
--create-btn-hover-color: #fff; --create-btn-hover-color: #fff;
/* Landing page styles */
--landing-overlay: rgba(0, 0, 0, 0.48);
--landing-glass-bg: rgba(255, 255, 255, 0.12);
--landing-glass-border: rgba(255, 255, 255, 0.25);
--landing-glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.28);
--landing-section-alt: #f4f6fb;
--landing-section-dark: #1a1830;
--landing-hero-text: #ffffff;
--landing-hero-subtext: rgba(255, 255, 255, 0.82);
--landing-problem-heading: #2d2a4a;
--landing-problem-body: #4a4870;
--landing-card-number: #667eea;
--landing-feature-heading: #ffffff;
--landing-feature-body: rgba(255, 255, 255, 0.88);
--landing-placeholder-bg: rgba(255, 255, 255, 0.08);
--landing-placeholder-border: rgba(255, 255, 255, 0.2);
--landing-footer-text: rgba(255, 255, 255, 0.7);
--landing-nav-bg: rgba(255, 255, 255, 0.06);
--landing-nav-border: rgba(255, 255, 255, 0.15);
} }

View File

@@ -23,6 +23,7 @@ function handleUnauthorizedResponse(): void {
logoutUser() logoutUser()
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
if (window.location.pathname.startsWith('/auth')) return if (window.location.pathname.startsWith('/auth')) return
if (window.location.pathname === '/') return
if (unauthorizedRedirectHandler) { if (unauthorizedRedirectHandler) {
unauthorizedRedirectHandler() unauthorizedRedirectHandler()
return return

View File

@@ -577,6 +577,35 @@ onUnmounted(() => {
<div v-if="choreDueLabel(item)" class="due-label">{{ choreDueLabel(item) }}</div> <div v-if="choreDueLabel(item)" class="due-label">{{ choreDueLabel(item) }}</div>
</template> </template>
</ScrollingList> </ScrollingList>
<ScrollingList
title="Rewards"
ref="childRewardListRef"
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
itemKey="reward_status"
imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="triggerReward"
:getItemClass="
(item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming })
"
>
<template #item="{ item }: { item: RewardStatus }">
<div class="item-name">{{ item.name }}</div>
<img
v-if="item.image_url"
:src="item.image_url"
alt="Reward Image"
class="item-image"
/>
<div class="item-points">
<span v-if="item.redeeming" class="pending">PENDING</span>
<span v-if="item.points_needed <= 0" class="ready">REWARD READY</span>
<span v-else>{{ item.points_needed }} more points</span>
</div>
</template>
</ScrollingList>
<ScrollingList <ScrollingList
title="Kindness Acts" title="Kindness Acts"
ref="childKindnessListRef" ref="childKindnessListRef"
@@ -631,35 +660,6 @@ onUnmounted(() => {
</div> </div>
</template> </template>
</ScrollingList> </ScrollingList>
<ScrollingList
title="Rewards"
ref="childRewardListRef"
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
itemKey="reward_status"
imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="triggerReward"
:getItemClass="
(item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming })
"
>
<template #item="{ item }: { item: RewardStatus }">
<div class="item-name">{{ item.name }}</div>
<img
v-if="item.image_url"
:src="item.image_url"
alt="Reward Image"
class="item-image"
/>
<div class="item-points">
<span v-if="item.redeeming" class="pending">PENDING</span>
<span v-if="item.points_needed <= 0" class="ready">REWARD READY</span>
<span v-else>{{ item.points_needed }} more points</span>
</div>
</template>
</ScrollingList>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,201 @@
<template>
<section class="features-section">
<div class="container">
<div class="section-label">The Solution</div>
<h2 class="section-heading">A System That Actually Works</h2>
<p class="section-subheading">
Chorly gives your family a structured, fun, and rewarding system so chores become
something kids want to do.
</p>
<div class="features-list">
<div
v-for="(feature, index) in features"
:key="feature.title"
class="feature-row"
:class="{ 'feature-row-reverse': index % 2 !== 0 }"
>
<div class="feature-screenshot">
<div class="screenshot-placeholder">
<span class="placeholder-icon">{{ feature.icon }}</span>
<span class="placeholder-label">{{ feature.screenshotLabel }}</span>
</div>
</div>
<div class="feature-text">
<div class="feature-tag">{{ feature.tag }}</div>
<h3 class="feature-title">{{ feature.title }}</h3>
<p class="feature-body">{{ feature.body }}</p>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const features = [
{
tag: 'Points & Chores',
icon: '⭐',
title: 'Chores Worth Collecting',
body: 'Create chores with fully customizable point values. Kids earn points for every task they complete, turning daily responsibilities into something genuinely satisfying to collect.',
screenshotLabel: 'Chore Dashboard',
},
{
tag: 'Rewards',
icon: '🎁',
title: 'Goals Kids Actually Care About',
body: "Set up custom rewards that mean something to your child — screen time, a special outing, a toy they've been wanting. Saving up points teaches patience and goal-setting.",
screenshotLabel: 'Reward Store',
},
{
tag: 'Child View',
icon: '👧',
title: 'Kids Stay in the Loop',
body: 'A simple, beautiful child interface shows exactly which chores need to be done today and which rewards are within reach — no confusion, no excuses.',
screenshotLabel: 'Child Dashboard',
},
{
tag: 'Parent Controls',
icon: '🔒',
title: 'Powerful Parental Controls',
body: "Hidden behind a PIN, the parent panel lets you add chores, adjust rewards, review history, and manage everything — without interrupting your child's flow.",
screenshotLabel: 'Parent Panel',
},
]
</script>
<style scoped>
.features-section {
background: var(--header-bg);
padding: 5rem 1.5rem;
}
.container {
max-width: 1100px;
margin: 0 auto;
}
.section-label {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.78rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 0.6rem;
text-align: center;
}
.section-heading {
font-size: clamp(1.6rem, 3.5vw, 2.4rem);
font-weight: 800;
color: var(--landing-feature-heading);
text-align: center;
margin: 0 0 1rem;
line-height: 1.2;
}
.section-subheading {
font-size: clamp(0.95rem, 2vw, 1.1rem);
color: var(--landing-feature-body);
text-align: center;
max-width: 620px;
margin: 0 auto 4rem;
line-height: 1.65;
}
.features-list {
display: flex;
flex-direction: column;
gap: 4rem;
}
.feature-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3rem;
align-items: center;
}
.feature-row-reverse {
direction: rtl;
}
.feature-row-reverse > * {
direction: ltr;
}
.screenshot-placeholder {
background: var(--landing-placeholder-bg);
border: 1.5px solid var(--landing-placeholder-border);
border-radius: 16px;
aspect-ratio: 16 / 10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.placeholder-icon {
font-size: 2.8rem;
line-height: 1;
}
.placeholder-label {
font-size: 0.85rem;
color: var(--landing-hero-subtext);
font-weight: 500;
letter-spacing: 0.04em;
}
.feature-tag {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.72rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.55);
margin-bottom: 0.5rem;
}
.feature-title {
font-size: clamp(1.2rem, 2.5vw, 1.55rem);
font-weight: 800;
color: var(--landing-feature-heading);
margin: 0 0 0.75rem;
line-height: 1.3;
}
.feature-body {
font-size: 0.97rem;
color: var(--landing-feature-body);
line-height: 1.7;
margin: 0;
}
/* Tablet */
@media (max-width: 800px) {
.feature-row,
.feature-row-reverse {
grid-template-columns: 1fr;
direction: ltr;
gap: 1.5rem;
}
.feature-row-reverse > * {
direction: ltr;
}
}
/* Mobile */
@media (max-width: 560px) {
.features-section {
padding: 3.5rem 1rem;
}
.features-list {
gap: 3rem;
}
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<footer class="landing-footer">
<div class="container">
<div class="footer-brand">
<img src="/images/chorly_logo.png" alt="Chorly" class="footer-logo" />
</div>
<div class="footer-links">
<button class="footer-link" @click="goToLogin">Sign In</button>
<span class="footer-divider">·</span>
<button class="footer-link" @click="goToSignup">Get Started</button>
</div>
<!-- Future: pricing section -->
<p class="footer-copy">© {{ year }} Chorly. All rights reserved.</p>
</div>
</footer>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
const year = new Date().getFullYear()
function goToLogin() {
router.push({ name: 'Login' })
}
function goToSignup() {
router.push({ name: 'Signup' })
}
</script>
<style scoped>
.landing-footer {
background: var(--landing-section-dark);
padding: 3rem 1.5rem 2rem;
}
.container {
max-width: 1100px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.2rem;
text-align: center;
}
.footer-logo {
height: 80px;
opacity: 0.85;
filter: brightness(1.1);
}
.footer-links {
display: flex;
align-items: center;
gap: 0.6rem;
}
.footer-link {
background: none;
border: none;
cursor: pointer;
color: var(--landing-footer-text);
font-size: 0.9rem;
padding: 0;
transition: color 0.15s;
}
.footer-link:hover {
color: #fff;
}
.footer-divider {
color: var(--landing-footer-text);
opacity: 0.4;
}
.footer-copy {
font-size: 0.78rem;
color: var(--landing-footer-text);
opacity: 0.5;
margin: 0;
}
</style>

View File

@@ -0,0 +1,155 @@
<template>
<section class="hero">
<div class="hero-overlay" />
<div class="hero-content">
<img src="/images/full_logo.png" alt="Chorly Logo" class="hero-logo" />
<p class="hero-tagline">
Turn chores into achievements.<br />Raise kids who own their responsibilities.
</p>
<div class="hero-card">
<div class="hero-actions">
<button class="btn-hero btn-hero-signin" @click="goToLogin">Sign In</button>
<button class="btn-hero btn-hero-signup" @click="goToSignup">Get Started Free</button>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
function goToLogin() {
router.push({ name: 'Login' })
}
function goToSignup() {
router.push({ name: 'Signup' })
}
</script>
<style scoped>
.hero {
position: relative;
min-height: 100svh;
display: flex;
align-items: center;
justify-content: center;
background-image: url('/images/hero.png');
background-size: cover;
background-position: center;
overflow: hidden;
}
.hero-overlay {
position: absolute;
inset: 0;
background: var(--landing-overlay);
z-index: 0;
}
.hero-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 2rem 1.5rem;
max-width: 600px;
width: 100%;
}
.hero-logo {
width: min(280px, 70vw);
margin-bottom: 1.5rem;
filter: drop-shadow(0 4px 16px rgba(0, 0, 0, 0.4));
}
.hero-tagline {
color: var(--landing-hero-text);
font-size: clamp(1.1rem, 2.5vw, 1.4rem);
line-height: 1.55;
margin: 0 0 2rem;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
font-weight: 400;
}
.hero-card {
background: var(--landing-glass-bg);
border: 1px solid var(--landing-glass-border);
box-shadow: var(--landing-glass-shadow);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 18px;
padding: 1.8rem 2.5rem;
width: 100%;
max-width: 440px;
}
.hero-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.btn-hero {
flex: 1;
min-width: 130px;
padding: 0.85rem 1.6rem;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
border: none;
transition:
transform 0.15s,
box-shadow 0.15s,
background 0.18s;
}
.btn-hero:hover {
transform: translateY(-2px);
}
.btn-hero-signin {
background: rgba(255, 255, 255, 0.18);
color: var(--landing-hero-text);
border: 1.5px solid var(--landing-glass-border);
}
.btn-hero-signin:hover {
background: rgba(255, 255, 255, 0.28);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.btn-hero-signup {
background: var(--btn-primary);
color: #fff;
box-shadow: 0 4px 18px rgba(102, 126, 234, 0.45);
}
.btn-hero-signup:hover {
background: var(--btn-primary-hover);
box-shadow: 0 6px 22px rgba(102, 126, 234, 0.6);
}
/* Mobile */
@media (max-width: 480px) {
.hero-card {
padding: 1.4rem 1.2rem;
}
.hero-actions {
flex-direction: column;
}
.btn-hero {
width: 100%;
flex: none;
}
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div class="landing-page">
<!-- Sticky nav -->
<nav class="landing-nav" :class="{ 'landing-nav-scrolled': scrolled }">
<div class="nav-inner">
<img src="/images/c_logo.png" alt="Chorly" class="nav-logo" />
<button class="nav-signin" @click="goToLogin">Sign In</button>
</div>
</nav>
<LandingHero />
<LandingProblem />
<LandingFeatures />
<LandingFooter />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import LandingHero from '@/components/landing/LandingHero.vue'
import LandingProblem from '@/components/landing/LandingProblem.vue'
import LandingFeatures from '@/components/landing/LandingFeatures.vue'
import LandingFooter from '@/components/landing/LandingFooter.vue'
const router = useRouter()
const scrolled = ref(false)
function onScroll() {
scrolled.value = window.scrollY > 40
}
function goToLogin() {
router.push({ name: 'Login' })
}
onMounted(() => {
window.addEventListener('scroll', onScroll, { passive: true })
})
onUnmounted(() => {
window.removeEventListener('scroll', onScroll)
})
</script>
<style scoped>
.landing-page {
min-height: 100svh;
overflow-x: hidden;
}
/* ── Sticky nav ── */
.landing-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
padding: 0.9rem 1.5rem;
transition:
background 0.25s,
box-shadow 0.25s,
border-bottom 0.25s;
}
.landing-nav-scrolled {
background: rgba(26, 24, 48, 0.82);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.3);
border-bottom: 1px solid var(--landing-nav-border);
}
.nav-inner {
max-width: 1100px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-logo {
height: 34px;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.35));
}
.nav-signin {
background: var(--landing-glass-bg);
border: 1px solid var(--landing-glass-border);
color: #fff;
padding: 0.45rem 1.2rem;
border-radius: 8px;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition:
background 0.15s,
box-shadow 0.15s;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.nav-signin:hover {
background: rgba(255, 255, 255, 0.22);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
/* Mobile */
@media (max-width: 480px) {
.landing-nav {
padding: 0.75rem 1rem;
}
.nav-logo {
height: 28px;
}
}
</style>

View File

@@ -0,0 +1,138 @@
<template>
<section class="problem-section">
<div class="container">
<div class="section-label">Why It Matters</div>
<h2 class="section-heading">Why Most Chore Systems Fail</h2>
<p class="section-subheading">
Nagging doesn't work. Vague expectations don't work. But a clear, consistent system changes
everything for your kids and for you.
</p>
<div class="benefits-grid">
<div v-for="benefit in benefits" :key="benefit.title" class="benefit-card">
<h3 class="benefit-title">{{ benefit.title }}</h3>
<p class="benefit-body">{{ benefit.body }}</p>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const benefits = [
{
title: 'Develops Executive Function',
body: 'Managing a to-do list teaches kids how to prioritize tasks, manage their time, and follow multi-step directions — skills that translate directly to schoolwork and future careers.',
},
{
title: 'Fosters a "Can-Do" Attitude',
body: "Completing a task provides a tangible sense of mastery. This builds genuine self-esteem because it's based on actual competence rather than empty praise.",
},
{
title: 'Encourages Accountability',
body: 'A system moves chores from "Mom/Dad is nagging me" to "This is my responsibility." It teaches that their actions have a direct impact on the people they live with.',
},
{
title: 'Normalizes Life Skills',
body: 'By the time they head out on their own, "adulting" won\'t feel like a mountain to climb. Cooking, cleaning, and organizing will be second nature rather than a stressful learning curve.',
},
{
title: 'Promotes Family Belonging',
body: 'Contributing to the home makes a child feel like a teammate rather than a guest. It reinforces the idea that a family is a unit where everyone supports one another.',
},
]
</script>
<style scoped>
.problem-section {
background: var(--landing-section-alt);
padding: 5rem 1.5rem;
}
.container {
max-width: 1100px;
margin: 0 auto;
}
.section-label {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.78rem;
font-weight: 700;
color: var(--primary);
margin-bottom: 0.6rem;
text-align: center;
}
.section-heading {
font-size: clamp(1.6rem, 3.5vw, 2.4rem);
font-weight: 800;
color: var(--landing-problem-heading);
text-align: center;
margin: 0 0 1rem;
line-height: 1.2;
}
.section-subheading {
font-size: clamp(0.95rem, 2vw, 1.1rem);
color: var(--landing-problem-body);
text-align: center;
max-width: 640px;
margin: 0 auto 3.5rem;
line-height: 1.65;
}
.benefits-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
.benefit-card {
background: var(--card-bg);
border-radius: 14px;
padding: 1.6rem 1.6rem 1.6rem 1.4rem;
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.09);
border-left: 3px solid var(--primary);
transition:
transform 0.18s,
box-shadow 0.18s;
}
.benefit-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 28px rgba(102, 126, 234, 0.16);
}
.benefit-title {
font-size: 1rem;
font-weight: 700;
color: var(--landing-problem-heading);
margin: 0 0 0.6rem;
line-height: 1.3;
}
.benefit-body {
font-size: 0.9rem;
color: var(--landing-problem-body);
line-height: 1.65;
margin: 0;
}
/* Tablet */
@media (max-width: 900px) {
.benefits-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* Mobile */
@media (max-width: 560px) {
.problem-section {
padding: 3.5rem 1rem;
}
.benefits-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import LandingHero from '../LandingHero.vue'
const { pushMock } = vi.hoisted(() => ({
pushMock: vi.fn(),
}))
vi.mock('vue-router', () => ({
useRouter: vi.fn(() => ({ push: pushMock })),
}))
describe('LandingHero.vue', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders the logo image', () => {
const wrapper = mount(LandingHero)
const logo = wrapper.find('img.hero-logo')
expect(logo.exists()).toBe(true)
expect(logo.attributes('src')).toBe('/images/full_logo.png')
})
it('renders a tagline', () => {
const wrapper = mount(LandingHero)
expect(wrapper.find('.hero-tagline').exists()).toBe(true)
})
it('renders Sign In and Get Started buttons', () => {
const wrapper = mount(LandingHero)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(2)
expect(buttons[0].text()).toBe('Sign In')
expect(buttons[1].text()).toBe('Get Started Free')
})
it('navigates to Login route when Sign In is clicked', async () => {
const wrapper = mount(LandingHero)
await wrapper.find('.btn-hero-signin').trigger('click')
expect(pushMock).toHaveBeenCalledWith({ name: 'Login' })
})
it('navigates to Signup route when Get Started is clicked', async () => {
const wrapper = mount(LandingHero)
await wrapper.find('.btn-hero-signup').trigger('click')
expect(pushMock).toHaveBeenCalledWith({ name: 'Signup' })
})
})

View File

@@ -94,20 +94,20 @@ describe('router auth guard', () => {
expect(path).toBe('/auth/login') expect(path).toBe('/auth/login')
}) })
// ── Unauthenticated users are redirected to /auth from protected routes ─── // ── Unauthenticated users are redirected to / from protected routes ────────
it('redirects unauthenticated user from /parent to /auth', async () => { it('redirects unauthenticated user from /parent to /', async () => {
isUserLoggedInMock.value = false isUserLoggedInMock.value = false
const path = await navigate('/parent') const path = await navigate('/parent')
expect(path).toBe('/auth') expect(path).toBe('/')
}) })
it('redirects unauthenticated user from /child to /auth', async () => { it('redirects unauthenticated user from /child to /', async () => {
isUserLoggedInMock.value = false isUserLoggedInMock.value = false
const path = await navigate('/child') const path = await navigate('/child')
expect(path).toBe('/auth') expect(path).toBe('/')
}) })
// ── Authenticated users are routed to the correct section ───────────────── // ── Authenticated users are routed to the correct section ─────────────────
@@ -152,4 +152,29 @@ describe('router auth guard', () => {
const path = await navigate('/parent/pin-setup') const path = await navigate('/parent/pin-setup')
expect(path).toBe('/parent/pin-setup') expect(path).toBe('/parent/pin-setup')
}) })
// ── Landing page (/) ────────────────────────────────────────────────────────
it('allows unauthenticated user to access / (landing page)', async () => {
isUserLoggedInMock.value = false
const path = await navigate('/')
expect(path).toBe('/')
})
it('redirects logged-in child user from / to /child', async () => {
isUserLoggedInMock.value = true
isParentAuthenticatedMock.value = false
const path = await navigate('/')
expect(path).toBe('/child')
})
it('redirects logged-in parent user from / to /parent', async () => {
isUserLoggedInMock.value = true
isParentAuthenticatedMock.value = true
const path = await navigate('/')
expect(path).toBe('/parent')
})
}) })

View File

@@ -32,6 +32,7 @@ import {
enforceParentExpiry, enforceParentExpiry,
} from '../stores/auth' } from '../stores/auth'
import ParentPinSetup from '@/components/auth/ParentPinSetup.vue' import ParentPinSetup from '@/components/auth/ParentPinSetup.vue'
import LandingPage from '@/components/landing/LandingPage.vue'
const routes = [ const routes = [
{ {
@@ -235,7 +236,9 @@ const routes = [
}, },
{ {
path: '/', path: '/',
redirect: '/child', name: 'LandingPage',
component: LandingPage,
meta: { isPublic: true },
}, },
] ]
@@ -260,8 +263,8 @@ router.beforeEach(async (to, from, next) => {
}) })
} }
// If already logged in and trying to access /auth, redirect to appropriate view // If already logged in and trying to access /auth or landing, redirect to appropriate view
if (to.path.startsWith('/auth') && isUserLoggedIn.value) { if ((to.path.startsWith('/auth') || to.path === '/') && isUserLoggedIn.value) {
if (isParentAuthenticated.value) { if (isParentAuthenticated.value) {
return next('/parent') return next('/parent')
} else { } else {
@@ -269,14 +272,14 @@ router.beforeEach(async (to, from, next) => {
} }
} }
// Always allow /auth and /parent/pin-setup // Always allow /auth, landing page, and /parent/pin-setup
if (to.path.startsWith('/auth') || to.name === 'ParentPinSetup') { if (to.path.startsWith('/auth') || to.path === '/' || to.name === 'ParentPinSetup') {
return next() return next()
} }
// If not logged in, redirect to /auth // If not logged in, redirect to landing page
if (!isUserLoggedIn.value) { if (!isUserLoggedIn.value) {
return next('/auth') return next('/')
} }
// If parent-authenticated, allow all /parent routes // If parent-authenticated, allow all /parent routes

View File

@@ -134,8 +134,8 @@ export function initAuthSync() {
const payload = JSON.parse(event.newValue) const payload = JSON.parse(event.newValue)
if (payload?.type === 'logout') { if (payload?.type === 'logout') {
applyLoggedOutState() applyLoggedOutState()
if (!window.location.pathname.startsWith('/auth')) { if (!window.location.pathname.startsWith('/auth') && window.location.pathname !== '/') {
window.location.href = '/auth/login' window.location.href = '/'
} }
} else if (payload?.type === 'parent_logout') { } else if (payload?.type === 'parent_logout') {
applyParentLoggedOutState() applyParentLoggedOutState()