Skip to content
10 changes: 2 additions & 8 deletions apps/web/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="flex w-full min-h-screen">
<main className="flex flex-1 flex-col w-full min-h-screen px-5 pr-16">
<header className="flex items-center justify-between p-4 border-b border-gray-800"></header>
<header className="flex items-center justify-between p-4 border-b border-gray-800" />

<section className="p-4">
<SearchBar />
Expand All @@ -23,11 +21,7 @@ export default function Home() {
</span>
</div>

<Suspense
fallback={<div className="py-16 text-center text-white">Loading properties...</div>}
>
<PropertyGrid />
</Suspense>
<PropertyGrid />
</section>
</main>

Expand Down
41 changes: 40 additions & 1 deletion apps/web/src/components/search/PropertyGrid.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 <LoadingGrid count={8} columns={4} />;
}

// Show error state
if (error) {
return (
<div className="py-12">
<ErrorDisplay
title="Failed to load properties"
message={error}
onRetry={onRetry}
variant="destructive"
/>
</div>
);
}

// Show empty state
if (!mockProperties || mockProperties.length === 0) {
return (
<div className="py-12 text-center">
<p className="text-gray-400 text-lg">No properties found</p>
<p className="text-gray-500 text-sm mt-2">Try adjusting your search filters</p>
</div>
);
}

