From ebd3e71d9de380fb6ce212355d72f902a7c0df91 Mon Sep 17 00:00:00 2001 From: sublime247 Date: Thu, 22 Jan 2026 23:48:49 +0100 Subject: [PATCH 1/6] feat: loading states and error handling #200 --- apps/web/src/app/page.tsx | 10 +- .../src/components/search/PropertyGrid.tsx | 41 +++++- .../components/shared/layout/providers.tsx | 9 +- apps/web/src/components/ui/error-display.tsx | 80 ++++++++++++ .../src/components/ui/loading-skeleton.tsx | 87 +++++++++++++ apps/web/src/hooks/useApiCall.ts | 118 ++++++++++++++++++ apps/web/src/hooks/useDashboard.ts | 68 ++++++---- apps/web/src/hooks/useUserRole.tsx | 47 ------- 8 files changed, 378 insertions(+), 82 deletions(-) create mode 100644 apps/web/src/components/ui/error-display.tsx create mode 100644 apps/web/src/components/ui/loading-skeleton.tsx create mode 100644 apps/web/src/hooks/useApiCall.ts diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 91e20786..3cbe357e 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -2,14 +2,12 @@ import { SearchBar } from '@/components/features/search/SearchBar'; import { RightSidebar } from '@/components/layout/RightSidebar'; import { PropertyGrid } from '@/components/search/PropertyGrid'; import { House } from 'lucide-react'; -import Image from 'next/image'; -import { Suspense } from 'react'; export default function Home() { return (
-
+
@@ -23,11 +21,7 @@ export default function Home() {
- Loading properties...} - > - - + diff --git a/apps/web/src/components/search/PropertyGrid.tsx b/apps/web/src/components/search/PropertyGrid.tsx index bb1e70a1..3dc57700 100644 --- a/apps/web/src/components/search/PropertyGrid.tsx +++ b/apps/web/src/components/search/PropertyGrid.tsx @@ -1,5 +1,8 @@ 'use client'; +import { useState } from 'react'; +import { ErrorDisplay } from '../ui/error-display'; +import { LoadingGrid } from '../ui/loading-skeleton'; import { PropertyCard } from './PropertyCard'; // Mock data for properties @@ -126,7 +129,43 @@ const mockProperties = [ }, ]; -export const PropertyGrid = () => { +interface PropertyGridProps { + isLoading?: boolean; + error?: string | null; + onRetry?: () => void; +} + +export const PropertyGrid = ({ isLoading = false, error = null, onRetry }: PropertyGridProps) => { + // Show loading state + if (isLoading) { + return ; + } + + // Show error state + if (error) { + return ( +
+ +
+ ); + } + + // Show empty state + if (!mockProperties || mockProperties.length === 0) { + return ( +
+

No properties found

+

Try adjusting your search filters

