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
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:
125
.github/specs/feat-landing-page.md
vendored
Normal file
125
.github/specs/feat-landing-page.md
vendored
Normal 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 it’s 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
1
.gitignore
vendored
@@ -7,3 +7,4 @@ backend/test_data/db/tasks.json
|
||||
backend/test_data/db/users.json
|
||||
logs/account_deletion.log
|
||||
backend/test_data/db/tracking_events.json
|
||||
resources/
|
||||
|
||||
BIN
frontend/vue-app/public/images/c_logo.png
Normal file
BIN
frontend/vue-app/public/images/c_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
BIN
frontend/vue-app/public/images/chorly_logo.png
Normal file
BIN
frontend/vue-app/public/images/chorly_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 401 KiB |
BIN
frontend/vue-app/public/images/full_logo.png
Normal file
BIN
frontend/vue-app/public/images/full_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 392 KiB |
BIN
frontend/vue-app/public/images/hero.png
Normal file
BIN
frontend/vue-app/public/images/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
@@ -183,4 +183,24 @@
|
||||
--create-btn-border: #2563eb;
|
||||
--create-btn-hover-bg: #2563eb;
|
||||
--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);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ function handleUnauthorizedResponse(): void {
|
||||
logoutUser()
|
||||
if (typeof window === 'undefined') return
|
||||
if (window.location.pathname.startsWith('/auth')) return
|
||||
if (window.location.pathname === '/') return
|
||||
if (unauthorizedRedirectHandler) {
|
||||
unauthorizedRedirectHandler()
|
||||
return
|
||||
|
||||
@@ -577,6 +577,35 @@ onUnmounted(() => {
|
||||
<div v-if="choreDueLabel(item)" class="due-label">{{ choreDueLabel(item) }}</div>
|
||||
</template>
|
||||
</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
|
||||
title="Kindness Acts"
|
||||
ref="childKindnessListRef"
|
||||
@@ -631,35 +660,6 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
201
frontend/vue-app/src/components/landing/LandingFeatures.vue
Normal file
201
frontend/vue-app/src/components/landing/LandingFeatures.vue
Normal 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>
|
||||
86
frontend/vue-app/src/components/landing/LandingFooter.vue
Normal file
86
frontend/vue-app/src/components/landing/LandingFooter.vue
Normal 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>
|
||||
155
frontend/vue-app/src/components/landing/LandingHero.vue
Normal file
155
frontend/vue-app/src/components/landing/LandingHero.vue
Normal 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>
|
||||
118
frontend/vue-app/src/components/landing/LandingPage.vue
Normal file
118
frontend/vue-app/src/components/landing/LandingPage.vue
Normal 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>
|
||||
138
frontend/vue-app/src/components/landing/LandingProblem.vue
Normal file
138
frontend/vue-app/src/components/landing/LandingProblem.vue
Normal 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>
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -94,20 +94,20 @@ describe('router auth guard', () => {
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
const path = await navigate('/child')
|
||||
expect(path).toBe('/auth')
|
||||
expect(path).toBe('/')
|
||||
})
|
||||
|
||||
// ── Authenticated users are routed to the correct section ─────────────────
|
||||
@@ -152,4 +152,29 @@ describe('router auth guard', () => {
|
||||
const path = await navigate('/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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
enforceParentExpiry,
|
||||
} from '../stores/auth'
|
||||
import ParentPinSetup from '@/components/auth/ParentPinSetup.vue'
|
||||
import LandingPage from '@/components/landing/LandingPage.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -235,7 +236,9 @@ const routes = [
|
||||
},
|
||||
{
|
||||
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 (to.path.startsWith('/auth') && isUserLoggedIn.value) {
|
||||
// If already logged in and trying to access /auth or landing, redirect to appropriate view
|
||||
if ((to.path.startsWith('/auth') || to.path === '/') && isUserLoggedIn.value) {
|
||||
if (isParentAuthenticated.value) {
|
||||
return next('/parent')
|
||||
} else {
|
||||
@@ -269,14 +272,14 @@ router.beforeEach(async (to, from, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Always allow /auth and /parent/pin-setup
|
||||
if (to.path.startsWith('/auth') || to.name === 'ParentPinSetup') {
|
||||
// Always allow /auth, landing page, and /parent/pin-setup
|
||||
if (to.path.startsWith('/auth') || to.path === '/' || to.name === 'ParentPinSetup') {
|
||||
return next()
|
||||
}
|
||||
|
||||
// If not logged in, redirect to /auth
|
||||
// If not logged in, redirect to landing page
|
||||
if (!isUserLoggedIn.value) {
|
||||
return next('/auth')
|
||||
return next('/')
|
||||
}
|
||||
|
||||
// If parent-authenticated, allow all /parent routes
|
||||
|
||||
@@ -134,8 +134,8 @@ export function initAuthSync() {
|
||||
const payload = JSON.parse(event.newValue)
|
||||
if (payload?.type === 'logout') {
|
||||
applyLoggedOutState()
|
||||
if (!window.location.pathname.startsWith('/auth')) {
|
||||
window.location.href = '/auth/login'
|
||||
if (!window.location.pathname.startsWith('/auth') && window.location.pathname !== '/') {
|
||||
window.location.href = '/'
|
||||
}
|
||||
} else if (payload?.type === 'parent_logout') {
|
||||
applyParentLoggedOutState()
|
||||
|
||||
Reference in New Issue
Block a user