// Show properties
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{mockProperties.map((property) => (
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/components/shared/layout/providers.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,8 +13,8 @@ const ThemeProvider = dynamic(
{
ssr: false,
loading: () => (
<div className="min-h-screen bg-background text-foreground">
<div className="container mx-auto p-4">Cargando...</div>
<div className="min-h-screen bg-background text-foreground flex items-center justify-center">
<Spinner size="lg" label="Loading application..." />
</div>
),
}
Expand Down Expand Up @@ -41,8 +42,8 @@ export function Providers({ children }: ProvidersProps) {

if (!mounted) {
return (
<div className="min-h-screen bg-background text-foreground">
<div className="container mx-auto p-4">Cargando...</div>
<div className="min-h-screen bg-background text-foreground flex items-center justify-center">
<Spinner size="lg" label="Initializing..." />
</div>
);
}
Expand Down
80 changes: 80 additions & 0 deletions apps/web/src/components/ui/error-display.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={`rounded-xl border p-6 ${variantStyles[variant]} ${className}`}
role="alert"
aria-live="polite"
>
<div className="flex items-start gap-4">
<Icon className="w-6 h-6 flex-shrink-0 mt-0.5" />
<div className="flex-1 space-y-2">
<h3 className="font-semibold text-white">{title}</h3>
<p className="text-sm opacity-90">{message}</p>
{onRetry && (
<Button
onClick={onRetry}
variant="outline"
size="sm"
className="mt-4 gap-2 border-current text-current hover:bg-white/10"
>
<RefreshCw className="w-4 h-4" />
Try Again
</Button>
)}
</div>
</div>
</div>
);
};

// Inline error variant for smaller spaces
interface InlineErrorProps {
message: string;
onRetry?: () => void;
className?: string;
}

export const InlineError = ({ message, onRetry, className = '' }: InlineErrorProps) => {
return (
<div className={`flex items-center gap-2 text-red-400 text-sm ${className}`} role="alert">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{message}</span>
{onRetry && (
<button
onClick={onRetry}
className="ml-2 underline hover:text-red-300 transition-colors"
type="button"
>
Retry
</button>
)}
</div>
);
};
87 changes: 87 additions & 0 deletions apps/web/src/components/ui/loading-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
'use client';

import { Loader2 } from 'lucide-react';

interface LoadingSkeletonProps {
className?: string;
}

export const LoadingSkeleton = ({ className = '' }: LoadingSkeletonProps) => {
return (
<div
className={`animate-pulse bg-gray-700/30 rounded-lg ${className}`}
role="status"
aria-label="Loading"
/>
);
};

// Property card skeleton
export const PropertyCardSkeleton = () => {
return (
<div className="rounded-2xl overflow-hidden bg-secondary border border-gray-700/50">
<LoadingSkeleton className="w-full h-48" />
<div className="p-4 space-y-3">
<LoadingSkeleton className="h-6 w-3/4" />
<LoadingSkeleton className="h-4 w-1/2" />
<div className="flex gap-2">
<LoadingSkeleton className="h-4 w-16" />
<LoadingSkeleton className="h-4 w-16" />
</div>
<LoadingSkeleton className="h-8 w-full" />
</div>
</div>
);
};

// Grid loading state
interface LoadingGridProps {
count?: number;
columns?: number;
}

export const LoadingGrid = ({ count = 8, columns = 4 }: LoadingGridProps) => {
return (
<div className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-${columns} gap-8`}>
{Array.from({ length: count }, () => crypto.randomUUID()).map((id) => (
<PropertyCardSkeleton key={id} />
))}
</div>
Comment on lines +43 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check Tailwind configuration for safelists and grid-cols classes
find . -name 'tailwind.config.*' -o -name 'tailwind.config.ts' -o -name 'tailwind.config.js' | head -20

Repository: Stellar-Rent/stellar-rent

Length of output: 98


🏁 Script executed:

# Search for Tailwind config files
fd -n 'tailwind.config\.' .

Repository: Stellar-Rent/stellar-rent

Length of output: 299


🏁 Script executed:

# Look for any existing safelist configurations
rg -n 'safelist' . -t ts -t js -t tsx -t jsx

Repository: Stellar-Rent/stellar-rent

Length of output: 96


🏁 Script executed:

# Check for xl:grid-cols usage in the codebase
rg -n 'xl:grid-cols' apps/web/src -g'*.{ts,tsx,js,jsx}'

Repository: Stellar-Rent/stellar-rent

Length of output: 650


🏁 Script executed:

# Examine the loading-skeleton file context
cat -n apps/web/src/components/ui/loading-skeleton.tsx

Repository: Stellar-Rent/stellar-rent

Length of output: 2967


🏁 Script executed:

# Read the Tailwind configuration to check for safelist and content settings
cat -n apps/web/tailwind.config.js

Repository: Stellar-Rent/stellar-rent

Length of output: 2776


🏁 Script executed:

# Check if there are any other safelist configurations in the codebase
rg -i 'safelist|content.*grid-cols' . -t js

Repository: Stellar-Rent/stellar-rent

Length of output: 51


Fix dynamic Tailwind class and unstable keys in LoadingGrid.

The dynamic class xl:grid-cols-${columns} will be purged by Tailwind in production builds since the Tailwind config has no safelist and relies on static content scanning. Additionally, crypto.randomUUID() regenerates keys on every render, causing unnecessary remounts of PropertyCardSkeleton.

Map columns to static class names and use stable indices for skeleton keys:

♻️ Refactor
 export const LoadingGrid = ({ count = 8, columns = 4 }: LoadingGridProps) => {
+  const columnClasses: Record<number, string> = {
+    1: 'xl:grid-cols-1',
+    2: 'xl:grid-cols-2',
+    3: 'xl:grid-cols-3',
+    4: 'xl:grid-cols-4',
+    5: 'xl:grid-cols-5',
+    6: 'xl:grid-cols-6',
+  };
+  const xlColumnsClass = columnClasses[columns] ?? 'xl:grid-cols-4';
+
   return (
-    <div className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-${columns} gap-8`}>
-      {Array.from({ length: count }, () => crypto.randomUUID()).map((id) => (
-        <PropertyCardSkeleton key={id} />
+    <div className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 ${xlColumnsClass} gap-8`}>
+      {Array.from({ length: count }).map((_, idx) => (
+        <PropertyCardSkeleton key={idx} />
       ))}
     </div>
   );
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const LoadingGrid = ({ count = 8, columns = 4 }: LoadingGridProps) => {
return (
<div className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-${columns} gap-8`}>
{Array.from({ length: count }, () => crypto.randomUUID()).map((id) => (
<PropertyCardSkeleton key={id} />
))}
</div>
export const LoadingGrid = ({ count = 8, columns = 4 }: LoadingGridProps) => {
const columnClasses: Record<number, string> = {
1: 'xl:grid-cols-1',
2: 'xl:grid-cols-2',
3: 'xl:grid-cols-3',
4: 'xl:grid-cols-4',
5: 'xl:grid-cols-5',
6: 'xl:grid-cols-6',
};
const xlColumnsClass = columnClasses[columns] ?? 'xl:grid-cols-4';
return (
<div className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 ${xlColumnsClass} gap-8`}>
{Array.from({ length: count }).map((_, idx) => (
<PropertyCardSkeleton key={idx} />
))}
</div>
);
};
🤖 Prompt for AI Agents
In `@apps/web/src/components/ui/loading-skeleton.tsx` around lines 43 - 49,
LoadingGrid uses a dynamic Tailwind class xl:grid-cols-${columns} (which will be
purged) and unstable keys generated by crypto.randomUUID() (causing remounts);
replace the dynamic class with a mapping from allowed columns to static class
names (e.g., map 1..6 to "xl:grid-cols-1".."xl:grid-cols-6" and default to a
safe value) and use stable keys for the skeletons by iterating with Array.from({
length: count }).map((_, idx) => ...) and keying PropertyCardSkeleton with a
stable identifier like `skeleton-${idx}`; update the LoadingGrid component to
compute the static colClass and use it in the container className and remove
crypto.randomUUID() usage.

);
};

// 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 (
<div className="flex flex-col items-center justify-center gap-3">
<Loader2 className={`animate-spin text-primary ${sizeClasses[size]} ${className}`} />
{label && <span className="text-sm text-gray-400">{label}</span>}
<span className="sr-only">Loading...</span>
</div>
);
};

// Full page loader
interface FullPageLoaderProps {
message?: string;
}

export const FullPageLoader = ({ message = 'Loading...' }: FullPageLoaderProps) => {
return (
<div className="flex items-center justify-center min-h-[400px] w-full">
<Spinner size="lg" label={message} />
</div>
);
};
118 changes: 118 additions & 0 deletions apps/web/src/hooks/useApiCall.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
data: T | null;
error: Error | null;
isLoading: boolean;
execute: (...args: unknown[]) => Promise<T | null>;
retry: () => Promise<T | null>;
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<T>(
apiFunction: (...args: unknown[]) => Promise<T>,
options: UseApiCallOptions = {}
): UseApiCallReturn<T> {
const { retryCount = 3, retryDelay = 1000, onSuccess, onError } = options;

const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [lastArgs, setLastArgs] = useState<unknown[]>([]);

const executeWithRetry = useCallback(
async (args: unknown[], currentRetry = 0): Promise<T | null> => {
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<T | null> => {
setLastArgs(args);
return executeWithRetry(args);
},
[executeWithRetry]
);

const retry = useCallback(async (): Promise<T | null> => {
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<T>(
apiFunction: () => Promise<T>,
maxRetries = 3,
delay = 1000
): Promise<T> {
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;
}
Loading