+
+ ); + } + + // Show properties return (
{mockProperties.map((property) => ( diff --git a/apps/web/src/components/shared/layout/providers.tsx b/apps/web/src/components/shared/layout/providers.tsx index 26339e79..c9b22a9d 100644 --- a/apps/web/src/components/shared/layout/providers.tsx +++ b/apps/web/src/components/shared/layout/providers.tsx @@ -1,5 +1,6 @@ 'use client'; +import { Spinner } from '@/components/ui/loading-skeleton'; import { useTheme } from 'next-themes'; import dynamic from 'next/dynamic'; import React from 'react'; @@ -12,8 +13,8 @@ const ThemeProvider = dynamic( { ssr: false, loading: () => ( -
-
Cargando...
+
+
), } @@ -41,8 +42,8 @@ export function Providers({ children }: ProvidersProps) { if (!mounted) { return ( -
-
Cargando...
+
+
); } diff --git a/apps/web/src/components/ui/error-display.tsx b/apps/web/src/components/ui/error-display.tsx new file mode 100644 index 00000000..f79d742a --- /dev/null +++ b/apps/web/src/components/ui/error-display.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { AlertCircle, RefreshCw, XCircle } from 'lucide-react'; +import { Button } from './button'; + +interface ErrorDisplayProps { + title?: string; + message: string; + onRetry?: () => void; + variant?: 'default' | 'destructive' | 'warning'; + className?: string; +} + +export const ErrorDisplay = ({ + title = 'Something went wrong', + message, + onRetry, + variant = 'destructive', + className = '', +}: ErrorDisplayProps) => { + const variantStyles = { + default: 'bg-gray-500/10 border-gray-500/20 text-gray-400', + destructive: 'bg-red-500/10 border-red-500/20 text-red-400', + warning: 'bg-yellow-500/10 border-yellow-500/20 text-yellow-400', + }; + + const Icon = variant === 'warning' ? AlertCircle : XCircle; + + return ( +
+
+ +
+

{title}

+

{message}

+ {onRetry && ( + + )} +
+
+
+ ); +}; + +// Inline error variant for smaller spaces +interface InlineErrorProps { + message: string; + onRetry?: () => void; + className?: string; +} + +export const InlineError = ({ message, onRetry, className = '' }: InlineErrorProps) => { + return ( +
+ + {message} + {onRetry && ( + + )} +
+ ); +}; diff --git a/apps/web/src/components/ui/loading-skeleton.tsx b/apps/web/src/components/ui/loading-skeleton.tsx new file mode 100644 index 00000000..b6e95935 --- /dev/null +++ b/apps/web/src/components/ui/loading-skeleton.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { Loader2 } from 'lucide-react'; + +interface LoadingSkeletonProps { + className?: string; +} + +export const LoadingSkeleton = ({ className = '' }: LoadingSkeletonProps) => { + return ( +
+ ); +}; + +// Property card skeleton +export const PropertyCardSkeleton = () => { + return ( +
+ +
+ + +
+ + +
+ +
+
+ ); +}; + +// Grid loading state +interface LoadingGridProps { + count?: number; + columns?: number; +} + +export const LoadingGrid = ({ count = 8, columns = 4 }: LoadingGridProps) => { + return ( +
+ {Array.from({ length: count }, () => crypto.randomUUID()).map((id) => ( + + ))} +
+ ); +}; + +// Spinner loader +interface SpinnerProps { + size?: 'sm' | 'md' | 'lg'; + className?: string; + label?: string; +} + +export const Spinner = ({ size = 'md', className = '', label }: SpinnerProps) => { + const sizeClasses = { + sm: 'w-4 h-4', + md: 'w-8 h-8', + lg: 'w-12 h-12', + }; + + return ( +
+ + {label && {label}} + Loading... +
+ ); +}; + +// Full page loader +interface FullPageLoaderProps { + message?: string; +} + +export const FullPageLoader = ({ message = 'Loading...' }: FullPageLoaderProps) => { + return ( +
+ +
+ ); +}; diff --git a/apps/web/src/hooks/useApiCall.ts b/apps/web/src/hooks/useApiCall.ts new file mode 100644 index 00000000..36e8c21a --- /dev/null +++ b/apps/web/src/hooks/useApiCall.ts @@ -0,0 +1,118 @@ +import { useCallback, useState } from 'react'; + +interface UseApiCallOptions { + retryCount?: number; + retryDelay?: number; + onSuccess?: (data: unknown) => void; + onError?: (error: Error) => void; +} + +interface UseApiCallReturn { + data: T | null; + error: Error | null; + isLoading: boolean; + execute: (...args: unknown[]) => Promise; + retry: () => Promise; + reset: () => void; +} + +/** + * Custom hook for making API calls with automatic retry logic + * @param apiFunction - The async function to call + * @param options - Configuration options for retry behavior + */ +export function useApiCall( + apiFunction: (...args: unknown[]) => Promise, + options: UseApiCallOptions = {} +): UseApiCallReturn { + const { retryCount = 3, retryDelay = 1000, onSuccess, onError } = options; + + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [lastArgs, setLastArgs] = useState([]); + + const executeWithRetry = useCallback( + async (args: unknown[], currentRetry = 0): Promise => { + try { + setIsLoading(true); + setError(null); + + const result = await apiFunction(...args); + setData(result); + onSuccess?.(result); + return result; + } catch (err) { + const error = err instanceof Error ? err : new Error('An unknown error occurred'); + + // Retry logic + if (currentRetry < retryCount) { + console.log(`Retry attempt ${currentRetry + 1} of ${retryCount}...`); + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + return executeWithRetry(args, currentRetry + 1); + } + + // Max retries reached + setError(error); + onError?.(error); + return null; + } finally { + setIsLoading(false); + } + }, + [apiFunction, retryCount, retryDelay, onSuccess, onError] + ); + + const execute = useCallback( + async (...args: unknown[]): Promise => { + setLastArgs(args); + return executeWithRetry(args); + }, + [executeWithRetry] + ); + + const retry = useCallback(async (): Promise => { + return executeWithRetry(lastArgs); + }, [executeWithRetry, lastArgs]); + + const reset = useCallback(() => { + setData(null); + setError(null); + setIsLoading(false); + }, []); + + return { + data, + error, + isLoading, + execute, + retry, + reset, + }; +} + +/** + * Utility function to create a retry-enabled API call + */ +export async function retryApiCall( + apiFunction: () => Promise, + maxRetries = 3, + delay = 1000 +): Promise { + let lastError: Error = new Error('API call failed'); + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await apiFunction(); + } catch (error) { + lastError = error instanceof Error ? error : new Error('Unknown error'); + + if (attempt < maxRetries) { + console.log(`Retry attempt ${attempt + 1} of ${maxRetries}...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + + throw lastError; +} diff --git a/apps/web/src/hooks/useDashboard.ts b/apps/web/src/hooks/useDashboard.ts index 340ee956..cc343d35 100644 --- a/apps/web/src/hooks/useDashboard.ts +++ b/apps/web/src/hooks/useDashboard.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import { bookingAPI, dashboardAPI, handleAPIError, profileAPI, walletAPI } from '../services/api'; import type { DashboardBooking, Transaction, UserProfile } from '../types'; +import { retryApiCall } from './useApiCall'; interface UseDashboardProps { userId: string; @@ -56,10 +57,19 @@ export const useDashboard = ({ userId, userType }: UseDashboardProps): UseDashbo setError(null); try { - const response = await fetch('/api/bookings'); - if (!response.ok) { - throw new Error(`Failed to fetch bookings: ${response.statusText}`); - } + // Retry logic for bookings fetch + const response = await retryApiCall( + async () => { + const res = await fetch('/api/bookings'); + if (!res.ok) { + throw new Error(`Failed to fetch bookings: ${res.statusText}`); + } + return res; + }, + 3, // max retries + 1000 // delay between retries + ); + const data = await response.json(); // Handle the response structure from backend const bookingsData = data.data?.bookings || data.bookings || data || []; @@ -67,7 +77,7 @@ export const useDashboard = ({ userId, userType }: UseDashboardProps): UseDashbo } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to fetch bookings'; setError(errorMessage); - console.error('Failed to fetch bookings:', err); + console.error('Failed to fetch bookings after retries:', err); setBookings([]); // Reset to empty array on error } finally { setIsLoadingBookings(false); @@ -81,16 +91,23 @@ export const useDashboard = ({ userId, userType }: UseDashboardProps): UseDashbo setError(null); try { - const response = await fetch('/api/profile'); - if (!response.ok) { - throw new Error(`Failed to fetch profile: ${response.statusText}`); - } + const response = await retryApiCall( + async () => { + const res = await fetch('/api/profile'); + if (!res.ok) { + throw new Error(`Failed to fetch profile: ${res.statusText}`); + } + return res; + }, + 3, + 1000 + ); const data = await response.json(); setProfile(data || null); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to fetch profile'; setError(errorMessage); - console.error('Failed to fetch profile:', err); + console.error('Failed to fetch profile after retries:', err); setProfile(null); // Reset to null on error } finally { setIsLoadingProfile(false); @@ -108,23 +125,23 @@ export const useDashboard = ({ userId, userType }: UseDashboardProps): UseDashbo // TODO: Replace with real API call when /api/wallet/transactions is available const mockTransactions: Transaction[] = [ { - id: '1', + id: 1, date: '2025-05-28', description: 'Luxury Downtown Apartment', amount: -1250, - type: 'booking', + type: 'payment', status: 'completed', }, { - id: '2', + id: 2, date: '2025-05-26', description: 'Cozy Beach House', amount: -900, - type: 'booking', + type: 'payment', status: 'pending', }, { - id: '3', + id: 3, date: '2025-05-20', description: 'Wallet Top-up', amount: 2000, @@ -132,11 +149,11 @@ export const useDashboard = ({ userId, userType }: UseDashboardProps): UseDashbo status: 'completed', }, { - id: '4', + id: 4, date: '2025-05-15', description: 'Mountain Cabin Retreat', amount: -1600, - type: 'booking', + type: 'payment', status: 'completed', }, ]; @@ -163,10 +180,17 @@ export const useDashboard = ({ userId, userType }: UseDashboardProps): UseDashbo try { // Calculate stats from bookings data instead of calling non-existent analytics endpoint // TODO: Replace with real analytics API when /api/analytics/overview is implemented - const response = await fetch('/api/bookings'); - if (!response.ok) { - throw new Error(`Failed to fetch bookings for stats: ${response.statusText}`); - } + const response = await retryApiCall( + async () => { + const res = await fetch('/api/bookings'); + if (!res.ok) { + throw new Error(`Failed to fetch bookings for stats: ${res.statusText}`); + } + return res; + }, + 3, + 1000 + ); const data = await response.json(); const bookingsData = data.data?.bookings || data.bookings || []; @@ -198,7 +222,7 @@ export const useDashboard = ({ userId, userType }: UseDashboardProps): UseDashbo } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to fetch dashboard stats'; setError(errorMessage); - console.error('Failed to fetch dashboard stats:', err); + console.error('Failed to fetch dashboard stats after retries:', err); // Set default stats on error setStats({ totalBookings: 0, diff --git a/apps/web/src/hooks/useUserRole.tsx b/apps/web/src/hooks/useUserRole.tsx index 0205a095..753421d8 100644 --- a/apps/web/src/hooks/useUserRole.tsx +++ b/apps/web/src/hooks/useUserRole.tsx @@ -1,7 +1,6 @@ 'use client'; import { useEffect, useState } from 'react'; -<<<<<<< HEAD import { profileAPI } from '~/services/api'; import type { RoleInfo, UserRole } from '~/types/roles'; import { useAuth } from './auth/use-auth'; @@ -11,19 +10,12 @@ interface UseUserRoleReturn extends RoleInfo { } export function useUserRole(): UseUserRoleReturn { -======= -import type { RoleInfo, UserRole } from '~/types/roles'; -import { useAuth } from './auth/use-auth'; - -export function useUserRole(): RoleInfo { ->>>>>>> 60310ea (feat: add stellar contract dependencies and integration setup) const { user, isAuthenticated } = useAuth(); const [roleInfo, setRoleInfo] = useState({ role: 'guest', canAccessHostDashboard: false, hasProperties: false, }); -<<<<<<< HEAD const [isLoading, setIsLoading] = useState(true); useEffect(() => { @@ -120,43 +112,4 @@ export function useUserRole(): RoleInfo { }, [user, isAuthenticated]); return { ...roleInfo, isLoading }; -======= - - useEffect(() => { - if (!isAuthenticated || !user) { - setRoleInfo({ - role: 'guest', - canAccessHostDashboard: false, - hasProperties: false, - }); - return; - } - - // Check if user has host status in localStorage or from API - const storedHostStatus = localStorage.getItem('hostStatus'); - const storedHasProperties = localStorage.getItem('hasProperties') === 'true'; - - let role: UserRole = 'guest'; - let canAccessHostDashboard = false; - - // User is a host if they have verified host status and properties - if (storedHostStatus === 'verified' && storedHasProperties) { - role = 'dual'; // Can be both guest and host - canAccessHostDashboard = true; - } else if (storedHostStatus === 'verified') { - // Verified but no properties yet - role = 'host'; - canAccessHostDashboard = false; // No dashboard access without properties - } - - setRoleInfo({ - role, - hostStatus: storedHostStatus as 'pending' | 'verified' | 'rejected' | 'suspended' | undefined, - canAccessHostDashboard, - hasProperties: storedHasProperties, - }); - }, [user, isAuthenticated]); - - return roleInfo; ->>>>>>> 60310ea (feat: add stellar contract dependencies and integration setup) } From 88b07886c1732c9f83c0675cee64a476aecfae6b Mon Sep 17 00:00:00 2001 From: sublime247 Date: Tue, 27 Jan 2026 13:32:34 +0100 Subject: [PATCH 2/6] feat(web): refactor dashboard and search architecture with improved state management --- apps/web/.storybook/main.ts | 19 + apps/web/.storybook/preview.ts | 17 + apps/web/biome_errors.txt | 110 + apps/web/package.json | 24 +- apps/web/public/mock-data.ts | 192 +- apps/web/src/app/booking/page.tsx | 12 +- .../src/app/dashboard/host-dashboard/page.tsx | 175 +- .../app/dashboard/tenant-dashboard/page.tsx | 430 ++-- apps/web/src/app/page.tsx | 16 +- apps/web/src/app/search/page.tsx | 13 +- .../components/profile-management.tsx | 186 -- apps/web/src/app/tenant-dashboard/page.tsx | 199 +- .../src/components/booking/BookingForm.tsx | 4 +- .../BookingConfirmationPage.test.tsx | 2 +- .../components/dashboard/BookingHistory.tsx | 17 +- .../dashboard/ProfileManagement.tsx | 69 +- .../components/features/search/SearchBar.tsx | 13 +- apps/web/src/components/guards/RoleGuard.tsx | 48 +- .../src/components/search/PropertyCard.tsx | 44 +- .../search/PropertyGrid.stories.tsx | 56 + .../src/components/search/PropertyGrid.tsx | 176 +- .../src/components/shared/Testimonials.tsx | 6 +- .../components/ui/error-display.stories.tsx | 53 + .../ui/loading-skeleton.stories.tsx | 37 + .../src/hooks/__tests__/useDashboard.test.ts | 121 + .../src/hooks/__tests__/useProperties.test.ts | 50 + .../hooks/stellar/tests/useStellar.spec.ts | 10 +- apps/web/src/hooks/useBookingDetails.ts | 6 +- apps/web/src/hooks/useDashboard.ts | 264 +- apps/web/src/hooks/useProperties.ts | 40 + apps/web/src/test/setup.ts | 16 + apps/web/src/types/index.ts | 13 +- apps/web/src/types/shared.ts | 2 + apps/web/vitest.config.ts | 18 + bun.lock | 2246 +++++++++++++++-- package.json | 1 + tsconfig.json | 19 +- 37 files changed, 3501 insertions(+), 1223 deletions(-) create mode 100644 apps/web/.storybook/main.ts create mode 100644 apps/web/.storybook/preview.ts create mode 100644 apps/web/biome_errors.txt delete mode 100644 apps/web/src/app/tenant-dashboard/components/profile-management.tsx create mode 100644 apps/web/src/components/search/PropertyGrid.stories.tsx create mode 100644 apps/web/src/components/ui/error-display.stories.tsx create mode 100644 apps/web/src/components/ui/loading-skeleton.stories.tsx create mode 100644 apps/web/src/hooks/__tests__/useDashboard.test.ts create mode 100644 apps/web/src/hooks/__tests__/useProperties.test.ts create mode 100644 apps/web/src/hooks/useProperties.ts create mode 100644 apps/web/src/test/setup.ts create mode 100644 apps/web/vitest.config.ts diff --git a/apps/web/.storybook/main.ts b/apps/web/.storybook/main.ts new file mode 100644 index 00000000..7e34cbdc --- /dev/null +++ b/apps/web/.storybook/main.ts @@ -0,0 +1,19 @@ +import type { StorybookConfig } from '@storybook/nextjs'; + +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [ + '@storybook/addon-links', + '@storybook/addon-essentials', + '@storybook/addon-interactions', + ], + framework: { + name: '@storybook/nextjs', + options: {}, + }, + docs: { + autodocs: 'tag', + }, + staticDirs: ['../public'], +}; +export default config; diff --git a/apps/web/.storybook/preview.ts b/apps/web/.storybook/preview.ts new file mode 100644 index 00000000..8c0be08d --- /dev/null +++ b/apps/web/.storybook/preview.ts @@ -0,0 +1,17 @@ +import type { Preview } from '@storybook/react'; + +import '../src/app/globals.css'; + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, +}; + +export default preview; diff --git a/apps/web/biome_errors.txt b/apps/web/biome_errors.txt new file mode 100644 index 00000000..62e85af1 --- /dev/null +++ b/apps/web/biome_errors.txt @@ -0,0 +1,110 @@ +src/app/tenant-dashboard/page.tsx:119:39 lint/suspicious/noExplicitAny ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Unexpected any. Specify a different type. + + 117 │ }, []); + 118 │ + > 119 │ const handleViewDetails = (booking: any): void => { + │ ^^^ + 120 │ // Legacy mapping or handle directly + 121 │ const found = apiBookings.find((b) => b.id === booking.id); + + i any disables many type checking rules. Its use should be avoided. + + +src/app/tenant-dashboard/page.tsx:128:41 lint/suspicious/noExplicitAny ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Unexpected any. Specify a different type. + + 126 │ }; + 127 │ + > 128 │ const handleCancelBooking = (booking: any): void => { + │ ^^^ + 129 │ const found = apiBookings.find((b) => b.id === booking.id); + 130 │ if (found) { + + i any disables many type checking rules. Its use should be avoided. + + +src/app/tenant-dashboard/page.tsx:359:29 lint/suspicious/noExplicitAny ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Unexpected any. Specify a different type. + + 357 │ ) : user ? ( + 358 │ 359 │ user={user as any} + │ ^^^ + 360 │ onUpdateProfile={handleUpdateUser as any} + 361 │ onUploadAvatar={async (file) => { + + i any disables many type checking rules. Its use should be avoided. + + +src/app/tenant-dashboard/page.tsx:360:52 lint/suspicious/noExplicitAny ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Unexpected any. Specify a different type. + + 358 │ 360 │ onUpdateProfile={handleUpdateUser as any} + │ ^^^ + 361 │ onUploadAvatar={async (file) => { + 362 │ await apiUploadAvatar(user.id.toString(), file); + + i any disables many type checking rules. Its use should be avoided. + + +src/app/tenant-dashboard/page.tsx:396:45 lint/suspicious/noExplicitAny ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Unexpected any. Specify a different type. + + 394 │ walletBalance={walletBalance} + 395 │ pendingTransactions={pendingTransactions} + > 396 │ transactions={transactions as any} + │ ^^^ + 397 │ onExportTransactions={apiExportTransactions} + 398 │ /> + + i any disables many type checking rules. Its use should be avoided. + + +src/app/tenant-dashboard/page.tsx format ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Formatter would have printed the following content: + + 207 207 │
+ 208 208 │
+ 214 215 │
+ ······· │ + 316 317 │ type="button" + 317 318 │ onClick={() => setActiveTab(tab.id)} + 318 │ - ··················className={`flex·items-center·space-x-2·py-4·px-1·border-b-2·font-medium·text-sm·${activeTab·===·tab.id + 319 │ - ····················?·'border-blue-500·text-blue-600·dark:text-blue-400' + 320 │ - ····················:·'border-transparent·text-gray-500·dark:text-white·hover:border-gray-300' + 321 │ - ····················}`} + 319 │ + ··················className={`flex·items-center·space-x-2·py-4·px-1·border-b-2·font-medium·text-sm·${ + 320 │ + ····················activeTab·===·tab.id + 321 │ + ······················?·'border-blue-500·text-blue-600·dark:text-blue-400' + 322 │ + ······················:·'border-transparent·text-gray-500·dark:text-white·hover:border-gray-300' + 323 │ + ··················}`} + 322 324 │ > + 323 325 │ + + +Checked 1 file in 7ms. No fixes applied. +Found 6 errors. +check ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Some errors were emitted while running checks. + + diff --git a/apps/web/package.json b/apps/web/package.json index e2af0336..894c5a51 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,11 @@ "lint": "next lint", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", - "test:e2e:headed": "playwright test --headed" + "test:e2e:headed": "playwright test --headed", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@hookform/resolvers": "^3.3.4", @@ -50,16 +54,32 @@ }, "devDependencies": { "@playwright/test": "^1.55.0", + "@storybook/addon-essentials": "8.6.14", + "@storybook/addon-interactions": "8.6.14", + "@storybook/addon-links": "8.6.14", + "@storybook/blocks": "8.6.14", + "@storybook/nextjs": "8.6.14", + "@storybook/react": "8.6.14", + "@storybook/test": "8.6.14", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "14.2.2", + "@testing-library/user-event": "14.5.2", + "@types/jest": "^30.0.0", + "@types/jsdom": "^27.0.0", "@types/leaflet": "^1.9.19", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@vitejs/plugin-react": "^5.1.2", "autoprefixer": "^10.4.17", "eslint": "^8", "eslint-config-next": "14.1.0", + "jsdom": "^27.4.0", "node-loader": "^2.1.0", "postcss": "^8.4.35", + "storybook": "8.6.14", "tailwindcss": "^3.4.1", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.0.18" } } diff --git a/apps/web/public/mock-data.ts b/apps/web/public/mock-data.ts index 1c01a5bc..ec2092fe 100644 --- a/apps/web/public/mock-data.ts +++ b/apps/web/public/mock-data.ts @@ -18,10 +18,10 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ location: 'Luján, Buenos Aires', price: 2500, images: [ - '/images/house1.jpg', - '/images/house2.jpg', - '/images/house3.jpg', - '/images/house4.jpg', + '/images/house1.webp', + '/images/house2.webp', + '/images/house3.webp', + '/images/house4.webp', ], rating: 4.1, maxGuests: 4, @@ -35,10 +35,10 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ location: 'Mendoza, Argentina', price: 6000, images: [ - '/images/house2.jpg', - '/images/house3.jpg', - '/images/house4.jpg', - '/images/house5.jpg', + '/images/house2.webp', + '/images/house3.webp', + '/images/house4.webp', + '/images/house5.webp', ], rating: 4.8, maxGuests: 8, @@ -51,7 +51,12 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ title: 'Cozy Bedroom Suite', location: 'Rosario, Santa Fe', price: 4500, - images: ['/images/house3.jpg', '/images/house1.jpg', '/images/house4.jpg', '/images/house.jpg'], + images: [ + '/images/house3.webp', + '/images/house1.webp', + '/images/house4.webp', + '/images/house.webp', + ], rating: 3.9, maxGuests: 7, bedrooms: 4, @@ -64,10 +69,10 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ location: 'Luján, Buenos Aires', price: 5600, images: [ - '/images/house4.jpg', - '/images/house1.jpg', - '/images/house2.jpg', - '/images/house3.jpg', + '/images/house4.webp', + '/images/house1.webp', + '/images/house2.webp', + '/images/house3.webp', ], rating: 4.5, maxGuests: 10, @@ -81,10 +86,10 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ location: 'Luján, Buenos Aires', price: 2100, images: [ - '/images/house5.jpg', - '/images/house1.jpg', - '/images/house2.jpg', - '/images/house3.jpg', + '/images/house5.webp', + '/images/house1.webp', + '/images/house2.webp', + '/images/house3.webp', ], rating: 4.2, maxGuests: 14, @@ -97,7 +102,12 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ title: 'Modern Architectural House', location: 'Córdoba, Argentina', price: 6500, - images: ['/images/house.jpg', '/images/house1.jpg', '/images/house2.jpg', '/images/house3.jpg'], + images: [ + '/images/house.webp', + '/images/house1.webp', + '/images/house2.webp', + '/images/house3.webp', + ], rating: 4.7, maxGuests: 2, bedrooms: 1, @@ -109,7 +119,12 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ title: 'Cozy kitchen home with garden view', location: 'San Isidro, Buenos Aires', price: 2500, - images: ['/images/house.jpg', '/images/house2.jpg', '/images/house3.jpg', '/images/house4.jpg'], + images: [ + '/images/house.webp', + '/images/house2.webp', + '/images/house3.webp', + '/images/house4.webp', + ], rating: 4.1, maxGuests: 6, bedrooms: 2, @@ -121,7 +136,12 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ title: 'Rustic Country Bungalow', location: 'Palermo, Buenos Aires', price: 3100, - images: ['/images/house1.jpg', '/images/house4.jpg', '/images/house2.jpg', '/images/house.jpg'], + images: [ + '/images/house1.webp', + '/images/house4.webp', + '/images/house2.webp', + '/images/house.webp', + ], rating: 4.3, maxGuests: 10, bedrooms: 5, @@ -134,10 +154,10 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ location: 'Luján, Buenos Aires', price: 3900, images: [ - '/images/house2.jpg', - '/images/house3.jpg', - '/images/property-1.jpg', - '/images/house1.jpg', + '/images/house2.webp', + '/images/house3.webp', + '/images/property-1.webp', + '/images/house1.webp', ], rating: 4.0, maxGuests: 12, @@ -151,10 +171,10 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ location: 'Rosario, Santa Fe', price: 7000, images: [ - '/images/house5.jpg', - '/images/house1.jpg', - '/images/house4.jpg', - '/images/house2.jpg', + '/images/house5.webp', + '/images/house1.webp', + '/images/house4.webp', + '/images/house2.webp', ], rating: 4.9, maxGuests: 6, @@ -167,7 +187,12 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ title: 'Downtown Artist’s Loft', location: 'Mendoza, Argentina', price: 3300, - images: ['/images/house3.jpg', '/images/house.jpg', '/images/house2.jpg', '/images/house5.jpg'], + images: [ + '/images/house3.webp', + '/images/house.webp', + '/images/house2.webp', + '/images/house5.webp', + ], rating: 3.8, maxGuests: 13, bedrooms: 8, @@ -180,10 +205,10 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ location: 'Rosario, Santa Fe', price: 4200, images: [ - '/images/house4.jpg', - '/images/property-1.jpg', - '/images/house1.jpg', - '/images/house2.jpg', + '/images/house4.webp', + '/images/property-1.webp', + '/images/house1.webp', + '/images/house2.webp', ], rating: 4.4, maxGuests: 4, @@ -196,7 +221,12 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ title: 'Smart Home with Modern Tech', location: 'Córdoba, Argentina', price: 4800, - images: ['/images/house.jpg', '/images/house2.jpg', '/images/house3.jpg', '/images/house4.jpg'], + images: [ + '/images/house.webp', + '/images/house2.webp', + '/images/house3.webp', + '/images/house4.webp', + ], rating: 4.6, maxGuests: 3, bedrooms: 3, @@ -209,10 +239,10 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ location: 'San Isidro, Buenos Aires', price: 1900, images: [ - '/images/house5.jpg', - '/images/house1.jpg', - '/images/house4.jpg', - '/images/house3.jpg', + '/images/house5.webp', + '/images/house1.webp', + '/images/house4.webp', + '/images/house3.webp', ], rating: 3.5, maxGuests: 2, @@ -226,10 +256,10 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ location: 'Luján, Buenos Aires', price: 5300, images: [ - '/images/house2.jpg', - '/images/house.jpg', - '/images/property-1.jpg', - '/images/house4.jpg', + '/images/house2.webp', + '/images/house.webp', + '/images/property-1.webp', + '/images/house4.webp', ], rating: 4.7, maxGuests: 3, @@ -243,10 +273,10 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ location: 'Córdoba, Argentina', price: 2750, images: [ - '/images/house3.jpg', - '/images/house4.jpg', - '/images/house5.jpg', - '/images/house1.jpg', + '/images/house3.webp', + '/images/house4.webp', + '/images/house5.webp', + '/images/house1.webp', ], rating: 4.1, maxGuests: 9, @@ -260,10 +290,10 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ location: 'Luján, Buenos Aires', price: 2300, images: [ - '/images/house1.jpg', - '/images/house5.jpg', - '/images/house3.jpg', - '/images/house2.jpg', + '/images/house1.webp', + '/images/house5.webp', + '/images/house3.webp', + '/images/house2.webp', ], rating: 3.9, maxGuests: 6, @@ -276,7 +306,12 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ title: 'Stylish Loft with Workspace', location: 'San Isidro, Buenos Aires', price: 3700, - images: ['/images/house2.jpg', '/images/house3.jpg', '/images/house4.jpg', '/images/house.jpg'], + images: [ + '/images/house2.webp', + '/images/house3.webp', + '/images/house4.webp', + '/images/house.webp', + ], rating: 4.2, maxGuests: 7, bedrooms: 4, @@ -288,7 +323,12 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ title: 'Luxury Farm House', location: 'Mendoza, Argentina', price: 7200, - images: ['/images/house.jpg', '/images/house5.jpg', '/images/house2.jpg', '/images/house3.jpg'], + images: [ + '/images/house.webp', + '/images/house5.webp', + '/images/house2.webp', + '/images/house3.webp', + ], rating: 4.9, maxGuests: 1, bedrooms: 1, @@ -301,10 +341,10 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ location: 'Palermo, Buenos Aires', price: 3200, images: [ - '/images/house4.jpg', - '/images/property-1.jpg', - '/images/house1.jpg', - '/images/house5.jpg', + '/images/house4.webp', + '/images/property-1.webp', + '/images/house1.webp', + '/images/house5.webp', ], rating: 4.3, maxGuests: 2, @@ -318,10 +358,10 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ location: 'Luján, Buenos Aires', price: 3100, images: [ - '/images/house1.jpg', - '/images/house2.jpg', - '/images/house3.jpg', - '/images/house4.jpg', + '/images/house1.webp', + '/images/house2.webp', + '/images/house3.webp', + '/images/house4.webp', ], rating: 4.0, maxGuests: 2, @@ -335,10 +375,10 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ location: 'Córdoba, Argentina', price: 4100, images: [ - '/images/house5.jpg', - '/images/house2.jpg', - '/images/house4.jpg', - '/images/house3.jpg', + '/images/house5.webp', + '/images/house2.webp', + '/images/house4.webp', + '/images/house3.webp', ], rating: 4.2, maxGuests: 5, @@ -352,10 +392,10 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ location: 'Mendoza, Argentina', price: 2990, images: [ - '/images/house3.jpg', - '/images/house2.jpg', - '/images/house1.jpg', - '/images/house5.jpg', + '/images/house3.webp', + '/images/house2.webp', + '/images/house1.webp', + '/images/house5.webp', ], rating: 3.7, maxGuests: 3, @@ -369,10 +409,10 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ location: 'Rosario, Santa Fe', price: 5200, images: [ - '/images/house.jpg', - '/images/house2.jpg', - '/images/house4.jpg', - '/images/property-1.jpg', + '/images/house.webp', + '/images/house2.webp', + '/images/house4.webp', + '/images/property-1.webp', ], rating: 4.6, maxGuests: 8, @@ -386,10 +426,10 @@ export const MOCK_PROPERTIES: FullPropertyProps[] = [ location: 'Luján, Buenos Aires', price: 5800, images: [ - '/images/house1.jpg', - '/images/house5.jpg', - '/images/house4.jpg', - '/images/house3.jpg', + '/images/house1.webp', + '/images/house5.webp', + '/images/house4.webp', + '/images/house3.webp', ], rating: 4.5, maxGuests: 3, diff --git a/apps/web/src/app/booking/page.tsx b/apps/web/src/app/booking/page.tsx index ca259e6c..12fe0415 100644 --- a/apps/web/src/app/booking/page.tsx +++ b/apps/web/src/app/booking/page.tsx @@ -3,7 +3,6 @@ import { BookingConfirmation } from '@/components/booking/BookingConfirmation'; import { BookingForm } from '@/components/booking/BookingForm'; import { WalletConnectionModal } from '@/components/booking/WalletConnectionModal'; import { useWallet } from '@/hooks/useWallet'; -import { useTheme } from 'next-themes'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; import type { DateRange } from 'react-day-picker'; @@ -20,7 +19,6 @@ interface BookingPageProps { type BookingFlowStep = 'form' | 'payment' | 'confirmation'; export default function BookingPage({ params }: BookingPageProps) { - const { theme } = useTheme(); const _router = useRouter(); const { isConnected, connect, publicKey } = useWallet(); @@ -54,7 +52,7 @@ export default function BookingPage({ params }: BookingPageProps) { const _property = { id: params.propertyId, title: 'Luxury Beachfront Villa', - image: '/images/property-placeholder.jpg', + image: '/images/property-placeholder.webp', pricePerNight: 150, deposit: 500, commission: 0.00001, @@ -107,8 +105,10 @@ export default function BookingPage({ params }: BookingPageProps) { propertyId: data.property.id, userId: publicKey, dates: data.dates, + checkIn: data.dates.from.toISOString(), + checkOut: data.dates.to.toISOString(), guests: data.guests, - total: data.totalAmount, + totalAmount: data.totalAmount, deposit: data.depositAmount, }); @@ -116,12 +116,12 @@ export default function BookingPage({ params }: BookingPageProps) { toast.success('Booking created! Proceeding to payment.'); setCurrentBookingData({ - bookingId: createdBooking.bookingId, + bookingId: createdBooking.data.id, property: data.property, dates: data.dates, guests: data.guests, totalAmount: data.totalAmount, - escrowAddress: createdBooking.escrowAddress, + escrowAddress: createdBooking.data.escrowAddress || '', }); setBookingStep('payment'); diff --git a/apps/web/src/app/dashboard/host-dashboard/page.tsx b/apps/web/src/app/dashboard/host-dashboard/page.tsx index 84926551..b232067b 100644 --- a/apps/web/src/app/dashboard/host-dashboard/page.tsx +++ b/apps/web/src/app/dashboard/host-dashboard/page.tsx @@ -6,7 +6,7 @@ import ProfileManagement from '@/components/dashboard/ProfileManagement'; import PropertyManagement from '@/components/dashboard/PropertyManagement'; import RoleGuard from '@/hooks/auth/RoleGuard'; import { useRealTimeNotifications } from '@/hooks/useRealTimeUpdates'; -import { Calendar, DollarSign, Settings, User, Wallet } from 'lucide-react'; +import { Calendar, DollarSign, Loader2, RefreshCw, Settings, User, Wallet } from 'lucide-react'; import Image from 'next/image'; import { useState } from 'react'; import { AddPropertyModal } from './components/AddPropertyModal'; @@ -20,12 +20,21 @@ import { RecentTransactions } from './components/RecentTransactions'; import { mockBookings, mockEarnings, mockProperties, mockUser } from './mockData'; import type { Property, UserProfile } from './types'; +import { ErrorDisplay } from '@/components/ui/error-display'; +import { LoadingGrid } from '@/components/ui/loading-skeleton'; +import { useDashboard } from '@/hooks/useDashboard'; +import { + type UserProfile as ApiUserProfile, + transformToLegacyBooking, + transformToLegacyUser, +} from '@/types'; +import type { Booking } from '@/types/shared'; + const HostDashboard = () => { const [activeTab, setActiveTab] = useState('properties'); - const [properties, setProperties] = useState(mockProperties); - const [selectedProperty, _setSelectedProperty] = useState(null); const [showCalendarModal, setShowCalendarModal] = useState(false); const [showAddPropertyModal, setShowAddPropertyModal] = useState(false); + const [selectedProperty, _setSelectedProperty] = useState(null); const [selectedDates, setSelectedDates] = useState>(new Set()); const [newProperty, setNewProperty] = useState({ title: '', @@ -40,8 +49,38 @@ const HostDashboard = () => { images: [] as string[], rules: '', }); - const [user, setUser] = useState(mockUser); - const [bookings, setBookings] = useState(mockBookings); + + const { + bookings: apiBookings, + user: apiUser, + isLoadingBookings, + isLoadingProfile, + bookingsError, + profileError, + refetchAll, + cancelBooking: apiCancelBooking, + updateProfile: apiUpdateProfile, + uploadAvatar: apiUploadAvatar, + } = useDashboard({ userId: 'host-1', userType: 'host' }); + + const user = apiUser || mockUser; + const bookings: Booking[] = apiBookings.map((b) => ({ + id: b.id, + propertyTitle: b.propertyTitle, + propertyImage: b.propertyImage, + location: b.propertyLocation, + checkIn: b.checkIn, + checkOut: b.checkOut, + guests: b.guests, + totalAmount: b.totalAmount, + status: b.status as 'pending' | 'confirmed' | 'completed' | 'cancelled', + bookingDate: b.bookingDate, + propertyId: '1', + canCancel: true, + canReview: b.status === 'completed', + })); + + const [properties, setProperties] = useState(mockProperties); const { notifications, @@ -51,7 +90,11 @@ const HostDashboard = () => { markAllAsRead: handleMarkAllAsRead, deleteNotification: handleDeleteNotification, deleteAllNotifications: handleDeleteAllNotifications, - } = useRealTimeNotifications(user.id); + } = useRealTimeNotifications(user.id.toString()); + + const handleRefresh = async () => { + await refetchAll(); + }; const handleAddProperty = (e: React.FormEvent) => { e.preventDefault(); @@ -116,25 +159,16 @@ const HostDashboard = () => { const handleCancelBooking = async (bookingId: string) => { try { - await new Promise((resolve) => setTimeout(resolve, 1000)); - - setBookings((prev) => - prev.map((booking) => - booking.id === bookingId - ? { ...booking, status: 'cancelled' as const, canCancel: false } - : booking - ) - ); - + await apiCancelBooking(bookingId); addNotification({ id: Date.now().toString(), - type: 'booking', + type: 'booking' as const, title: 'Booking Cancelled', message: 'A booking has been cancelled', priority: 'medium', isRead: false, createdAt: new Date().toISOString(), - userId: user.id, + userId: user.id.toString(), }); } catch (error) { console.error('Failed to cancel booking:', error); @@ -143,19 +177,32 @@ const HostDashboard = () => { const handleUpdateProfile = async (updatedProfile: Partial) => { try { - await new Promise((resolve) => setTimeout(resolve, 1000)); + if (!apiUser) return; + + const safeUpdates = { + ...updatedProfile, + preferences: updatedProfile.preferences + ? { + currency: apiUser.preferences?.currency || 'USD', + language: apiUser.preferences?.language || 'en', + notifications: updatedProfile.preferences.notifications, + emailNotifications: updatedProfile.preferences.emailUpdates, + marketingEmails: apiUser.preferences?.marketingEmails, + } + : apiUser.preferences, + }; - setUser((prev) => ({ ...prev, ...updatedProfile })); + await apiUpdateProfile({ ...apiUser, ...safeUpdates } as unknown as ApiUserProfile); addNotification({ id: Date.now().toString(), - type: 'system', + type: 'system' as const, title: 'Profile Updated', message: 'Your profile has been successfully updated', priority: 'low', isRead: false, createdAt: new Date().toISOString(), - userId: user.id, + userId: user.id.toString(), }); } catch (error) { console.error('Failed to update profile:', error); @@ -164,20 +211,16 @@ const HostDashboard = () => { const handleUploadAvatar = async (file: File) => { try { - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const avatarUrl = URL.createObjectURL(file); - setUser((prev) => ({ ...prev, avatar: avatarUrl })); - + await apiUploadAvatar(user.id.toString(), file); addNotification({ id: Date.now().toString(), - type: 'system', + type: 'system' as const, title: 'Avatar Updated', message: 'Your profile picture has been successfully updated', priority: 'low', isRead: false, createdAt: new Date().toISOString(), - userId: user.id, + userId: user.id.toString(), }); } catch (error) { console.error('Failed to upload avatar:', error); @@ -204,8 +247,20 @@ const HostDashboard = () => {

Host Dashboard

+ {(isLoadingBookings || isLoadingProfile) && ( + + )}
+ ({ @@ -315,14 +370,23 @@ const HostDashboard = () => {

- {/* Statistics Cards */} - - - + {bookingsError ? ( + + ) : ( + <> + + + + )}
)} @@ -347,14 +411,35 @@ const HostDashboard = () => {
)} - {activeTab === 'profile' && ( - - )} + {activeTab === 'profile' && + (profileError ? ( + + ) : ( + + ))} {activeTab === 'wallet' && (
diff --git a/apps/web/src/app/dashboard/tenant-dashboard/page.tsx b/apps/web/src/app/dashboard/tenant-dashboard/page.tsx index 5c4af42a..bb33534e 100644 --- a/apps/web/src/app/dashboard/tenant-dashboard/page.tsx +++ b/apps/web/src/app/dashboard/tenant-dashboard/page.tsx @@ -3,7 +3,13 @@ import BookingHistory from '@/components/dashboard/BookingHistory'; import NotificationSystem from '@/components/dashboard/NotificationSystem'; import ProfileManagement from '@/components/dashboard/ProfileManagement'; +import { ErrorDisplay } from '@/components/ui/error-display'; +import { LoadingGrid } from '@/components/ui/loading-skeleton'; import RoleGuard from '@/hooks/auth/RoleGuard'; +import { useDashboard } from '@/hooks/useDashboard'; +import type { UserProfile as ApiUserProfile } from '@/types'; +import type { UserProfile } from '@/types/shared'; +import type { Booking } from '@/types/shared'; import { Activity, AlertCircle, @@ -25,10 +31,12 @@ import { Filter, Home, Info, + Loader2, MapPin, MessageSquare, PieChart, Plus, + RefreshCw, Search, Settings, Star, @@ -44,113 +52,6 @@ import Image from 'next/image'; import type React from 'react'; import { useEffect, useState } from 'react'; -interface Booking { - id: string; - propertyTitle: string; - propertyImage: string; - location: string; - checkIn: string; - checkOut: string; - guests: number; - totalAmount: number; - status: 'pending' | 'confirmed' | 'completed' | 'cancelled'; - bookingDate: string; - propertyId: string; - escrowAddress?: string; - transactionHash?: string; - canCancel: boolean; - canReview: boolean; -} - -interface UserProfile { - id: string; - name: string; - email: string; - avatar: string; - phone?: string; - location?: string; - bio?: string; - memberSince: string; - totalBookings: number; - totalSpent: number; - preferences: { - notifications: boolean; - emailUpdates: boolean; - pushNotifications: boolean; - }; -} - -// Mock data for demonstration -const mockBookings: Booking[] = [ - { - id: '1', - propertyTitle: 'Luxury Downtown Apartment', - propertyImage: - 'https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?w=800&auto=format&fit=crop', - location: 'New York, NY', - checkIn: '2025-06-15', - checkOut: '2025-06-20', - guests: 2, - totalAmount: 1250, - status: 'confirmed', - bookingDate: '2025-05-28', - propertyId: '1', - escrowAddress: 'GCO2IP3MJNUOKS4PUDI4C7LGGMQDJGXG3COYX3WSB4HHNAHKYV5YL3VC', - canCancel: true, - canReview: false, - }, - { - id: '2', - propertyTitle: 'Cozy Beach House', - propertyImage: - 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=800&auto=format&fit=crop', - location: 'Miami, FL', - checkIn: '2025-07-10', - checkOut: '2025-07-15', - guests: 4, - totalAmount: 900, - status: 'pending', - bookingDate: '2025-05-26', - propertyId: '2', - canCancel: true, - canReview: false, - }, - { - id: '3', - propertyTitle: 'Mountain Cabin Retreat', - propertyImage: - 'https://images.unsplash.com/photo-1449824913935-59a10b8d2000?w=800&auto=format&fit=crop', - location: 'Aspen, CO', - checkIn: '2025-05-20', - checkOut: '2025-05-25', - guests: 3, - totalAmount: 1600, - status: 'completed', - bookingDate: '2025-04-15', - propertyId: '3', - canCancel: false, - canReview: true, - }, -]; - -const mockUser: UserProfile = { - id: '1', - name: 'Sarah Johnson', - email: 'sarah.johnson@example.com', - avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100', - phone: '+1 (555) 123-4567', - location: 'San Francisco, CA', - bio: 'Travel enthusiast and adventure seeker. Love exploring new places and meeting interesting people.', - memberSince: '2023', - totalBookings: 12, - totalSpent: 8500, - preferences: { - notifications: true, - emailUpdates: true, - pushNotifications: false, - }, -}; - const mockTransactions = [ { id: 1, @@ -188,21 +89,55 @@ const mockTransactions = [ const TenantDashboard = () => { const [activeTab, setActiveTab] = useState('bookings'); - const [bookings, setBookings] = useState(mockBookings); - const [user, setUser] = useState(mockUser); const [transactions, _setTransactions] = useState(mockTransactions); - type NotificationItem = { + const [walletBalance, _setWalletBalance] = useState(2500); + + const { + bookings: apiBookings, + user: apiUser, + isLoadingBookings, + isLoadingProfile, + bookingsError, + profileError, + // error: generalError, + refetchAll, + cancelBooking: apiCancelBooking, + updateProfile: apiUpdateProfile, + uploadAvatar: apiUploadAvatar, + deleteAccount: apiDeleteAccount, + } = useDashboard({ userId: 'tenant-1', userType: 'tenant' }); + + interface LocalNotification { id: string; + type: 'booking' | 'payment' | 'review' | 'system' | 'message' | 'reminder'; title: string; message: string; + timestamp: Date; read: boolean; - createdAt: string; - }; + priority: 'low' | 'medium' | 'high'; + actionUrl?: string; + actionText?: string; + } - const [notifications, setNotifications] = useState([]); + const [notifications, setNotifications] = useState([]); const [unreadNotifications, setUnreadNotifications] = useState(0); - const [walletBalance, _setWalletBalance] = useState(2500); - const [isLoading, setIsLoading] = useState(false); + + const user = apiUser || null; + const bookings: Booking[] = apiBookings.map((b) => ({ + id: b.id, + propertyTitle: b.propertyTitle, + propertyImage: b.propertyImage, + location: b.propertyLocation, + checkIn: b.checkIn, + checkOut: b.checkOut, + guests: b.guests, + totalAmount: b.totalAmount, + status: b.status as 'pending' | 'confirmed' | 'completed' | 'cancelled', + bookingDate: b.bookingDate, + propertyId: '1', + canCancel: true, + canReview: b.status === 'completed', + })); const stats = { totalBookings: bookings.length, @@ -210,9 +145,9 @@ const TenantDashboard = () => { (b) => b.status === 'confirmed' && new Date(b.checkIn) > new Date() ).length, completedBookings: bookings.filter((b) => b.status === 'completed').length, - totalSpent: user.totalSpent, + totalSpent: user?.totalSpent || 0, averageRating: 4.8, - memberSince: user.memberSince, + memberSince: user?.memberSince || '2023', }; const handleMarkAsRead = (id: string) => { @@ -239,89 +174,78 @@ const TenantDashboard = () => { setUnreadNotifications(0); }; + const handleRefresh = async () => { + await refetchAll(); + }; + const handleCancelBooking = async (bookingId: string) => { - setIsLoading(true); try { - // Simulate API call - await new Promise((resolve) => setTimeout(resolve, 1000)); - - setBookings((prev) => - prev.map((booking) => - booking.id === bookingId - ? { ...booking, status: 'cancelled' as const, canCancel: false } - : booking - ) - ); - + await apiCancelBooking(bookingId); const newNotification = { id: Date.now().toString(), - type: 'booking', + type: 'booking' as const, title: 'Booking Cancelled', message: 'Your booking has been successfully cancelled', timestamp: new Date(), read: false, priority: 'medium' as const, }; - setNotifications((prev) => [newNotification, ...prev]); setUnreadNotifications((prev) => prev + 1); } catch (error) { console.error('Failed to cancel booking:', error); - } finally { - setIsLoading(false); } }; - const handleUpdateProfile = async (updatedProfile: Partial) => { - setIsLoading(true); + const handleUpdateProfile = async (updates: Partial) => { try { - await new Promise((resolve) => setTimeout(resolve, 1000)); - - setUser((prev) => ({ ...prev, ...updatedProfile })); - + if (!apiUser) return; + const safeUpdates = { + ...updates, + preferences: updates.preferences + ? { + currency: apiUser.preferences?.currency || 'USD', + language: apiUser.preferences?.language || 'en', + notifications: updates.preferences.notifications, + emailNotifications: updates.preferences.emailUpdates, + marketingEmails: apiUser.preferences?.marketingEmails, + } + : apiUser.preferences, + }; + await apiUpdateProfile({ ...apiUser, ...safeUpdates } as unknown as ApiUserProfile); const newNotification = { id: Date.now().toString(), - type: 'system', + type: 'system' as const, title: 'Profile Updated', message: 'Your profile has been successfully updated', timestamp: new Date(), read: false, priority: 'low' as const, }; - setNotifications((prev) => [newNotification, ...prev]); setUnreadNotifications((prev) => prev + 1); } catch (error) { console.error('Failed to update profile:', error); - } finally { - setIsLoading(false); } }; const handleUploadAvatar = async (file: File) => { - setIsLoading(true); try { - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const avatarUrl = URL.createObjectURL(file); - setUser((prev) => ({ ...prev, avatar: avatarUrl })); - + if (!user) return; + await apiUploadAvatar(user.id.toString(), file); const newNotification = { id: Date.now().toString(), - type: 'system', + type: 'system' as const, title: 'Avatar Updated', message: 'Your profile picture has been successfully updated', timestamp: new Date(), read: false, priority: 'low' as const, }; - setNotifications((prev) => [newNotification, ...prev]); setUnreadNotifications((prev) => prev + 1); } catch (error) { console.error('Failed to upload avatar:', error); - } finally { - setIsLoading(false); } }; @@ -335,8 +259,20 @@ const TenantDashboard = () => {

Tenant Dashboard

+ {(isLoadingBookings || isLoadingProfile) && ( + + )}
+ { -
- {user.name} - - {user.name} - -
+ {user && ( +
+ {user.name} + + {user.name} + +
+ )}
@@ -405,67 +343,86 @@ const TenantDashboard = () => {

-
-
-
-
-

- Total Bookings -

-

- {stats.totalBookings} -

-
-
- + {bookingsError ? ( + + ) : ( + <> +
+
+
+
+

+ Total Bookings +

+

+ {stats.totalBookings} +

+
+
+ +
+
-
-
-
-
-
-

Upcoming

-

{stats.upcomingBookings}

-
-
- +
+
+
+

+ Upcoming +

+

+ {stats.upcomingBookings} +

+
+
+ +
+
-
-
-
-
-
-

Completed

-

{stats.completedBookings}

-
-
- +
+
+
+

+ Completed +

+

+ {stats.completedBookings} +

+
+
+ +
+
-
-
-
-
-
-

- Total Spent -

-

${stats.totalSpent}

-
-
- +
+
+
+

+ Total Spent +

+

${stats.totalSpent}

+
+
+ +
+
-
-
- + + + )}
)} @@ -625,14 +582,37 @@ const TenantDashboard = () => {
)} - {activeTab === 'profile' && ( - - )} + {activeTab === 'profile' && + (profileError ? ( + + ) : user ? ( + { + if (user) await apiDeleteAccount(user.id.toString()); + }} + isLoading={isLoadingProfile} + /> + ) : ( +
+ +
+ ))} {activeTab === 'analytics' && (
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 3cbe357e..97da317e 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,9 +1,14 @@ +'use client'; + import { SearchBar } from '@/components/features/search/SearchBar'; import { RightSidebar } from '@/components/layout/RightSidebar'; -import { PropertyGrid } from '@/components/search/PropertyGrid'; +import PropertyGrid from '@/components/search/PropertyGrid'; +import { useProperties } from '@/hooks/useProperties'; import { House } from 'lucide-react'; export default function Home() { + const { properties, isLoading, error, refresh } = useProperties(); + return (
@@ -17,11 +22,16 @@ export default function Home() {
- Showing 23 properties + Showing {properties.length} properties
- +
diff --git a/apps/web/src/app/search/page.tsx b/apps/web/src/app/search/page.tsx index b572d27c..a144dfa9 100644 --- a/apps/web/src/app/search/page.tsx +++ b/apps/web/src/app/search/page.tsx @@ -19,6 +19,7 @@ export default function SearchPage() { const pageSize = 3; const [sort, setSort] = useState('price_asc'); const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); const [filters, setFilters] = useState({ price: 0, amenities: {} as Record, @@ -110,8 +111,16 @@ export default function SearchPage() {
- - {isLoading &&

Loading more properties...

} + { + setError(null); + loadNextPage(); + }} + />
diff --git a/apps/web/src/app/tenant-dashboard/components/profile-management.tsx b/apps/web/src/app/tenant-dashboard/components/profile-management.tsx deleted file mode 100644 index bb23d4ef..00000000 --- a/apps/web/src/app/tenant-dashboard/components/profile-management.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import type { LegacyUserProfile as UserProfile } from '@/types'; -import Image from 'next/image'; -import type React from 'react'; -import { useState } from 'react'; - -interface ProfileManagementProps { - user: UserProfile; - onUpdateUser: (user: UserProfile) => void; -} - -const ProfileManagement: React.FC = ({ user, onUpdateUser }) => { - const [isEditing, setIsEditing] = useState(false); - const [editedUser, setEditedUser] = useState(user); - - const handleSaveProfile = () => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(editedUser.email)) { - alert('Please enter a valid email address'); - return; - } - const phoneRegex = /^[\d\s\-\+\(\)]+$/; - if (editedUser.phone && !phoneRegex.test(editedUser.phone)) { - alert('Please enter a valid phone number'); - return; - } - onUpdateUser(editedUser); - setIsEditing(false); - }; - - const handleCancel = () => { - setEditedUser(user); - setIsEditing(false); - }; - - return ( -
-
-

Profile Settings

-

Manage your account information

-
- -
-
-
- {editedUser.name} -
-

- {editedUser.name} -

-

{editedUser.email}

-

- Member since {editedUser.memberSince} -

- {editedUser.verified && ( - - Verified - - )} -
- -
- -
-
- - setEditedUser((prev) => ({ ...prev, name: e.target.value }))} - className="w-full px-3 py-2 dark:text-white bg-transparent border border-gray-300 rounded-lg focus:ring-0 focus:ring-blue-500 focus:border-transparent disabled:opacity-60 disabled:cursor-not-allowed" - /> -
-
- - setEditedUser((prev) => ({ ...prev, email: e.target.value }))} - className="w-full px-3 py-2 dark:text-white border border-gray-300 rounded-lg focus:ring-0 bg-transparent focus:ring-blue-500 focus:border-transparent disabled:opacity-60 disabled:cursor-not-allowed" - /> -
-
- - setEditedUser((prev) => ({ ...prev, phone: e.target.value }))} - placeholder="+1 (555) 123-4567" - className="w-full px-3 py-2 border dark:text-white border-gray-300 rounded-lg focus:ring-0 bg-transparent focus:ring-blue-500 focus:border-transparent disabled:opacity-60 disabled:cursor-not-allowed" - /> -
-
- - setEditedUser((prev) => ({ ...prev, location: e.target.value }))} - placeholder="City, State" - className="w-full px-3 py-2 dark:text-white border border-gray-300 rounded-lg focus:ring-0 bg-transparent focus:ring-blue-500 focus:border-transparent disabled:opacity-60 disabled:cursor-not-allowed" - /> -
-
- -
- -