diff --git a/.github/specs/feat-login-security.md b/.github/specs/archive/feat-login-security.md similarity index 100% rename from .github/specs/feat-login-security.md rename to .github/specs/archive/feat-login-security.md diff --git a/.github/specs/feat-landing-page.md b/.github/specs/feat-landing-page.md new file mode 100644 index 0000000..17e481b --- /dev/null +++ b/.github/specs/feat-landing-page.md @@ -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 `/`. diff --git a/.gitignore b/.gitignore index c921730..204f7f1 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/frontend/vue-app/public/images/c_logo.png b/frontend/vue-app/public/images/c_logo.png new file mode 100644 index 0000000..07dc295 Binary files /dev/null and b/frontend/vue-app/public/images/c_logo.png differ diff --git a/frontend/vue-app/public/images/chorly_logo.png b/frontend/vue-app/public/images/chorly_logo.png new file mode 100644 index 0000000..8013084 Binary files /dev/null and b/frontend/vue-app/public/images/chorly_logo.png differ diff --git a/frontend/vue-app/public/images/full_logo.png b/frontend/vue-app/public/images/full_logo.png new file mode 100644 index 0000000..2d57165 Binary files /dev/null and b/frontend/vue-app/public/images/full_logo.png differ diff --git a/frontend/vue-app/public/images/hero.png b/frontend/vue-app/public/images/hero.png new file mode 100644 index 0000000..4107fb1 Binary files /dev/null and b/frontend/vue-app/public/images/hero.png differ diff --git a/frontend/vue-app/src/assets/colors.css b/frontend/vue-app/src/assets/colors.css index 4c6393e..9495bab 100644 --- a/frontend/vue-app/src/assets/colors.css +++ b/frontend/vue-app/src/assets/colors.css @@ -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); } diff --git a/frontend/vue-app/src/common/api.ts b/frontend/vue-app/src/common/api.ts index ecd121b..a4c3c43 100644 --- a/frontend/vue-app/src/common/api.ts +++ b/frontend/vue-app/src/common/api.ts @@ -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 diff --git a/frontend/vue-app/src/components/child/ChildView.vue b/frontend/vue-app/src/components/child/ChildView.vue index 2b24966..3dd2213 100644 --- a/frontend/vue-app/src/components/child/ChildView.vue +++ b/frontend/vue-app/src/components/child/ChildView.vue @@ -577,6 +577,35 @@ onUnmounted(() => {
{{ choreDueLabel(item) }}
+ + + { - - - diff --git a/frontend/vue-app/src/components/landing/LandingFeatures.vue b/frontend/vue-app/src/components/landing/LandingFeatures.vue new file mode 100644 index 0000000..720cbdb --- /dev/null +++ b/frontend/vue-app/src/components/landing/LandingFeatures.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/frontend/vue-app/src/components/landing/LandingFooter.vue b/frontend/vue-app/src/components/landing/LandingFooter.vue new file mode 100644 index 0000000..b985dfc --- /dev/null +++ b/frontend/vue-app/src/components/landing/LandingFooter.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/frontend/vue-app/src/components/landing/LandingHero.vue b/frontend/vue-app/src/components/landing/LandingHero.vue new file mode 100644 index 0000000..8a81c60 --- /dev/null +++ b/frontend/vue-app/src/components/landing/LandingHero.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/frontend/vue-app/src/components/landing/LandingPage.vue b/frontend/vue-app/src/components/landing/LandingPage.vue new file mode 100644 index 0000000..11cf071 --- /dev/null +++ b/frontend/vue-app/src/components/landing/LandingPage.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/frontend/vue-app/src/components/landing/LandingProblem.vue b/frontend/vue-app/src/components/landing/LandingProblem.vue new file mode 100644 index 0000000..ea99b3a --- /dev/null +++ b/frontend/vue-app/src/components/landing/LandingProblem.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/frontend/vue-app/src/components/landing/__tests__/LandingHero.spec.ts b/frontend/vue-app/src/components/landing/__tests__/LandingHero.spec.ts new file mode 100644 index 0000000..a31a884 --- /dev/null +++ b/frontend/vue-app/src/components/landing/__tests__/LandingHero.spec.ts @@ -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' }) + }) +}) diff --git a/frontend/vue-app/src/router/__tests__/authGuard.spec.ts b/frontend/vue-app/src/router/__tests__/authGuard.spec.ts index 2df3e71..ef57fcd 100644 --- a/frontend/vue-app/src/router/__tests__/authGuard.spec.ts +++ b/frontend/vue-app/src/router/__tests__/authGuard.spec.ts @@ -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') + }) }) diff --git a/frontend/vue-app/src/router/index.ts b/frontend/vue-app/src/router/index.ts index 3216110..7053648 100644 --- a/frontend/vue-app/src/router/index.ts +++ b/frontend/vue-app/src/router/index.ts @@ -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 diff --git a/frontend/vue-app/src/stores/auth.ts b/frontend/vue-app/src/stores/auth.ts index d6061b8..c4203ec 100644 --- a/frontend/vue-app/src/stores/auth.ts +++ b/frontend/vue-app/src/stores/auth.ts @@ -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()