├── public ├── placeholder.jpg ├── placeholder-logo.png ├── placeholder-user.jpg ├── placeholder-logo.svg └── placeholder.svg ├── pnpm-lock.yaml ├── postcss.config.mjs ├── lib ├── utils.ts └── api.ts ├── app ├── location │ ├── sender │ │ └── page.tsx │ └── receiver │ │ └── page.tsx ├── layout.tsx ├── providers.tsx ├── globals.css ├── page.tsx └── users │ └── page.tsx ├── next.config.mjs ├── components ├── ui │ ├── skeleton.tsx │ ├── label.tsx │ ├── input.tsx │ ├── switch.tsx │ ├── badge.tsx │ ├── avatar.tsx │ ├── alert.tsx │ ├── button.tsx │ └── card.tsx ├── user │ ├── VirtualizedUserItem.tsx │ ├── UserCardSkeleton.tsx │ └── UserCard.tsx ├── map-component.tsx └── location │ ├── LocationReceiver.tsx │ └── LocationSender.tsx ├── types └── index.ts ├── .gitignore ├── components.json ├── tsconfig.json ├── README.md ├── package.json ├── styles └── globals.css ├── tailwind.config.ts └── hooks └── use-signalr.ts /public/placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hazrat-Ali9/Real-Time-Location/HEAD/public/placeholder.jpg -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false -------------------------------------------------------------------------------- /public/placeholder-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hazrat-Ali9/Real-Time-Location/HEAD/public/placeholder-logo.png -------------------------------------------------------------------------------- /public/placeholder-user.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hazrat-Ali9/Real-Time-Location/HEAD/public/placeholder-user.jpg -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /app/location/sender/page.tsx: -------------------------------------------------------------------------------- 1 | import LocationSender from '@/components/location/LocationSender'; 2 | import React from 'react'; 3 | 4 | const page = () => { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | }; 11 | 12 | export default page; 13 | -------------------------------------------------------------------------------- /app/location/receiver/page.tsx: -------------------------------------------------------------------------------- 1 | import LocationReceiver from '@/components/location/LocationReceiver'; 2 | import React from 'react'; 3 | 4 | const page = () => { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | }; 11 | 12 | export default page; 13 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | eslint: { 4 | ignoreDuringBuilds: true, 5 | }, 6 | typescript: { 7 | ignoreBuildErrors: true, 8 | }, 9 | images: { 10 | unoptimized: true, 11 | }, 12 | } 13 | 14 | export default nextConfig 15 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /lib/api.ts: -------------------------------------------------------------------------------- 1 | import { ApiResponse } from "../types/index" 2 | 3 | 4 | export async function fetchUsers({ pageParam = 0 }): Promise { 5 | const response = await fetch(`https://tech-test.raintor.com/api/users/GetUsersList?take=10&skip=${pageParam}`) 6 | 7 | if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) 8 | return response.json() 9 | } 10 | -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id: number 3 | firstName: string 4 | lastName: string 5 | email: string 6 | phone: string 7 | image: string 8 | university: string 9 | company: { 10 | title: string 11 | } 12 | } 13 | 14 | export interface ApiResponse { 15 | users: User[] 16 | total: number 17 | skip: number 18 | limit: number 19 | } 20 | -------------------------------------------------------------------------------- /components/user/VirtualizedUserItem.tsx: -------------------------------------------------------------------------------- 1 | import { UserCard } from "./UserCard"; 2 | 3 | 4 | export function VirtualizedUserItem({ index, style, data }: { index: number; style: any; data: any[] }) { 5 | const user = data[index] 6 | return ( 7 |
8 |
9 | 10 |
11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # next.js 7 | /.next/ 8 | /out/ 9 | 10 | # production 11 | /build 12 | 13 | # debug 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | .pnpm-debug.log* 18 | 19 | # env files 20 | .env* 21 | 22 | # vercel 23 | .vercel 24 | 25 | # typescript 26 | *.tsbuildinfo 27 | next-env.d.ts 28 | .vercel 29 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "target": "ES6", 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react" 2 | import type { Metadata } from "next" 3 | import { Inter } from "next/font/google" 4 | import "./globals.css" 5 | import Providers from "./providers" 6 | 7 | const inter = Inter({ subsets: ["latin"] }) 8 | 9 | export const metadata: Metadata = { 10 | title: "Real-Time Location & User Feed", 11 | description: "SignalR integration with infinite scroll patterns", 12 | generator: 'v0.dev' 13 | } 14 | 15 | export default function RootLayout({ 16 | children, 17 | }: { 18 | children: React.ReactNode 19 | }) { 20 | return ( 21 | 22 | 23 | {children} 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /components/user/UserCardSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent } from "@/components/ui/card" 2 | import { Skeleton } from "@/components/ui/skeleton" 3 | 4 | export function UserCardSkeleton() { 5 | return ( 6 | 7 | 8 |
9 | 10 |
11 | 12 | 13 | 14 |
15 | 16 | 17 |
18 |
19 |
20 |
21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /app/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import type React from "react" 4 | 5 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query" 6 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools" 7 | import { useState } from "react" 8 | 9 | export default function Providers({ children }: { children: React.ReactNode }) { 10 | const [queryClient] = useState( 11 | () => 12 | new QueryClient({ 13 | defaultOptions: { 14 | queries: { 15 | staleTime: 60 * 1000, // 1 minute 16 | retry: (failureCount, error) => { 17 | // Don't retry on 4xx errors 18 | if (error instanceof Error && error.message.includes("4")) { 19 | return false 20 | } 21 | return failureCount < 3 22 | }, 23 | }, 24 | }, 25 | }), 26 | ) 27 | 28 | return ( 29 | 30 | {children} 31 | 32 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SwitchPrimitives from "@radix-ui/react-switch" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )) 27 | Switch.displayName = SwitchPrimitives.Root.displayName 28 | 29 | export { Switch } 30 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SignalR Real-Time Location & Infinite Scroll App 2 | 3 | This project showcases real-time communication with SignalR for live location sharing and an efficient infinite scroll user feed, built with Next.js and React Query. 4 | 5 | ### Live Link 6 | []() 7 | 8 | ## ✨ Key Features 9 | 10 | * **Real-Time Location Sharing**: 11 | * **User A (Sender)**: Broadcasts live GPS coordinates (simulated or real) via SignalR. 12 | * **User B (Receiver)**: Displays real-time location updates on an interactive Leaflet map. 13 | * Includes a custom `useSignalR` hook, connection tester, and a mock mode for development. 14 | * **Infinite Scroll User Feed**: 15 | * Fetches and displays a paginated list of users from an API. 16 | * Uses React Query for data management, caching, and automatic retries. 17 | * Features `react-window` for list virtualization, skeleton loaders, and robust error handling. 18 | * Automatically scrolls to the top once all data is loaded. 19 | 20 | ## 🚀 Technologies Used 21 | 22 | * **Next.js 15 (App Router)** 23 | * **React Query** 24 | * **SignalR (`@microsoft/signalr`)** 25 | * **Leaflet (`leaflet`, `react-leaflet`)** 26 | * **React Window (`react-window`)** 27 | * **Tailwind CSS & Shadcn/ui** 28 | * **TypeScript** 29 | 30 | ### Installation & Run 31 | 32 | # Clone and Install 33 | 34 | ``` bash 35 | git clone https://github.com/Hazrat-Ali9/Real-Time-Location 36 | cd Real-Time-Location-Sharing 37 | npm install 38 | # or 39 | npm install -f 40 | 41 | ``` 42 | 43 | # Run development server 44 | ``` bash 45 | npm run dev 46 | ``` 47 | 48 | Open [http://localhost:3000](http://localhost:3000) 49 | 50 | 51 | ### Build for Production 52 | 53 | ``` bash 54 | npm run start 55 | ``` 56 | -------------------------------------------------------------------------------- /components/user/UserCard.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent } from "@/components/ui/card" 2 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" 3 | import { Badge } from "@/components/ui/badge" 4 | 5 | interface User { 6 | id: number 7 | firstName: string 8 | lastName: string 9 | email: string 10 | phone: string 11 | image: string 12 | university: string 13 | company: { 14 | title: string 15 | } 16 | } 17 | 18 | export function UserCard({ user }: { user: User }) { 19 | return ( 20 | 21 | 22 |
23 | 24 | 25 | 26 | {user.firstName[0]} 27 | {user.lastName[0]} 28 | 29 | 30 | 31 |
32 |

{user.firstName} {user.lastName}

33 |

{user.email}

34 |

{user.phone}

35 |
36 | {user.company.title} 37 | {user.university} 38 |
39 |
40 |
41 |
42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLDivElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |
64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-v0-project", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next dev", 8 | "lint": "next lint", 9 | "start": "next start" 10 | }, 11 | "dependencies": { 12 | "@hookform/resolvers": "^3.9.1", 13 | "@microsoft/signalr": "latest", 14 | "@radix-ui/react-accordion": "1.2.2", 15 | "@radix-ui/react-alert-dialog": "1.1.4", 16 | "@radix-ui/react-aspect-ratio": "1.1.1", 17 | "@radix-ui/react-avatar": "1.1.2", 18 | "@radix-ui/react-checkbox": "1.1.3", 19 | "@radix-ui/react-collapsible": "1.1.2", 20 | "@radix-ui/react-context-menu": "2.2.4", 21 | "@radix-ui/react-dialog": "1.1.4", 22 | "@radix-ui/react-dropdown-menu": "2.1.4", 23 | "@radix-ui/react-hover-card": "1.1.4", 24 | "@radix-ui/react-label": "2.1.1", 25 | "@radix-ui/react-menubar": "1.1.4", 26 | "@radix-ui/react-navigation-menu": "1.2.3", 27 | "@radix-ui/react-popover": "1.1.4", 28 | "@radix-ui/react-progress": "1.1.1", 29 | "@radix-ui/react-radio-group": "1.2.2", 30 | "@radix-ui/react-scroll-area": "1.2.2", 31 | "@radix-ui/react-select": "2.1.4", 32 | "@radix-ui/react-separator": "1.1.1", 33 | "@radix-ui/react-slider": "1.2.2", 34 | "@radix-ui/react-slot": "1.1.1", 35 | "@radix-ui/react-switch": "latest", 36 | "@radix-ui/react-tabs": "1.1.2", 37 | "@radix-ui/react-toast": "1.2.4", 38 | "@radix-ui/react-toggle": "1.1.1", 39 | "@radix-ui/react-toggle-group": "1.1.1", 40 | "@radix-ui/react-tooltip": "1.1.6", 41 | "@tanstack/react-query": "latest", 42 | "@tanstack/react-query-devtools": "latest", 43 | "autoprefixer": "^10.4.20", 44 | "class-variance-authority": "^0.7.1", 45 | "clsx": "^2.1.1", 46 | "cmdk": "1.0.4", 47 | "date-fns": "4.1.0", 48 | "embla-carousel-react": "8.5.1", 49 | "input-otp": "1.4.1", 50 | "leaflet": "latest", 51 | "lucide-react": "^0.454.0", 52 | "next": "15.2.4", 53 | "next-themes": "^0.4.4", 54 | "react": "^19", 55 | "react-day-picker": "8.10.1", 56 | "react-dom": "^19", 57 | "react-hook-form": "^7.54.1", 58 | "react-resizable-panels": "^2.1.7", 59 | "react-window": "latest", 60 | "recharts": "2.15.0", 61 | "sonner": "^1.7.1", 62 | "tailwind-merge": "^2.5.5", 63 | "tailwindcss-animate": "^1.0.7", 64 | "vaul": "^0.9.6", 65 | "zod": "^3.24.1" 66 | }, 67 | "devDependencies": { 68 | "@types/leaflet": "^1.9.20", 69 | "@types/node": "^22", 70 | "@types/react": "^19", 71 | "@types/react-dom": "^19", 72 | "@types/react-window": "^1.8.8", 73 | "postcss": "^8.5", 74 | "tailwindcss": "^3.4.17", 75 | "typescript": "^5" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: Arial, Helvetica, sans-serif; 7 | } 8 | 9 | @layer utilities { 10 | .text-balance { 11 | text-wrap: balance; 12 | } 13 | } 14 | 15 | @layer base { 16 | :root { 17 | --background: 0 0% 100%; 18 | --foreground: 0 0% 3.9%; 19 | --card: 0 0% 100%; 20 | --card-foreground: 0 0% 3.9%; 21 | --popover: 0 0% 100%; 22 | --popover-foreground: 0 0% 3.9%; 23 | --primary: 0 0% 9%; 24 | --primary-foreground: 0 0% 98%; 25 | --secondary: 0 0% 96.1%; 26 | --secondary-foreground: 0 0% 9%; 27 | --muted: 0 0% 96.1%; 28 | --muted-foreground: 0 0% 45.1%; 29 | --accent: 0 0% 96.1%; 30 | --accent-foreground: 0 0% 9%; 31 | --destructive: 0 84.2% 60.2%; 32 | --destructive-foreground: 0 0% 98%; 33 | --border: 0 0% 89.8%; 34 | --input: 0 0% 89.8%; 35 | --ring: 0 0% 3.9%; 36 | --chart-1: 12 76% 61%; 37 | --chart-2: 173 58% 39%; 38 | --chart-3: 197 37% 24%; 39 | --chart-4: 43 74% 66%; 40 | --chart-5: 27 87% 67%; 41 | --radius: 0.5rem; 42 | --sidebar-background: 0 0% 98%; 43 | --sidebar-foreground: 240 5.3% 26.1%; 44 | --sidebar-primary: 240 5.9% 10%; 45 | --sidebar-primary-foreground: 0 0% 98%; 46 | --sidebar-accent: 240 4.8% 95.9%; 47 | --sidebar-accent-foreground: 240 5.9% 10%; 48 | --sidebar-border: 220 13% 91%; 49 | --sidebar-ring: 217.2 91.2% 59.8%; 50 | } 51 | .dark { 52 | --background: 0 0% 3.9%; 53 | --foreground: 0 0% 98%; 54 | --card: 0 0% 3.9%; 55 | --card-foreground: 0 0% 98%; 56 | --popover: 0 0% 3.9%; 57 | --popover-foreground: 0 0% 98%; 58 | --primary: 0 0% 98%; 59 | --primary-foreground: 0 0% 9%; 60 | --secondary: 0 0% 14.9%; 61 | --secondary-foreground: 0 0% 98%; 62 | --muted: 0 0% 14.9%; 63 | --muted-foreground: 0 0% 63.9%; 64 | --accent: 0 0% 14.9%; 65 | --accent-foreground: 0 0% 98%; 66 | --destructive: 0 62.8% 30.6%; 67 | --destructive-foreground: 0 0% 98%; 68 | --border: 0 0% 14.9%; 69 | --input: 0 0% 14.9%; 70 | --ring: 0 0% 83.1%; 71 | --chart-1: 220 70% 50%; 72 | --chart-2: 160 60% 45%; 73 | --chart-3: 30 80% 55%; 74 | --chart-4: 280 65% 60%; 75 | --chart-5: 340 75% 55%; 76 | --sidebar-background: 240 5.9% 10%; 77 | --sidebar-foreground: 240 4.8% 95.9%; 78 | --sidebar-primary: 224.3 76.3% 48%; 79 | --sidebar-primary-foreground: 0 0% 100%; 80 | --sidebar-accent: 240 3.7% 15.9%; 81 | --sidebar-accent-foreground: 240 4.8% 95.9%; 82 | --sidebar-border: 240 3.7% 15.9%; 83 | --sidebar-ring: 217.2 91.2% 59.8%; 84 | } 85 | } 86 | 87 | @layer base { 88 | * { 89 | @apply border-border; 90 | } 91 | body { 92 | @apply bg-background text-foreground; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: Arial, Helvetica, sans-serif; 7 | } 8 | 9 | @layer utilities { 10 | .text-balance { 11 | text-wrap: balance; 12 | } 13 | } 14 | 15 | @layer base { 16 | :root { 17 | --background: 0 0% 100%; 18 | --foreground: 0 0% 3.9%; 19 | --card: 0 0% 100%; 20 | --card-foreground: 0 0% 3.9%; 21 | --popover: 0 0% 100%; 22 | --popover-foreground: 0 0% 3.9%; 23 | --primary: 0 0% 9%; 24 | --primary-foreground: 0 0% 98%; 25 | --secondary: 0 0% 96.1%; 26 | --secondary-foreground: 0 0% 9%; 27 | --muted: 0 0% 96.1%; 28 | --muted-foreground: 0 0% 45.1%; 29 | --accent: 0 0% 96.1%; 30 | --accent-foreground: 0 0% 9%; 31 | --destructive: 0 84.2% 60.2%; 32 | --destructive-foreground: 0 0% 98%; 33 | --border: 0 0% 89.8%; 34 | --input: 0 0% 89.8%; 35 | --ring: 0 0% 3.9%; 36 | --chart-1: 12 76% 61%; 37 | --chart-2: 173 58% 39%; 38 | --chart-3: 197 37% 24%; 39 | --chart-4: 43 74% 66%; 40 | --chart-5: 27 87% 67%; 41 | --radius: 0.5rem; 42 | --sidebar-background: 0 0% 98%; 43 | --sidebar-foreground: 240 5.3% 26.1%; 44 | --sidebar-primary: 240 5.9% 10%; 45 | --sidebar-primary-foreground: 0 0% 98%; 46 | --sidebar-accent: 240 4.8% 95.9%; 47 | --sidebar-accent-foreground: 240 5.9% 10%; 48 | --sidebar-border: 220 13% 91%; 49 | --sidebar-ring: 217.2 91.2% 59.8%; 50 | } 51 | .dark { 52 | --background: 0 0% 3.9%; 53 | --foreground: 0 0% 98%; 54 | --card: 0 0% 3.9%; 55 | --card-foreground: 0 0% 98%; 56 | --popover: 0 0% 3.9%; 57 | --popover-foreground: 0 0% 98%; 58 | --primary: 0 0% 98%; 59 | --primary-foreground: 0 0% 9%; 60 | --secondary: 0 0% 14.9%; 61 | --secondary-foreground: 0 0% 98%; 62 | --muted: 0 0% 14.9%; 63 | --muted-foreground: 0 0% 63.9%; 64 | --accent: 0 0% 14.9%; 65 | --accent-foreground: 0 0% 98%; 66 | --destructive: 0 62.8% 30.6%; 67 | --destructive-foreground: 0 0% 98%; 68 | --border: 0 0% 14.9%; 69 | --input: 0 0% 14.9%; 70 | --ring: 0 0% 83.1%; 71 | --chart-1: 220 70% 50%; 72 | --chart-2: 160 60% 45%; 73 | --chart-3: 30 80% 55%; 74 | --chart-4: 280 65% 60%; 75 | --chart-5: 340 75% 55%; 76 | --sidebar-background: 240 5.9% 10%; 77 | --sidebar-foreground: 240 4.8% 95.9%; 78 | --sidebar-primary: 224.3 76.3% 48%; 79 | --sidebar-primary-foreground: 0 0% 100%; 80 | --sidebar-accent: 240 3.7% 15.9%; 81 | --sidebar-accent-foreground: 240 4.8% 95.9%; 82 | --sidebar-border: 240 3.7% 15.9%; 83 | --sidebar-ring: 217.2 91.2% 59.8%; 84 | } 85 | } 86 | 87 | @layer base { 88 | * { 89 | @apply border-border; 90 | } 91 | body { 92 | @apply bg-background text-foreground; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; 3 | import { MapPin, Users, Wifi } from 'lucide-react'; 4 | import Link from 'next/link'; 5 | import React from 'react'; 6 | 7 | const Main = () => { 8 | return ( 9 |
10 |
11 |
12 |

Real-Time Location & User Feed

13 |

Demonstrate SignalR integration and infinite scroll patterns

14 |
15 | 16 |
17 | 18 | 19 | 20 | 21 | Location Sharing 22 | 23 | Real-time GPS coordinate sharing between users using SignalR 24 | 25 | 26 |
27 | 31 | User A - Send Location 32 | 33 | 37 | User B - Receive Location 38 | 39 |
40 |
41 |
42 | 43 | 44 | 45 | 46 | 47 | User Feed 48 | 49 | Infinite scroll user list with virtualization and error handling 50 | 51 | 52 | 56 | View User Feed 57 | 58 | 59 | 60 |
61 | 62 | 63 |
64 |
65 | ); 66 | }; 67 | 68 | export default Main; 69 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | // all in fixtures is set to tailwind v3 as interims solutions 4 | 5 | const config: Config = { 6 | darkMode: ["class"], 7 | content: [ 8 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 9 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 10 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 11 | "*.{js,ts,jsx,tsx,mdx}" 12 | ], 13 | theme: { 14 | extend: { 15 | colors: { 16 | background: 'hsl(var(--background))', 17 | foreground: 'hsl(var(--foreground))', 18 | card: { 19 | DEFAULT: 'hsl(var(--card))', 20 | foreground: 'hsl(var(--card-foreground))' 21 | }, 22 | popover: { 23 | DEFAULT: 'hsl(var(--popover))', 24 | foreground: 'hsl(var(--popover-foreground))' 25 | }, 26 | primary: { 27 | DEFAULT: 'hsl(var(--primary))', 28 | foreground: 'hsl(var(--primary-foreground))' 29 | }, 30 | secondary: { 31 | DEFAULT: 'hsl(var(--secondary))', 32 | foreground: 'hsl(var(--secondary-foreground))' 33 | }, 34 | muted: { 35 | DEFAULT: 'hsl(var(--muted))', 36 | foreground: 'hsl(var(--muted-foreground))' 37 | }, 38 | accent: { 39 | DEFAULT: 'hsl(var(--accent))', 40 | foreground: 'hsl(var(--accent-foreground))' 41 | }, 42 | destructive: { 43 | DEFAULT: 'hsl(var(--destructive))', 44 | foreground: 'hsl(var(--destructive-foreground))' 45 | }, 46 | border: 'hsl(var(--border))', 47 | input: 'hsl(var(--input))', 48 | ring: 'hsl(var(--ring))', 49 | chart: { 50 | '1': 'hsl(var(--chart-1))', 51 | '2': 'hsl(var(--chart-2))', 52 | '3': 'hsl(var(--chart-3))', 53 | '4': 'hsl(var(--chart-4))', 54 | '5': 'hsl(var(--chart-5))' 55 | }, 56 | sidebar: { 57 | DEFAULT: 'hsl(var(--sidebar-background))', 58 | foreground: 'hsl(var(--sidebar-foreground))', 59 | primary: 'hsl(var(--sidebar-primary))', 60 | 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', 61 | accent: 'hsl(var(--sidebar-accent))', 62 | 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', 63 | border: 'hsl(var(--sidebar-border))', 64 | ring: 'hsl(var(--sidebar-ring))' 65 | } 66 | }, 67 | borderRadius: { 68 | lg: 'var(--radius)', 69 | md: 'calc(var(--radius) - 2px)', 70 | sm: 'calc(var(--radius) - 4px)' 71 | }, 72 | keyframes: { 73 | 'accordion-down': { 74 | from: { 75 | height: '0' 76 | }, 77 | to: { 78 | height: 'var(--radix-accordion-content-height)' 79 | } 80 | }, 81 | 'accordion-up': { 82 | from: { 83 | height: 'var(--radix-accordion-content-height)' 84 | }, 85 | to: { 86 | height: '0' 87 | } 88 | } 89 | }, 90 | animation: { 91 | 'accordion-down': 'accordion-down 0.2s ease-out', 92 | 'accordion-up': 'accordion-up 0.2s ease-out' 93 | } 94 | } 95 | }, 96 | plugins: [require("tailwindcss-animate")], 97 | }; 98 | export default config; 99 | -------------------------------------------------------------------------------- /public/placeholder-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/placeholder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/map-component.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, useRef } from "react" 4 | import L from "leaflet" 5 | import "leaflet/dist/leaflet.css" 6 | 7 | // Fix for default markers in Leaflet 8 | delete (L.Icon.Default.prototype as any)._getIconUrl 9 | L.Icon.Default.mergeOptions({ 10 | iconRetinaUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png", 11 | iconUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png", 12 | shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png", 13 | }) 14 | 15 | interface LocationData { 16 | userName: string 17 | lat: number 18 | lon: number 19 | timestamp?: number 20 | } 21 | 22 | interface MapComponentProps { 23 | locations: LocationData[] 24 | } 25 | 26 | export default function MapComponent({ locations }: MapComponentProps) { 27 | const mapRef = useRef(null) 28 | const markersRef = useRef>(new Map()) 29 | const mapContainerRef = useRef(null) 30 | 31 | useEffect(() => { 32 | if (!mapContainerRef.current) return 33 | 34 | // Initialize map 35 | if (!mapRef.current) { 36 | mapRef.current = L.map(mapContainerRef.current).setView([25.73736464, 90.3644747], 13) 37 | 38 | L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { 39 | attribution: "© OpenStreetMap contributors", 40 | }).addTo(mapRef.current) 41 | } 42 | 43 | return () => { 44 | if (mapRef.current) { 45 | mapRef.current.remove() 46 | mapRef.current = null 47 | } 48 | } 49 | }, []) 50 | 51 | useEffect(() => { 52 | if (!mapRef.current) return 53 | 54 | const map = mapRef.current 55 | const markers = markersRef.current 56 | 57 | // Update markers for current locations 58 | const activeUserNames = new Set() 59 | 60 | locations.forEach((location) => { 61 | activeUserNames.add(location.userName) 62 | 63 | if (markers.has(location.userName)) { 64 | // Update existing marker 65 | const marker = markers.get(location.userName)! 66 | marker.setLatLng([location.lat, location.lon]) 67 | marker.setPopupContent(` 68 |
69 | ${location.userName}
70 | Lat: ${location.lat.toFixed(6)}
71 | Lon: ${location.lon.toFixed(6)}
72 | ${location.timestamp ? `Updated: ${new Date(location.timestamp).toLocaleTimeString()}` : ""} 73 |
74 | `) 75 | } else { 76 | // Create new marker 77 | const marker = L.marker([location.lat, location.lon]) 78 | .addTo(map) 79 | .bindPopup(` 80 |
81 | ${location.userName}
82 | Lat: ${location.lat.toFixed(6)}
83 | Lon: ${location.lon.toFixed(6)}
84 | ${location.timestamp ? `Updated: ${new Date(location.timestamp).toLocaleTimeString()}` : ""} 85 |
86 | `) 87 | 88 | markers.set(location.userName, marker) 89 | } 90 | }) 91 | 92 | // Remove markers for users no longer active 93 | markers.forEach((marker, userName) => { 94 | if (!activeUserNames.has(userName)) { 95 | map.removeLayer(marker) 96 | markers.delete(userName) 97 | } 98 | }) 99 | 100 | // Auto-fit map to show all markers 101 | if (locations.length > 0) { 102 | const group = new L.FeatureGroup(Array.from(markers.values())) 103 | map.fitBounds(group.getBounds().pad(0.1)) 104 | } 105 | }, [locations]) 106 | 107 | return
108 | } 109 | -------------------------------------------------------------------------------- /components/location/LocationReceiver.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState, useEffect, useRef } from "react" 4 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 5 | import { useSignalR } from "@/hooks/use-signalr" 6 | import { MapPin, Wifi, WifiOff, Users } from "lucide-react" 7 | import Link from "next/link" 8 | import dynamic from "next/dynamic" 9 | 10 | const MapComponent = dynamic(() => import("@/components/map-component"), { 11 | ssr: false, 12 | loading: () =>
Loading map...
, 13 | }) 14 | 15 | interface LocationData { 16 | userName: string 17 | lat: number 18 | lon: number 19 | timestamp?: number 20 | } 21 | 22 | const LocationReceiver = () => { 23 | const [locations, setLocations] = useState([]) 24 | const timersRef = useRef>(new Map()) 25 | 26 | const { isConnected, error, onLocationReceived, connectionState } = useSignalR( 27 | "https://tech-test.raintor.com/Hub" 28 | ) 29 | 30 | useEffect(() => { 31 | onLocationReceived((data: LocationData) => { 32 | const timestamp = Date.now() 33 | const updatedData: LocationData = { ...data, timestamp } 34 | 35 | setLocations(prev => { 36 | const others = prev.filter(loc => loc.userName !== data.userName) 37 | return [...others, updatedData] 38 | }) 39 | 40 | if (timersRef.current.has(data.userName)) { 41 | clearTimeout(timersRef.current.get(data.userName)!) 42 | } 43 | 44 | const timeout = setTimeout(() => { 45 | setLocations(prev => prev.filter(loc => loc.userName !== data.userName)) 46 | timersRef.current.delete(data.userName) 47 | }, 30000) 48 | 49 | timersRef.current.set(data.userName, timeout) 50 | }) 51 | }, [onLocationReceived]) 52 | 53 | return ( 54 |
55 |
56 |
57 | 58 | ← Back to Home 59 | 60 |
61 | 62 |
63 |
64 | 65 | 66 | 67 | 68 | User B - Location Receiver 69 | 70 | 71 | 72 |
73 |
74 | {isConnected ? ( 75 | <> 76 | 77 | {connectionState} 78 | 79 | ) : ( 80 | <> 81 | 82 | {connectionState} 83 | 84 | )} 85 |
86 |
87 | 88 | {error && ( 89 |
90 | Error: {error} 91 |
92 | )} 93 | 94 |
95 |
96 | 97 | Active Users: {locations.length} 98 |
99 |
100 |
101 |
102 | 103 | 104 | 105 | Active Locations 106 | 107 | 108 | {locations.length === 0 ? ( 109 |
110 |

No active locations.

111 |
112 | ) : ( 113 |
114 | {locations.map((location, index) => ( 115 |
116 |

{location.userName}

117 |

118 | {location.lat.toFixed(6)}, {location.lon.toFixed(6)} 119 |

120 | {location.timestamp && ( 121 |

{new Date(location.timestamp).toLocaleTimeString()}

122 | )} 123 |
124 | ))} 125 |
126 | )} 127 |
128 |
129 |
130 | 131 |
132 | 133 | 134 | Real-Time Location Map 135 | 136 | 137 | 138 | 139 | 140 |
141 |
142 |
143 |
144 | ) 145 | } 146 | 147 | export default LocationReceiver 148 | -------------------------------------------------------------------------------- /app/users/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState, useEffect, useRef, useCallback } from "react" 4 | import { useInfiniteQuery } from "@tanstack/react-query" 5 | import { fetchUsers } from "@/lib/api" 6 | import { FixedSizeList as List } from "react-window" 7 | import { Users, AlertCircle, Wifi, WifiOff } from "lucide-react" 8 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 9 | import { Alert, AlertDescription } from "@/components/ui/alert" 10 | import { Badge } from "@/components/ui/badge" 11 | import { type User } from "@/types" 12 | import { UserCardSkeleton } from "@/components/user/UserCardSkeleton" 13 | import { UserCard } from "@/components/user/UserCard" 14 | import { VirtualizedUserItem } from "@/components/user/VirtualizedUserItem" 15 | import Link from "next/link" 16 | 17 | const UsersPage = () => { 18 | const [isOnline, setIsOnline] = useState(true) 19 | const [useVirtualization, setUseVirtualization] = useState(false) 20 | const observerRef = useRef(null) 21 | const loadMoreRef = useRef(null) 22 | 23 | useEffect(() => { 24 | const handleOnline = () => setIsOnline(true) 25 | const handleOffline = () => setIsOnline(false) 26 | 27 | window.addEventListener("online", handleOnline) 28 | window.addEventListener("offline", handleOffline) 29 | 30 | return () => { 31 | window.removeEventListener("online", handleOnline) 32 | window.removeEventListener("offline", handleOffline) 33 | } 34 | }, []) 35 | 36 | const { data, error, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, status } = useInfiniteQuery({ 37 | queryKey: ["users"], 38 | queryFn: fetchUsers, 39 | getNextPageParam: (lastPage) => { 40 | const nextSkip = lastPage.skip + lastPage.limit 41 | return nextSkip < lastPage.total ? nextSkip : undefined 42 | }, 43 | initialPageParam: 0, 44 | staleTime: 5 * 60 * 1000, 45 | retry: 3, 46 | }) 47 | 48 | const lastUserElementRef = useCallback( 49 | (node: HTMLDivElement) => { 50 | if (isFetchingNextPage) return 51 | if (observerRef.current) observerRef.current.disconnect() 52 | 53 | observerRef.current = new IntersectionObserver( 54 | (entries) => { 55 | if (entries[0].isIntersecting && hasNextPage && !isFetching) { 56 | fetchNextPage() 57 | } 58 | }, 59 | { 60 | threshold: 0.1, 61 | rootMargin: "100px", 62 | }, 63 | ) 64 | 65 | if (node) observerRef.current.observe(node) 66 | }, 67 | [isFetchingNextPage, hasNextPage, isFetching, fetchNextPage], 68 | ) 69 | 70 | const allUsers = data?.pages.flatMap((page) => page.users) ?? [] 71 | const totalUsers = data?.pages[0]?.total ?? 0 72 | 73 | useEffect(() => { 74 | setUseVirtualization(allUsers.length > 50) 75 | }, [allUsers.length]) 76 | 77 | if (status === "pending") { 78 | return ( 79 |
80 | {Array.from({ length: 5 }).map((_, i) => ( 81 | 82 | ))} 83 |
84 | ) 85 | } 86 | 87 | if (status === "error") { 88 | return ( 89 | 90 | 91 | 92 | Error loading users: {error?.message || "Unknown error"} {!isOnline && "(Offline)"} 93 | 94 | 95 | ) 96 | } 97 | 98 | return ( 99 |
100 |
101 |
102 | 103 | ← Back to Home 104 | 105 |
106 | 107 | 108 | 109 |
110 | 111 | Infinite Scroll User Feed 112 |
113 |
114 |
115 | {isOnline ? : } 116 | {isOnline ? "Online" : "Offline"} 117 |
118 | {allUsers.length} of {totalUsers} users 119 | {useVirtualization && Virtualized} 120 |
121 |
122 |
123 | 124 | {useVirtualization ? ( 125 |
126 | 133 | {VirtualizedUserItem} 134 | 135 |
136 | 137 | ) : ( 138 |
139 | {allUsers.map((user, index) => ( 140 |
141 | 142 |
143 | ))} 144 |
145 | )} 146 | 147 | {isFetchingNextPage && Array.from({ length: 3 }).map((_, i) => )} 148 | 149 | {!hasNextPage && allUsers.length > 0 && ( 150 |
151 | You've reached the end! {allUsers.length} of {totalUsers} 152 |
153 | )} 154 | 155 | {!useVirtualization && hasNextPage && ( 156 |
157 | {isFetching && Loading more users...} 158 |
159 | )} 160 |
161 |
162 |
163 |
164 | ) 165 | } 166 | 167 | 168 | export default UsersPage 169 | -------------------------------------------------------------------------------- /components/location/LocationSender.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState, useEffect } from "react" 4 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 5 | import { Input } from "@/components/ui/input" 6 | import { Label } from "@/components/ui/label" 7 | import { MapPin, Wifi, WifiOff, Send } from "lucide-react" 8 | import Link from "next/link" 9 | import { Button } from "@/components/ui/button" 10 | import { useSignalR } from "@/hooks/use-signalr" 11 | 12 | const LocationSender = () => { 13 | const [userName, setUserName] = useState("") 14 | const [isSharing, setIsSharing] = useState(false) 15 | const [currentLocation, setCurrentLocation] = useState<{ lat: number; lon: number } | null>(null) 16 | 17 | const { isConnected, error, sendLocation, connectionState } = useSignalR( 18 | "https://tech-test.raintor.com/Hub", 19 | ) 20 | 21 | useEffect(() => { 22 | if (isSharing && isConnected && userName) { 23 | const interval = setInterval(() => { 24 | if (navigator.geolocation) { 25 | navigator.geolocation.getCurrentPosition( 26 | (position) => { 27 | const { latitude, longitude } = position.coords 28 | setCurrentLocation({ lat: latitude, lon: longitude }) 29 | sendLocation(latitude, longitude, userName) 30 | }, 31 | (error) => { 32 | console.warn("GPS failed, using simulated location:", error) 33 | const simulatedLat = 25.73736464 + (Math.random() - 0.5) * 0.01 34 | const simulatedLon = 90.3644747 + (Math.random() - 0.5) * 0.01 35 | setCurrentLocation({ lat: simulatedLat, lon: simulatedLon }) 36 | sendLocation(simulatedLat, simulatedLon, userName) 37 | }, 38 | { enableHighAccuracy: true, timeout: 5000, maximumAge: 1000 }, 39 | ) 40 | } else { 41 | const simulatedLat = 25.73736464 + (Math.random() - 0.5) * 0.01 42 | const simulatedLon = 90.3644747 + (Math.random() - 0.5) * 0.01 43 | setCurrentLocation({ lat: simulatedLat, lon: simulatedLon }) 44 | sendLocation(simulatedLat, simulatedLon, userName) 45 | } 46 | }, 2000) 47 | 48 | return () => clearInterval(interval) 49 | } 50 | }, [isSharing, isConnected, userName, sendLocation]) 51 | 52 | const handleStartSharing = () => { 53 | if (userName.trim()) { 54 | setIsSharing(true) 55 | } 56 | } 57 | 58 | const handleStopSharing = () => { 59 | setIsSharing(false) 60 | setCurrentLocation(null) 61 | } 62 | 63 | return ( 64 |
65 |
66 |
67 | 68 | ← Back to Home 69 | 70 |
71 | 72 | 73 | 74 | 75 | User A - Location Sender 76 | 77 | 78 | 79 |
80 |
81 |
82 | {isConnected ? ( 83 | <> 84 | 85 | {connectionState} 86 | 87 | ) : ( 88 | <> 89 | 90 | {connectionState} 91 | 92 | )} 93 |
94 |
95 |
96 | 97 | {error && ( 98 |
99 |

Connection Error:

100 |

{error}

101 | 102 |
103 | )} 104 |
105 | 106 | setUserName(e.target.value)} 112 | disabled={isSharing} 113 | /> 114 |
115 | 116 |
117 | {!isSharing ? ( 118 | 126 | ) : ( 127 | 130 | )} 131 |
132 | 133 | {isSharing && ( 134 |
135 |

Current Location

136 | {currentLocation ? ( 137 |
138 |

139 | Latitude: {currentLocation.lat.toFixed(6)} 140 |

141 |

142 | Longitude: {currentLocation.lon.toFixed(6)} 143 |

144 |

145 | User: {userName} 146 |

147 |

148 | ✓ Location being sent every 2 seconds Real SignalR 149 |

150 |
151 | ) : ( 152 |
153 |

Getting location...

154 |
155 | )} 156 |
157 | )} 158 |
159 |
160 |
161 |
162 | ) 163 | } 164 | 165 | export default LocationSender 166 | -------------------------------------------------------------------------------- /hooks/use-signalr.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, useRef, useState, useCallback } from "react" 4 | import * as signalR from "@microsoft/signalr" 5 | 6 | interface LocationData { 7 | userName: string 8 | lat: number 9 | lon: number 10 | } 11 | 12 | interface UseSignalRReturn { 13 | connection: signalR.HubConnection | null 14 | isConnected: boolean 15 | error: string | null 16 | sendLocation: (lat: number, lon: number, userName: string) => Promise 17 | onLocationReceived: (callback: (data: LocationData) => void) => void 18 | connectionState: string 19 | useMockMode: boolean 20 | toggleMockMode: () => void 21 | } 22 | 23 | export function useSignalR(hubUrl: string): UseSignalRReturn { 24 | const [connection, setConnection] = useState(null) 25 | const [isConnected, setIsConnected] = useState(false) 26 | const [error, setError] = useState(null) 27 | const [connectionState, setConnectionState] = useState("Disconnected") 28 | const [useMockMode, setUseMockMode] = useState(false) 29 | const locationCallbackRef = useRef<((data: LocationData) => void) | null>(null) 30 | const mockIntervalRef = useRef(null) 31 | 32 | // Test endpoint accessibility 33 | const testEndpoint = useCallback(async (url: string) => { 34 | try { 35 | console.log(`Testing SignalR endpoint: ${url}`) 36 | 37 | // Try to access the negotiate endpoint 38 | const negotiateUrl = `${url}/negotiate` 39 | const response = await fetch(negotiateUrl, { 40 | method: "POST", 41 | headers: { 42 | "Content-Type": "application/json", 43 | }, 44 | }) 45 | 46 | console.log(`Negotiate response status: ${response.status}`) 47 | 48 | if (!response.ok) { 49 | throw new Error(`Negotiate failed: ${response.status} ${response.statusText}`) 50 | } 51 | 52 | const negotiateData = await response.json() 53 | console.log("Negotiate successful:", negotiateData) 54 | return true 55 | } catch (err) { 56 | console.error("Endpoint test failed:", err) 57 | return false 58 | } 59 | }, []) 60 | 61 | const toggleMockMode = useCallback(() => { 62 | setUseMockMode((prev) => !prev) 63 | setError(null) 64 | }, []) 65 | 66 | // Mock SignalR functionality for development/testing 67 | const startMockMode = useCallback(() => { 68 | console.log("Starting mock SignalR mode") 69 | setIsConnected(true) 70 | setConnectionState("Connected (Mock)") 71 | setError(null) 72 | 73 | // Simulate receiving location updates in mock mode 74 | if (mockIntervalRef.current) { 75 | clearInterval(mockIntervalRef.current) 76 | } 77 | 78 | mockIntervalRef.current = setInterval(() => { 79 | if (locationCallbackRef.current) { 80 | // Generate mock location data 81 | const mockData: LocationData = { 82 | userName: "mock.user@example.com", 83 | lat: 25.73736464 + (Math.random() - 0.5) * 0.01, 84 | lon: 90.3644747 + (Math.random() - 0.5) * 0.01, 85 | } 86 | console.log("Mock location received:", mockData) 87 | locationCallbackRef.current(mockData) 88 | } 89 | }, 3000) 90 | }, []) 91 | 92 | const stopMockMode = useCallback(() => { 93 | console.log("Stopping mock SignalR mode") 94 | if (mockIntervalRef.current) { 95 | clearInterval(mockIntervalRef.current) 96 | mockIntervalRef.current = null 97 | } 98 | setIsConnected(false) 99 | setConnectionState("Disconnected") 100 | }, []) 101 | 102 | useEffect(() => { 103 | if (useMockMode) { 104 | startMockMode() 105 | return () => stopMockMode() 106 | } 107 | 108 | let newConnection: signalR.HubConnection | null = null 109 | 110 | const startConnection = async () => { 111 | try { 112 | setError(null) 113 | setConnectionState("Testing endpoint...") 114 | 115 | // Test if endpoint is accessible 116 | const isAccessible = await testEndpoint(hubUrl) 117 | if (!isAccessible) { 118 | throw new Error(`SignalR endpoint not accessible: ${hubUrl}. The server may be down or the URL is incorrect.`) 119 | } 120 | 121 | setConnectionState("Connecting...") 122 | 123 | // Create connection with detailed logging 124 | newConnection = new signalR.HubConnectionBuilder() 125 | .withUrl(hubUrl, { 126 | skipNegotiation: false, 127 | transport: 128 | signalR.HttpTransportType.WebSockets | 129 | signalR.HttpTransportType.ServerSentEvents | 130 | signalR.HttpTransportType.LongPolling, 131 | headers: { 132 | "Access-Control-Allow-Origin": "*", 133 | }, 134 | }) 135 | .withAutomaticReconnect({ 136 | nextRetryDelayInMilliseconds: (retryContext) => { 137 | if (retryContext.previousRetryCount < 3) { 138 | return Math.random() * 10000 + 2000 // 2-12 seconds 139 | } else { 140 | return null // Stop retrying 141 | } 142 | }, 143 | }) 144 | .configureLogging(signalR.LogLevel.Information) 145 | .build() 146 | 147 | // Connection event handlers 148 | newConnection.onclose((error) => { 149 | console.log("SignalR connection closed:", error) 150 | setIsConnected(false) 151 | setConnectionState("Disconnected") 152 | if (error) { 153 | setError(`Connection closed: ${error.message}`) 154 | } 155 | }) 156 | 157 | newConnection.onreconnecting((error) => { 158 | console.log("SignalR reconnecting:", error) 159 | setIsConnected(false) 160 | setConnectionState("Reconnecting...") 161 | setError("Connection lost, attempting to reconnect...") 162 | }) 163 | 164 | newConnection.onreconnected((connectionId) => { 165 | console.log("SignalR reconnected:", connectionId) 166 | setIsConnected(true) 167 | setConnectionState("Connected") 168 | setError(null) 169 | }) 170 | 171 | // Set up location receiver 172 | newConnection.on("ReceiveLatLon", (data: LocationData) => { 173 | console.log("Received location via SignalR:", data) 174 | if (locationCallbackRef.current) { 175 | locationCallbackRef.current(data) 176 | } 177 | }) 178 | 179 | // Start the connection 180 | await newConnection.start() 181 | 182 | console.log("SignalR Connected successfully") 183 | setConnection(newConnection) 184 | setIsConnected(true) 185 | setConnectionState("Connected") 186 | setError(null) 187 | } catch (err) { 188 | console.error("SignalR Connection Error:", err) 189 | const errorMessage = err instanceof Error ? err.message : "Unknown connection error" 190 | setError(`Connection failed: ${errorMessage}`) 191 | setConnectionState("Failed") 192 | setIsConnected(false) 193 | 194 | // Auto-switch to mock mode if connection fails 195 | setTimeout(() => { 196 | console.log("Auto-switching to mock mode due to connection failure") 197 | setUseMockMode(true) 198 | }, 2000) 199 | } 200 | } 201 | 202 | startConnection() 203 | 204 | return () => { 205 | if (newConnection) { 206 | console.log("Cleaning up SignalR connection") 207 | newConnection.stop() 208 | } 209 | stopMockMode() 210 | } 211 | }, [hubUrl, useMockMode, testEndpoint, startMockMode, stopMockMode]) 212 | 213 | const sendLocation = useCallback( 214 | async (lat: number, lon: number, userName: string) => { 215 | if (useMockMode) { 216 | console.log("Mock location sent:", { lat, lon, userName }) 217 | return 218 | } 219 | 220 | if (connection && isConnected) { 221 | try { 222 | await connection.invoke("SendLatLon", lat, lon, userName) 223 | console.log("Location sent via SignalR:", { lat, lon, userName }) 224 | } catch (err) { 225 | console.error("Error sending location:", err) 226 | const errorMessage = err instanceof Error ? err.message : "Failed to send location" 227 | setError(`Send failed: ${errorMessage}`) 228 | } 229 | } else { 230 | console.warn("Cannot send location: not connected") 231 | setError("Cannot send location: not connected to SignalR hub") 232 | } 233 | }, 234 | [connection, isConnected, useMockMode], 235 | ) 236 | 237 | const onLocationReceived = useCallback((callback: (data: LocationData) => void) => { 238 | locationCallbackRef.current = callback 239 | }, []) 240 | 241 | return { 242 | connection, 243 | isConnected, 244 | error, 245 | sendLocation, 246 | onLocationReceived, 247 | connectionState, 248 | useMockMode, 249 | toggleMockMode, 250 | } 251 | } 252 | --------------------------------------------------------------------------------