diff --git a/Terranes/rules/completion-log.md b/Terranes/rules/completion-log.md index 494b270..b46fd02 100644 --- a/Terranes/rules/completion-log.md +++ b/Terranes/rules/completion-log.md @@ -763,3 +763,282 @@ Deployment manifests are not applicable to the in-memory demo platform. All serv - `rules/milestones.md` — Added Chunk 063 (done) **Tests:** 29 Playwright E2E tests × 6 browser projects = 174 cross-browser runs. 83 Vitest component tests. 446 NUnit tests. All passing. + +--- + +### Chunk 052 — Responsive Layout Overhaul (2026-04-09) + +**Goal:** Migrate sidebar breakpoint from 641px to Bootstrap md (768px). Slide animation sidebar. Mobile-first column classes on all views. + +**Changes:** +- `style.css` — All breakpoints changed from 641px→768px. `.nav-scrollable` uses `max-height` transition instead of `display:none`. `prefers-reduced-motion` support. Card hover effect added. Hardcoded colours replaced with CSS variables (`var(--bs-body-bg)`, `var(--bs-border-color)`). +- All 7 views — Added `col-12` base class for mobile stacking (`col-12 col-md-4`, `col-12 col-md-6`, etc.) + +**Tests:** 4 new tests in `responsive-layout.spec.ts`. + +--- + +### Chunk 053 — Accessibility & Keyboard Navigation (2026-04-09) + +**Goal:** Add ARIA attributes, Escape-to-close, focus trap, skip-to-content link. + +**Changes:** +- `App.vue` — Added skip-to-content link, `id="main-content"` on `
`, `aria-label="Toggle navigation"` on toggler +- `DetailModal.vue` — Added `role="dialog"`, `aria-modal="true"`, `aria-label="Close modal"`, Escape key listener, focus trap on open +- VillagesView, HomeModelsView, LandBlocksView, MarketplaceView — `aria-label` on all action buttons + +**Tests:** 7 new tests in `accessibility.spec.ts`. + +--- + +### Chunk 054 — Dark Mode Support (2026-04-09) + +**Goal:** useTheme composable with system-preference detection, localStorage persistence, toggle button. + +**Files created:** +- `src/composables/useTheme.ts` — `useTheme()` composable with `isDark` ref, `toggleTheme()`, `_resetTheme()` test helper. Reads localStorage first, then `prefers-color-scheme`. Sets `data-bs-theme` attribute. +- `src/__tests__/composables/useTheme.spec.ts` — 5 tests + +**Files modified:** +- `App.vue` — Theme toggle button in sidebar +- `style.css` — Replaced hardcoded `#f7f7f7` and `#d6d5d5` with CSS variables + +--- + +### Chunk 055 — Enhanced Home Landing Page (2026-04-09) + +**Goal:** Hero section, how-it-works flow, testimonials, footer. + +**Files modified:** +- `HomeView.vue` — Added hero section with animated gradient, "How It Works" 4-step section, testimonials carousel with prev/next, footer. `scrollTo()` for smooth scroll. All sections responsive with `col-12 col-md-*`. + +**Tests:** 4 new tests added to `HomeView.spec.ts` (total 10). + +--- + +### Chunk 056 — Search & Filter UX Improvements (2026-04-09) + +**Goal:** Debounced search, filter chips, result count, empty-state SVGs, URL query-string sync. + +**Files created:** +- `src/composables/useDebounce.ts` — Debounce composable (300ms default) +- `src/components/FilterChip.vue` — Badge with ×-remove button +- `src/components/EmptyState.vue` — SVG magnifying glass + message +- `src/__tests__/composables/useDebounce.spec.ts` — 3 tests +- `src/__tests__/components/FilterChip.spec.ts` — 2 tests +- `src/__tests__/components/EmptyState.spec.ts` — 2 tests + +**Files modified:** +- VillagesView, HomeModelsView, LandBlocksView, MarketplaceView — Debounced search inputs, filter chips, result count badges, EmptyState component, URL query-string sync with `useRoute`/`useRouter` + +--- + +### Chunk 057 — Card & List Interaction Polish (2026-04-09) + +**Goal:** Card hover lift, pagination, sort-by dropdowns. + +**Files created:** +- `src/components/PaginationBar.vue` — Reusable pagination (prev/next, page numbers, "Showing X–Y of Z") +- `src/__tests__/components/PaginationBar.spec.ts` — 4 tests + +**Files modified:** +- `style.css` — Card hover effect (`translateY(-4px)` + shadow), `prefers-reduced-motion` +- MarketplaceView — Sort-by dropdown (price/date), pagination +- LandBlocksView — Sort-by dropdown (area/suburb), pagination + +--- + +### Chunk 058 — Journey UX Enhancement (2026-04-09) + +**Goal:** Step indicator, confirmation dialog, timeline, confetti on completion. + +**Files created:** +- `src/components/StepIndicator.vue` — Horizontal stepper with circles and connecting lines +- `src/components/ConfirmDialog.vue` — Confirmation modal (confirm/cancel emits) +- `src/components/ConfettiEffect.vue` — 30+ CSS-animated confetti particles, auto-dismiss 3s +- `src/components/JourneyTimeline.vue` — Vertical timeline of completed stages +- `src/__tests__/components/StepIndicator.spec.ts` — 3 tests +- `src/__tests__/components/ConfirmDialog.spec.ts` — 3 tests + +**Files modified:** +- JourneyView.vue — Replaced progress bar with StepIndicator, added ConfirmDialog before completion, ConfettiEffect on completion, JourneyTimeline sidebar + +--- + +### Chunk 059 — Dashboard Widgets & Charts (2026-04-09) + +**Goal:** StatCard with animated count-up, sparkline SVG, notification bell, quick actions. + +**Files created:** +- `src/components/StatCard.vue` — Animated count-up with requestAnimationFrame, `prefers-reduced-motion` skip +- `src/components/SparklineChart.vue` — Pure SVG sparkline (polyline from data points) +- `src/__tests__/components/StatCard.spec.ts` — 3 tests +- `src/__tests__/components/SparklineChart.spec.ts` — 2 tests + +**Files modified:** +- DashboardView.vue — StatCard components, SparklineChart in each card, notification bell with unread count badge, quick-action router-links + +--- + +### Chunk 060 — Breadcrumbs, Page Titles & Navigation (2026-04-09) + +**Goal:** BreadcrumbBar, document title per route, 404 page. + +**Files created:** +- `src/components/BreadcrumbBar.vue` — Auto-generated from `route.meta.breadcrumb` +- `src/views/NotFoundView.vue` — 404 page with back-to-home link +- `src/__tests__/components/BreadcrumbBar.spec.ts` — 3 tests +- `src/__tests__/NotFoundView.spec.ts` — 2 tests + +**Files modified:** +- `router/index.ts` — Added `meta: { title, breadcrumb }` to all routes, catch-all 404 route +- `App.vue` — `router.afterEach` sets `document.title`, BreadcrumbBar component + +--- + +### Chunk 061 — Form Validation & Input UX (2026-04-09) + +**Goal:** Real-time validation, clear-all-filters, auto-focus. + +**Files created:** +- `src/composables/useValidation.ts` — Validation composable with `required`, `minValue`, `maxValue`, `pattern` rules +- `src/__tests__/composables/useValidation.spec.ts` — 5 tests + +**Files modified:** +- HomeModelsView — minBedrooms validation (0–10), auto-focus, clear-all-filters +- MarketplaceView — maxPrice validation (≥0), auto-focus, clear-all-filters +- LandBlocksView — Auto-focus, clear-all-filters + +--- + +### Chunk 062 — Performance & Bundle Optimisation (2026-04-09) + +**Goal:** Route-based code splitting verification, paged list, lazy loading. + +**Files created:** +- `src/composables/usePagedList.ts` — Batched list rendering (20 items per batch, "Show More") +- `src/__tests__/composables/usePagedList.spec.ts` — 3 tests + +**Files modified:** +- LandBlocksView — Uses `usePagedList` for "Show More" rendering instead of full list +- `router/index.ts` — Verified all routes use `() => import(...)` lazy loading (including catch-all 404) + +**Phase 13 Summary:** +- 18 shared components, 6 composables in src/Web.Vue +- 7 view pages + 1 NotFoundView +- 141 Vitest tests across 32 test files +- 29 Playwright E2E tests across 5 spec files +- 446 NUnit tests (.NET backend) + +--- + +## Phase 14 — Feature Expansion + +### Chunk 064 — Global Search (2026-04-09) + +**Goal:** Wire frontend to /api/search endpoints. Global search bar + full search page. + +**Files created:** +- `src/views/SearchView.vue` — Full search page with query input, entity type filter, result cards, debounce, URL sync +- `src/components/SearchBar.vue` — Compact search bar for App.vue header, navigates to /search on Enter +- `src/__tests__/SearchView.spec.ts` — 4 tests +- `src/__tests__/components/SearchBar.spec.ts` — 4 tests + +**Files modified:** +- `src/types/index.ts` — Added `SearchResult` type +- `src/api/client.ts` — Added `search()` and `searchByType()` methods +- `src/router/index.ts` — Added /search route +- `src/App.vue` — Added SearchBar in top-row header + +--- + +### Chunk 065 — Quote Details in Journey (2026-04-09) + +**Goal:** Wire journey quote flow to /api/aggregated-quotes. Show quote breakdown after QuoteReceived. + +**Files created:** +- `src/components/QuoteSummary.vue` — Quote display with total amount, line items table, loading state +- `src/__tests__/components/QuoteSummary.spec.ts` — 3 tests + +**Files modified:** +- `src/types/index.ts` — Added `AggregatedQuote` and `QuoteLineItem` types +- `src/api/client.ts` — Added `aggregateQuote()` and `getJourneyQuotes()` methods +- `src/views/JourneyView.vue` — Added QuoteSummary display after QuoteReceived stage + +--- + +### Chunk 066 — User Authentication (2026-04-09) + +**Goal:** Login/register views with useAuth composable. Wire to /api/auth endpoints. + +**Files created:** +- `src/composables/useAuth.ts` — Auth composable with login, register, logout, restoreSession, localStorage persistence +- `src/views/LoginView.vue` — Login form with validation, error handling, redirect to dashboard +- `src/views/RegisterView.vue` — Register form with password confirmation, validation, redirect +- `src/__tests__/composables/useAuth.spec.ts` — 6 tests +- `src/__tests__/LoginView.spec.ts` — 3 tests + +**Files modified:** +- `src/types/index.ts` — Added `PlatformUser` type +- `src/api/client.ts` — Added `login()`, `register()`, `getUser()` methods +- `src/router/index.ts` — Added /login and /register routes +- `src/App.vue` — Added auth state display (user name + logout or login link), restoreSession on mount + +--- + +### Chunk 067 — Partner Directory (2026-04-09) + +**Goal:** Partner directory page with category filter. Wire to partner endpoints. + +**Files created:** +- `src/views/PartnersView.vue` — Partner directory with category filter, search, card grid, detail modal +- `src/__tests__/PartnersView.spec.ts` — 5 tests + +**Files modified:** +- `src/types/index.ts` — Added `Partner` type +- `src/api/client.ts` — Added `getBuilders()` and `getBuilderProfile()` methods +- `src/router/index.ts` — Added /partners route +- `src/App.vue` — Added Partners + Search nav links + +--- + +### Chunk 068 — 3D Walkthroughs & Design Editor (2026-04-09) + +**Goal:** Walkthrough sessions list and text-based design editor. Wire to /api/walkthroughs and /api/design-editor. + +**Files created:** +- `src/views/WalkthroughsView.vue` — Walkthrough sessions with generate form, card grid, detail modal +- `src/views/DesignEditorView.vue` — Design editor with edit form, operation selector, edit history, undo +- `src/__tests__/WalkthroughsView.spec.ts` — 4 tests +- `src/__tests__/DesignEditorView.spec.ts` — 3 tests + +**Files modified:** +- `src/types/index.ts` — Added `Walkthrough`, `WalkthroughScene`, `WalkthroughPoi`, `DesignEdit` types +- `src/api/client.ts` — Added `generateWalkthrough()`, `getWalkthrough()`, `getWalkthroughsByModel()`, `getWalkthroughPois()`, `applyEdit()`, `getEditHistory()`, `undoLastEdit()` methods +- `src/router/index.ts` — Added /walkthroughs and /design-editor routes +- `src/App.vue` — Added Walkthroughs + Design Editor nav links + +--- + +### Chunk 069 — Reporting & Compliance (2026-04-09) + +**Goal:** Reports generation and compliance checking. Wire to /api/reports and /api/compliance. + +**Files created:** +- `src/views/ReportsView.vue` — Two-section page: reports (generate, list, view) and compliance (check, results) +- `src/__tests__/ReportsView.spec.ts` — 4 tests + +**Files modified:** +- `src/types/index.ts` — Added `Report` and `ComplianceResult` types +- `src/api/client.ts` — Added `generateReport()`, `getReport()`, `getTenantReports()`, `getReportTypes()`, `runComplianceCheck()`, `getComplianceResult()`, `getComplianceByPlacement()` methods +- `src/router/index.ts` — Added /reports route +- `src/App.vue` — Added Reports nav link + +**Phase 14 Summary:** +- 7 new views (Search, Login, Register, Partners, Walkthroughs, DesignEditor, Reports) +- 2 new components (SearchBar, QuoteSummary) +- 1 new composable (useAuth) +- 18 new API methods in client.ts +- 8 new TypeScript types +- 177 Vitest tests across 41 test files +- All backend API endpoint groups now wired to frontend diff --git a/Terranes/rules/milestones.md b/Terranes/rules/milestones.md index bb74bc1..cca3511 100644 --- a/Terranes/rules/milestones.md +++ b/Terranes/rules/milestones.md @@ -90,30 +90,30 @@ Vue frontend cleanup, reusable components, and 49 Vitest component tests. Old Bl smooth interactions, responsive layouts, accessible controls, and user-friendly feedback. Each chunk is independently deployable and testable. -| Chunk | Scope | Status | -|-------|-------|--------| -| 050 | **Toast Notifications & Action Feedback** — Create `ToastContainer.vue` + `useToast()` composable. Add success/error toasts to Journey actions (advance stage, request quote, complete). Add loading-disabled state to all async buttons. 6+ Vitest tests. | `done` | -| 051 | **Skeleton Loaders & Smooth Transitions** — Create `SkeletonCard.vue` and `SkeletonTable.vue` components. Replace `` with skeleton placeholders in all 5 data views. Add Vue `` fade on route changes and list enter/leave in card grids. 8+ Vitest tests. | `done` | -| 052 | **Responsive Layout Overhaul** — Migrate sidebar breakpoint from 641px to Bootstrap md (768px). Add collapsible sidebar with slide animation. Make all card grids stack to 1-column on mobile. Add responsive table scrolling. Fix top-bar on mobile. Verify on 320px/768px/1200px. 4+ Vitest tests. | `not-started` | -| 053 | **Accessibility & Keyboard Navigation** — Add `aria-label` to all buttons and interactive elements. Add Escape-to-close on all modals. Add focus trap inside modals. Add skip-to-content link. Add `aria-live` region for toast announcements. Audit with axe-core rules. 6+ Vitest tests. | `not-started` | -| 054 | **Dark Mode Support** — Add `useTheme()` composable with system-preference detection + manual toggle. Add theme toggle button in sidebar. Migrate `style.css` hardcoded colours to Bootstrap CSS variables. Sidebar gradient adapts to dark mode. Persist preference in localStorage. 4+ Vitest tests. | `not-started` | -| 055 | **Enhanced Home Landing Page** — Add hero section with animated gradient background. Add "How it works" 4-step visual flow. Add testimonial carousel (static data). Add footer with links. Add smooth scroll to sections. Mobile-optimised layout. 4+ Vitest tests. | `not-started` | -| 056 | **Search & Filter UX Improvements** — Add debounced search inputs (300ms) across Villages, Home Models, Land, Marketplace. Add filter chips showing active filters with ×-remove. Add result count badge. Add empty-state illustrations (SVG). Add URL query-string sync for shareable filter URLs. 6+ Vitest tests. | `not-started` | -| 057 | **Card & List Interaction Polish** — Add hover lift effect on all cards (transform + shadow). Add click-ripple feedback. Add image placeholder gradients on model/village cards. Add pagination component for lists > 12 items. Add sort-by dropdown on Marketplace and Land views. 6+ Vitest tests. | `not-started` | -| 058 | **Journey UX Enhancement** — Add animated step indicator (horizontal stepper with connecting lines). Add confirmation dialogs before irreversible actions (complete journey). Add journey timeline sidebar showing all past actions with timestamps. Add confetti animation on journey completion. 5+ Vitest tests. | `not-started` | -| 059 | **Dashboard Widgets & Charts** — Add `StatCard.vue` with animated count-up numbers. Add mini sparkline chart for analytics trends (pure SVG, no chart library). Add notification bell icon in top-bar with unread count badge. Add quick-action buttons (New Journey, Browse Designs). 6+ Vitest tests. | `not-started` | -| 060 | **Breadcrumbs, Page Titles & Navigation** — Add `BreadcrumbBar.vue` component with auto-generated breadcrumbs from route meta. Add `` updates per route. Add active-page icon highlighting in sidebar. Add "Back to" links in detail modals. Add 404 page. 5+ Vitest tests. | `not-started` | -| 061 | **Form Validation & Input UX** — Add real-time validation on all filter inputs (number ranges, required fields). Add input masking for price fields (AUD format). Add clear-all-filters button. Add auto-focus on first input when views mount. Standardise form-group spacing. 5+ Vitest tests. | `not-started` | -| 062 | **Performance & Bundle Optimisation** — Add route-based code splitting verification. Add image lazy loading for card thumbnails. Add virtual scrolling for large lists (> 50 items). Add web font preloading. Measure and log Lighthouse scores. 3+ Vitest tests. | `not-started` | -| 063 | **Playwright Multi-Browser E2E Tests** — Add Playwright with Chromium, Firefox, WebKit + mobile + tablet viewports. 29 E2E tests across 5 spec files: navigation, home page, responsive layout, views smoke, UX feedback/accessibility. AI agent rule in `rules/playwright-rules.md`. | `done` | +✅ Phase 13 complete — see `rules/completion-log.md` for full history. + +18 shared components, 6 composables, 141 Vitest tests, 29 Playwright E2E tests. Full UX/UI polish: responsive layout (Bootstrap md breakpoints), accessibility (ARIA, focus trap, skip link), dark mode, enhanced home page, search/filter UX, card hover effects, pagination, journey step indicator with confetti, dashboard widgets with sparklines, breadcrumbs with route titles, form validation, and performance optimisation. --- -## Next Chunk +## Phase 14 — Feature Expansion (Wire Frontend to Backend APIs) + +> **AI Agent Rule:** Before implementing any chunk in this phase, read `rules/ux-rules.md` for +> component conventions, design principles, and implementation patterns. + +**Goal:** Connect the Vue 3 frontend to the remaining backend API endpoints that are already +implemented but not yet exposed in the UI. Each chunk adds a new view or feature page with +full API integration, tests, and navigation. -**Chunk 052** — Responsive Layout Overhaul. +✅ Phase 14 complete — see `rules/completion-log.md` for full history. + +6 chunks (064-069). 5 new views (SearchView, LoginView, RegisterView, PartnersView, WalkthroughsView, DesignEditorView, ReportsView) + 2 new components (SearchBar, QuoteSummary) + 1 composable (useAuth). 177 Vitest tests. 18 API methods added. All backend API groups now wired to frontend. + +--- + +## Next Chunk -Read `rules/ux-rules.md` before implementing. +All Phase 14 chunks are complete. Ready for Phase 15 or next feature work. --- diff --git a/Terranes/src/Web.Vue/src/App.vue b/Terranes/src/Web.Vue/src/App.vue index bc8ed2e..6b0f716 100644 --- a/Terranes/src/Web.Vue/src/App.vue +++ b/Terranes/src/Web.Vue/src/App.vue @@ -1,21 +1,38 @@ <script setup lang="ts"> import { ref } from 'vue'; -import { RouterLink, RouterView } from 'vue-router'; +import { RouterLink, RouterView, useRouter } from 'vue-router'; import ToastContainer from './components/ToastContainer.vue'; +import BreadcrumbBar from './components/BreadcrumbBar.vue'; +import SearchBar from './components/SearchBar.vue'; +import { useTheme } from './composables/useTheme'; +import { useAuth } from './composables/useAuth'; const sidebarOpen = ref(false); function toggleSidebar() { sidebarOpen.value = !sidebarOpen.value; } + +const { isDark, toggleTheme } = useTheme(); +const { currentUser, isAuthenticated, logout, restoreSession } = useAuth(); +restoreSession(); + +const router = useRouter(); +router.afterEach((to) => { + const title = to.meta?.title as string | undefined; + if (title) { + document.title = title; + } +}); </script> <template> <div class="page"> + <a href="#main-content" class="visually-hidden-focusable skip-link">Skip to content</a> <div class="sidebar"> <div class="top-row ps-3 navbar navbar-dark"> <div class="container-fluid"> <RouterLink class="navbar-brand" to="/">🏠 Terranes</RouterLink> - <button class="navbar-toggler" type="button" @click="toggleSidebar"> + <button class="navbar-toggler" type="button" aria-label="Toggle navigation" @click="toggleSidebar"> <span class="navbar-toggler-icon"></span> </button> </div> @@ -53,20 +70,60 @@ function toggleSidebar() { <span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> My Journey </RouterLink> </div> + <div class="nav-item px-3"> + <RouterLink class="nav-link" to="/walkthroughs" active-class="active"> + <span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Walkthroughs + </RouterLink> + </div> + <div class="nav-item px-3"> + <RouterLink class="nav-link" to="/design-editor" active-class="active"> + <span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Design Editor + </RouterLink> + </div> + <div class="nav-item px-3"> + <RouterLink class="nav-link" to="/search" active-class="active"> + <span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Search + </RouterLink> + </div> + <div class="nav-item px-3"> + <RouterLink class="nav-link" to="/partners" active-class="active"> + <span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Partners + </RouterLink> + </div> + <div class="nav-item px-3"> + <RouterLink class="nav-link" to="/reports" active-class="active"> + <span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Reports + </RouterLink> + </div> <div class="nav-item px-3"> <RouterLink class="nav-link" to="/dashboard" active-class="active"> <span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Dashboard </RouterLink> </div> + <div class="nav-item px-3 mt-auto"> + <button class="nav-link btn text-start" aria-label="Toggle dark mode" @click="toggleTheme"> + <span aria-hidden="true">{{ isDark ? '☀️' : '🌙' }}</span> + {{ isDark ? 'Light Mode' : 'Dark Mode' }} + </button> + </div> </nav> </div> </div> - <main> - <div class="top-row px-4"> - <a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a> + <main id="main-content"> + <div class="top-row px-4 d-flex justify-content-between align-items-center"> + <SearchBar /> + <div class="d-flex align-items-center gap-3"> + <template v-if="isAuthenticated"> + <span class="text-white">{{ currentUser?.displayName }}</span> + <button class="btn btn-sm btn-outline-light" @click="logout">Logout</button> + </template> + <RouterLink v-else to="/login" class="text-white">Login</RouterLink> + <a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a> + </div> </div> <article class="content px-4"> + <BreadcrumbBar /> <RouterView v-slot="{ Component }"> <Transition name="fade" mode="out-in"> <component :is="Component" /> diff --git a/Terranes/src/Web.Vue/src/__tests__/DashboardView.spec.ts b/Terranes/src/Web.Vue/src/__tests__/DashboardView.spec.ts index eb4d970..ab8ee35 100644 --- a/Terranes/src/Web.Vue/src/__tests__/DashboardView.spec.ts +++ b/Terranes/src/Web.Vue/src/__tests__/DashboardView.spec.ts @@ -124,4 +124,35 @@ describe('DashboardView', () => { expect(wrapper.text()).toContain('Modern Villa'); expect(wrapper.text()).toContain('4 bed'); }); + + it('renders StatCard components with values', async () => { + setupMocks(); + const router = await createTestRouter(); + const wrapper = mount(DashboardView, { global: { plugins: [router] } }); + await flushPromises(); + const statCards = wrapper.findAll('.stat-card'); + expect(statCards.length).toBe(4); + expect(wrapper.find('.stat-label').exists()).toBe(true); + }); + + it('shows quick action buttons', async () => { + setupMocks(); + const router = await createTestRouter(); + const wrapper = mount(DashboardView, { global: { plugins: [router] } }); + await flushPromises(); + const quickActions = wrapper.findAll('.quick-action'); + expect(quickActions.length).toBe(2); + expect(wrapper.text()).toContain('New Journey'); + expect(wrapper.text()).toContain('Browse Designs'); + }); + + it('shows notification count badge for unread notifications', async () => { + setupMocks(); + const router = await createTestRouter(); + const wrapper = mount(DashboardView, { global: { plugins: [router] } }); + await flushPromises(); + const badge = wrapper.find('.notification-count'); + expect(badge.exists()).toBe(true); + expect(badge.text()).toBe('1'); + }); }); diff --git a/Terranes/src/Web.Vue/src/__tests__/DesignEditorView.spec.ts b/Terranes/src/Web.Vue/src/__tests__/DesignEditorView.spec.ts new file mode 100644 index 0000000..2f826fd --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/DesignEditorView.spec.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import type { DesignEdit } from '../types'; + +vi.mock('../api/client', () => ({ + api: { + applyEdit: vi.fn(), + getEditHistory: vi.fn(), + undoLastEdit: vi.fn(), + }, +})); + +import { api } from '../api/client'; +import DesignEditorView from '../views/DesignEditorView.vue'; + +const mockEdits: DesignEdit[] = [ + { + id: 'e1', sitePlacementId: 'sp1', operation: 'ColorChange', + targetElement: 'Wall-North', newValue: '#FF5733', appliedUtc: '2026-03-01T10:00:00Z', + }, + { + id: 'e2', sitePlacementId: 'sp1', operation: 'Move', + targetElement: 'Door-Front', newValue: '1.5,0,0', appliedUtc: '2026-03-01T11:00:00Z', + }, +]; + +async function createTestRouter() { + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: { template: '<div />' } }], + }); + await router.push('/'); + await router.isReady(); + return router; +} + +describe('DesignEditorView', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows form inputs', async () => { + const router = await createTestRouter(); + const wrapper = mount(DesignEditorView, { global: { plugins: [router] } }); + await flushPromises(); + expect(wrapper.text()).toContain('Site Placement ID'); + expect(wrapper.text()).toContain('Operation'); + expect(wrapper.text()).toContain('Target Element'); + expect(wrapper.text()).toContain('New Value'); + expect(wrapper.text()).toContain('Apply Edit'); + }); + + it('shows empty state for edit history', async () => { + const router = await createTestRouter(); + const wrapper = mount(DesignEditorView, { global: { plugins: [router] } }); + await flushPromises(); + expect(wrapper.text()).toContain('No edits yet'); + }); + + it('shows edit history when loaded', async () => { + vi.mocked(api.getEditHistory).mockResolvedValue(mockEdits); + const router = await createTestRouter(); + const wrapper = mount(DesignEditorView, { global: { plugins: [router] } }); + await flushPromises(); + // Set placement ID and click Load + const inputs = wrapper.findAll('input[type="text"]'); + const historyInput = inputs.find((i) => i.attributes('placeholder')?.includes('Placement ID to load')); + await historyInput!.setValue('sp1'); + const loadBtn = wrapper.findAll('button').find((b) => b.text() === 'Load'); + await loadBtn!.trigger('click'); + await flushPromises(); + expect(wrapper.text()).toContain('ColorChange'); + expect(wrapper.text()).toContain('Wall-North'); + expect(wrapper.text()).toContain('#FF5733'); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/HomeView.spec.ts b/Terranes/src/Web.Vue/src/__tests__/HomeView.spec.ts index d7836dd..bee7433 100644 --- a/Terranes/src/Web.Vue/src/__tests__/HomeView.spec.ts +++ b/Terranes/src/Web.Vue/src/__tests__/HomeView.spec.ts @@ -75,4 +75,47 @@ describe('HomeView', () => { expect(text).toContain('Start Your Journey'); expect(text).toContain('Dashboard'); }); + + it('renders hero section with animated gradient', async () => { + const router = await createTestRouter(); + const wrapper = mount(HomeView, { global: { plugins: [router] } }); + const hero = wrapper.find('#hero'); + expect(hero.exists()).toBe(true); + expect(hero.classes()).toContain('hero'); + expect(hero.text()).toContain('Welcome to Terranes'); + }); + + it('shows how-it-works section with 4 steps', async () => { + const router = await createTestRouter(); + const wrapper = mount(HomeView, { global: { plugins: [router] } }); + const section = wrapper.find('#how-it-works'); + expect(section.exists()).toBe(true); + expect(section.text()).toContain('Browse'); + expect(section.text()).toContain('Select'); + expect(section.text()).toContain('Customise'); + expect(section.text()).toContain('Quote'); + const steps = section.findAll('.col-12.col-md-3'); + expect(steps.length).toBe(4); + }); + + it('has testimonial carousel with prev/next buttons', async () => { + const router = await createTestRouter(); + const wrapper = mount(HomeView, { global: { plugins: [router] } }); + const testimonials = wrapper.find('.testimonials'); + expect(testimonials.exists()).toBe(true); + const buttons = testimonials.findAll('button'); + const prevBtn = buttons.find((b) => b.text().includes('Prev')); + const nextBtn = buttons.find((b) => b.text().includes('Next')); + expect(prevBtn).toBeTruthy(); + expect(nextBtn).toBeTruthy(); + }); + + it('renders footer with copyright', async () => { + const router = await createTestRouter(); + const wrapper = mount(HomeView, { global: { plugins: [router] } }); + const footer = wrapper.find('.site-footer'); + expect(footer.exists()).toBe(true); + expect(footer.text()).toContain('Terranes'); + expect(footer.text()).toContain('All rights reserved'); + }); }); diff --git a/Terranes/src/Web.Vue/src/__tests__/JourneyView.spec.ts b/Terranes/src/Web.Vue/src/__tests__/JourneyView.spec.ts index bae2f34..9904c3b 100644 --- a/Terranes/src/Web.Vue/src/__tests__/JourneyView.spec.ts +++ b/Terranes/src/Web.Vue/src/__tests__/JourneyView.spec.ts @@ -56,15 +56,15 @@ describe('JourneyView', () => { expect(wrapper.text()).toContain('Begin Journey'); }); - it('shows progress bar when journey is active', async () => { + it('shows step indicator when journey is active', async () => { const browsingJourney = makeMockJourney('Browsing'); vi.mocked(api.getBuyerJourneys).mockResolvedValue([browsingJourney]); vi.mocked(api.getHomeModels).mockResolvedValue(mockModels); const router = await createTestRouter(); const wrapper = mount(JourneyView, { global: { plugins: [router] } }); await flushPromises(); - expect(wrapper.find('.progress').exists()).toBe(true); - expect(wrapper.find('.progress-bar').exists()).toBe(true); + expect(wrapper.find('.step-indicator').exists()).toBe(true); + expect(wrapper.findAll('.step-circle').length).toBeGreaterThan(0); }); it('shows browsing stage with design cards', async () => { diff --git a/Terranes/src/Web.Vue/src/__tests__/LoginView.spec.ts b/Terranes/src/Web.Vue/src/__tests__/LoginView.spec.ts new file mode 100644 index 0000000..24ac913 --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/LoginView.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import { _resetAuth } from '../composables/useAuth'; + +vi.mock('../api/client', () => ({ + api: { + login: vi.fn(), + }, +})); + +import { api } from '../api/client'; +import LoginView from '../views/LoginView.vue'; + +async function createTestRouter() { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: { template: '<div />' } }, + { path: '/login', component: { template: '<div />' } }, + { path: '/dashboard', component: { template: '<div />' } }, + { path: '/register', component: { template: '<div />' } }, + ], + }); + await router.push('/login'); + await router.isReady(); + return router; +} + +describe('LoginView', () => { + beforeEach(() => { + vi.clearAllMocks(); + _resetAuth(); + }); + + it('shows email and password inputs', async () => { + const router = await createTestRouter(); + const wrapper = mount(LoginView, { global: { plugins: [router] } }); + expect(wrapper.find('input#email').exists()).toBe(true); + expect(wrapper.find('input#password').exists()).toBe(true); + }); + + it('shows error on failed login', async () => { + vi.mocked(api.login).mockRejectedValue(new Error('Invalid credentials')); + const router = await createTestRouter(); + const wrapper = mount(LoginView, { global: { plugins: [router] } }); + await wrapper.find('input#email').setValue('bad@test.com'); + await wrapper.find('input#password').setValue('wrong'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + expect(wrapper.text()).toContain('Invalid credentials'); + }); + + it('disables button during loading', async () => { + vi.mocked(api.login).mockReturnValue(new Promise(() => {})); + const router = await createTestRouter(); + const wrapper = mount(LoginView, { global: { plugins: [router] } }); + await wrapper.find('input#email').setValue('test@test.com'); + await wrapper.find('input#password').setValue('pass'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + const btn = wrapper.find('button[type="button"]'); + expect(btn.attributes('disabled')).toBeDefined(); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/NotFoundView.spec.ts b/Terranes/src/Web.Vue/src/__tests__/NotFoundView.spec.ts new file mode 100644 index 0000000..4b90302 --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/NotFoundView.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import NotFoundView from '../views/NotFoundView.vue'; + +async function mountNotFound() { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'home', component: { template: '<div>Home</div>' } }, + { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView }, + ], + }); + await router.push('/some-nonexistent-page'); + await router.isReady(); + return mount(NotFoundView, { global: { plugins: [router] } }); +} + +describe('NotFoundView', () => { + it('shows 404 text', async () => { + const wrapper = await mountNotFound(); + expect(wrapper.text()).toContain('404'); + expect(wrapper.text()).toContain('Page Not Found'); + }); + + it('has a link back to home', async () => { + const wrapper = await mountNotFound(); + const link = wrapper.find('a[href="/"]'); + expect(link.exists()).toBe(true); + expect(link.text()).toContain('Go Home'); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/PartnersView.spec.ts b/Terranes/src/Web.Vue/src/__tests__/PartnersView.spec.ts new file mode 100644 index 0000000..a4d30f1 --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/PartnersView.spec.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import type { Partner } from '../types'; + +vi.mock('../api/client', () => ({ + api: { + getBuilders: vi.fn(), + getBuilderProfile: vi.fn(), + }, +})); + +import { api } from '../api/client'; +import PartnersView from '../views/PartnersView.vue'; + +const mockBuilders: Partner[] = [ + { + id: 'b1', name: 'Ace Builders', category: 'Builder', + description: 'Quality home builder', contactEmail: 'info@ace.demo', + isActive: true, + }, + { + id: 'b2', name: 'Summit Construction', category: 'Builder', + description: 'Luxury builds', contactEmail: 'hello@summit.demo', + isActive: false, + }, +]; + +async function createTestRouter() { + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: { template: '<div />' } }], + }); + await router.push('/'); + await router.isReady(); + return router; +} + +describe('PartnersView', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows loading skeleton initially', () => { + vi.mocked(api.getBuilders).mockReturnValue(new Promise(() => {})); + const wrapper = mount(PartnersView, { + global: { plugins: [createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] })] }, + }); + expect(wrapper.find('.placeholder-glow').exists()).toBe(true); + }); + + it('displays partner cards after load', async () => { + vi.mocked(api.getBuilders).mockResolvedValue(mockBuilders); + const router = await createTestRouter(); + const wrapper = mount(PartnersView, { global: { plugins: [router] } }); + await flushPromises(); + expect(wrapper.text()).toContain('Ace Builders'); + expect(wrapper.text()).toContain('Summit Construction'); + // Also includes static partners + expect(wrapper.text()).toContain('GreenScape Gardens'); + }); + + it('shows empty state when no results match filter', async () => { + vi.useFakeTimers(); + vi.mocked(api.getBuilders).mockResolvedValue([]); + const router = await createTestRouter(); + const wrapper = mount(PartnersView, { global: { plugins: [router] } }); + await flushPromises(); + // Set search name to something that won't match any static partners + await wrapper.find('input[type="text"]').setValue('zzzznonexistent'); + vi.advanceTimersByTime(400); + await flushPromises(); + expect(wrapper.text()).toContain('No partners found'); + vi.useRealTimers(); + }); + + it('category filter works', async () => { + vi.mocked(api.getBuilders).mockResolvedValue(mockBuilders); + const router = await createTestRouter(); + const wrapper = mount(PartnersView, { global: { plugins: [router] } }); + await flushPromises(); + // Select "Landscaper" category + await wrapper.find('select').setValue('Landscaper'); + await flushPromises(); + // Should only show landscaper + expect(wrapper.text()).toContain('GreenScape Gardens'); + expect(wrapper.text()).not.toContain('Ace Builders'); + }); + + it('opens detail modal when clicking View Details', async () => { + vi.mocked(api.getBuilders).mockResolvedValue(mockBuilders); + const router = await createTestRouter(); + const wrapper = mount(PartnersView, { global: { plugins: [router] } }); + await flushPromises(); + const viewBtn = wrapper.findAll('button').find((b) => b.text().includes('View Details')); + await viewBtn!.trigger('click'); + await flushPromises(); + expect(wrapper.find('.modal').exists()).toBe(true); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/ReportsView.spec.ts b/Terranes/src/Web.Vue/src/__tests__/ReportsView.spec.ts new file mode 100644 index 0000000..bc2245a --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/ReportsView.spec.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import type { Report, ComplianceResult } from '../types'; + +vi.mock('../api/client', () => ({ + api: { + getReportTypes: vi.fn(), + getTenantReports: vi.fn(), + generateReport: vi.fn(), + getComplianceByPlacement: vi.fn(), + runComplianceCheck: vi.fn(), + }, +})); + +import { api } from '../api/client'; +import ReportsView from '../views/ReportsView.vue'; + +const mockReports: Report[] = [ + { + id: 'r1', reportType: 'Summary', title: 'Q1 Summary', + contentMarkdown: '# Q1 Report\nAll good.', generatedByUserId: 'u1', + tenantId: 't1', generatedUtc: '2026-03-01T00:00:00Z', + }, +]; + +const mockCompliance: ComplianceResult[] = [ + { + id: 'c1', sitePlacementId: 'sp1', jurisdiction: 'NSW', + isCompliant: true, issues: [], checkedUtc: '2026-03-01T00:00:00Z', + }, + { + id: 'c2', sitePlacementId: 'sp1', jurisdiction: 'VIC', + isCompliant: false, issues: ['Setback too small', 'Missing fire rating'], + checkedUtc: '2026-03-02T00:00:00Z', + }, +]; + +async function createTestRouter() { + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: { template: '<div />' } }], + }); + await router.push('/'); + await router.isReady(); + return router; +} + +describe('ReportsView', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows generate report form', async () => { + vi.mocked(api.getReportTypes).mockResolvedValue(['Summary', 'Financial']); + vi.mocked(api.getTenantReports).mockResolvedValue([]); + vi.mocked(api.getComplianceByPlacement).mockResolvedValue([]); + const router = await createTestRouter(); + const wrapper = mount(ReportsView, { global: { plugins: [router] } }); + await flushPromises(); + expect(wrapper.text()).toContain('Generate Report'); + expect(wrapper.text()).toContain('Report Type'); + expect(wrapper.text()).toContain('Title'); + }); + + it('shows compliance check form', async () => { + vi.mocked(api.getReportTypes).mockResolvedValue(['Summary']); + vi.mocked(api.getTenantReports).mockResolvedValue([]); + vi.mocked(api.getComplianceByPlacement).mockResolvedValue([]); + const router = await createTestRouter(); + const wrapper = mount(ReportsView, { global: { plugins: [router] } }); + await flushPromises(); + // Switch to compliance tab + const complianceTab = wrapper.findAll('button').find((b) => b.text() === 'Compliance Checks'); + await complianceTab!.trigger('click'); + await flushPromises(); + expect(wrapper.text()).toContain('Run Compliance Check'); + expect(wrapper.text()).toContain('Site Placement ID'); + expect(wrapper.text()).toContain('Jurisdiction'); + }); + + it('shows report cards after loading', async () => { + vi.mocked(api.getReportTypes).mockResolvedValue(['Summary']); + vi.mocked(api.getTenantReports).mockResolvedValue(mockReports); + vi.mocked(api.getComplianceByPlacement).mockResolvedValue([]); + const router = await createTestRouter(); + const wrapper = mount(ReportsView, { global: { plugins: [router] } }); + await flushPromises(); + expect(wrapper.text()).toContain('Q1 Summary'); + expect(wrapper.text()).toContain('Summary'); + }); + + it('shows compliance results', async () => { + vi.mocked(api.getReportTypes).mockResolvedValue(['Summary']); + vi.mocked(api.getTenantReports).mockResolvedValue([]); + vi.mocked(api.getComplianceByPlacement).mockResolvedValue(mockCompliance); + const router = await createTestRouter(); + const wrapper = mount(ReportsView, { global: { plugins: [router] } }); + await flushPromises(); + // Switch to compliance tab + const complianceTab = wrapper.findAll('button').find((b) => b.text() === 'Compliance Checks'); + await complianceTab!.trigger('click'); + await flushPromises(); + expect(wrapper.text()).toContain('NSW'); + expect(wrapper.text()).toContain('VIC'); + expect(wrapper.text()).toContain('Setback too small'); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/SearchView.spec.ts b/Terranes/src/Web.Vue/src/__tests__/SearchView.spec.ts new file mode 100644 index 0000000..0590f9e --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/SearchView.spec.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import type { SearchResult } from '../types'; + +vi.mock('../api/client', () => ({ + api: { + search: vi.fn(), + searchByType: vi.fn(), + }, +})); + +import { api } from '../api/client'; +import SearchView from '../views/SearchView.vue'; + +const mockResults: SearchResult[] = [ + { entityType: 'HomeModel', entityId: 'h1', title: 'Modern Villa', summary: 'A stylish home', relevanceScore: 0.95 }, + { entityType: 'Village', entityId: 'v1', title: 'Sunset Cove', summary: 'Coastal village', relevanceScore: 0.85 }, +]; + +async function createTestRouter(query: Record<string, string> = {}) { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: { template: '<div />' } }, + { path: '/search', component: { template: '<div />' } }, + ], + }); + await router.push({ path: '/search', query }); + await router.isReady(); + return router; +} + +describe('SearchView', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows loading skeleton while fetching', async () => { + vi.mocked(api.search).mockReturnValue(new Promise(() => {})); + const router = await createTestRouter({ query: 'villa' }); + const wrapper = mount(SearchView, { global: { plugins: [router] } }); + await flushPromises(); + expect(wrapper.find('.placeholder-glow').exists()).toBe(true); + }); + + it('displays results after fetch', async () => { + vi.mocked(api.search).mockResolvedValue(mockResults); + const router = await createTestRouter({ query: 'villa' }); + const wrapper = mount(SearchView, { global: { plugins: [router] } }); + await flushPromises(); + expect(wrapper.text()).toContain('Modern Villa'); + expect(wrapper.text()).toContain('Sunset Cove'); + }); + + it('shows empty state when no results', async () => { + vi.mocked(api.search).mockResolvedValue([]); + const router = await createTestRouter({ query: 'nonexistent' }); + const wrapper = mount(SearchView, { global: { plugins: [router] } }); + await flushPromises(); + expect(wrapper.text()).toContain('No results found'); + }); + + it('filters by entity type', async () => { + vi.mocked(api.searchByType).mockResolvedValue([mockResults[0]]); + const router = await createTestRouter({ query: 'villa', type: 'HomeModel' }); + const wrapper = mount(SearchView, { global: { plugins: [router] } }); + await flushPromises(); + expect(api.searchByType).toHaveBeenCalledWith('HomeModel', 'villa'); + expect(wrapper.text()).toContain('Modern Villa'); + }); + + it('shows result count badge', async () => { + vi.mocked(api.search).mockResolvedValue(mockResults); + const router = await createTestRouter({ query: 'villa' }); + const wrapper = mount(SearchView, { global: { plugins: [router] } }); + await flushPromises(); + expect(wrapper.find('.result-count').text()).toContain('2'); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/WalkthroughsView.spec.ts b/Terranes/src/Web.Vue/src/__tests__/WalkthroughsView.spec.ts new file mode 100644 index 0000000..5acffb0 --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/WalkthroughsView.spec.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import type { Walkthrough } from '../types'; + +vi.mock('../api/client', () => ({ + api: { + getWalkthroughsByModel: vi.fn(), + getWalkthroughPois: vi.fn(), + generateWalkthrough: vi.fn(), + }, +})); + +import { api } from '../api/client'; +import WalkthroughsView from '../views/WalkthroughsView.vue'; + +const mockWalkthroughs: Walkthrough[] = [ + { + id: 'wt1', + homeModelId: '00000000-0000-0000-0000-000000000001', + userId: 'u1', + scenes: [ + { id: 's1', walkthroughId: 'wt1', sceneName: 'Entrance', sceneOrder: 1, durationSeconds: 30 }, + { id: 's2', walkthroughId: 'wt1', sceneName: 'Living Room', sceneOrder: 2, durationSeconds: 45 }, + ], + generatedUtc: '2026-03-01T00:00:00Z', + }, +]; + +async function createTestRouter() { + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: { template: '<div />' } }], + }); + await router.push('/'); + await router.isReady(); + return router; +} + +describe('WalkthroughsView', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows loading skeleton initially', () => { + vi.mocked(api.getWalkthroughsByModel).mockReturnValue(new Promise(() => {})); + const wrapper = mount(WalkthroughsView, { + global: { plugins: [createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] })] }, + }); + expect(wrapper.find('.placeholder-glow').exists()).toBe(true); + }); + + it('shows empty state when no walkthroughs', async () => { + vi.mocked(api.getWalkthroughsByModel).mockResolvedValue([]); + const router = await createTestRouter(); + const wrapper = mount(WalkthroughsView, { global: { plugins: [router] } }); + await flushPromises(); + expect(wrapper.text()).toContain('No walkthroughs found'); + }); + + it('shows walkthrough cards when data loaded', async () => { + vi.mocked(api.getWalkthroughsByModel).mockResolvedValue(mockWalkthroughs); + const router = await createTestRouter(); + const wrapper = mount(WalkthroughsView, { global: { plugins: [router] } }); + await flushPromises(); + expect(wrapper.text()).toContain('Walkthrough'); + expect(wrapper.text()).toContain('2 scenes'); + }); + + it('opens detail modal when clicking View Details', async () => { + vi.mocked(api.getWalkthroughsByModel).mockResolvedValue(mockWalkthroughs); + vi.mocked(api.getWalkthroughPois).mockResolvedValue([]); + const router = await createTestRouter(); + const wrapper = mount(WalkthroughsView, { global: { plugins: [router] } }); + await flushPromises(); + const viewBtn = wrapper.findAll('button').find((b) => b.text().includes('View Details')); + await viewBtn!.trigger('click'); + await flushPromises(); + expect(wrapper.find('.modal').exists()).toBe(true); + expect(wrapper.text()).toContain('Scenes (2)'); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/accessibility.spec.ts b/Terranes/src/Web.Vue/src/__tests__/accessibility.spec.ts new file mode 100644 index 0000000..273209b --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/accessibility.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import App from '../App.vue'; +import DetailModal from '../components/DetailModal.vue'; + +async function createTestRouter() { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: { template: '<div />' } }, + { path: '/villages', component: { template: '<div />' } }, + { path: '/home-models', component: { template: '<div />' } }, + { path: '/land', component: { template: '<div />' } }, + { path: '/marketplace', component: { template: '<div />' } }, + { path: '/journey', component: { template: '<div />' } }, + { path: '/dashboard', component: { template: '<div />' } }, + ], + }); + await router.push('/'); + await router.isReady(); + return router; +} + +describe('Accessibility', () => { + it('skip-to-content link exists in App', async () => { + const router = await createTestRouter(); + const wrapper = mount(App, { global: { plugins: [router] } }); + const skipLink = wrapper.find('a.skip-link'); + expect(skipLink.exists()).toBe(true); + expect(skipLink.attributes('href')).toBe('#main-content'); + expect(skipLink.text()).toBe('Skip to content'); + }); + + it('main element has id="main-content"', async () => { + const router = await createTestRouter(); + const wrapper = mount(App, { global: { plugins: [router] } }); + const main = wrapper.find('main'); + expect(main.attributes('id')).toBe('main-content'); + }); + + it('navbar toggler has aria-label', async () => { + const router = await createTestRouter(); + const wrapper = mount(App, { global: { plugins: [router] } }); + const toggler = wrapper.find('.navbar-toggler'); + expect(toggler.attributes('aria-label')).toBe('Toggle navigation'); + }); + + it('DetailModal has role="dialog" and aria-modal="true"', () => { + const wrapper = mount(DetailModal, { + props: { show: true, title: 'Test' }, + }); + const modal = wrapper.find('.modal'); + expect(modal.attributes('role')).toBe('dialog'); + expect(modal.attributes('aria-modal')).toBe('true'); + }); + + it('DetailModal close button has aria-label', () => { + const wrapper = mount(DetailModal, { + props: { show: true, title: 'Test' }, + }); + const closeBtn = wrapper.find('.btn-close'); + expect(closeBtn.attributes('aria-label')).toBe('Close modal'); + }); + + it('DetailModal emits close on Escape key', async () => { + const wrapper = mount(DetailModal, { + props: { show: false, title: 'Test' }, + }); + // Trigger the watch by changing show to true + await wrapper.setProps({ show: true }); + // Simulate Escape keydown on document + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + document.dispatchEvent(event); + expect(wrapper.emitted('close')).toBeTruthy(); + expect(wrapper.emitted('close')!.length).toBe(1); + }); + + it('theme toggle button has aria-label', async () => { + const router = await createTestRouter(); + const wrapper = mount(App, { global: { plugins: [router] } }); + const btn = wrapper.find('button[aria-label="Toggle dark mode"]'); + expect(btn.exists()).toBe(true); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/components/BreadcrumbBar.spec.ts b/Terranes/src/Web.Vue/src/__tests__/components/BreadcrumbBar.spec.ts new file mode 100644 index 0000000..830567c --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/components/BreadcrumbBar.spec.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import BreadcrumbBar from '../../components/BreadcrumbBar.vue'; + +async function mountWithRoute(path: string, routeMeta: Record<string, unknown> = {}) { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'home', component: { template: '<div />' }, meta: { breadcrumb: 'Home' } }, + { path: '/dashboard', name: 'dashboard', component: { template: '<div />' }, meta: { breadcrumb: 'Dashboard', ...routeMeta } }, + { path: '/villages', name: 'villages', component: { template: '<div />' }, meta: { breadcrumb: 'Villages' } }, + ], + }); + await router.push(path); + await router.isReady(); + const wrapper = mount(BreadcrumbBar, { + global: { plugins: [router] }, + }); + return wrapper; +} + +describe('BreadcrumbBar', () => { + it('renders Home as first breadcrumb on non-home pages', async () => { + const wrapper = await mountWithRoute('/dashboard'); + const items = wrapper.findAll('.breadcrumb-item'); + expect(items.length).toBeGreaterThanOrEqual(2); + expect(items[0].text()).toBe('Home'); + }); + + it('shows current page as active breadcrumb', async () => { + const wrapper = await mountWithRoute('/dashboard'); + const items = wrapper.findAll('.breadcrumb-item'); + const last = items[items.length - 1]; + expect(last.classes()).toContain('active'); + expect(last.text()).toBe('Dashboard'); + }); + + it('generates breadcrumb from route meta', async () => { + const wrapper = await mountWithRoute('/villages'); + expect(wrapper.text()).toContain('Villages'); + expect(wrapper.text()).toContain('Home'); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/components/ConfirmDialog.spec.ts b/Terranes/src/Web.Vue/src/__tests__/components/ConfirmDialog.spec.ts new file mode 100644 index 0000000..a478152 --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/components/ConfirmDialog.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import ConfirmDialog from '../../components/ConfirmDialog.vue'; + +describe('ConfirmDialog', () => { + it('does not render when show is false', () => { + const wrapper = mount(ConfirmDialog, { + props: { show: false, title: 'Test', message: 'Are you sure?' }, + }); + expect(wrapper.find('.modal').exists()).toBe(false); + }); + + it('renders title and message when show is true', () => { + const wrapper = mount(ConfirmDialog, { + props: { show: true, title: 'Confirm Action', message: 'This is permanent.' }, + }); + expect(wrapper.find('.modal').exists()).toBe(true); + expect(wrapper.find('.modal-title').text()).toBe('Confirm Action'); + expect(wrapper.text()).toContain('This is permanent.'); + }); + + it('emits confirm when confirm button clicked', async () => { + const wrapper = mount(ConfirmDialog, { + props: { show: true, title: 'Test', message: 'Sure?', confirmText: 'Yes' }, + }); + const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Yes'); + await confirmBtn!.trigger('click'); + expect(wrapper.emitted('confirm')).toHaveLength(1); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/components/EmptyState.spec.ts b/Terranes/src/Web.Vue/src/__tests__/components/EmptyState.spec.ts new file mode 100644 index 0000000..a2fc657 --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/components/EmptyState.spec.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import EmptyState from '../../components/EmptyState.vue'; + +describe('EmptyState', () => { + it('renders default message when no prop given', () => { + const wrapper = mount(EmptyState); + expect(wrapper.text()).toContain('No results found'); + expect(wrapper.find('svg').exists()).toBe(true); + }); + + it('renders custom message', () => { + const wrapper = mount(EmptyState, { props: { message: 'Nothing here yet!' } }); + expect(wrapper.text()).toContain('Nothing here yet!'); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/components/FilterChip.spec.ts b/Terranes/src/Web.Vue/src/__tests__/components/FilterChip.spec.ts new file mode 100644 index 0000000..8d0f96c --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/components/FilterChip.spec.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import FilterChip from '../../components/FilterChip.vue'; + +describe('FilterChip', () => { + it('renders label text in a badge', () => { + const wrapper = mount(FilterChip, { props: { label: 'Type: Grid' } }); + expect(wrapper.text()).toContain('Type: Grid'); + expect(wrapper.find('.badge').exists()).toBe(true); + }); + + it('emits remove event when close button clicked', async () => { + const wrapper = mount(FilterChip, { props: { label: 'Status: Active' } }); + await wrapper.find('.btn-close').trigger('click'); + expect(wrapper.emitted('remove')).toHaveLength(1); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/components/PaginationBar.spec.ts b/Terranes/src/Web.Vue/src/__tests__/components/PaginationBar.spec.ts new file mode 100644 index 0000000..a48ba7f --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/components/PaginationBar.spec.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import PaginationBar from '../../components/PaginationBar.vue'; + +describe('PaginationBar', () => { + it('shows "Showing X–Y of Z" text', () => { + const wrapper = mount(PaginationBar, { + props: { totalItems: 50, pageSize: 12, currentPage: 1 }, + }); + expect(wrapper.text()).toContain('Showing 1–12 of 50'); + }); + + it('disables Prev button on first page', () => { + const wrapper = mount(PaginationBar, { + props: { totalItems: 50, pageSize: 12, currentPage: 1 }, + }); + const prevBtn = wrapper.findAll('button').find((b) => b.text().includes('Prev')); + expect(prevBtn!.attributes('disabled')).toBeDefined(); + }); + + it('disables Next button on last page', () => { + const wrapper = mount(PaginationBar, { + props: { totalItems: 10, pageSize: 12, currentPage: 1 }, + }); + const nextBtn = wrapper.findAll('button').find((b) => b.text().includes('Next')); + expect(nextBtn!.attributes('disabled')).toBeDefined(); + }); + + it('emits pageChange on next click', async () => { + const wrapper = mount(PaginationBar, { + props: { totalItems: 50, pageSize: 12, currentPage: 1 }, + }); + const nextBtn = wrapper.findAll('button').find((b) => b.text().includes('Next')); + await nextBtn!.trigger('click'); + expect(wrapper.emitted('pageChange')).toBeTruthy(); + expect(wrapper.emitted('pageChange')![0]).toEqual([2]); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/components/QuoteSummary.spec.ts b/Terranes/src/Web.Vue/src/__tests__/components/QuoteSummary.spec.ts new file mode 100644 index 0000000..e421294 --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/components/QuoteSummary.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import type { AggregatedQuote } from '../../types'; +import QuoteSummary from '../../components/QuoteSummary.vue'; + +const mockQuote: AggregatedQuote = { + id: 'q1', + journeyId: 'j1', + totalAmountAud: 450000, + lineItems: [ + { id: 'li1', quoteRequestId: 'qr1', category: 'Construction', description: 'Base build', amountAud: 350000 }, + { id: 'li2', quoteRequestId: 'qr1', category: 'Landscaping', description: 'Garden design', amountAud: 100000 }, + ], + generatedUtc: '2026-03-15T10:00:00Z', +}; + +describe('QuoteSummary', () => { + it('shows loading spinner when loading', () => { + const wrapper = mount(QuoteSummary, { + props: { quote: null, loading: true }, + }); + expect(wrapper.text()).toContain('Loading'); + }); + + it('shows "No quote available" when null and not loading', () => { + const wrapper = mount(QuoteSummary, { + props: { quote: null, loading: false }, + }); + expect(wrapper.text()).toContain('No quote available'); + }); + + it('shows total and line items when quote provided', () => { + const wrapper = mount(QuoteSummary, { + props: { quote: mockQuote, loading: false }, + }); + expect(wrapper.text()).toContain('450,000'); + expect(wrapper.text()).toContain('Construction'); + expect(wrapper.text()).toContain('Base build'); + expect(wrapper.text()).toContain('Landscaping'); + expect(wrapper.text()).toContain('Garden design'); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/components/SearchBar.spec.ts b/Terranes/src/Web.Vue/src/__tests__/components/SearchBar.spec.ts new file mode 100644 index 0000000..58a20e6 --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/components/SearchBar.spec.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import SearchBar from '../../components/SearchBar.vue'; + +async function createTestRouter() { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: { template: '<div />' } }, + { path: '/search', component: { template: '<div />' } }, + ], + }); + await router.push('/'); + await router.isReady(); + return router; +} + +describe('SearchBar', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders input with placeholder', async () => { + const router = await createTestRouter(); + const wrapper = mount(SearchBar, { global: { plugins: [router] } }); + const input = wrapper.find('input'); + expect(input.exists()).toBe(true); + expect(input.attributes('placeholder')).toBe('Search...'); + }); + + it('navigates on Enter key', async () => { + const router = await createTestRouter(); + const pushSpy = vi.spyOn(router, 'push'); + const wrapper = mount(SearchBar, { global: { plugins: [router] } }); + const input = wrapper.find('input'); + await input.setValue('test query'); + await input.trigger('keydown.enter'); + await flushPromises(); + expect(pushSpy).toHaveBeenCalledWith({ path: '/search', query: { query: 'test query' } }); + }); + + it('has magnifying glass icon', async () => { + const router = await createTestRouter(); + const wrapper = mount(SearchBar, { global: { plugins: [router] } }); + expect(wrapper.text()).toContain('🔍'); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/components/SparklineChart.spec.ts b/Terranes/src/Web.Vue/src/__tests__/components/SparklineChart.spec.ts new file mode 100644 index 0000000..15c2545 --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/components/SparklineChart.spec.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import SparklineChart from '../../components/SparklineChart.vue'; + +describe('SparklineChart', () => { + it('renders an SVG with a polyline', () => { + const wrapper = mount(SparklineChart, { + props: { data: [5, 10, 3, 8] }, + }); + expect(wrapper.find('svg').exists()).toBe(true); + expect(wrapper.find('polyline').exists()).toBe(true); + }); + + it('applies custom dimensions and color', () => { + const wrapper = mount(SparklineChart, { + props: { data: [1, 2, 3], color: '#ff0000', width: 200, height: 60 }, + }); + const svg = wrapper.find('svg'); + expect(svg.attributes('width')).toBe('200'); + expect(svg.attributes('height')).toBe('60'); + expect(wrapper.find('polyline').attributes('stroke')).toBe('#ff0000'); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/components/StatCard.spec.ts b/Terranes/src/Web.Vue/src/__tests__/components/StatCard.spec.ts new file mode 100644 index 0000000..7da18be --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/components/StatCard.spec.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import StatCard from '../../components/StatCard.vue'; + +describe('StatCard', () => { + it('renders the label text', () => { + const wrapper = mount(StatCard, { + props: { label: 'Active Users', value: 42 }, + }); + expect(wrapper.text()).toContain('Active Users'); + }); + + it('displays the value (animated or immediate)', async () => { + // In test env, matchMedia may indicate reduced motion → value set immediately + const wrapper = mount(StatCard, { + props: { label: 'Count', value: 10 }, + }); + // After mount, value should be set (reduced motion path or 0 start) + const statValue = wrapper.find('.stat-value'); + expect(statValue.exists()).toBe(true); + }); + + it('applies the color class from prop', () => { + const wrapper = mount(StatCard, { + props: { label: 'Test', value: 5, color: 'success' }, + }); + expect(wrapper.find('.stat-value').classes()).toContain('text-success'); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/components/StepIndicator.spec.ts b/Terranes/src/Web.Vue/src/__tests__/components/StepIndicator.spec.ts new file mode 100644 index 0000000..9d09a9f --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/components/StepIndicator.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import StepIndicator from '../../components/StepIndicator.vue'; + +const stages = ['Browsing', 'DesignSelected', 'PlacedOnLand', 'Completed']; + +describe('StepIndicator', () => { + it('renders all stages', () => { + const wrapper = mount(StepIndicator, { + props: { stages, currentStage: 'PlacedOnLand' }, + }); + expect(wrapper.findAll('.step-item').length).toBe(4); + }); + + it('marks completed stages with ✓', () => { + const wrapper = mount(StepIndicator, { + props: { stages, currentStage: 'PlacedOnLand' }, + }); + const completed = wrapper.findAll('.step-item.completed'); + expect(completed.length).toBe(2); // Browsing and DesignSelected + expect(completed[0].text()).toContain('✓'); + }); + + it('highlights current stage as active', () => { + const wrapper = mount(StepIndicator, { + props: { stages, currentStage: 'PlacedOnLand' }, + }); + const active = wrapper.findAll('.step-item.active'); + expect(active.length).toBe(1); + expect(active[0].text()).toContain('3'); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/composables/useAuth.spec.ts b/Terranes/src/Web.Vue/src/__tests__/composables/useAuth.spec.ts new file mode 100644 index 0000000..0e9cd64 --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/composables/useAuth.spec.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { PlatformUser } from '../../types'; +import { useAuth, _resetAuth } from '../../composables/useAuth'; + +vi.mock('../../api/client', () => ({ + api: { + login: vi.fn(), + register: vi.fn(), + }, +})); + +import { api } from '../../api/client'; + +const mockUser: PlatformUser = { + id: 'u1', + email: 'test@example.com', + displayName: 'Test User', + role: 'buyer', + isActive: true, + createdUtc: '2026-01-01T00:00:00Z', +}; + +describe('useAuth', () => { + beforeEach(() => { + vi.clearAllMocks(); + _resetAuth(); + localStorage.clear(); + }); + + it('isAuthenticated is false by default', () => { + const { isAuthenticated } = useAuth(); + expect(isAuthenticated.value).toBe(false); + }); + + it('login sets currentUser', async () => { + vi.mocked(api.login).mockResolvedValue(mockUser); + const { login, currentUser, isAuthenticated } = useAuth(); + await login('test@example.com', 'password'); + expect(currentUser.value).toEqual(mockUser); + expect(isAuthenticated.value).toBe(true); + }); + + it('logout clears currentUser', async () => { + vi.mocked(api.login).mockResolvedValue(mockUser); + const { login, logout, currentUser, isAuthenticated } = useAuth(); + await login('test@example.com', 'password'); + logout(); + expect(currentUser.value).toBeNull(); + expect(isAuthenticated.value).toBe(false); + }); + + it('register sets currentUser', async () => { + vi.mocked(api.register).mockResolvedValue(mockUser); + const { register, currentUser, isAuthenticated } = useAuth(); + await register('test@example.com', 'Test User', 'password'); + expect(currentUser.value).toEqual(mockUser); + expect(isAuthenticated.value).toBe(true); + }); + + it('restoreSession reads from localStorage', () => { + localStorage.setItem('terranes-user', JSON.stringify(mockUser)); + const { restoreSession, currentUser, isAuthenticated } = useAuth(); + restoreSession(); + expect(currentUser.value).toEqual(mockUser); + expect(isAuthenticated.value).toBe(true); + }); + + it('restoreSession handles invalid JSON', () => { + localStorage.setItem('terranes-user', 'not-json'); + const { restoreSession, currentUser } = useAuth(); + restoreSession(); + expect(currentUser.value).toBeNull(); + expect(localStorage.getItem('terranes-user')).toBeNull(); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/composables/useDebounce.spec.ts b/Terranes/src/Web.Vue/src/__tests__/composables/useDebounce.spec.ts new file mode 100644 index 0000000..bfe536b --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/composables/useDebounce.spec.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, vi } from 'vitest'; +import { ref, nextTick } from 'vue'; +import { useDebounce } from '../../composables/useDebounce'; + +describe('useDebounce', () => { + it('returns initial value immediately', () => { + const source = ref('hello'); + const debounced = useDebounce(source, 300); + expect(debounced.value).toBe('hello'); + }); + + it('does not update debounced value immediately on change', async () => { + vi.useFakeTimers(); + const source = ref('hello'); + const debounced = useDebounce(source, 300); + source.value = 'world'; + await nextTick(); + // Value hasn't changed yet (debounce hasn't fired) + expect(debounced.value).toBe('hello'); + vi.useRealTimers(); + }); + + it('updates debounced value after delay', async () => { + vi.useFakeTimers(); + const source = ref('hello'); + const debounced = useDebounce(source, 300); + source.value = 'world'; + await nextTick(); // flush watcher so setTimeout is scheduled + vi.advanceTimersByTime(350); + await nextTick(); // flush reactivity after timeout fires + expect(debounced.value).toBe('world'); + vi.useRealTimers(); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/composables/usePagedList.spec.ts b/Terranes/src/Web.Vue/src/__tests__/composables/usePagedList.spec.ts new file mode 100644 index 0000000..9ddceae --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/composables/usePagedList.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { ref } from 'vue'; +import { usePagedList } from '../../composables/usePagedList'; + +describe('usePagedList', () => { + it('shows only the first batch of items', () => { + const items = ref(Array.from({ length: 50 }, (_, i) => i)); + const { visibleItems, hasMore } = usePagedList(items, 20); + expect(visibleItems.value).toHaveLength(20); + expect(hasMore.value).toBe(true); + }); + + it('showMore reveals the next batch', () => { + const items = ref(Array.from({ length: 50 }, (_, i) => i)); + const { visibleItems, showMore, hasMore } = usePagedList(items, 20); + showMore(); + expect(visibleItems.value).toHaveLength(40); + expect(hasMore.value).toBe(true); + showMore(); + expect(visibleItems.value).toHaveLength(50); + expect(hasMore.value).toBe(false); + }); + + it('resetVisible resets to initial batch size', () => { + const items = ref(Array.from({ length: 50 }, (_, i) => i)); + const { visibleItems, showMore, resetVisible } = usePagedList(items, 20); + showMore(); + expect(visibleItems.value).toHaveLength(40); + resetVisible(); + expect(visibleItems.value).toHaveLength(20); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/composables/useTheme.spec.ts b/Terranes/src/Web.Vue/src/__tests__/composables/useTheme.spec.ts new file mode 100644 index 0000000..d2522d9 --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/composables/useTheme.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useTheme, _resetTheme } from '../../composables/useTheme'; + +describe('useTheme', () => { + beforeEach(() => { + _resetTheme(); + localStorage.clear(); + document.documentElement.removeAttribute('data-bs-theme'); + }); + + it('isDark defaults to false', () => { + const { isDark } = useTheme(); + expect(isDark.value).toBe(false); + }); + + it('toggleTheme toggles isDark', () => { + const { isDark, toggleTheme } = useTheme(); + expect(isDark.value).toBe(false); + toggleTheme(); + expect(isDark.value).toBe(true); + toggleTheme(); + expect(isDark.value).toBe(false); + }); + + it('toggleTheme saves to localStorage', () => { + const { toggleTheme } = useTheme(); + toggleTheme(); + expect(localStorage.getItem('terranes-theme')).toBe('dark'); + toggleTheme(); + expect(localStorage.getItem('terranes-theme')).toBe('light'); + }); + + it('init reads from localStorage', () => { + localStorage.setItem('terranes-theme', 'dark'); + const { isDark } = useTheme(); + expect(isDark.value).toBe(true); + }); + + it('toggleTheme sets data-bs-theme attribute on document', () => { + const { toggleTheme } = useTheme(); + toggleTheme(); + expect(document.documentElement.getAttribute('data-bs-theme')).toBe('dark'); + toggleTheme(); + expect(document.documentElement.getAttribute('data-bs-theme')).toBe('light'); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/composables/useValidation.spec.ts b/Terranes/src/Web.Vue/src/__tests__/composables/useValidation.spec.ts new file mode 100644 index 0000000..a3b1b29 --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/composables/useValidation.spec.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { required, minValue, maxValue, pattern, useValidation } from '../../composables/useValidation'; + +describe('useValidation', () => { + it('required rule fails for empty string', () => { + const rule = required(); + expect(rule.validate('')).toBe(false); + expect(rule.validate('hello')).toBe(true); + }); + + it('minValue rule validates correctly', () => { + const rule = minValue(5); + expect(rule.validate(3)).toBe(false); + expect(rule.validate(5)).toBe(true); + expect(rule.validate(10)).toBe(true); + }); + + it('maxValue rule validates correctly', () => { + const rule = maxValue(10); + expect(rule.validate(5)).toBe(true); + expect(rule.validate(10)).toBe(true); + expect(rule.validate(15)).toBe(false); + }); + + it('pattern rule validates regex', () => { + const rule = pattern(/^\d{3}$/); + expect(rule.validate('123')).toBe(true); + expect(rule.validate('12')).toBe(false); + expect(rule.validate('abcd')).toBe(false); + }); + + it('useValidation returns errors for failing rules', () => { + const { errors, validate } = useValidation(); + const valid = validate('', [required(), minValue(1)]); + expect(valid).toBe(false); + expect(errors.value.length).toBeGreaterThan(0); + expect(errors.value).toContain('This field is required'); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/responsive-layout.spec.ts b/Terranes/src/Web.Vue/src/__tests__/responsive-layout.spec.ts new file mode 100644 index 0000000..4867ab6 --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/responsive-layout.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import App from '../App.vue'; +import HomeView from '../views/HomeView.vue'; + +vi.mock('../api/client', () => ({ + api: { + getLandBlocks: vi.fn().mockResolvedValue([ + { id: 'b1', address: '10 Main St', suburb: 'Surry Hills', state: 'NSW', areaSqm: 450, frontageMetre: 15, depthMetre: 30, zoning: 'R2' }, + ]), + getHomeModels: vi.fn().mockResolvedValue([]), + createSitePlacement: vi.fn().mockResolvedValue({}), + }, +})); + +import LandBlocksView from '../views/LandBlocksView.vue'; + +async function createTestRouter() { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: HomeView }, + { path: '/villages', component: { template: '<div />' } }, + { path: '/home-models', component: { template: '<div />' } }, + { path: '/land', component: LandBlocksView }, + { path: '/marketplace', component: { template: '<div />' } }, + { path: '/journey', component: { template: '<div />' } }, + { path: '/dashboard', component: { template: '<div />' } }, + ], + }); + await router.push('/'); + await router.isReady(); + return router; +} + +describe('Responsive Layout', () => { + it('sidebar nav-scrollable is rendered', async () => { + const router = await createTestRouter(); + const wrapper = mount(App, { global: { plugins: [router] } }); + expect(wrapper.find('.nav-scrollable').exists()).toBe(true); + }); + + it('sidebar toggle button exists', async () => { + const router = await createTestRouter(); + const wrapper = mount(App, { global: { plugins: [router] } }); + expect(wrapper.find('.navbar-toggler').exists()).toBe(true); + }); + + it('HomeView has mobile-first column classes (col-12 col-md-4)', async () => { + const router = await createTestRouter(); + const wrapper = mount(HomeView, { global: { plugins: [router] } }); + const cols = wrapper.findAll('.col-12.col-md-4'); + expect(cols.length).toBeGreaterThanOrEqual(1); + }); + + it('LandBlocksView has table-responsive class', async () => { + const router = await createTestRouter(); + const wrapper = mount(LandBlocksView, { global: { plugins: [router] } }); + await flushPromises(); + expect(wrapper.html()).toContain('table-responsive'); + }); +}); diff --git a/Terranes/src/Web.Vue/src/api/client.ts b/Terranes/src/Web.Vue/src/api/client.ts index 789315f..5f4a786 100644 --- a/Terranes/src/Web.Vue/src/api/client.ts +++ b/Terranes/src/Web.Vue/src/api/client.ts @@ -7,6 +7,15 @@ import type { VillageLot, BuyerJourney, Notification, + SearchResult, + AggregatedQuote, + PlatformUser, + Partner, + Walkthrough, + WalkthroughPoi, + DesignEdit, + Report, + ComplianceResult, } from '../types'; const API_BASE = import.meta.env.VITE_API_URL ?? '/api'; @@ -102,4 +111,107 @@ export const api = { return r.json() as Promise<{ status: string; timestamp: string }>; }); }, + + // Search + search(query: string, maxResults?: number) { + const qs = buildQuery({ query, maxResults }); + return fetchJson<SearchResult[]>(`/search${qs}`); + }, + searchByType(entityType: string, query: string, maxResults?: number) { + const qs = buildQuery({ query, maxResults }); + return fetchJson<SearchResult[]>(`/search/${entityType}${qs}`); + }, + + // Aggregated Quotes + aggregateQuote(journeyId: string) { + const qs = buildQuery({ journeyId }); + return fetchJson<AggregatedQuote>(`/aggregated-quotes${qs}`, { method: 'POST' }); + }, + getJourneyQuotes(journeyId: string) { + return fetchJson<AggregatedQuote[]>(`/aggregated-quotes/journey/${journeyId}`); + }, + + // Auth + login(email: string, password: string) { + const qs = buildQuery({ email, password }); + return fetchJson<PlatformUser>(`/auth/login${qs}`, { method: 'POST' }); + }, + register(user: { email: string; displayName: string }, password: string) { + const qs = buildQuery({ password }); + return fetchJson<PlatformUser>(`/auth/register${qs}`, { + method: 'POST', + body: JSON.stringify(user), + }); + }, + getUser(userId: string) { + return fetchJson<PlatformUser>(`/auth/users/${userId}`); + }, + + // Partners + getBuilders(params?: { bedrooms?: number; floorArea?: number }) { + const qs = buildQuery({ bedrooms: params?.bedrooms, floorArea: params?.floorArea }); + return fetchJson<Partner[]>(`/partners/builders/search${qs}`); + }, + getBuilderProfile(partnerId: string) { + return fetchJson<Partner>(`/partners/builders/${partnerId}`); + }, + + // Walkthroughs + generateWalkthrough(homeModelId: string, userId: string, sitePlacementId?: string) { + const qs = buildQuery({ homeModelId, userId, sitePlacementId }); + return fetchJson<Walkthrough>(`/walkthroughs/generate${qs}`, { method: 'POST' }); + }, + getWalkthrough(id: string) { + return fetchJson<Walkthrough>(`/walkthroughs/${id}`); + }, + getWalkthroughsByModel(homeModelId: string) { + return fetchJson<Walkthrough[]>(`/walkthroughs/by-model/${homeModelId}`); + }, + getWalkthroughPois(walkthroughId: string, room?: string) { + const qs = buildQuery({ room }); + return fetchJson<WalkthroughPoi[]>(`/walkthroughs/${walkthroughId}/pois${qs}`); + }, + + // Design Editor + applyEdit(edit: { sitePlacementId: string; operation: string; targetElement: string; newValue: string }) { + return fetchJson<DesignEdit>('/design-editor/edits', { + method: 'POST', + body: JSON.stringify(edit), + }); + }, + getEditHistory(sitePlacementId: string) { + return fetchJson<DesignEdit[]>(`/design-editor/placements/${sitePlacementId}/history`); + }, + undoLastEdit(sitePlacementId: string) { + return fetchJson<DesignEdit>(`/design-editor/placements/${sitePlacementId}/undo`, { method: 'POST' }); + }, + + // Reports + generateReport(reportType: string, title: string, userId: string, tenantId: string) { + const qs = buildQuery({ reportType, title, generatedByUserId: userId, tenantId }); + return fetchJson<Report>(`/reports${qs}`, { method: 'POST' }); + }, + getReport(reportId: string) { + return fetchJson<Report>(`/reports/${reportId}`); + }, + getTenantReports(tenantId: string) { + return fetchJson<Report[]>(`/reports/tenant/${tenantId}`); + }, + getReportTypes() { + return fetchJson<string[]>('/reports/types'); + }, + + // Compliance + runComplianceCheck(sitePlacementId: string, jurisdiction: string) { + return fetchJson<ComplianceResult>('/compliance/check', { + method: 'POST', + body: JSON.stringify({ sitePlacementId, jurisdiction }), + }); + }, + getComplianceResult(id: string) { + return fetchJson<ComplianceResult>(`/compliance/${id}`); + }, + getComplianceByPlacement(sitePlacementId: string) { + return fetchJson<ComplianceResult[]>(`/compliance/placement/${sitePlacementId}`); + }, }; diff --git a/Terranes/src/Web.Vue/src/components/BreadcrumbBar.vue b/Terranes/src/Web.Vue/src/components/BreadcrumbBar.vue new file mode 100644 index 0000000..fdca3e4 --- /dev/null +++ b/Terranes/src/Web.Vue/src/components/BreadcrumbBar.vue @@ -0,0 +1,39 @@ +<script setup lang="ts"> +import { computed } from 'vue'; +import { useRoute } from 'vue-router'; + +const route = useRoute(); + +const crumbs = computed(() => { + const items: { label: string; to?: string }[] = []; + + if (route.name !== 'home') { + items.push({ label: 'Home', to: '/' }); + } + + const breadcrumb = route.meta?.breadcrumb as string | undefined; + if (breadcrumb) { + items.push({ label: breadcrumb }); + } + + return items; +}); +</script> + +<template> + <nav aria-label="breadcrumb" class="breadcrumb-bar"> + <ol class="breadcrumb mb-2"> + <li + v-for="(crumb, index) in crumbs" + :key="index" + class="breadcrumb-item" + :class="{ active: index === crumbs.length - 1 }" + > + <RouterLink v-if="crumb.to && index < crumbs.length - 1" :to="crumb.to"> + {{ crumb.label }} + </RouterLink> + <span v-else>{{ crumb.label }}</span> + </li> + </ol> + </nav> +</template> diff --git a/Terranes/src/Web.Vue/src/components/ConfettiEffect.vue b/Terranes/src/Web.Vue/src/components/ConfettiEffect.vue new file mode 100644 index 0000000..4300959 --- /dev/null +++ b/Terranes/src/Web.Vue/src/components/ConfettiEffect.vue @@ -0,0 +1,72 @@ +<script setup lang="ts"> +import { ref, onMounted, onUnmounted } from 'vue'; + +const visible = ref(true); +let timer: ReturnType<typeof setTimeout>; + +onMounted(() => { + timer = setTimeout(() => { visible.value = false; }, 3000); +}); + +onUnmounted(() => { + clearTimeout(timer); +}); + +const particles = Array.from({ length: 32 }, (_, i) => ({ + id: i, + color: ['#ff6b6b','#feca57','#48dbfb','#ff9ff3','#54a0ff','#5f27cd','#01a3a4','#f368e0'][i % 8], + left: `${Math.random() * 100}%`, + delay: `${Math.random() * 2}s`, + duration: `${2 + Math.random() * 2}s`, +})); +</script> + +<template> + <div v-if="visible" class="confetti-container" aria-hidden="true"> + <div + v-for="p in particles" + :key="p.id" + class="confetti-particle" + :style="{ + backgroundColor: p.color, + left: p.left, + animationDelay: p.delay, + animationDuration: p.duration, + }" + ></div> + </div> +</template> + +<style scoped> +.confetti-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 9999; + overflow: hidden; +} + +.confetti-particle { + position: absolute; + top: -10px; + width: 10px; + height: 10px; + border-radius: 2px; + animation: confetti-fall linear forwards; +} + +@keyframes confetti-fall { + 0% { transform: translateY(0) rotate(0deg); opacity: 1; } + 100% { transform: translateY(100vh) rotate(720deg); opacity: 0; } +} + +@media (prefers-reduced-motion: reduce) { + .confetti-particle { + animation: none; + display: none; + } +} +</style> diff --git a/Terranes/src/Web.Vue/src/components/ConfirmDialog.vue b/Terranes/src/Web.Vue/src/components/ConfirmDialog.vue new file mode 100644 index 0000000..3209ae6 --- /dev/null +++ b/Terranes/src/Web.Vue/src/components/ConfirmDialog.vue @@ -0,0 +1,49 @@ +<script setup lang="ts"> +withDefaults(defineProps<{ + show: boolean; + title: string; + message: string; + confirmText?: string; + confirmVariant?: string; +}>(), { + confirmText: 'Confirm', + confirmVariant: 'primary', +}); + +defineEmits<{ confirm: []; cancel: [] }>(); +</script> + +<template> + <div v-if="show" class="modal d-block" tabindex="-1" role="dialog" aria-modal="true"> + <div class="modal-backdrop fade show" @click="$emit('cancel')"></div> + <div class="modal-dialog modal-dialog-centered" role="document"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title">{{ title }}</h5> + <button type="button" class="btn-close" aria-label="Close" @click="$emit('cancel')"></button> + </div> + <div class="modal-body"> + <p>{{ message }}</p> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" @click="$emit('cancel')">Cancel</button> + <button type="button" :class="`btn btn-${confirmVariant}`" @click="$emit('confirm')"> + {{ confirmText }} + </button> + </div> + </div> + </div> + </div> +</template> + +<style scoped> +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 1040; +} +.modal-dialog { + z-index: 1050; + position: relative; +} +</style> diff --git a/Terranes/src/Web.Vue/src/components/DetailModal.vue b/Terranes/src/Web.Vue/src/components/DetailModal.vue index dc890ae..4fd4c02 100644 --- a/Terranes/src/Web.Vue/src/components/DetailModal.vue +++ b/Terranes/src/Web.Vue/src/components/DetailModal.vue @@ -1,15 +1,43 @@ <script setup lang="ts"> -defineProps<{ title: string; show: boolean }>(); -defineEmits<{ close: [] }>(); +import { watch, nextTick, onUnmounted, ref } from 'vue'; + +const props = defineProps<{ title: string; show: boolean }>(); +const emit = defineEmits<{ close: [] }>(); + +const closeButtonRef = ref<HTMLButtonElement | null>(null); + +function onKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + emit('close'); + } +} + +watch( + () => props.show, + (newVal) => { + if (newVal) { + document.addEventListener('keydown', onKeydown); + nextTick(() => { + closeButtonRef.value?.focus(); + }); + } else { + document.removeEventListener('keydown', onKeydown); + } + }, +); + +onUnmounted(() => { + document.removeEventListener('keydown', onKeydown); +}); </script> <template> - <div v-if="show" class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);"> + <div v-if="show" class="modal show d-block" tabindex="-1" role="dialog" aria-modal="true" style="background-color: rgba(0,0,0,0.5);"> <div class="modal-dialog modal-lg"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title">{{ title }}</h5> - <button type="button" class="btn-close" @click="$emit('close')"></button> + <button ref="closeButtonRef" type="button" class="btn-close" aria-label="Close modal" @click="$emit('close')"></button> </div> <div class="modal-body"> <slot /> diff --git a/Terranes/src/Web.Vue/src/components/EmptyState.vue b/Terranes/src/Web.Vue/src/components/EmptyState.vue new file mode 100644 index 0000000..9d70db2 --- /dev/null +++ b/Terranes/src/Web.Vue/src/components/EmptyState.vue @@ -0,0 +1,22 @@ +<script setup lang="ts"> +withDefaults(defineProps<{ message?: string }>(), { + message: 'No results found. Try adjusting your filters.', +}); +</script> + +<template> + <div class="text-center py-5 empty-state"> + <svg + xmlns="http://www.w3.org/2000/svg" + width="64" + height="64" + fill="currentColor" + class="text-muted mb-3" + viewBox="0 0 16 16" + aria-hidden="true" + > + <path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85zm-5.242.656a5.5 5.5 0 1 1 0-11 5.5 5.5 0 0 1 0 11z"/> + </svg> + <p class="text-muted">{{ message }}</p> + </div> +</template> diff --git a/Terranes/src/Web.Vue/src/components/FilterChip.vue b/Terranes/src/Web.Vue/src/components/FilterChip.vue new file mode 100644 index 0000000..baa07a3 --- /dev/null +++ b/Terranes/src/Web.Vue/src/components/FilterChip.vue @@ -0,0 +1,17 @@ +<script setup lang="ts"> +defineProps<{ label: string }>(); +defineEmits<{ remove: [] }>(); +</script> + +<template> + <span class="badge bg-primary d-inline-flex align-items-center me-2 mb-1 filter-chip"> + {{ label }} + <button + type="button" + class="btn-close btn-close-white ms-2" + style="font-size: 0.6em;" + aria-label="Remove filter" + @click="$emit('remove')" + ></button> + </span> +</template> diff --git a/Terranes/src/Web.Vue/src/components/JourneyTimeline.vue b/Terranes/src/Web.Vue/src/components/JourneyTimeline.vue new file mode 100644 index 0000000..f551b08 --- /dev/null +++ b/Terranes/src/Web.Vue/src/components/JourneyTimeline.vue @@ -0,0 +1,64 @@ +<script setup lang="ts"> +import { computed } from 'vue'; + +const props = defineProps<{ + stages: string[]; + currentStage: string; + startedUtc: string; +}>(); + +const currentIndex = computed(() => props.stages.indexOf(props.currentStage)); +const startDate = computed(() => new Date(props.startedUtc).toLocaleDateString()); +</script> + +<template> + <div class="journey-timeline"> + <h6 class="mb-3">Journey Timeline</h6> + <div class="timeline-list"> + <div + v-for="(stage, idx) in stages" + :key="stage" + class="timeline-item d-flex mb-3" + :class="{ + completed: idx < currentIndex, + active: idx === currentIndex, + future: idx > currentIndex, + }" + > + <div class="timeline-marker me-3"> + <span + class="badge rounded-circle d-flex align-items-center justify-content-center" + :class="{ + 'bg-success': idx < currentIndex, + 'bg-primary': idx === currentIndex, + 'bg-light text-muted border': idx > currentIndex, + }" + style="width: 28px; height: 28px;" + > + <span v-if="idx < currentIndex">✓</span> + <span v-else>{{ idx + 1 }}</span> + </span> + </div> + <div class="timeline-content"> + <strong :class="{ 'text-muted': idx > currentIndex }">{{ stage }}</strong> + <div v-if="idx === 0" class="text-muted small">Started {{ startDate }}</div> + <div v-if="idx === currentIndex && idx > 0" class="text-muted small">In progress</div> + </div> + </div> + </div> + </div> +</template> + +<style scoped> +.timeline-item + .timeline-item { + border-left: 2px solid #dee2e6; + margin-left: 13px; + padding-left: 24px; +} +.timeline-item:first-child { + padding-left: 0; +} +.timeline-item.completed + .timeline-item { + border-left-color: #198754; +} +</style> diff --git a/Terranes/src/Web.Vue/src/components/PaginationBar.vue b/Terranes/src/Web.Vue/src/components/PaginationBar.vue new file mode 100644 index 0000000..33da052 --- /dev/null +++ b/Terranes/src/Web.Vue/src/components/PaginationBar.vue @@ -0,0 +1,57 @@ +<script setup lang="ts"> +import { computed } from 'vue'; + +const props = withDefaults(defineProps<{ + totalItems: number; + pageSize?: number; + currentPage?: number; +}>(), { + pageSize: 12, + currentPage: 1, +}); + +const emit = defineEmits<{ pageChange: [page: number] }>(); + +const totalPages = computed(() => Math.max(1, Math.ceil(props.totalItems / props.pageSize))); + +const startItem = computed(() => props.totalItems === 0 ? 0 : (props.currentPage - 1) * props.pageSize + 1); +const endItem = computed(() => Math.min(props.currentPage * props.pageSize, props.totalItems)); + +const pageNumbers = computed(() => { + const pages: number[] = []; + const total = totalPages.value; + const current = props.currentPage; + const start = Math.max(1, current - 2); + const end = Math.min(total, current + 2); + for (let i = start; i <= end; i++) pages.push(i); + return pages; +}); +</script> + +<template> + <nav aria-label="Pagination" class="pagination-bar d-flex justify-content-between align-items-center mt-4"> + <small class="text-muted showing-text"> + Showing {{ startItem }}–{{ endItem }} of {{ totalItems }} + </small> + <ul class="pagination mb-0"> + <li class="page-item" :class="{ disabled: currentPage <= 1 }"> + <button class="page-link" :disabled="currentPage <= 1" @click="emit('pageChange', currentPage - 1)"> + « Prev + </button> + </li> + <li + v-for="p in pageNumbers" + :key="p" + class="page-item" + :class="{ active: p === currentPage }" + > + <button class="page-link" @click="emit('pageChange', p)">{{ p }}</button> + </li> + <li class="page-item" :class="{ disabled: currentPage >= totalPages }"> + <button class="page-link" :disabled="currentPage >= totalPages" @click="emit('pageChange', currentPage + 1)"> + Next » + </button> + </li> + </ul> + </nav> +</template> diff --git a/Terranes/src/Web.Vue/src/components/QuoteSummary.vue b/Terranes/src/Web.Vue/src/components/QuoteSummary.vue new file mode 100644 index 0000000..f24ddb7 --- /dev/null +++ b/Terranes/src/Web.Vue/src/components/QuoteSummary.vue @@ -0,0 +1,38 @@ +<script setup lang="ts"> +import type { AggregatedQuote } from '../types'; +import LoadingSpinner from './LoadingSpinner.vue'; + +defineProps<{ + quote: AggregatedQuote | null; + loading: boolean; +}>(); +</script> + +<template> + <div class="quote-summary"> + <LoadingSpinner v-if="loading" message="Loading quote..." /> + <template v-else-if="quote"> + <div class="mb-3"> + <span class="fs-3 fw-bold text-success">${{ quote.totalAmountAud.toLocaleString() }} AUD</span> + </div> + <table class="table table-sm table-striped"> + <thead> + <tr> + <th>Category</th> + <th>Description</th> + <th class="text-end">Amount</th> + </tr> + </thead> + <tbody> + <tr v-for="item in quote.lineItems" :key="item.id"> + <td>{{ item.category }}</td> + <td>{{ item.description }}</td> + <td class="text-end">${{ item.amountAud.toLocaleString() }}</td> + </tr> + </tbody> + </table> + <small class="text-muted">Generated: {{ new Date(quote.generatedUtc).toLocaleString() }}</small> + </template> + <p v-else class="text-muted">No quote available</p> + </div> +</template> diff --git a/Terranes/src/Web.Vue/src/components/SearchBar.vue b/Terranes/src/Web.Vue/src/components/SearchBar.vue new file mode 100644 index 0000000..7d3dd13 --- /dev/null +++ b/Terranes/src/Web.Vue/src/components/SearchBar.vue @@ -0,0 +1,29 @@ +<script setup lang="ts"> +import { ref } from 'vue'; +import { useRouter } from 'vue-router'; + +const router = useRouter(); +const query = ref(''); + +function performSearch() { + if (query.value.trim()) { + router.push({ path: '/search', query: { query: query.value.trim() } }); + } +} +</script> + +<template> + <div class="d-flex align-items-center search-bar"> + <div class="input-group input-group-sm"> + <span class="input-group-text">🔍</span> + <input + type="text" + class="form-control" + placeholder="Search..." + v-model="query" + @keydown.enter="performSearch" + /> + <button class="btn btn-outline-secondary" type="button" @click="performSearch">Go</button> + </div> + </div> +</template> diff --git a/Terranes/src/Web.Vue/src/components/SparklineChart.vue b/Terranes/src/Web.Vue/src/components/SparklineChart.vue new file mode 100644 index 0000000..22cb7c3 --- /dev/null +++ b/Terranes/src/Web.Vue/src/components/SparklineChart.vue @@ -0,0 +1,47 @@ +<script setup lang="ts"> +import { computed } from 'vue'; + +const props = withDefaults( + defineProps<{ + data: number[]; + color?: string; + height?: number; + width?: number; + }>(), + { color: '#0d6efd', height: 40, width: 120 }, +); + +const points = computed(() => { + if (props.data.length === 0) return ''; + const max = Math.max(...props.data); + const min = Math.min(...props.data); + const range = max - min || 1; + const stepX = props.width / Math.max(props.data.length - 1, 1); + return props.data + .map((v, i) => { + const x = i * stepX; + const y = props.height - ((v - min) / range) * (props.height - 4) - 2; + return `${x},${y}`; + }) + .join(' '); +}); +</script> + +<template> + <svg + :width="width" + :height="height" + role="img" + aria-label="Sparkline chart" + class="sparkline-chart" + > + <polyline + :points="points" + fill="none" + :stroke="color" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + /> + </svg> +</template> diff --git a/Terranes/src/Web.Vue/src/components/StatCard.vue b/Terranes/src/Web.Vue/src/components/StatCard.vue new file mode 100644 index 0000000..b852281 --- /dev/null +++ b/Terranes/src/Web.Vue/src/components/StatCard.vue @@ -0,0 +1,57 @@ +<script setup lang="ts"> +import { ref, onMounted, watch } from 'vue'; + +const props = withDefaults( + defineProps<{ + label: string; + value: number; + color?: string; + icon?: string; + }>(), + { color: 'primary', icon: undefined }, +); + +const displayValue = ref(0); + +function animateTo(target: number) { + const mql = typeof window !== 'undefined' && window.matchMedia + ? window.matchMedia('(prefers-reduced-motion: reduce)') + : null; + const prefersReducedMotion = mql?.matches ?? false; + if (prefersReducedMotion || target === 0) { + displayValue.value = target; + return; + } + + const duration = 1000; + const start = performance.now(); + const from = 0; + + function step(now: number) { + const elapsed = now - start; + const progress = Math.min(elapsed / duration, 1); + displayValue.value = Math.round(from + (target - from) * progress); + if (progress < 1) { + requestAnimationFrame(step); + } + } + + requestAnimationFrame(step); +} + +onMounted(() => animateTo(props.value)); +watch(() => props.value, (v) => { displayValue.value = v; }); +</script> + +<template> + <div class="card shadow-sm text-center stat-card"> + <div class="card-body"> + <span v-if="icon" class="stat-icon" aria-hidden="true">{{ icon }}</span> + <h3 :class="`text-${color}`" class="stat-value">{{ displayValue }}</h3> + <small class="text-muted stat-label">{{ label }}</small> + <div class="mt-1"> + <slot /> + </div> + </div> + </div> +</template> diff --git a/Terranes/src/Web.Vue/src/components/StepIndicator.vue b/Terranes/src/Web.Vue/src/components/StepIndicator.vue new file mode 100644 index 0000000..aaff7f1 --- /dev/null +++ b/Terranes/src/Web.Vue/src/components/StepIndicator.vue @@ -0,0 +1,62 @@ +<script setup lang="ts"> +import { computed } from 'vue'; + +const props = defineProps<{ + stages: string[]; + currentStage: string; +}>(); + +const currentIndex = computed(() => props.stages.indexOf(props.currentStage)); +</script> + +<template> + <div class="step-indicator d-flex align-items-center justify-content-between" role="progressbar"> + <template v-for="(stage, idx) in stages" :key="stage"> + <div class="step-item text-center" :class="{ + completed: idx < currentIndex, + active: idx === currentIndex, + future: idx > currentIndex, + }"> + <div class="step-circle mx-auto mb-1" :class="{ + 'bg-success text-white': idx < currentIndex, + 'bg-primary text-white': idx === currentIndex, + 'bg-light text-muted border': idx > currentIndex, + }"> + <span v-if="idx < currentIndex">✓</span> + <span v-else>{{ idx + 1 }}</span> + </div> + <small class="step-label d-none d-md-block">{{ stage }}</small> + </div> + <div v-if="idx < stages.length - 1" class="step-line flex-grow-1 mx-1" :class="{ + 'bg-success': idx < currentIndex, + 'bg-secondary': idx >= currentIndex, + }"></div> + </template> + </div> +</template> + +<style scoped> +.step-circle { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + font-weight: bold; +} +.step-line { + height: 3px; + border-radius: 2px; + opacity: 0.6; +} +.step-label { + font-size: 0.7rem; + max-width: 80px; + word-wrap: break-word; +} +.step-item { + flex-shrink: 0; +} +</style> diff --git a/Terranes/src/Web.Vue/src/composables/useAuth.ts b/Terranes/src/Web.Vue/src/composables/useAuth.ts new file mode 100644 index 0000000..b87609e --- /dev/null +++ b/Terranes/src/Web.Vue/src/composables/useAuth.ts @@ -0,0 +1,54 @@ +import { ref, computed } from 'vue'; +import type { PlatformUser } from '../types'; +import { api } from '../api/client'; + +const currentUser = ref<PlatformUser | null>(null); + +export function useAuth() { + const isAuthenticated = computed(() => currentUser.value !== null); + + async function login(email: string, password: string): Promise<PlatformUser> { + const user = await api.login(email, password); + currentUser.value = user; + if (typeof localStorage !== 'undefined') { + localStorage.setItem('terranes-user', JSON.stringify(user)); + } + return user; + } + + async function register(email: string, displayName: string, password: string): Promise<PlatformUser> { + const user = await api.register({ email, displayName }, password); + currentUser.value = user; + if (typeof localStorage !== 'undefined') { + localStorage.setItem('terranes-user', JSON.stringify(user)); + } + return user; + } + + function logout() { + currentUser.value = null; + if (typeof localStorage !== 'undefined') { + localStorage.removeItem('terranes-user'); + } + } + + function restoreSession() { + if (typeof localStorage !== 'undefined') { + const stored = localStorage.getItem('terranes-user'); + if (stored) { + try { + currentUser.value = JSON.parse(stored); + } catch { + localStorage.removeItem('terranes-user'); + } + } + } + } + + return { currentUser, isAuthenticated, login, register, logout, restoreSession }; +} + +/** Test-only reset */ +export function _resetAuth() { + currentUser.value = null; +} diff --git a/Terranes/src/Web.Vue/src/composables/useDebounce.ts b/Terranes/src/Web.Vue/src/composables/useDebounce.ts new file mode 100644 index 0000000..9d1aec3 --- /dev/null +++ b/Terranes/src/Web.Vue/src/composables/useDebounce.ts @@ -0,0 +1,11 @@ +import { ref, watch, type Ref } from 'vue'; + +export function useDebounce<T>(source: Ref<T>, delay = 300): Ref<T> { + const debounced = ref(source.value) as Ref<T>; + let timeout: ReturnType<typeof setTimeout>; + watch(source, (val) => { + clearTimeout(timeout); + timeout = setTimeout(() => { debounced.value = val; }, delay); + }); + return debounced; +} diff --git a/Terranes/src/Web.Vue/src/composables/usePagedList.ts b/Terranes/src/Web.Vue/src/composables/usePagedList.ts new file mode 100644 index 0000000..960d5a7 --- /dev/null +++ b/Terranes/src/Web.Vue/src/composables/usePagedList.ts @@ -0,0 +1,10 @@ +import { ref, computed, type Ref } from 'vue'; + +export function usePagedList<T>(items: Ref<T[]>, batchSize = 20) { + const visibleCount = ref(batchSize); + const visibleItems = computed(() => items.value.slice(0, visibleCount.value)); + const hasMore = computed(() => visibleCount.value < items.value.length); + function showMore() { visibleCount.value += batchSize; } + function resetVisible() { visibleCount.value = batchSize; } + return { visibleItems, hasMore, showMore, resetVisible }; +} diff --git a/Terranes/src/Web.Vue/src/composables/useTheme.ts b/Terranes/src/Web.Vue/src/composables/useTheme.ts new file mode 100644 index 0000000..3a02d64 --- /dev/null +++ b/Terranes/src/Web.Vue/src/composables/useTheme.ts @@ -0,0 +1,46 @@ +import { ref } from 'vue'; + +const isDark = ref(false); +let initialized = false; + +function applyTheme(dark: boolean) { + isDark.value = dark; + if (typeof document !== 'undefined') { + document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light'); + } +} + +function init() { + if (initialized) return; + initialized = true; + if (typeof localStorage !== 'undefined') { + const stored = localStorage.getItem('terranes-theme'); + if (stored) { + applyTheme(stored === 'dark'); + return; + } + } + if (typeof window !== 'undefined' && window.matchMedia) { + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + applyTheme(mq.matches); + mq.addEventListener('change', (e) => applyTheme(e.matches)); + } +} + +export function useTheme() { + init(); + function toggleTheme() { + const newVal = !isDark.value; + applyTheme(newVal); + if (typeof localStorage !== 'undefined') { + localStorage.setItem('terranes-theme', newVal ? 'dark' : 'light'); + } + } + return { isDark, toggleTheme }; +} + +/** Test-only reset */ +export function _resetTheme() { + initialized = false; + isDark.value = false; +} diff --git a/Terranes/src/Web.Vue/src/composables/useValidation.ts b/Terranes/src/Web.Vue/src/composables/useValidation.ts new file mode 100644 index 0000000..6636c18 --- /dev/null +++ b/Terranes/src/Web.Vue/src/composables/useValidation.ts @@ -0,0 +1,32 @@ +import { ref } from 'vue'; + +export interface ValidationRule { + validate: (value: unknown) => boolean; + message: string; +} + +export function required(msg = 'This field is required'): ValidationRule { + return { validate: (v) => v !== null && v !== undefined && v !== '', message: msg }; +} + +export function minValue(min: number, msg?: string): ValidationRule { + return { validate: (v) => typeof v === 'number' && v >= min, message: msg ?? `Must be at least ${min}` }; +} + +export function maxValue(max: number, msg?: string): ValidationRule { + return { validate: (v) => typeof v === 'number' && v <= max, message: msg ?? `Must be at most ${max}` }; +} + +export function pattern(regex: RegExp, msg = 'Invalid format'): ValidationRule { + return { validate: (v) => typeof v === 'string' && regex.test(v), message: msg }; +} + +export function useValidation() { + const errors = ref<string[]>([]); + function validate(value: unknown, rules: ValidationRule[]): boolean { + errors.value = rules.filter(r => !r.validate(value)).map(r => r.message); + return errors.value.length === 0; + } + function clearErrors() { errors.value = []; } + return { errors, validate, clearErrors }; +} diff --git a/Terranes/src/Web.Vue/src/router/index.ts b/Terranes/src/Web.Vue/src/router/index.ts index 87872e0..a429db9 100644 --- a/Terranes/src/Web.Vue/src/router/index.ts +++ b/Terranes/src/Web.Vue/src/router/index.ts @@ -7,36 +7,91 @@ const router = createRouter({ path: '/', name: 'home', component: () => import('../views/HomeView.vue'), + meta: { title: 'Home | Terranes', breadcrumb: 'Home' }, }, { path: '/villages', name: 'villages', component: () => import('../views/VillagesView.vue'), + meta: { title: 'Villages | Terranes', breadcrumb: 'Villages' }, }, { path: '/home-models', name: 'home-models', component: () => import('../views/HomeModelsView.vue'), + meta: { title: 'Home Designs | Terranes', breadcrumb: 'Home Designs' }, }, { path: '/land', name: 'land', component: () => import('../views/LandBlocksView.vue'), + meta: { title: 'Land Blocks | Terranes', breadcrumb: 'Land Blocks' }, }, { path: '/marketplace', name: 'marketplace', component: () => import('../views/MarketplaceView.vue'), + meta: { title: 'Marketplace | Terranes', breadcrumb: 'Marketplace' }, }, { path: '/journey', name: 'journey', component: () => import('../views/JourneyView.vue'), + meta: { title: 'My Journey | Terranes', breadcrumb: 'My Journey' }, }, { path: '/dashboard', name: 'dashboard', component: () => import('../views/DashboardView.vue'), + meta: { title: 'Dashboard | Terranes', breadcrumb: 'Dashboard' }, + }, + { + path: '/search', + name: 'search', + component: () => import('../views/SearchView.vue'), + meta: { title: 'Search | Terranes', breadcrumb: 'Search' }, + }, + { + path: '/login', + name: 'login', + component: () => import('../views/LoginView.vue'), + meta: { title: 'Login | Terranes', breadcrumb: 'Login' }, + }, + { + path: '/register', + name: 'register', + component: () => import('../views/RegisterView.vue'), + meta: { title: 'Register | Terranes', breadcrumb: 'Register' }, + }, + { + path: '/partners', + name: 'partners', + component: () => import('../views/PartnersView.vue'), + meta: { title: 'Partners | Terranes', breadcrumb: 'Partners' }, + }, + { + path: '/walkthroughs', + name: 'walkthroughs', + component: () => import('../views/WalkthroughsView.vue'), + meta: { title: 'Walkthroughs | Terranes', breadcrumb: 'Walkthroughs' }, + }, + { + path: '/design-editor', + name: 'design-editor', + component: () => import('../views/DesignEditorView.vue'), + meta: { title: 'Design Editor | Terranes', breadcrumb: 'Design Editor' }, + }, + { + path: '/reports', + name: 'reports', + component: () => import('../views/ReportsView.vue'), + meta: { title: 'Reports | Terranes', breadcrumb: 'Reports' }, + }, + { + path: '/:pathMatch(.*)*', + name: 'not-found', + component: () => import('../views/NotFoundView.vue'), + meta: { title: 'Page Not Found | Terranes', breadcrumb: 'Not Found' }, }, ], }); diff --git a/Terranes/src/Web.Vue/src/style.css b/Terranes/src/Web.Vue/src/style.css index fd0c21b..b8b7149 100644 --- a/Terranes/src/Web.Vue/src/style.css +++ b/Terranes/src/Web.Vue/src/style.css @@ -14,8 +14,8 @@ main { } .top-row { - background-color: #f7f7f7; - border-bottom: 1px solid #d6d5d5; + background-color: var(--bs-body-bg); + border-bottom: 1px solid var(--bs-border-color); justify-content: flex-end; height: 3.5rem; display: flex; @@ -34,7 +34,7 @@ main { text-decoration: underline; } -@media (max-width: 640.98px) { +@media (max-width: 767.98px) { .top-row { justify-content: space-between; } @@ -44,7 +44,7 @@ main { } } -@media (min-width: 641px) { +@media (min-width: 768px) { .page { flex-direction: row; } @@ -155,21 +155,48 @@ main { } .nav-scrollable { - display: none; + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; } .nav-scrollable.open { - display: block; + max-height: 100vh; } -@media (min-width: 641px) { +@media (min-width: 768px) { .navbar-toggler { display: none; } .nav-scrollable { - display: block; - height: calc(100vh - 3.5rem); + max-height: none; overflow-y: auto; + height: calc(100vh - 3.5rem); + } +} + +@media (prefers-reduced-motion: reduce) { + .nav-scrollable { + transition: none; + } +} + +/* Card hover effects */ +.card { + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 25px rgba(0,0,0,0.15) !important; +} + +@media (prefers-reduced-motion: reduce) { + .card { + transition: none; + } + .card:hover { + transform: none; } } diff --git a/Terranes/src/Web.Vue/src/types/index.ts b/Terranes/src/Web.Vue/src/types/index.ts index bc0ba0c..b82412a 100644 --- a/Terranes/src/Web.Vue/src/types/index.ts +++ b/Terranes/src/Web.Vue/src/types/index.ts @@ -84,3 +84,103 @@ export interface Notification { isRead: boolean; createdUtc: string; } + +export interface SearchResult { + entityType: string; + entityId: string; + title: string; + summary: string; + relevanceScore: number; +} + +export interface AggregatedQuote { + id: string; + journeyId: string; + totalAmountAud: number; + lineItems: QuoteLineItem[]; + generatedUtc: string; +} + +export interface QuoteLineItem { + id: string; + quoteRequestId: string; + category: string; + description: string; + amountAud: number; +} + +export interface PlatformUser { + id: string; + email: string; + displayName: string; + role: string; + isActive: boolean; + createdUtc: string; +} + +export interface Partner { + id: string; + name: string; + category: string; + description: string; + contactEmail: string; + phone?: string; + website?: string; + isActive: boolean; +} + +export interface Walkthrough { + id: string; + homeModelId: string; + sitePlacementId?: string; + userId: string; + scenes: WalkthroughScene[]; + generatedUtc: string; +} + +export interface WalkthroughScene { + id: string; + walkthroughId: string; + sceneName: string; + sceneOrder: number; + durationSeconds: number; +} + +export interface WalkthroughPoi { + id: string; + walkthroughId: string; + room: string; + label: string; + description: string; + positionX: number; + positionY: number; + positionZ: number; +} + +export interface DesignEdit { + id: string; + sitePlacementId: string; + operation: string; + targetElement: string; + newValue: string; + appliedUtc: string; +} + +export interface Report { + id: string; + reportType: string; + title: string; + contentMarkdown: string; + generatedByUserId: string; + tenantId: string; + generatedUtc: string; +} + +export interface ComplianceResult { + id: string; + sitePlacementId: string; + jurisdiction: string; + isCompliant: boolean; + issues: string[]; + checkedUtc: string; +} diff --git a/Terranes/src/Web.Vue/src/views/DashboardView.vue b/Terranes/src/Web.Vue/src/views/DashboardView.vue index 4454fa5..b786f15 100644 --- a/Terranes/src/Web.Vue/src/views/DashboardView.vue +++ b/Terranes/src/Web.Vue/src/views/DashboardView.vue @@ -1,9 +1,12 @@ <script setup lang="ts"> -import { ref, onMounted } from 'vue'; +import { ref, computed, onMounted } from 'vue'; +import { RouterLink } from 'vue-router'; import { api } from '../api/client'; import type { BuyerJourney, Notification as AppNotification, HomeModel } from '../types'; import LoadingSpinner from '../components/LoadingSpinner.vue'; import StatusBadge from '../components/StatusBadge.vue'; +import StatCard from '../components/StatCard.vue'; +import SparklineChart from '../components/SparklineChart.vue'; const DEMO_BUYER_ID = '00000000-0000-0000-0000-000000000001'; @@ -15,6 +18,15 @@ const homeModelCount = ref(0); const listingCount = ref(0); const analyticsEventCount = ref(0); +const unreadCount = computed(() => + notifications.value?.filter(n => !n.isRead).length ?? 0, +); + +const trendJourneys = [5, 8, 12, 9, 15, 20, 18]; +const trendModels = [3, 6, 4, 10, 8, 14, 12]; +const trendListings = [2, 5, 7, 6, 11, 9, 13]; +const trendAnalytics = [10, 25, 18, 30, 22, 35, 42]; + onMounted(async () => { const [journeys, notifs, models, listings, analytics] = await Promise.all([ api.getBuyerJourneys(DEMO_BUYER_ID), @@ -36,45 +48,51 @@ onMounted(async () => { <template> <div class="container"> - <h2 class="mb-4">📊 Dashboard</h2> + <div class="d-flex justify-content-between align-items-center mb-4"> + <h2>📊 Dashboard</h2> + <div class="d-flex align-items-center gap-3"> + <span class="position-relative notification-bell" aria-label="Notifications"> + 🔔 + <span + v-if="unreadCount > 0" + class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger notification-count" + > + {{ unreadCount }} + </span> + </span> + </div> + </div> + + <div class="d-flex flex-wrap gap-2 mb-4"> + <RouterLink to="/journey" class="btn btn-primary quick-action">New Journey</RouterLink> + <RouterLink to="/home-models" class="btn btn-outline-secondary quick-action">Browse Designs</RouterLink> + </div> <div class="row g-4 mb-4"> - <div class="col-md-3"> - <div class="card shadow-sm text-center"> - <div class="card-body"> - <h3 class="text-primary">{{ activeJourneyCount }}</h3> - <small class="text-muted">Active Journeys</small> - </div> - </div> + <div class="col-6 col-md-3"> + <StatCard label="Active Journeys" :value="activeJourneyCount" color="primary" icon="🚀"> + <SparklineChart :data="trendJourneys" color="#0d6efd" /> + </StatCard> </div> - <div class="col-md-3"> - <div class="card shadow-sm text-center"> - <div class="card-body"> - <h3 class="text-success">{{ homeModelCount }}</h3> - <small class="text-muted">Home Designs</small> - </div> - </div> + <div class="col-6 col-md-3"> + <StatCard label="Home Designs" :value="homeModelCount" color="success" icon="🏡"> + <SparklineChart :data="trendModels" color="#198754" /> + </StatCard> </div> - <div class="col-md-3"> - <div class="card shadow-sm text-center"> - <div class="card-body"> - <h3 class="text-info">{{ listingCount }}</h3> - <small class="text-muted">Marketplace Listings</small> - </div> - </div> + <div class="col-6 col-md-3"> + <StatCard label="Marketplace Listings" :value="listingCount" color="info" icon="🏬"> + <SparklineChart :data="trendListings" color="#0dcaf0" /> + </StatCard> </div> - <div class="col-md-3"> - <div class="card shadow-sm text-center"> - <div class="card-body"> - <h3 class="text-warning">{{ analyticsEventCount }}</h3> - <small class="text-muted">Analytics Events</small> - </div> - </div> + <div class="col-6 col-md-3"> + <StatCard label="Analytics Events" :value="analyticsEventCount" color="warning" icon="📈"> + <SparklineChart :data="trendAnalytics" color="#ffc107" /> + </StatCard> </div> </div> <div class="row g-4"> - <div class="col-md-6"> + <div class="col-12 col-md-6"> <div class="card shadow-sm h-100"> <div class="card-header"><strong>Active Buyer Journeys</strong></div> <div class="card-body"> @@ -97,7 +115,7 @@ onMounted(async () => { </div> </div> - <div class="col-md-6"> + <div class="col-12 col-md-6"> <div class="card shadow-sm h-100"> <div class="card-header"><strong>Recent Notifications</strong></div> <div class="card-body"> @@ -130,7 +148,7 @@ onMounted(async () => { <div class="card-header"><strong>Recent Home Designs</strong></div> <div class="card-body"> <div v-if="recentModels && recentModels.length > 0" class="row g-3"> - <div class="col-md-3" v-for="model in recentModels.slice(0, 4)" :key="model.id"> + <div class="col-6 col-md-3" v-for="model in recentModels.slice(0, 4)" :key="model.id"> <div class="card h-100"> <div class="card-body"> <h6>{{ model.name }}</h6> diff --git a/Terranes/src/Web.Vue/src/views/DesignEditorView.vue b/Terranes/src/Web.Vue/src/views/DesignEditorView.vue new file mode 100644 index 0000000..1c748ee --- /dev/null +++ b/Terranes/src/Web.Vue/src/views/DesignEditorView.vue @@ -0,0 +1,145 @@ +<script setup lang="ts"> +import { ref, onMounted } from 'vue'; +import { api } from '../api/client'; +import type { DesignEdit } from '../types'; +import ActionButton from '../components/ActionButton.vue'; +import EmptyState from '../components/EmptyState.vue'; +import SkeletonTable from '../components/SkeletonTable.vue'; +import { useToast } from '../composables/useToast'; + +const { showSuccess, showError } = useToast(); + +const sitePlacementId = ref(''); +const operation = ref('Move'); +const targetElement = ref(''); +const newValue = ref(''); +const applying = ref(false); +const undoing = ref(false); + +const editHistory = ref<DesignEdit[] | null>(null); +const historyPlacementId = ref(''); + +const operations = ['Move', 'Rotate', 'Scale', 'ColorChange', 'MaterialChange', 'AddElement', 'RemoveElement']; + +async function applyEdit() { + if (!sitePlacementId.value.trim() || !targetElement.value.trim() || !newValue.value.trim()) return; + applying.value = true; + try { + const edit = await api.applyEdit({ + sitePlacementId: sitePlacementId.value.trim(), + operation: operation.value, + targetElement: targetElement.value.trim(), + newValue: newValue.value.trim(), + }); + showSuccess('Edit applied successfully!'); + if (!editHistory.value) editHistory.value = []; + editHistory.value.unshift(edit); + historyPlacementId.value = sitePlacementId.value.trim(); + targetElement.value = ''; + newValue.value = ''; + } catch { + showError('Failed to apply edit.'); + } finally { + applying.value = false; + } +} + +async function loadHistory() { + if (!historyPlacementId.value.trim()) return; + try { + editHistory.value = await api.getEditHistory(historyPlacementId.value.trim()); + } catch { + editHistory.value = []; + } +} + +async function undoLast() { + if (!historyPlacementId.value.trim()) return; + undoing.value = true; + try { + await api.undoLastEdit(historyPlacementId.value.trim()); + showSuccess('Last edit undone!'); + await loadHistory(); + } catch { + showError('Failed to undo last edit.'); + } finally { + undoing.value = false; + } +} + +onMounted(() => { + editHistory.value = []; +}); +</script> + +<template> + <div class="container"> + <h2 class="mb-4">🎨 Design Editor</h2> + <p class="text-muted">Customise site placements with design operations. Full 3D editing coming soon.</p> + + <div class="card mb-4"> + <div class="card-body"> + <h5 class="card-title">Apply Design Edit</h5> + <div class="row g-3"> + <div class="col-md-6"> + <label class="form-label">Site Placement ID</label> + <input type="text" class="form-control" v-model="sitePlacementId" placeholder="Enter site placement ID" /> + </div> + <div class="col-md-6"> + <label class="form-label">Operation</label> + <select class="form-select" v-model="operation"> + <option v-for="op in operations" :key="op" :value="op">{{ op }}</option> + </select> + </div> + <div class="col-md-6"> + <label class="form-label">Target Element</label> + <input type="text" class="form-control" v-model="targetElement" placeholder="e.g. Wall-North" /> + </div> + <div class="col-md-6"> + <label class="form-label">New Value</label> + <input type="text" class="form-control" v-model="newValue" placeholder="e.g. #FF5733" /> + </div> + <div class="col-12"> + <ActionButton :loading="applying" variant="primary" @click="applyEdit">Apply Edit</ActionButton> + </div> + </div> + </div> + </div> + + <div class="card"> + <div class="card-body"> + <div class="d-flex justify-content-between align-items-center mb-3"> + <h5 class="card-title mb-0">Edit History</h5> + <div class="d-flex gap-2"> + <input type="text" class="form-control form-control-sm" style="width: 250px;" v-model="historyPlacementId" placeholder="Placement ID to load history" /> + <button class="btn btn-sm btn-outline-secondary" @click="loadHistory">Load</button> + <ActionButton :loading="undoing" variant="warning" size="sm" @click="undoLast">Undo Last</ActionButton> + </div> + </div> + + <SkeletonTable v-if="editHistory === null" :rows="3" :cols="5" /> + <EmptyState v-else-if="editHistory.length === 0" message="No edits yet. Apply a design edit above to get started." /> + <div v-else class="table-responsive"> + <table class="table table-sm table-striped"> + <thead> + <tr> + <th>Operation</th> + <th>Target</th> + <th>Value</th> + <th>Applied</th> + </tr> + </thead> + <tbody> + <tr v-for="edit in editHistory" :key="edit.id"> + <td><span class="badge bg-info">{{ edit.operation }}</span></td> + <td>{{ edit.targetElement }}</td> + <td><code>{{ edit.newValue }}</code></td> + <td>{{ new Date(edit.appliedUtc).toLocaleString() }}</td> + </tr> + </tbody> + </table> + </div> + </div> + </div> + </div> +</template> diff --git a/Terranes/src/Web.Vue/src/views/HomeModelsView.vue b/Terranes/src/Web.Vue/src/views/HomeModelsView.vue index e538353..c5562c3 100644 --- a/Terranes/src/Web.Vue/src/views/HomeModelsView.vue +++ b/Terranes/src/Web.Vue/src/views/HomeModelsView.vue @@ -1,24 +1,58 @@ <script setup lang="ts"> -import { ref, onMounted, watch } from 'vue'; +import { ref, computed, onMounted, watch } from 'vue'; +import { useRoute, useRouter } from 'vue-router'; import { api } from '../api/client'; import type { HomeModel } from '../types'; import DetailModal from '../components/DetailModal.vue'; import SkeletonCard from '../components/SkeletonCard.vue'; +import FilterChip from '../components/FilterChip.vue'; +import EmptyState from '../components/EmptyState.vue'; +import { useDebounce } from '../composables/useDebounce'; +import { useValidation, minValue, maxValue } from '../composables/useValidation'; + +const route = useRoute(); +const router = useRouter(); const models = ref<HomeModel[] | null>(null); -const minBedrooms = ref<number | undefined>(undefined); -const selectedFormat = ref(''); +const minBedrooms = ref<number | undefined>( + route.query.minBedrooms ? Number(route.query.minBedrooms) : undefined, +); +const selectedFormat = ref((route.query.format as string) || ''); const selectedModel = ref<HomeModel | null>(null); +const searchInput = ref<HTMLInputElement | null>(null); + +const debouncedBedrooms = useDebounce(minBedrooms); const formats = ['Gltf', 'Glb', 'Obj', 'Fbx', 'Usd']; +const resultCount = computed(() => models.value?.length ?? 0); + +const hasActiveFilters = computed(() => debouncedBedrooms.value !== undefined || !!selectedFormat.value); + +const { errors: bedroomErrors, validate: validateBedrooms, clearErrors: clearBedroomErrors } = useValidation(); + +watch(minBedrooms, (v) => { + if (v !== undefined && v !== null && String(v) !== '') { + validateBedrooms(v, [minValue(0), maxValue(10)]); + } else { + clearBedroomErrors(); + } +}); + async function search() { models.value = await api.getHomeModels({ - minBedrooms: minBedrooms.value, + minBedrooms: debouncedBedrooms.value, format: selectedFormat.value || undefined, }); } +function syncQuery() { + const query: Record<string, string> = {}; + if (debouncedBedrooms.value !== undefined) query.minBedrooms = String(debouncedBedrooms.value); + if (selectedFormat.value) query.format = selectedFormat.value; + router.replace({ query }); +} + function selectModel(model: HomeModel) { selectedModel.value = model; } @@ -27,8 +61,18 @@ function closeModal() { selectedModel.value = null; } -onMounted(search); -watch([minBedrooms, selectedFormat], search); +function removeBedroomsFilter() { minBedrooms.value = undefined; } +function removeFormatFilter() { selectedFormat.value = ''; } +function clearAllFilters() { + minBedrooms.value = undefined; + selectedFormat.value = ''; +} + +onMounted(() => { + search(); + searchInput.value?.focus(); +}); +watch([debouncedBedrooms, selectedFormat], () => { search(); syncQuery(); }); </script> <template> @@ -37,11 +81,14 @@ watch([minBedrooms, selectedFormat], search); <p class="text-muted">Browse our gallery of 3D home models.</p> <div class="row mb-3"> - <div class="col-md-3"> + <div class="col-12 col-md-3"> <label class="form-label">Min Bedrooms</label> - <input type="number" class="form-control" min="0" max="10" v-model.number="minBedrooms" /> + <input type="number" class="form-control" :class="{ 'is-invalid': bedroomErrors.length > 0 }" min="0" max="10" v-model.number="minBedrooms" ref="searchInput" /> + <div v-if="bedroomErrors.length > 0" class="invalid-feedback"> + {{ bedroomErrors[0] }} + </div> </div> - <div class="col-md-3"> + <div class="col-12 col-md-3"> <label class="form-label">Format</label> <select class="form-select" v-model="selectedFormat"> <option value="">All Formats</option> @@ -50,12 +97,17 @@ watch([minBedrooms, selectedFormat], search); </div> </div> - <SkeletonCard v-if="models === null" :count="3" :columns="3" /> - <div v-else-if="models.length === 0" class="alert alert-info"> - No home designs found matching your criteria. + <div class="mb-3 d-flex flex-wrap align-items-center"> + <FilterChip v-if="debouncedBedrooms !== undefined" :label="`Min Beds: ${debouncedBedrooms}`" @remove="removeBedroomsFilter" /> + <FilterChip v-if="selectedFormat" :label="`Format: ${selectedFormat}`" @remove="removeFormatFilter" /> + <button v-if="hasActiveFilters" class="btn btn-sm btn-outline-danger ms-2 clear-all-filters" @click="clearAllFilters">Clear All Filters</button> + <span v-if="models !== null" class="badge bg-secondary ms-auto result-count">Showing {{ resultCount }} results</span> </div> + + <SkeletonCard v-if="models === null" :count="3" :columns="3" /> + <EmptyState v-else-if="models.length === 0" message="No home designs found matching your criteria." /> <div v-else class="row g-4"> - <div class="col-md-4" v-for="model in models" :key="model.id"> + <div class="col-12 col-md-4" v-for="model in models" :key="model.id"> <div class="card h-100 shadow-sm"> <div class="card-body"> <h5 class="card-title">{{ model.name }}</h5> @@ -71,7 +123,7 @@ watch([minBedrooms, selectedFormat], search); </div> </div> <div class="card-footer"> - <button class="btn btn-sm btn-outline-primary" @click="selectModel(model)">View Details</button> + <button class="btn btn-sm btn-outline-primary" aria-label="View details for this design" @click="selectModel(model)">View Details</button> </div> </div> </div> diff --git a/Terranes/src/Web.Vue/src/views/HomeView.vue b/Terranes/src/Web.Vue/src/views/HomeView.vue index 441c518..cbc3155 100644 --- a/Terranes/src/Web.Vue/src/views/HomeView.vue +++ b/Terranes/src/Web.Vue/src/views/HomeView.vue @@ -1,19 +1,67 @@ <script setup lang="ts"> +import { ref } from 'vue'; + +const testimonialIndex = ref(0); + +const testimonials = [ + { quote: 'Terranes helped us find our dream home and understand the budget before committing. Amazing experience!', author: 'Sarah M., First Home Buyer' }, + { quote: 'The 3D village walkthrough was incredible. We felt like we were already living there.', author: 'James & Priya K., Upgraders' }, + { quote: 'Getting an indicative quote so early in the process saved us months of uncertainty.', author: 'David L., Investor' }, +]; + +function scrollTo(id: string) { + document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' }); +} + +function prevTestimonial() { + testimonialIndex.value = (testimonialIndex.value - 1 + testimonials.length) % testimonials.length; +} + +function nextTestimonial() { + testimonialIndex.value = (testimonialIndex.value + 1) % testimonials.length; +} </script> <template> <div class="container"> - <div class="text-center py-5"> - <h1 class="display-4">🏠 Welcome to Terranes</h1> - <p class="lead text-muted"> + <section id="hero" class="hero text-center text-white py-5 mb-5 rounded-4"> + <h1 class="display-4 fw-bold">🏠 Welcome to Terranes</h1> + <p class="lead mx-auto" style="max-width: 600px;"> An immersive 3D property platform where buyers explore virtual villages, walk through fully designed homes, test-fit designs onto real land, and receive end-to-end indicative quotes — all before committing. </p> - </div> + <a href="#how-it-works" class="btn btn-light btn-lg mt-3" @click.prevent="scrollTo('how-it-works')">Explore How It Works</a> + </section> + + <section id="how-it-works" class="mb-5"> + <h2 class="text-center mb-4">How It Works</h2> + <div class="row g-4 text-center"> + <div class="col-12 col-md-3"> + <div class="step-icon mb-2 fs-1">🔍</div> + <h5>Browse</h5> + <p class="text-muted">Explore virtual villages and home designs in immersive 3D.</p> + </div> + <div class="col-12 col-md-3"> + <div class="step-icon mb-2 fs-1">🏡</div> + <h5>Select</h5> + <p class="text-muted">Pick your favourite home design and land block.</p> + </div> + <div class="col-12 col-md-3"> + <div class="step-icon mb-2 fs-1">🎨</div> + <h5>Customise</h5> + <p class="text-muted">Personalise your home with finishes and layouts.</p> + </div> + <div class="col-12 col-md-3"> + <div class="step-icon mb-2 fs-1">💰</div> + <h5>Quote</h5> + <p class="text-muted">Get an indicative quote from our partner network.</p> + </div> + </div> + </section> <div class="row g-4 mb-5"> - <div class="col-md-4"> + <div class="col-12 col-md-4"> <div class="card h-100 shadow-sm"> <div class="card-body text-center"> <h3>🏘️ Virtual Villages</h3> @@ -23,7 +71,7 @@ </div> </div> - <div class="col-md-4"> + <div class="col-12 col-md-4"> <div class="card h-100 shadow-sm"> <div class="card-body text-center"> <h3>🏡 Home Designs</h3> @@ -33,7 +81,7 @@ </div> </div> - <div class="col-md-4"> + <div class="col-12 col-md-4"> <div class="card h-100 shadow-sm"> <div class="card-body text-center"> <h3>🗺️ Find Land</h3> @@ -45,7 +93,7 @@ </div> <div class="row g-4 mb-5"> - <div class="col-md-4"> + <div class="col-12 col-md-4"> <div class="card h-100 shadow-sm"> <div class="card-body text-center"> <h3>🏬 Marketplace</h3> @@ -55,7 +103,7 @@ </div> </div> - <div class="col-md-4"> + <div class="col-12 col-md-4"> <div class="card h-100 shadow-sm"> <div class="card-body text-center"> <h3>🚀 Start Your Journey</h3> @@ -65,7 +113,7 @@ </div> </div> - <div class="col-md-4"> + <div class="col-12 col-md-4"> <div class="card h-100 shadow-sm"> <div class="card-body text-center"> <h3>📊 Dashboard</h3> @@ -76,12 +124,51 @@ </div> </div> - <div class="text-center text-muted"> + <section class="testimonials text-center mb-5"> + <h2 class="mb-4">What Our Users Say</h2> + <div class="mx-auto" style="max-width: 600px;"> + <blockquote class="blockquote"> + <p>“{{ testimonials[testimonialIndex].quote }}”</p> + <footer class="blockquote-footer">{{ testimonials[testimonialIndex].author }}</footer> + </blockquote> + <div class="mt-3"> + <button class="btn btn-outline-secondary me-2" aria-label="Previous testimonial" @click="prevTestimonial">← Prev</button> + <button class="btn btn-outline-secondary" aria-label="Next testimonial" @click="nextTestimonial">Next →</button> + </div> + </div> + </section> + + <div class="text-center text-muted mb-4"> <p> <strong>Think CanIBuild + Envis + Matterport</strong> in one integrated platform. Instead of imagining the home — you <em>experience</em> it. Instead of guessing the budget — you <em>understand</em> it early. </p> </div> + + <footer class="site-footer text-center py-4 border-top text-muted"> + <p class="mb-1">© {{ new Date().getFullYear() }} Terranes. All rights reserved.</p> + <small>Built with Vue 3, TypeScript, and Bootstrap 5</small> + </footer> </div> </template> + +<style scoped> +.hero { + background: linear-gradient(135deg, #052767 0%, #3a0647 50%, #052767 100%); + background-size: 200% 200%; + animation: heroGradient 8s ease infinite; +} + +@keyframes heroGradient { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +@media (prefers-reduced-motion: reduce) { + .hero { + animation: none; + } +} +</style> diff --git a/Terranes/src/Web.Vue/src/views/JourneyView.vue b/Terranes/src/Web.Vue/src/views/JourneyView.vue index f534497..cb0ee11 100644 --- a/Terranes/src/Web.Vue/src/views/JourneyView.vue +++ b/Terranes/src/Web.Vue/src/views/JourneyView.vue @@ -1,10 +1,15 @@ <script setup lang="ts"> import { ref, onMounted } from 'vue'; import { api } from '../api/client'; -import type { BuyerJourney, HomeModel, LandBlock } from '../types'; +import type { BuyerJourney, HomeModel, LandBlock, AggregatedQuote } from '../types'; import StatusBadge from '../components/StatusBadge.vue'; import ErrorAlert from '../components/ErrorAlert.vue'; import ActionButton from '../components/ActionButton.vue'; +import StepIndicator from '../components/StepIndicator.vue'; +import ConfirmDialog from '../components/ConfirmDialog.vue'; +import ConfettiEffect from '../components/ConfettiEffect.vue'; +import JourneyTimeline from '../components/JourneyTimeline.vue'; +import QuoteSummary from '../components/QuoteSummary.vue'; import { useToast } from '../composables/useToast'; const { showSuccess, showError, showInfo } = useToast(); @@ -17,24 +22,30 @@ const availableModels = ref<HomeModel[] | null>(null); const availableLand = ref<LandBlock[] | null>(null); const errorMessage = ref<string | null>(null); const actionLoading = ref(false); +const showConfirmDialog = ref(false); +const showConfetti = ref(false); +const journeyQuotes = ref<AggregatedQuote | null>(null); +const quoteLoading = ref(false); const journeyStages = [ 'Browsing', 'DesignSelected', 'PlacedOnLand', 'Customising', 'QuoteRequested', 'QuoteReceived', 'Completed', ]; -function getProgressPercent(): number { - if (!currentJourney.value) return 0; - const idx = journeyStages.indexOf(currentJourney.value.currentStage); - return idx < 0 ? 0 : Math.round(((idx + 1) / journeyStages.length) * 100); -} - async function loadStageData() { errorMessage.value = null; if (currentJourney.value?.currentStage === 'Browsing') { availableModels.value = await api.getHomeModels(); } else if (currentJourney.value?.currentStage === 'DesignSelected') { availableLand.value = await api.getLandBlocks(); + } else if (currentJourney.value?.currentStage === 'QuoteReceived') { + quoteLoading.value = true; + try { + const quotes = await api.getJourneyQuotes(currentJourney.value.id); + journeyQuotes.value = quotes.length > 0 ? quotes[0] : null; + } finally { + quoteLoading.value = false; + } } } @@ -124,10 +135,16 @@ async function checkQuoteReady() { } } +function promptCompleteJourney() { + showConfirmDialog.value = true; +} + async function completeJourney() { + showConfirmDialog.value = false; actionLoading.value = true; try { currentJourney.value = await api.advanceJourney(currentJourney.value!.id, 'Completed'); + showConfetti.value = true; showSuccess('🎉 Journey complete! Thank you for using Terranes.'); } catch (err: unknown) { errorMessage.value = err instanceof Error ? err.message : 'Unknown error'; @@ -138,6 +155,7 @@ async function completeJourney() { } async function startNewJourney() { + showConfetti.value = false; currentJourney.value = null; await startJourney(); } @@ -188,124 +206,129 @@ onMounted(async () => { </template> <template v-else> - <div class="card shadow-sm mb-4"> - <div class="card-body"> - <div class="d-flex justify-content-between align-items-center mb-3"> - <h5 class="mb-0">Journey Progress</h5> - <StatusBadge :status="currentJourney.currentStage" /> - </div> + <div class="row"> + <div class="col-md-8"> + <div class="card shadow-sm mb-4"> + <div class="card-body"> + <div class="d-flex justify-content-between align-items-center mb-3"> + <h5 class="mb-0">Journey Progress</h5> + <StatusBadge :status="currentJourney.currentStage" /> + </div> - <div class="progress mb-3" style="height: 25px;"> - <div class="progress-bar bg-success" :style="{ width: getProgressPercent() + '%' }"> - {{ currentJourney.currentStage }} + <StepIndicator :stages="journeyStages" :current-stage="currentJourney.currentStage" /> </div> </div> - <div class="row text-center"> - <div - v-for="stage in journeyStages" - :key="stage" - class="col" - :class="{ - 'text-success': journeyStages.indexOf(currentJourney.currentStage) >= journeyStages.indexOf(stage), - 'text-muted': journeyStages.indexOf(currentJourney.currentStage) < journeyStages.indexOf(stage), - 'fw-bold': currentJourney.currentStage === stage, - }" - > - {{ journeyStages.indexOf(currentJourney.currentStage) >= journeyStages.indexOf(stage) ? '✅' : '⬜' }} - {{ stage }} + <!-- Browsing --> + <div v-if="currentJourney.currentStage === 'Browsing'" class="card shadow-sm mb-4"> + <div class="card-body"> + <h5>Step 1: Select a Home Design</h5> + <p>Choose a home design from our gallery.</p> + <div v-if="availableModels" class="row g-3"> + <div class="col-12 col-md-4" v-for="model in availableModels.slice(0, 6)" :key="model.id"> + <div class="card h-100"> + <div class="card-body"> + <h6>{{ model.name }}</h6> + <small>{{ model.bedrooms }} bed, {{ model.bathrooms }} bath, {{ model.floorAreaSqm.toFixed(0) }} m²</small> + </div> + <div class="card-footer"> + <button class="btn btn-sm btn-primary w-100" :disabled="actionLoading" @click="selectDesign(model.id)"> + <span v-if="actionLoading" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span> + Select Design + </button> + </div> + </div> + </div> + </div> </div> </div> - </div> - </div> - <!-- Browsing --> - <div v-if="currentJourney.currentStage === 'Browsing'" class="card shadow-sm mb-4"> - <div class="card-body"> - <h5>Step 1: Select a Home Design</h5> - <p>Choose a home design from our gallery.</p> - <div v-if="availableModels" class="row g-3"> - <div class="col-md-4" v-for="model in availableModels.slice(0, 6)" :key="model.id"> - <div class="card h-100"> - <div class="card-body"> - <h6>{{ model.name }}</h6> - <small>{{ model.bedrooms }} bed, {{ model.bathrooms }} bath, {{ model.floorAreaSqm.toFixed(0) }} m²</small> - </div> - <div class="card-footer"> - <button class="btn btn-sm btn-primary w-100" :disabled="actionLoading" @click="selectDesign(model.id)"> - <span v-if="actionLoading" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span> - Select Design - </button> - </div> + <!-- DesignSelected --> + <div v-if="currentJourney.currentStage === 'DesignSelected'" class="card shadow-sm mb-4"> + <div class="card-body"> + <h5>Step 2: Choose a Land Block</h5> + <p>Select a land block to test-fit your chosen design.</p> + <div v-if="availableLand" class="list-group"> + <button + v-for="block in availableLand.slice(0, 5)" + :key="block.id" + class="list-group-item list-group-item-action" + @click="selectLand(block.id)" + > + <strong>{{ block.address }}</strong>, {{ block.suburb }} {{ block.state }} — + {{ block.areaSqm.toFixed(0) }} m², {{ block.zoning }} + </button> </div> </div> </div> - </div> - </div> - <!-- DesignSelected --> - <div v-if="currentJourney.currentStage === 'DesignSelected'" class="card shadow-sm mb-4"> - <div class="card-body"> - <h5>Step 2: Choose a Land Block</h5> - <p>Select a land block to test-fit your chosen design.</p> - <div v-if="availableLand" class="list-group"> - <button - v-for="block in availableLand.slice(0, 5)" - :key="block.id" - class="list-group-item list-group-item-action" - @click="selectLand(block.id)" - > - <strong>{{ block.address }}</strong>, {{ block.suburb }} {{ block.state }} — - {{ block.areaSqm.toFixed(0) }} m², {{ block.zoning }} - </button> + <!-- PlacedOnLand --> + <div v-if="currentJourney.currentStage === 'PlacedOnLand'" class="card shadow-sm mb-4"> + <div class="card-body"> + <h5>Step 3: Customise Your Design</h5> + <p>Your design has been placed on the land. Move to customisation.</p> + <ActionButton :loading="actionLoading" loading-text="Starting customisation..." @click="moveToCustomising">Start Customising →</ActionButton> + </div> </div> - </div> - </div> - <!-- PlacedOnLand --> - <div v-if="currentJourney.currentStage === 'PlacedOnLand'" class="card shadow-sm mb-4"> - <div class="card-body"> - <h5>Step 3: Customise Your Design</h5> - <p>Your design has been placed on the land. Move to customisation.</p> - <ActionButton :loading="actionLoading" loading-text="Starting customisation..." @click="moveToCustomising">Start Customising →</ActionButton> - </div> - </div> + <!-- Customising --> + <div v-if="currentJourney.currentStage === 'Customising'" class="card shadow-sm mb-4"> + <div class="card-body"> + <h5>Step 4: Request a Quote</h5> + <p>Your design is customised. Request an indicative quote from our partner network.</p> + <ActionButton :loading="actionLoading" loading-text="Requesting quote..." @click="requestQuote">💰 Request Indicative Quote</ActionButton> + </div> + </div> - <!-- Customising --> - <div v-if="currentJourney.currentStage === 'Customising'" class="card shadow-sm mb-4"> - <div class="card-body"> - <h5>Step 4: Request a Quote</h5> - <p>Your design is customised. Request an indicative quote from our partner network.</p> - <ActionButton :loading="actionLoading" loading-text="Requesting quote..." @click="requestQuote">💰 Request Indicative Quote</ActionButton> - </div> - </div> + <!-- QuoteRequested --> + <div v-if="currentJourney.currentStage === 'QuoteRequested'" class="card shadow-sm mb-4"> + <div class="card-body text-center"> + <h5>Quote Requested</h5> + <p class="text-muted">Your quote is being prepared by our partner network...</p> + <ActionButton :loading="actionLoading" variant="outline-primary" loading-text="Checking..." @click="checkQuoteReady">Refresh Status</ActionButton> + </div> + </div> - <!-- QuoteRequested --> - <div v-if="currentJourney.currentStage === 'QuoteRequested'" class="card shadow-sm mb-4"> - <div class="card-body text-center"> - <h5>Quote Requested</h5> - <p class="text-muted">Your quote is being prepared by our partner network...</p> - <ActionButton :loading="actionLoading" variant="outline-primary" loading-text="Checking..." @click="checkQuoteReady">Refresh Status</ActionButton> + <!-- QuoteReceived --> + <div v-if="currentJourney.currentStage === 'QuoteReceived'" class="card shadow-sm mb-4"> + <div class="card-body"> + <h5>✅ Quote Received!</h5> + <p>Your indicative quote is ready. You can proceed to partner referral.</p> + <QuoteSummary :quote="journeyQuotes" :loading="quoteLoading" /> + <ActionButton :loading="actionLoading" variant="success" loading-text="Completing..." @click="promptCompleteJourney">🎉 Complete Journey</ActionButton> + </div> + </div> + + <!-- Completed / Referred --> + <div v-if="currentJourney.currentStage === 'Completed' || currentJourney.currentStage === 'Referred'" class="alert alert-success text-center"> + <h4>🎉 Journey Complete!</h4> + <p>Your journey is complete. Thank you for using Terranes!</p> + <ActionButton :loading="actionLoading" variant="outline-primary" loading-text="Starting..." @click="startNewJourney">Start New Journey</ActionButton> + </div> + + <ErrorAlert :message="errorMessage" /> </div> - </div> - <!-- QuoteReceived --> - <div v-if="currentJourney.currentStage === 'QuoteReceived'" class="card shadow-sm mb-4"> - <div class="card-body"> - <h5>✅ Quote Received!</h5> - <p>Your indicative quote is ready. You can proceed to partner referral.</p> - <ActionButton :loading="actionLoading" variant="success" loading-text="Completing..." @click="completeJourney">🎉 Complete Journey</ActionButton> + <div class="col-md-4"> + <JourneyTimeline + :stages="journeyStages" + :current-stage="currentJourney.currentStage" + :started-utc="currentJourney.startedUtc" + /> </div> </div> + </template> - <!-- Completed / Referred --> - <div v-if="currentJourney.currentStage === 'Completed' || currentJourney.currentStage === 'Referred'" class="alert alert-success text-center"> - <h4>🎉 Journey Complete!</h4> - <p>Your journey is complete. Thank you for using Terranes!</p> - <ActionButton :loading="actionLoading" variant="outline-primary" loading-text="Starting..." @click="startNewJourney">Start New Journey</ActionButton> - </div> + <ConfirmDialog + :show="showConfirmDialog" + title="Complete Journey" + message="Are you sure you want to complete this journey? This action cannot be undone." + confirm-text="Complete" + confirm-variant="success" + @confirm="completeJourney" + @cancel="showConfirmDialog = false" + /> - <ErrorAlert :message="errorMessage" /> - </template> + <ConfettiEffect v-if="showConfetti" /> </div> </template> diff --git a/Terranes/src/Web.Vue/src/views/LandBlocksView.vue b/Terranes/src/Web.Vue/src/views/LandBlocksView.vue index a4cf4bf..0e488f3 100644 --- a/Terranes/src/Web.Vue/src/views/LandBlocksView.vue +++ b/Terranes/src/Web.Vue/src/views/LandBlocksView.vue @@ -1,28 +1,82 @@ <script setup lang="ts"> -import { ref, onMounted, watch } from 'vue'; +import { ref, computed, onMounted, watch } from 'vue'; +import { useRoute, useRouter } from 'vue-router'; import { api } from '../api/client'; import type { LandBlock, HomeModel, SitePlacement } from '../types'; import LoadingSpinner from '../components/LoadingSpinner.vue'; import DetailModal from '../components/DetailModal.vue'; import ErrorAlert from '../components/ErrorAlert.vue'; import SkeletonTable from '../components/SkeletonTable.vue'; +import FilterChip from '../components/FilterChip.vue'; +import EmptyState from '../components/EmptyState.vue'; import { useToast } from '../composables/useToast'; +import { useDebounce } from '../composables/useDebounce'; +import { useValidation, required } from '../composables/useValidation'; +import { usePagedList } from '../composables/usePagedList'; const { showSuccess, showError } = useToast(); +const route = useRoute(); +const router = useRouter(); const blocks = ref<LandBlock[] | null>(null); -const searchSuburb = ref(''); -const searchState = ref(''); +const searchSuburb = ref((route.query.suburb as string) || ''); +const searchState = ref((route.query.state as string) || ''); const selectedBlock = ref<LandBlock | null>(null); const availableModels = ref<HomeModel[] | null>(null); const placementResult = ref<SitePlacement | null>(null); const placementError = ref<string | null>(null); +const sortBy = ref('area'); +const searchInput = ref<HTMLInputElement | null>(null); + +const debouncedSuburb = useDebounce(searchSuburb); +const debouncedState = useDebounce(searchState); + +const { validate: validateSuburb, clearErrors: clearSuburbErrors } = useValidation(); +const { validate: validateState, clearErrors: clearStateErrors } = useValidation(); + +watch(searchSuburb, (v) => { + if (v.length > 0) { + validateSuburb(v, [required('Suburb cannot be empty')]); + } else { + clearSuburbErrors(); + } +}); + +watch(searchState, (v) => { + if (v.length > 0) { + validateState(v, [required('State cannot be empty')]); + } else { + clearStateErrors(); + } +}); + +const sortedBlocks = computed(() => { + if (!blocks.value) return []; + const sorted = [...blocks.value]; + if (sortBy.value === 'area') sorted.sort((a, b) => a.areaSqm - b.areaSqm); + else if (sortBy.value === 'suburb') sorted.sort((a, b) => a.suburb.localeCompare(b.suburb)); + return sorted; +}); + +const { visibleItems: pagedBlocks, hasMore, showMore, resetVisible } = usePagedList(sortedBlocks, 20); + +const hasActiveFilters = computed(() => !!debouncedSuburb.value || !!debouncedState.value); + +const resultCount = computed(() => blocks.value?.length ?? 0); async function search() { blocks.value = await api.getLandBlocks({ - suburb: searchSuburb.value || undefined, - state: searchState.value || undefined, + suburb: debouncedSuburb.value || undefined, + state: debouncedState.value || undefined, }); + resetVisible(); +} + +function syncQuery() { + const query: Record<string, string> = {}; + if (debouncedSuburb.value) query.suburb = debouncedSuburb.value; + if (debouncedState.value) query.state = debouncedState.value; + router.replace({ query }); } async function selectBlock(block: LandBlock) { @@ -51,8 +105,18 @@ function closeModal() { placementError.value = null; } -onMounted(search); -watch([searchSuburb, searchState], search); +function removeSuburbFilter() { searchSuburb.value = ''; } +function removeStateFilter() { searchState.value = ''; } +function clearAllFilters() { + searchSuburb.value = ''; + searchState.value = ''; +} + +onMounted(() => { + search(); + searchInput.value?.focus(); +}); +watch([debouncedSuburb, debouncedState], () => { search(); syncQuery(); }); </script> <template> @@ -61,18 +125,29 @@ watch([searchSuburb, searchState], search); <p class="text-muted">Search available land blocks and test-fit home designs.</p> <div class="row mb-3"> - <div class="col-md-4"> - <input type="text" class="form-control" placeholder="Search by suburb..." v-model="searchSuburb" /> + <div class="col-12 col-md-4"> + <input type="text" class="form-control" placeholder="Search by suburb..." v-model="searchSuburb" ref="searchInput" /> </div> - <div class="col-md-3"> + <div class="col-12 col-md-3"> <input type="text" class="form-control" placeholder="State (e.g. NSW)" v-model="searchState" /> </div> + <div class="col-12 col-md-3"> + <select class="form-select" v-model="sortBy"> + <option value="area">Sort by Area</option> + <option value="suburb">Sort by Suburb</option> + </select> + </div> </div> - <SkeletonTable v-if="blocks === null" :rows="5" :cols="8" /> - <div v-else-if="blocks.length === 0" class="alert alert-info"> - No land blocks found. Try a different search. + <div class="mb-3 d-flex flex-wrap align-items-center"> + <FilterChip v-if="debouncedSuburb" :label="`Suburb: ${debouncedSuburb}`" @remove="removeSuburbFilter" /> + <FilterChip v-if="debouncedState" :label="`State: ${debouncedState}`" @remove="removeStateFilter" /> + <button v-if="hasActiveFilters" class="btn btn-sm btn-outline-danger ms-2 clear-all-filters" @click="clearAllFilters">Clear All Filters</button> + <span v-if="blocks !== null" class="badge bg-secondary ms-auto result-count">Showing {{ resultCount }} results</span> </div> + + <SkeletonTable v-if="blocks === null" :rows="5" :cols="8" /> + <EmptyState v-else-if="blocks.length === 0" message="No land blocks found. Try a different search." /> <div v-else class="table-responsive"> <table class="table table-hover"> <thead> @@ -88,7 +163,7 @@ watch([searchSuburb, searchState], search); </tr> </thead> <tbody> - <tr v-for="block in blocks" :key="block.id"> + <tr v-for="block in pagedBlocks" :key="block.id"> <td>{{ block.address }}</td> <td>{{ block.suburb }}</td> <td>{{ block.state }}</td> @@ -97,11 +172,14 @@ watch([searchSuburb, searchState], search); <td>{{ block.depthMetre.toFixed(1) }}m</td> <td><span class="badge bg-secondary">{{ block.zoning }}</span></td> <td> - <button class="btn btn-sm btn-outline-primary" @click="selectBlock(block)">Test-Fit</button> + <button class="btn btn-sm btn-outline-primary" aria-label="Test-fit a design on this land block" @click="selectBlock(block)">Test-Fit</button> </td> </tr> </tbody> </table> + <div v-if="hasMore" class="text-center mt-3"> + <button class="btn btn-outline-primary show-more-btn" @click="showMore">Show More</button> + </div> </div> <DetailModal :show="!!selectedBlock" :title="selectedBlock ? 'Test-Fit on ' + selectedBlock.address : ''" @close="closeModal"> diff --git a/Terranes/src/Web.Vue/src/views/LoginView.vue b/Terranes/src/Web.Vue/src/views/LoginView.vue new file mode 100644 index 0000000..f19a496 --- /dev/null +++ b/Terranes/src/Web.Vue/src/views/LoginView.vue @@ -0,0 +1,82 @@ +<script setup lang="ts"> +import { ref } from 'vue'; +import { useRouter } from 'vue-router'; +import { useAuth } from '../composables/useAuth'; +import { useValidation, required } from '../composables/useValidation'; +import ErrorAlert from '../components/ErrorAlert.vue'; +import ActionButton from '../components/ActionButton.vue'; + +const router = useRouter(); +const { login } = useAuth(); + +const email = ref(''); +const password = ref(''); +const loading = ref(false); +const errorMessage = ref<string | null>(null); + +const emailValidation = useValidation(); +const passwordValidation = useValidation(); + +async function handleLogin() { + const emailOk = emailValidation.validate(email.value, [required('Email is required')]); + const passOk = passwordValidation.validate(password.value, [required('Password is required')]); + if (!emailOk || !passOk) return; + + loading.value = true; + errorMessage.value = null; + try { + await login(email.value, password.value); + router.push('/dashboard'); + } catch (err: unknown) { + errorMessage.value = err instanceof Error ? err.message : 'Login failed'; + } finally { + loading.value = false; + } +} +</script> + +<template> + <div class="container"> + <div class="row justify-content-center"> + <div class="col-md-6 col-lg-4"> + <h2 class="mb-4 text-center">🔐 Login</h2> + + <ErrorAlert :message="errorMessage" /> + + <form @submit.prevent="handleLogin"> + <div class="mb-3"> + <label for="email" class="form-label">Email</label> + <input + id="email" + type="email" + class="form-control" + placeholder="you@example.com" + v-model="email" + /> + <div v-if="emailValidation.errors.value.length" class="text-danger small mt-1"> + {{ emailValidation.errors.value[0] }} + </div> + </div> + <div class="mb-3"> + <label for="password" class="form-label">Password</label> + <input + id="password" + type="password" + class="form-control" + placeholder="Password" + v-model="password" + /> + <div v-if="passwordValidation.errors.value.length" class="text-danger small mt-1"> + {{ passwordValidation.errors.value[0] }} + </div> + </div> + <ActionButton :loading="loading" variant="primary" class="w-100" loading-text="Logging in..." @click="handleLogin">Login</ActionButton> + </form> + + <p class="mt-3 text-center"> + Don't have an account? <RouterLink to="/register">Register</RouterLink> + </p> + </div> + </div> + </div> +</template> diff --git a/Terranes/src/Web.Vue/src/views/MarketplaceView.vue b/Terranes/src/Web.Vue/src/views/MarketplaceView.vue index df150f7..16bf5e8 100644 --- a/Terranes/src/Web.Vue/src/views/MarketplaceView.vue +++ b/Terranes/src/Web.Vue/src/views/MarketplaceView.vue @@ -1,19 +1,64 @@ <script setup lang="ts"> -import { ref, onMounted, watch } from 'vue'; +import { ref, computed, onMounted, watch } from 'vue'; +import { useRoute, useRouter } from 'vue-router'; import { api } from '../api/client'; import type { PropertyListing } from '../types'; import DetailModal from '../components/DetailModal.vue'; import StatusBadge from '../components/StatusBadge.vue'; import SkeletonCard from '../components/SkeletonCard.vue'; +import FilterChip from '../components/FilterChip.vue'; +import EmptyState from '../components/EmptyState.vue'; +import PaginationBar from '../components/PaginationBar.vue'; +import { useDebounce } from '../composables/useDebounce'; +import { useValidation, minValue } from '../composables/useValidation'; + +const route = useRoute(); +const router = useRouter(); const listings = ref<PropertyListing[] | null>(null); -const searchSuburb = ref(''); -const maxPrice = ref<number | undefined>(undefined); -const selectedStatus = ref(''); +const searchSuburb = ref((route.query.suburb as string) || ''); +const maxPrice = ref<number | undefined>( + route.query.maxPrice ? Number(route.query.maxPrice) : undefined, +); +const selectedStatus = ref((route.query.status as string) || ''); const selectedListing = ref<PropertyListing | null>(null); +const sortBy = ref('price'); +const currentPage = ref(1); +const pageSize = 12; +const searchInput = ref<HTMLInputElement | null>(null); + +const debouncedSuburb = useDebounce(searchSuburb); +const debouncedPrice = useDebounce(maxPrice); const statuses = ['Active', 'Draft', 'UnderOffer', 'Sold', 'Withdrawn']; +const resultCount = computed(() => listings.value?.length ?? 0); + +const hasActiveFilters = computed(() => !!debouncedSuburb.value || debouncedPrice.value !== undefined || !!selectedStatus.value); + +const { errors: priceErrors, validate: validatePrice, clearErrors: clearPriceErrors } = useValidation(); + +watch(maxPrice, (v) => { + if (v !== undefined && v !== null && String(v) !== '') { + validatePrice(v, [minValue(0)]); + } else { + clearPriceErrors(); + } +}); + +const sortedListings = computed(() => { + if (!listings.value) return []; + const sorted = [...listings.value]; + if (sortBy.value === 'price') sorted.sort((a, b) => (a.askingPriceAud ?? Infinity) - (b.askingPriceAud ?? Infinity)); + else if (sortBy.value === 'date') sorted.sort((a, b) => new Date(b.listedUtc).getTime() - new Date(a.listedUtc).getTime()); + return sorted; +}); + +const paginatedListings = computed(() => { + const start = (currentPage.value - 1) * pageSize; + return sortedListings.value.slice(start, start + pageSize); +}); + function formatPrice(price?: number): string { if (price == null) return 'Price on Application'; return `$${price.toLocaleString('en-AU', { maximumFractionDigits: 0 })}`; @@ -21,10 +66,19 @@ function formatPrice(price?: number): string { async function search() { listings.value = await api.getListings({ - suburb: searchSuburb.value || undefined, - maxPriceAud: maxPrice.value, + suburb: debouncedSuburb.value || undefined, + maxPriceAud: debouncedPrice.value, status: selectedStatus.value || undefined, }); + currentPage.value = 1; +} + +function syncQuery() { + const query: Record<string, string> = {}; + if (debouncedSuburb.value) query.suburb = debouncedSuburb.value; + if (debouncedPrice.value !== undefined) query.maxPrice = String(debouncedPrice.value); + if (selectedStatus.value) query.status = selectedStatus.value; + router.replace({ query }); } function viewListing(listing: PropertyListing) { @@ -35,8 +89,20 @@ function closeModal() { selectedListing.value = null; } -onMounted(search); -watch([searchSuburb, maxPrice, selectedStatus], search); +function removeSuburbFilter() { searchSuburb.value = ''; } +function removePriceFilter() { maxPrice.value = undefined; } +function removeStatusFilter() { selectedStatus.value = ''; } +function clearAllFilters() { + searchSuburb.value = ''; + maxPrice.value = undefined; + selectedStatus.value = ''; +} + +onMounted(() => { + search(); + searchInput.value?.focus(); +}); +watch([debouncedSuburb, debouncedPrice, selectedStatus], () => { search(); syncQuery(); }); </script> <template> @@ -45,47 +111,70 @@ watch([searchSuburb, maxPrice, selectedStatus], search); <p class="text-muted">Browse property listings from agents, builders, and homeowners.</p> <div class="row mb-3"> - <div class="col-md-3"> - <input type="text" class="form-control" placeholder="Suburb..." v-model="searchSuburb" /> + <div class="col-12 col-md-3"> + <input type="text" class="form-control" placeholder="Suburb..." v-model="searchSuburb" ref="searchInput" /> </div> - <div class="col-md-3"> - <input type="number" class="form-control" placeholder="Max price ($)" v-model.number="maxPrice" /> + <div class="col-12 col-md-3"> + <input type="number" class="form-control" :class="{ 'is-invalid': priceErrors.length > 0 }" placeholder="Max price ($)" v-model.number="maxPrice" /> + <div v-if="priceErrors.length > 0" class="invalid-feedback"> + {{ priceErrors[0] }} + </div> </div> - <div class="col-md-3"> + <div class="col-12 col-md-3"> <select class="form-select" v-model="selectedStatus"> <option value="">All Statuses</option> <option v-for="s in statuses" :key="s" :value="s">{{ s }}</option> </select> </div> + <div class="col-12 col-md-3"> + <select class="form-select" v-model="sortBy"> + <option value="price">Sort by Price</option> + <option value="date">Sort by Date</option> + </select> + </div> </div> - <SkeletonCard v-if="listings === null" :count="2" :columns="2" /> - <div v-else-if="listings.length === 0" class="alert alert-info"> - No listings found matching your criteria. + <div class="mb-3 d-flex flex-wrap align-items-center"> + <FilterChip v-if="debouncedSuburb" :label="`Suburb: ${debouncedSuburb}`" @remove="removeSuburbFilter" /> + <FilterChip v-if="debouncedPrice !== undefined" :label="`Max: ${formatPrice(debouncedPrice)}`" @remove="removePriceFilter" /> + <FilterChip v-if="selectedStatus" :label="`Status: ${selectedStatus}`" @remove="removeStatusFilter" /> + <button v-if="hasActiveFilters" class="btn btn-sm btn-outline-danger ms-2 clear-all-filters" @click="clearAllFilters">Clear All Filters</button> + <span v-if="listings !== null" class="badge bg-secondary ms-auto result-count">Showing {{ resultCount }} results</span> </div> - <div v-else class="row g-4"> - <div class="col-md-6" v-for="listing in listings" :key="listing.id"> - <div class="card h-100 shadow-sm"> - <div class="card-body"> - <div class="d-flex justify-content-between align-items-start"> - <h5 class="card-title">{{ listing.title }}</h5> - <StatusBadge :status="listing.status" /> + + <SkeletonCard v-if="listings === null" :count="2" :columns="2" /> + <EmptyState v-else-if="listings.length === 0" message="No listings found matching your criteria." /> + <template v-else> + <div class="row g-4"> + <div class="col-12 col-md-6" v-for="listing in paginatedListings" :key="listing.id"> + <div class="card h-100 shadow-sm"> + <div class="card-body"> + <div class="d-flex justify-content-between align-items-start"> + <h5 class="card-title">{{ listing.title }}</h5> + <StatusBadge :status="listing.status" /> + </div> + <p class="card-text text-muted">{{ listing.description }}</p> + <div class="d-flex justify-content-between"> + <span v-if="listing.askingPriceAud != null" class="h5 text-success"> + {{ formatPrice(listing.askingPriceAud) }} + </span> + <span v-else class="text-muted">Price on Application</span> + <small class="text-muted">Listed {{ new Date(listing.listedUtc).toLocaleDateString() }}</small> + </div> </div> - <p class="card-text text-muted">{{ listing.description }}</p> - <div class="d-flex justify-content-between"> - <span v-if="listing.askingPriceAud != null" class="h5 text-success"> - {{ formatPrice(listing.askingPriceAud) }} - </span> - <span v-else class="text-muted">Price on Application</span> - <small class="text-muted">Listed {{ new Date(listing.listedUtc).toLocaleDateString() }}</small> + <div class="card-footer"> + <button class="btn btn-sm btn-outline-primary" aria-label="View details for this listing" @click="viewListing(listing)">View Details</button> </div> </div> - <div class="card-footer"> - <button class="btn btn-sm btn-outline-primary" @click="viewListing(listing)">View Details</button> - </div> </div> </div> - </div> + <PaginationBar + :total-items="sortedListings.length" + :page-size="pageSize" + :current-page="currentPage" + @page-change="currentPage = $event" + /> + </template> <DetailModal :show="!!selectedListing" :title="selectedListing?.title ?? ''" @close="closeModal"> <template v-if="selectedListing"> diff --git a/Terranes/src/Web.Vue/src/views/NotFoundView.vue b/Terranes/src/Web.Vue/src/views/NotFoundView.vue new file mode 100644 index 0000000..b8d8c93 --- /dev/null +++ b/Terranes/src/Web.Vue/src/views/NotFoundView.vue @@ -0,0 +1,11 @@ +<script setup lang="ts"> +</script> + +<template> + <div class="container text-center py-5"> + <h1 class="display-1 text-muted">404</h1> + <h2>Page Not Found</h2> + <p class="text-muted">The page you're looking for doesn't exist.</p> + <RouterLink to="/" class="btn btn-primary">Go Home</RouterLink> + </div> +</template> diff --git a/Terranes/src/Web.Vue/src/views/PartnersView.vue b/Terranes/src/Web.Vue/src/views/PartnersView.vue new file mode 100644 index 0000000..20b4ea9 --- /dev/null +++ b/Terranes/src/Web.Vue/src/views/PartnersView.vue @@ -0,0 +1,141 @@ +<script setup lang="ts"> +import { ref, computed, onMounted, watch } from 'vue'; +import { useRoute, useRouter } from 'vue-router'; +import { api } from '../api/client'; +import type { Partner } from '../types'; +import DetailModal from '../components/DetailModal.vue'; +import StatusBadge from '../components/StatusBadge.vue'; +import SkeletonCard from '../components/SkeletonCard.vue'; +import FilterChip from '../components/FilterChip.vue'; +import EmptyState from '../components/EmptyState.vue'; +import { useDebounce } from '../composables/useDebounce'; + +const route = useRoute(); +const router = useRouter(); + +const partners = ref<Partner[] | null>(null); +const searchName = ref((route.query.name as string) || ''); +const selectedCategory = ref((route.query.category as string) || ''); +const selectedPartner = ref<Partner | null>(null); + +const debouncedName = useDebounce(searchName); + +const categories = ['Builder', 'Landscaper', 'Furniture', 'SmartHome', 'Solicitor', 'RealEstateAgent']; + +const staticPartners: Partner[] = [ + { id: 'sp1', name: 'GreenScape Gardens', category: 'Landscaper', description: 'Professional landscaping services for new builds.', contactEmail: 'info@greenscape.demo', isActive: true }, + { id: 'sp2', name: 'Modern Furnish Co', category: 'Furniture', description: 'Contemporary furniture packages for display homes.', contactEmail: 'sales@modernfurnish.demo', isActive: true }, + { id: 'sp3', name: 'SmartLiving Tech', category: 'SmartHome', description: 'Home automation and smart device installation.', contactEmail: 'hello@smartliving.demo', isActive: true }, + { id: 'sp4', name: 'Carter & Associates', category: 'Solicitor', description: 'Property conveyancing and legal services.', contactEmail: 'contact@carter.demo', isActive: false }, + { id: 'sp5', name: 'Prime Realty', category: 'RealEstateAgent', description: 'Specialist new home sales agents.', contactEmail: 'team@primerealty.demo', isActive: true }, +]; + +const allPartners = computed<Partner[]>(() => { + const builders = partners.value ?? []; + return [...builders, ...staticPartners]; +}); + +const filteredPartners = computed(() => { + let result = allPartners.value; + if (selectedCategory.value) { + result = result.filter((p) => p.category === selectedCategory.value); + } + if (debouncedName.value) { + const q = debouncedName.value.toLowerCase(); + result = result.filter((p) => p.name.toLowerCase().includes(q)); + } + return result; +}); + +const resultCount = computed(() => filteredPartners.value.length); + +async function loadBuilders() { + try { + partners.value = await api.getBuilders(); + } catch { + partners.value = []; + } +} + +function syncQuery() { + const query: Record<string, string> = {}; + if (debouncedName.value) query.name = debouncedName.value; + if (selectedCategory.value) query.category = selectedCategory.value; + router.replace({ query }); +} + +function viewPartner(partner: Partner) { + selectedPartner.value = partner; +} + +function closeModal() { + selectedPartner.value = null; +} + +function removeNameFilter() { searchName.value = ''; } +function removeCategoryFilter() { selectedCategory.value = ''; } + +onMounted(loadBuilders); +watch([debouncedName, selectedCategory], () => { syncQuery(); }); +</script> + +<template> + <div class="container"> + <h2 class="mb-4">🤝 Partner Directory</h2> + <p class="text-muted">Browse our network of trusted building, design, and property partners.</p> + + <div class="row mb-3"> + <div class="col-md-4"> + <input type="text" class="form-control" placeholder="Search by name..." v-model="searchName" /> + </div> + <div class="col-md-3"> + <select class="form-select" v-model="selectedCategory"> + <option value="">All Categories</option> + <option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</option> + </select> + </div> + </div> + + <div class="mb-3 d-flex flex-wrap align-items-center"> + <FilterChip v-if="debouncedName" :label="`Name: ${debouncedName}`" @remove="removeNameFilter" /> + <FilterChip v-if="selectedCategory" :label="`Category: ${selectedCategory}`" @remove="removeCategoryFilter" /> + <span v-if="partners !== null" class="badge bg-secondary ms-auto result-count">Showing {{ resultCount }} results</span> + </div> + + <SkeletonCard v-if="partners === null" :count="3" :columns="3" /> + <EmptyState v-else-if="filteredPartners.length === 0" message="No partners found matching your criteria." /> + <div v-else class="row g-4"> + <div class="col-12 col-md-4" v-for="partner in filteredPartners" :key="partner.id"> + <div class="card h-100 shadow-sm"> + <div class="card-body"> + <div class="d-flex justify-content-between align-items-start"> + <h5 class="card-title">{{ partner.name }}</h5> + <StatusBadge :status="partner.isActive ? 'Active' : 'Inactive'" /> + </div> + <span class="badge bg-info mb-2">{{ partner.category }}</span> + <p class="card-text text-muted">{{ partner.description }}</p> + <small class="text-muted">📧 {{ partner.contactEmail }}</small> + </div> + <div class="card-footer"> + <button class="btn btn-sm btn-outline-primary" aria-label="View details for this partner" @click="viewPartner(partner)">View Details</button> + </div> + </div> + </div> + </div> + + <DetailModal :show="!!selectedPartner" :title="selectedPartner?.name ?? ''" @close="closeModal"> + <template v-if="selectedPartner"> + <p>{{ selectedPartner.description }}</p> + <table class="table table-sm"> + <tbody> + <tr><th>Category</th><td>{{ selectedPartner.category }}</td></tr> + <tr><th>Email</th><td>{{ selectedPartner.contactEmail }}</td></tr> + <tr v-if="selectedPartner.phone"><th>Phone</th><td>{{ selectedPartner.phone }}</td></tr> + <tr v-if="selectedPartner.website"><th>Website</th><td><a :href="selectedPartner.website" target="_blank">{{ selectedPartner.website }}</a></td></tr> + <tr><th>Status</th><td><StatusBadge :status="selectedPartner.isActive ? 'Active' : 'Inactive'" /></td></tr> + </tbody> + </table> + </template> + </DetailModal> + </div> +</template> diff --git a/Terranes/src/Web.Vue/src/views/RegisterView.vue b/Terranes/src/Web.Vue/src/views/RegisterView.vue new file mode 100644 index 0000000..e73838b --- /dev/null +++ b/Terranes/src/Web.Vue/src/views/RegisterView.vue @@ -0,0 +1,120 @@ +<script setup lang="ts"> +import { ref } from 'vue'; +import { useRouter } from 'vue-router'; +import { useAuth } from '../composables/useAuth'; +import { useValidation, required } from '../composables/useValidation'; +import ErrorAlert from '../components/ErrorAlert.vue'; +import ActionButton from '../components/ActionButton.vue'; + +const router = useRouter(); +const { register } = useAuth(); + +const email = ref(''); +const displayName = ref(''); +const password = ref(''); +const confirmPassword = ref(''); +const loading = ref(false); +const errorMessage = ref<string | null>(null); + +const emailValidation = useValidation(); +const nameValidation = useValidation(); +const passwordValidation = useValidation(); +const confirmValidation = useValidation(); + +async function handleRegister() { + const emailOk = emailValidation.validate(email.value, [required('Email is required')]); + const nameOk = nameValidation.validate(displayName.value, [required('Display name is required')]); + const passOk = passwordValidation.validate(password.value, [required('Password is required')]); + + const matchRule = { + validate: () => password.value === confirmPassword.value, + message: 'Passwords must match', + }; + const confirmOk = confirmValidation.validate(confirmPassword.value, [required('Please confirm your password'), matchRule]); + + if (!emailOk || !nameOk || !passOk || !confirmOk) return; + + loading.value = true; + errorMessage.value = null; + try { + await register(email.value, displayName.value, password.value); + router.push('/dashboard'); + } catch (err: unknown) { + errorMessage.value = err instanceof Error ? err.message : 'Registration failed'; + } finally { + loading.value = false; + } +} +</script> + +<template> + <div class="container"> + <div class="row justify-content-center"> + <div class="col-md-6 col-lg-4"> + <h2 class="mb-4 text-center">📝 Register</h2> + + <ErrorAlert :message="errorMessage" /> + + <form @submit.prevent="handleRegister"> + <div class="mb-3"> + <label for="email" class="form-label">Email</label> + <input + id="email" + type="email" + class="form-control" + placeholder="you@example.com" + v-model="email" + /> + <div v-if="emailValidation.errors.value.length" class="text-danger small mt-1"> + {{ emailValidation.errors.value[0] }} + </div> + </div> + <div class="mb-3"> + <label for="displayName" class="form-label">Display Name</label> + <input + id="displayName" + type="text" + class="form-control" + placeholder="Your name" + v-model="displayName" + /> + <div v-if="nameValidation.errors.value.length" class="text-danger small mt-1"> + {{ nameValidation.errors.value[0] }} + </div> + </div> + <div class="mb-3"> + <label for="password" class="form-label">Password</label> + <input + id="password" + type="password" + class="form-control" + placeholder="Password" + v-model="password" + /> + <div v-if="passwordValidation.errors.value.length" class="text-danger small mt-1"> + {{ passwordValidation.errors.value[0] }} + </div> + </div> + <div class="mb-3"> + <label for="confirmPassword" class="form-label">Confirm Password</label> + <input + id="confirmPassword" + type="password" + class="form-control" + placeholder="Confirm password" + v-model="confirmPassword" + /> + <div v-if="confirmValidation.errors.value.length" class="text-danger small mt-1"> + {{ confirmValidation.errors.value[0] }} + </div> + </div> + <ActionButton :loading="loading" variant="primary" class="w-100" loading-text="Registering..." @click="handleRegister">Register</ActionButton> + </form> + + <p class="mt-3 text-center"> + Already have an account? <RouterLink to="/login">Login</RouterLink> + </p> + </div> + </div> + </div> +</template> diff --git a/Terranes/src/Web.Vue/src/views/ReportsView.vue b/Terranes/src/Web.Vue/src/views/ReportsView.vue new file mode 100644 index 0000000..0cfcb59 --- /dev/null +++ b/Terranes/src/Web.Vue/src/views/ReportsView.vue @@ -0,0 +1,227 @@ +<script setup lang="ts"> +import { ref, onMounted } from 'vue'; +import { api } from '../api/client'; +import type { Report, ComplianceResult } from '../types'; +import DetailModal from '../components/DetailModal.vue'; +import StatusBadge from '../components/StatusBadge.vue'; +import SkeletonCard from '../components/SkeletonCard.vue'; +import EmptyState from '../components/EmptyState.vue'; +import ActionButton from '../components/ActionButton.vue'; +import { useToast } from '../composables/useToast'; + +const { showSuccess, showError } = useToast(); + +const DEMO_USER_ID = '00000000-0000-0000-0000-000000000001'; +const DEMO_TENANT_ID = '00000000-0000-0000-0000-000000000001'; + +const activeTab = ref<'reports' | 'compliance'>('reports'); + +// Reports state +const reportTypes = ref<string[]>([]); +const reports = ref<Report[] | null>(null); +const selectedReport = ref<Report | null>(null); +const reportTitle = ref(''); +const reportType = ref(''); +const generatingReport = ref(false); + +// Compliance state +const complianceResults = ref<ComplianceResult[] | null>(null); +const checkPlacementId = ref(''); +const checkJurisdiction = ref(''); +const runningCheck = ref(false); + +async function loadReportTypes() { + try { + reportTypes.value = await api.getReportTypes(); + if (reportTypes.value.length > 0) reportType.value = reportTypes.value[0]; + } catch { + reportTypes.value = ['Summary', 'Financial', 'Compliance', 'Design']; + reportType.value = reportTypes.value[0]; + } +} + +async function loadReports() { + try { + reports.value = await api.getTenantReports(DEMO_TENANT_ID); + } catch { + reports.value = []; + } +} + +async function generateReport() { + if (!reportTitle.value.trim() || !reportType.value) return; + generatingReport.value = true; + try { + const report = await api.generateReport(reportType.value, reportTitle.value.trim(), DEMO_USER_ID, DEMO_TENANT_ID); + showSuccess('Report generated successfully!'); + if (!reports.value) reports.value = []; + reports.value.unshift(report); + reportTitle.value = ''; + } catch { + showError('Failed to generate report.'); + } finally { + generatingReport.value = false; + } +} + +function viewReport(report: Report) { + selectedReport.value = report; +} + +function closeModal() { + selectedReport.value = null; +} + +async function loadComplianceResults() { + try { + complianceResults.value = await api.getComplianceByPlacement(DEMO_USER_ID); + } catch { + complianceResults.value = []; + } +} + +async function runComplianceCheck() { + if (!checkPlacementId.value.trim() || !checkJurisdiction.value.trim()) return; + runningCheck.value = true; + try { + const result = await api.runComplianceCheck(checkPlacementId.value.trim(), checkJurisdiction.value.trim()); + showSuccess('Compliance check completed!'); + if (!complianceResults.value) complianceResults.value = []; + complianceResults.value.unshift(result); + checkPlacementId.value = ''; + checkJurisdiction.value = ''; + } catch { + showError('Failed to run compliance check.'); + } finally { + runningCheck.value = false; + } +} + +onMounted(() => { + loadReportTypes(); + loadReports(); + loadComplianceResults(); +}); +</script> + +<template> + <div class="container"> + <h2 class="mb-4">📋 Reports & Compliance</h2> + <p class="text-muted">Generate reports and run compliance checks for your projects.</p> + + <ul class="nav nav-tabs mb-4"> + <li class="nav-item"> + <button class="nav-link" :class="{ active: activeTab === 'reports' }" @click="activeTab = 'reports'">Reports</button> + </li> + <li class="nav-item"> + <button class="nav-link" :class="{ active: activeTab === 'compliance' }" @click="activeTab = 'compliance'">Compliance Checks</button> + </li> + </ul> + + <!-- Reports Section --> + <div v-if="activeTab === 'reports'"> + <div class="card mb-4"> + <div class="card-body"> + <h5 class="card-title">Generate Report</h5> + <div class="row g-3"> + <div class="col-md-4"> + <label class="form-label">Report Type</label> + <select class="form-select" v-model="reportType"> + <option v-for="t in reportTypes" :key="t" :value="t">{{ t }}</option> + </select> + </div> + <div class="col-md-5"> + <label class="form-label">Title</label> + <input type="text" class="form-control" v-model="reportTitle" placeholder="Report title..." /> + </div> + <div class="col-md-3 d-flex align-items-end"> + <ActionButton :loading="generatingReport" variant="primary" @click="generateReport">Generate Report</ActionButton> + </div> + </div> + </div> + </div> + + <SkeletonCard v-if="reports === null" :count="3" :columns="3" /> + <EmptyState v-else-if="reports.length === 0" message="No reports yet. Generate one above to get started." /> + <div v-else class="row g-4"> + <div class="col-12 col-md-4" v-for="report in reports" :key="report.id"> + <div class="card h-100 shadow-sm"> + <div class="card-body"> + <h5 class="card-title">{{ report.title }}</h5> + <span class="badge bg-info mb-2">{{ report.reportType }}</span> + <p class="card-text text-muted"> + Generated {{ new Date(report.generatedUtc).toLocaleDateString() }} + </p> + </div> + <div class="card-footer"> + <button class="btn btn-sm btn-outline-primary" aria-label="View report" @click="viewReport(report)">View</button> + </div> + </div> + </div> + </div> + + <DetailModal :show="!!selectedReport" :title="selectedReport?.title ?? ''" @close="closeModal"> + <template v-if="selectedReport"> + <table class="table table-sm mb-3"> + <tbody> + <tr><th>Type</th><td><span class="badge bg-info">{{ selectedReport.reportType }}</span></td></tr> + <tr><th>Generated</th><td>{{ new Date(selectedReport.generatedUtc).toLocaleString() }}</td></tr> + <tr><th>Tenant</th><td><code>{{ selectedReport.tenantId }}</code></td></tr> + </tbody> + </table> + <h6>Content</h6> + <div class="border rounded p-3 bg-light"> + <pre class="mb-0" style="white-space: pre-wrap;">{{ selectedReport.contentMarkdown }}</pre> + </div> + </template> + </DetailModal> + </div> + + <!-- Compliance Section --> + <div v-if="activeTab === 'compliance'"> + <div class="card mb-4"> + <div class="card-body"> + <h5 class="card-title">Run Compliance Check</h5> + <div class="row g-3"> + <div class="col-md-4"> + <label class="form-label">Site Placement ID</label> + <input type="text" class="form-control" v-model="checkPlacementId" placeholder="Placement ID..." /> + </div> + <div class="col-md-4"> + <label class="form-label">Jurisdiction</label> + <input type="text" class="form-control" v-model="checkJurisdiction" placeholder="e.g. NSW, VIC..." /> + </div> + <div class="col-md-4 d-flex align-items-end"> + <ActionButton :loading="runningCheck" variant="primary" @click="runComplianceCheck">Run Check</ActionButton> + </div> + </div> + </div> + </div> + + <SkeletonCard v-if="complianceResults === null" :count="2" :columns="3" /> + <EmptyState v-else-if="complianceResults.length === 0" message="No compliance results yet. Run a check above." /> + <div v-else class="row g-4"> + <div class="col-12 col-md-4" v-for="result in complianceResults" :key="result.id"> + <div class="card h-100 shadow-sm"> + <div class="card-body"> + <div class="d-flex justify-content-between align-items-start mb-2"> + <h5 class="card-title mb-0">{{ result.jurisdiction }}</h5> + <StatusBadge :status="result.isCompliant ? 'Compliant' : 'Non-Compliant'" /> + </div> + <p class="card-text text-muted"> + Checked {{ new Date(result.checkedUtc).toLocaleDateString() }} + </p> + <div v-if="result.issues.length > 0"> + <strong>Issues:</strong> + <ul class="mb-0"> + <li v-for="(issue, i) in result.issues" :key="i" class="text-danger">{{ issue }}</li> + </ul> + </div> + <p v-else class="text-success mb-0">✅ No issues found</p> + </div> + </div> + </div> + </div> + </div> + </div> +</template> diff --git a/Terranes/src/Web.Vue/src/views/SearchView.vue b/Terranes/src/Web.Vue/src/views/SearchView.vue new file mode 100644 index 0000000..622ad6c --- /dev/null +++ b/Terranes/src/Web.Vue/src/views/SearchView.vue @@ -0,0 +1,102 @@ +<script setup lang="ts"> +import { ref, computed, onMounted, watch } from 'vue'; +import { useRoute, useRouter } from 'vue-router'; +import { api } from '../api/client'; +import type { SearchResult } from '../types'; +import SkeletonCard from '../components/SkeletonCard.vue'; +import EmptyState from '../components/EmptyState.vue'; +import StatusBadge from '../components/StatusBadge.vue'; +import { useDebounce } from '../composables/useDebounce'; + +const route = useRoute(); +const router = useRouter(); + +const searchQuery = ref((route.query.query as string) || ''); +const entityType = ref((route.query.type as string) || ''); +const results = ref<SearchResult[] | null>(null); +const loading = ref(false); + +const debouncedQuery = useDebounce(searchQuery); + +const entityTypes = ['HomeModel', 'LandBlock', 'Village', 'Listing', 'Journey']; + +const resultCount = computed(() => results.value?.length ?? 0); + +async function search() { + if (!debouncedQuery.value) { + results.value = []; + return; + } + loading.value = true; + results.value = null; + try { + if (entityType.value) { + results.value = await api.searchByType(entityType.value, debouncedQuery.value); + } else { + results.value = await api.search(debouncedQuery.value); + } + } catch { + results.value = []; + } finally { + loading.value = false; + } +} + +function syncQuery() { + const query: Record<string, string> = {}; + if (debouncedQuery.value) query.query = debouncedQuery.value; + if (entityType.value) query.type = entityType.value; + router.replace({ query }); +} + +onMounted(() => { + if (debouncedQuery.value) search(); +}); + +watch([debouncedQuery, entityType], () => { search(); syncQuery(); }); +</script> + +<template> + <div class="container"> + <h2 class="mb-4">🔍 Search</h2> + <p class="text-muted">Search across all Terranes entities.</p> + + <div class="row mb-3"> + <div class="col-md-6"> + <input + type="text" + class="form-control" + placeholder="Enter search query..." + v-model="searchQuery" + /> + </div> + <div class="col-md-3"> + <select class="form-select" v-model="entityType"> + <option value="">All Types</option> + <option v-for="t in entityTypes" :key="t" :value="t">{{ t }}</option> + </select> + </div> + </div> + + <div class="mb-3 d-flex flex-wrap align-items-center"> + <span v-if="results !== null" class="badge bg-secondary ms-auto result-count">Showing {{ resultCount }} results</span> + </div> + + <SkeletonCard v-if="results === null && loading" :count="3" :columns="3" /> + <EmptyState v-else-if="results !== null && results.length === 0" message="No results found. Try a different search query." /> + <div v-else-if="results" class="row g-4"> + <div class="col-12 col-md-4" v-for="result in results" :key="result.entityId"> + <div class="card h-100 shadow-sm"> + <div class="card-body"> + <h5 class="card-title">{{ result.title }}</h5> + <p class="card-text text-muted">{{ result.summary }}</p> + <div class="d-flex justify-content-between mb-2"> + <StatusBadge :status="result.entityType" /> + <span class="badge bg-info">Score: {{ result.relevanceScore.toFixed(1) }}</span> + </div> + </div> + </div> + </div> + </div> + </div> +</template> diff --git a/Terranes/src/Web.Vue/src/views/VillagesView.vue b/Terranes/src/Web.Vue/src/views/VillagesView.vue index 1eb9032..42b84b7 100644 --- a/Terranes/src/Web.Vue/src/views/VillagesView.vue +++ b/Terranes/src/Web.Vue/src/views/VillagesView.vue @@ -1,26 +1,44 @@ <script setup lang="ts"> -import { ref, onMounted, watch } from 'vue'; +import { ref, computed, onMounted, watch } from 'vue'; +import { useRoute, useRouter } from 'vue-router'; import { api } from '../api/client'; import type { VirtualVillage, VillageLot } from '../types'; import DetailModal from '../components/DetailModal.vue'; import StatusBadge from '../components/StatusBadge.vue'; import SkeletonCard from '../components/SkeletonCard.vue'; +import FilterChip from '../components/FilterChip.vue'; +import EmptyState from '../components/EmptyState.vue'; +import { useDebounce } from '../composables/useDebounce'; + +const route = useRoute(); +const router = useRouter(); const villages = ref<VirtualVillage[] | null>(null); -const searchName = ref(''); -const selectedLayout = ref(''); +const searchName = ref((route.query.name as string) || ''); +const selectedLayout = ref((route.query.layout as string) || ''); const selectedVillage = ref<VirtualVillage | null>(null); const villageLots = ref<VillageLot[] | null>(null); +const debouncedName = useDebounce(searchName); + const layouts = ['Grid', 'Radial', 'Linear', 'Cluster', 'Freeform']; +const resultCount = computed(() => villages.value?.length ?? 0); + async function search() { villages.value = await api.getVillages({ - name: searchName.value || undefined, + name: debouncedName.value || undefined, layout: selectedLayout.value || undefined, }); } +function syncQuery() { + const query: Record<string, string> = {}; + if (debouncedName.value) query.name = debouncedName.value; + if (selectedLayout.value) query.layout = selectedLayout.value; + router.replace({ query }); +} + async function viewVillage(village: VirtualVillage) { selectedVillage.value = village; villageLots.value = await api.getVillageLots(village.id); @@ -31,8 +49,11 @@ function closeModal() { villageLots.value = null; } +function removeNameFilter() { searchName.value = ''; } +function removeLayoutFilter() { selectedLayout.value = ''; } + onMounted(search); -watch([searchName, selectedLayout], search); +watch([debouncedName, selectedLayout], () => { search(); syncQuery(); }); </script> <template> @@ -52,12 +73,16 @@ watch([searchName, selectedLayout], search); </div> </div> - <SkeletonCard v-if="villages === null" :count="3" :columns="3" /> - <div v-else-if="villages.length === 0" class="alert alert-info"> - No villages found. Create one to get started! + <div class="mb-3 d-flex flex-wrap align-items-center"> + <FilterChip v-if="debouncedName" :label="`Name: ${debouncedName}`" @remove="removeNameFilter" /> + <FilterChip v-if="selectedLayout" :label="`Layout: ${selectedLayout}`" @remove="removeLayoutFilter" /> + <span v-if="villages !== null" class="badge bg-secondary ms-auto result-count">Showing {{ resultCount }} results</span> </div> + + <SkeletonCard v-if="villages === null" :count="3" :columns="3" /> + <EmptyState v-else-if="villages.length === 0" message="No villages found. Create one to get started!" /> <div v-else class="row g-4"> - <div class="col-md-4" v-for="village in villages" :key="village.id"> + <div class="col-12 col-md-4" v-for="village in villages" :key="village.id"> <div class="card h-100 shadow-sm"> <div class="card-body"> <h5 class="card-title">{{ village.name }}</h5> @@ -71,7 +96,7 @@ watch([searchName, selectedLayout], search); </small> </div> <div class="card-footer"> - <button class="btn btn-sm btn-outline-primary" @click="viewVillage(village)">View Details</button> + <button class="btn btn-sm btn-outline-primary" aria-label="View details for this village" @click="viewVillage(village)">View Details</button> </div> </div> </div> diff --git a/Terranes/src/Web.Vue/src/views/WalkthroughsView.vue b/Terranes/src/Web.Vue/src/views/WalkthroughsView.vue new file mode 100644 index 0000000..d979f4f --- /dev/null +++ b/Terranes/src/Web.Vue/src/views/WalkthroughsView.vue @@ -0,0 +1,155 @@ +<script setup lang="ts"> +import { ref, onMounted } from 'vue'; +import { api } from '../api/client'; +import type { Walkthrough, WalkthroughPoi } from '../types'; +import DetailModal from '../components/DetailModal.vue'; +import SkeletonCard from '../components/SkeletonCard.vue'; +import EmptyState from '../components/EmptyState.vue'; +import ActionButton from '../components/ActionButton.vue'; +import { useToast } from '../composables/useToast'; + +const { showSuccess, showError } = useToast(); + +const DEMO_MODEL_ID = '00000000-0000-0000-0000-000000000001'; +const DEMO_USER_ID = '00000000-0000-0000-0000-000000000001'; + +const walkthroughs = ref<Walkthrough[] | null>(null); +const selectedWalkthrough = ref<Walkthrough | null>(null); +const pois = ref<WalkthroughPoi[]>([]); +const generating = ref(false); +const showGenerateForm = ref(false); +const generateModelId = ref(''); + +async function loadWalkthroughs() { + try { + walkthroughs.value = await api.getWalkthroughsByModel(DEMO_MODEL_ID); + } catch { + walkthroughs.value = []; + } +} + +async function viewWalkthrough(wt: Walkthrough) { + selectedWalkthrough.value = wt; + try { + pois.value = await api.getWalkthroughPois(wt.id); + } catch { + pois.value = []; + } +} + +function closeModal() { + selectedWalkthrough.value = null; + pois.value = []; +} + +async function generateWalkthrough() { + const modelId = generateModelId.value.trim() || DEMO_MODEL_ID; + generating.value = true; + try { + const wt = await api.generateWalkthrough(modelId, DEMO_USER_ID); + showSuccess('Walkthrough generated successfully!'); + showGenerateForm.value = false; + generateModelId.value = ''; + if (!walkthroughs.value) walkthroughs.value = []; + walkthroughs.value.unshift(wt); + } catch { + showError('Failed to generate walkthrough.'); + } finally { + generating.value = false; + } +} + +function truncateId(id: string): string { + return id.length > 8 ? id.substring(0, 8) + '…' : id; +} + +onMounted(loadWalkthroughs); +</script> + +<template> + <div class="container"> + <h2 class="mb-4">🚶 3D Walkthroughs</h2> + <p class="text-muted">View and generate immersive 3D walkthrough sessions for home models.</p> + + <div class="mb-3"> + <button class="btn btn-primary" @click="showGenerateForm = !showGenerateForm"> + {{ showGenerateForm ? 'Cancel' : '+ Generate Walkthrough' }} + </button> + </div> + + <div v-if="showGenerateForm" class="card mb-4"> + <div class="card-body"> + <h5 class="card-title">Generate New Walkthrough</h5> + <div class="row g-2 align-items-end"> + <div class="col-md-6"> + <label class="form-label">Home Model ID</label> + <input type="text" class="form-control" v-model="generateModelId" placeholder="Leave blank for demo model" /> + </div> + <div class="col-md-3"> + <ActionButton :loading="generating" variant="success" @click="generateWalkthrough">Generate</ActionButton> + </div> + </div> + </div> + </div> + + <SkeletonCard v-if="walkthroughs === null" :count="3" :columns="3" /> + <EmptyState v-else-if="walkthroughs.length === 0" message="No walkthroughs found. Generate one to get started!" /> + <div v-else class="row g-4"> + <div class="col-12 col-md-4" v-for="wt in walkthroughs" :key="wt.id"> + <div class="card h-100 shadow-sm"> + <div class="card-body"> + <h5 class="card-title">Walkthrough</h5> + <p class="card-text"> + <span class="text-muted">Model:</span> <code>{{ truncateId(wt.homeModelId) }}</code> + </p> + <div class="d-flex justify-content-between mb-2"> + <span class="badge bg-primary">{{ wt.scenes.length }} scenes</span> + <small class="text-muted">{{ new Date(wt.generatedUtc).toLocaleDateString() }}</small> + </div> + </div> + <div class="card-footer"> + <button class="btn btn-sm btn-outline-primary" aria-label="View walkthrough details" @click="viewWalkthrough(wt)">View Details</button> + </div> + </div> + </div> + </div> + + <DetailModal :show="!!selectedWalkthrough" :title="'Walkthrough Details'" @close="closeModal"> + <template v-if="selectedWalkthrough"> + <table class="table table-sm"> + <tbody> + <tr><th>ID</th><td><code>{{ selectedWalkthrough.id }}</code></td></tr> + <tr><th>Home Model</th><td><code>{{ selectedWalkthrough.homeModelId }}</code></td></tr> + <tr v-if="selectedWalkthrough.sitePlacementId"><th>Site Placement</th><td><code>{{ selectedWalkthrough.sitePlacementId }}</code></td></tr> + <tr><th>Generated</th><td>{{ new Date(selectedWalkthrough.generatedUtc).toLocaleString() }}</td></tr> + </tbody> + </table> + + <h6>Scenes ({{ selectedWalkthrough.scenes.length }})</h6> + <table v-if="selectedWalkthrough.scenes.length > 0" class="table table-sm table-striped"> + <thead><tr><th>#</th><th>Name</th><th>Duration</th></tr></thead> + <tbody> + <tr v-for="scene in selectedWalkthrough.scenes" :key="scene.id"> + <td>{{ scene.sceneOrder }}</td> + <td>{{ scene.sceneName }}</td> + <td>{{ scene.durationSeconds }}s</td> + </tr> + </tbody> + </table> + + <h6>Points of Interest ({{ pois.length }})</h6> + <table v-if="pois.length > 0" class="table table-sm table-striped"> + <thead><tr><th>Room</th><th>Label</th><th>Description</th></tr></thead> + <tbody> + <tr v-for="poi in pois" :key="poi.id"> + <td>{{ poi.room }}</td> + <td>{{ poi.label }}</td> + <td>{{ poi.description }}</td> + </tr> + </tbody> + </table> + <p v-else class="text-muted">No points of interest loaded.</p> + </template> + </DetailModal> + </div> +</template>