All files

81.49% Statements 480/589
68.16% Branches 257/377
81.57% Functions 93/114
80.95% Lines 459/567

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
src
100% 20/20 100% 21/21 100% 1/1 100% 20/20
src/app
27.27% 3/11 0% 0/16 57.14% 4/7 27.27% 3/11
src/app/(auth)
0% 0/0 0% 0/0 0% 0/1 0% 0/0
src/app/(auth)/login
0% 0/0 0% 0/0 0% 0/1 0% 0/0
src/app/(auth)/register
0% 0/0 0% 0/0 0% 0/1 0% 0/0
src/app/actions
79.26% 172/217 72.22% 78/108 90.47% 19/21 78.97% 169/214
src/app/owner/[id]/edit
0% 0/5 0% 0/8 0% 0/1 0% 0/5
src/app/owner/[id]/reviews
0% 0/6 0% 0/6 0% 0/1 0% 0/6
src/app/owner/create
0% 0/0 0% 0/0 0% 1/1 0% 0/0
src/app/owner/my-restaurants
78.57% 11/14 100% 2/2 75% 6/8 84.61% 11/13
src/app/reviewer/restaurants/[id]
0% 0/6 0% 0/10 0% 0/2 0% 0/6
src/components
0% 0/1 100% 0/0 0% 0/1 0% 0/1
src/components/auth
100% 27/27 100% 10/10 100% 9/9 100% 27/27
src/components/filters
100% 73/73 72.88% 43/59 100% 14/14 100% 68/68
src/components/restaurants
93.22% 55/59 90% 36/40 93.33% 14/15 93.1% 54/58
src/components/reviews
100% 11/11 100% 18/18 100% 4/4 100% 11/11
src/components/ui
100% 13/13 94.73% 18/19 100% 4/4 100% 13/13
src/hooks
100% 19/19 100% 4/4 100% 5/5 100% 18/18
src/lib
73.07% 76/104 56.25% 27/48 80% 12/15 69.89% 65/93
src/pages
0% 0/3 0% 0/8 0% 0/2 0% 0/3

All files src/app/(auth)

0% Statements 0/0
0% Branches 0/0
0% Functions 0/1
0% Lines 0/0

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
layout.tsx
0% 0/0 0% 0/0 0% 0/1 0% 0/0

All files / src/app/(auth) layout.tsx

0% Statements 0/0
0% Branches 0/0
0% Functions 0/1
0% Lines 0/0

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13                         
export default function AuthLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center px-4">
      <div className="bg-white rounded-lg shadow-xl p-8 w-full max-w-md">
        {children}
      </div>
    </div>
  )
}

All files src/app/(auth)/login

0% Statements 0/0
0% Branches 0/0
0% Functions 0/1
0% Lines 0/0

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
page.tsx
0% 0/0 0% 0/0 0% 0/1 0% 0/0

All files / src/app/(auth)/login page.tsx

0% Statements 0/0
0% Branches 0/0
0% Functions 0/1
0% Lines 0/0

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13                         
import { LoginForm } from '@/components/auth/LoginForm';
 
export default function LoginPage() {
  return (
    <div className="flex min-h-full flex-col justify-center px-6 py-12 mb-48">
      <h2 className="mt-10 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
        Sign in
      </h2>
      <LoginForm />
    </div>
  );
}
 

All files src/app/(auth)/register

0% Statements 0/0
0% Branches 0/0
0% Functions 0/1
0% Lines 0/0

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
page.tsx
0% 0/0 0% 0/0 0% 0/1 0% 0/0

All files / src/app/(auth)/register page.tsx

0% Statements 0/0
0% Branches 0/0
0% Functions 0/1
0% Lines 0/0

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13                         
import { RegisterForm } from '@/components/auth/RegisterForm';
 
export default function RegisterPage() {
  return (
    <div className="flex min-h-full flex-col justify-center px-6 py-12 mb-48">
      <h2 className="mt-10 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
        Register
      </h2>
      <RegisterForm />
    </div>
  );
}
 

All files / src/app/actions auth.ts

100% Statements 44/44
100% Branches 14/14
100% Functions 4/4
100% Lines 44/44

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159                                        5x   5x 1x     4x 4x 4x       3x 1x     2x         2x 1x     1x           1x   1x                   1x 1x                   4x   4x 1x     3x 3x 3x       2x 1x     1x   1x                 1x           1x   1x                   1x 1x         1x 1x       5x   5x 1x     4x 4x   3x 1x     2x 2x                   2x   1x    
'use server'
 
import { cookies } from 'next/headers'
import bcryptjs from 'bcryptjs'
import { getPrisma } from '@/lib/db'
import { loginSchema, registerSchema } from '@/lib/validators'
import { generateToken, verifyToken, setTokenCookie } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { Role, User } from '@prisma/client'
 
type UserSession = Pick<User, 'id' | 'email' | 'name' | 'role'>
 
type AuthResponse =
  | { success: true; user: UserSession }
  | { success: false; error: string; details?: unknown }
 
export async function loginAction(
  email: string,
  password: string
): Promise<AuthResponse> {
  const validated = loginSchema.safeParse({ email, password })
 
  if (!validated.success) {
    return { success: false, error: 'Invalid input', details: validated.error.errors }
  }
 
  try {
    const prisma = getPrisma()
    const user = await prisma.user.findUnique({
      where: { email: validated.data.email }
    })
 
    if (!user) {
      return { success: false, error: 'Invalid email or password' }
    }
 
    const isPasswordValid = await bcryptjs.compare(
      validated.data.password,
      user.password
    )
 
    if (!isPasswordValid) {
      return { success: false, error: 'Invalid email or password' }
    }
 
    const token = await generateToken({
      userId: user.id,
      email: user.email,
      role: user.role
    })
 
    await setTokenCookie(token)
 
    return {
      success: true,
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
        role: user.role
      }
    }
  } catch (error) {
    console.error('Login error:', error)
    return { success: false, error: 'Internal server error' }
  }
}
 
export async function registerAction(
  email: string,
  password: string,
  name: string,
  role: Role
): Promise<AuthResponse> {
  const validated = registerSchema.safeParse({ email, password, name, role })
 
  if (!validated.success) {
    return { success: false, error: 'Validation failed', details: validated.error.errors }
  }
 
  try {
    const prisma = getPrisma()
    const existingUser = await prisma.user.findUnique({
      where: { email: validated.data.email }
    })
 
    if (existingUser) {
      return { success: false, error: 'Email already registered' }
    }
 
    const hashedPassword = await bcryptjs.hash(validated.data.password, 10)
 
    const user = await prisma.user.create({
      data: {
        email: validated.data.email,
        password: hashedPassword,
        name: validated.data.name,
        role: validated.data.role
      }
    })
 
    const token = await generateToken({
      userId: user.id,
      email: user.email,
      role: user.role
    })
 
    await setTokenCookie(token)
 
    return {
      success: true,
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
        role: user.role
      }
    }
  } catch (error) {
    console.error('Registration error:', error)
    return { success: false, error: 'Internal server error' }
  }
}
 
export async function logoutAction(): Promise<never> {
  cookies().delete('auth-token')
  redirect('/login')
}
 
export async function getCurrentUser(): Promise<UserSession | null> {
  const token = cookies().get('auth-token')?.value
  
  if (!token) {
    return null
  }
  
  try {
    const decoded = await verifyToken(token)
 
    if (!decoded) {
      return null
    }
    
    const prisma = getPrisma()
    const user = await prisma.user.findUnique({
      where: { id: decoded.userId },
      select: {
        id: true,
        email: true,
        name: true,
        role: true
      }
    })
    
    return user
  } catch (error) {
    return null
  }
}

All files src/app/actions

79.26% Statements 172/217
72.22% Branches 78/108
90.47% Functions 19/21
78.97% Lines 169/214

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
auth.ts
100% 44/44 100% 14/14 100% 4/4 100% 44/44
restaurants.ts
61.38% 62/101 58.06% 36/62 83.33% 10/12 60.2% 59/98
reviews.ts
96% 48/50 91.66% 22/24 100% 4/4 96% 48/50
upload.ts
81.81% 18/22 75% 6/8 100% 1/1 81.81% 18/22

All files / src/app/actions restaurants.ts

61.38% Statements 62/101
58.06% Branches 36/62
83.33% Functions 10/12
60.2% Lines 59/98

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337                                                                5x 5x                                                           14x             5x 3x       5x 4x   7x     5x       2x 2x                                                     4x   4x 2x       2x   2x       2x 2x 2x                     1x 1x   1x 1x         4x   4x 1x     3x 3x       3x 1x       2x   2x       2x   2x                 2x                     1x 1x 1x   1x 1x         4x   4x 1x     3x 3x       3x 1x     2x 2x       1x 1x 1x   1x 1x                 3x   3x 2x     1x 1x                                                                                                                                                                                          
'use server'
 
import { revalidatePath } from 'next/cache'
import { writeFile, unlink } from 'fs/promises'
import path from 'path'
import { getPrisma } from '@/lib/db'
import { restaurantSchema, type RestaurantInput, type SavedPreferences } from '@/lib/validators'
import { type ActionResult } from '@/types/actions'
import { getCurrentUser } from './auth'
import { Restaurant, Review } from '@prisma/client'
import { calculateAverageRating } from '@/lib/utils'
import { type CuisineType, MAX_IMAGE_SIZE, ALLOWED_IMAGE_TYPES, ERROR_MESSAGES } from '@/lib/constants'
 
type RestaurantWithRating = Omit<Restaurant, 'cuisine'> & {
  cuisine: CuisineType[]
  reviews: { rating: number }[]
  owner: { id: string; name: string }
  averageRating: number
  reviewCount: number
}
 
type RestaurantDetail = Omit<Restaurant, 'cuisine'> & {
  cuisine: CuisineType[]
  owner: { id: string; name: string }
  reviews: (Review & {
    user: { id: string; name: string }
  })[]
}
 
export async function getRestaurants(
  filters?: SavedPreferences
): Promise<RestaurantWithRating[]> {
  const prisma = getPrisma()
  const restaurants = await prisma.restaurant.findMany({
    where: {
      ...(filters?.cuisines && filters.cuisines.length > 0 && {
        cuisine: {
          hasSome: filters.cuisines
        }
      }),
      ...(filters?.location && {
        location: {
          contains: filters.location,
          mode: 'insensitive'
        }
      })
    },
    include: {
      reviews: {
        select: {
          rating: true
        }
      },
      owner: {
        select: {
          id: true,
          name: true
        }
      }
    }
  })
 
  // Calculate ratings and review count
  const withRatings = restaurants.map(r => ({
    ...r,
    averageRating: calculateAverageRating(r.reviews),
    reviewCount: r.reviews.length
  }))
 
  // Filter by minimum rating
  let filtered = filters?.minRating
    ? withRatings.filter(r => r.averageRating >= filters.minRating!)
    : withRatings
 
  // Sort
  if (filters?.sort === 'worst') {
    filtered.sort((a, b) => a.averageRating - b.averageRating)
  } else {
    filtered.sort((a, b) => b.averageRating - a.averageRating)
  }
 
  return filtered as RestaurantWithRating[]
}
 
export async function getRestaurant(id: string): Promise<RestaurantDetail | null> {
  const prisma = getPrisma()
  return await prisma.restaurant.findUnique({
    where: { id },
    include: {
      owner: {
        select: {
          id: true,
          name: true
        }
      },
      reviews: {
        include: {
          user: {
            select: {
              id: true,
              name: true
            }
          }
        },
        orderBy: {
          createdAt: 'desc'
        }
      }
    }
  }) as RestaurantDetail | null
}
 
export async function createRestaurant(data: RestaurantInput): Promise<ActionResult<Restaurant>> {
  const user = await getCurrentUser()
 
  if (!user || user.role !== 'OWNER') {
    return { success: false, error: 'Unauthorized' }
  }
 
  // Validate with Zod schema
  const validation = restaurantSchema.safeParse(data)
 
  Iif (!validation.success) {
    return { success: false, error: validation.error.errors[0].message }
  }
 
  try {
    const prisma = getPrisma()
    const restaurant = await prisma.restaurant.create({
      data: {
        title: validation.data.title,
        description: validation.data.description,
        location: validation.data.location,
        cuisine: validation.data.cuisine,
        imageUrl: validation.data.imageUrl || '/restaurant1.jpg',
        ownerId: user.id
      }
    })
 
    revalidatePath('/')
    return { success: true, data: restaurant }
  } catch (error: any) {
    console.error('Create restaurant error:', error)
    return { success: false, error: 'Failed to create restaurant' }
  }
}
 
export async function updateRestaurant(id: string, data: RestaurantInput): Promise<ActionResult<Restaurant>> {
  const user = await getCurrentUser()
 
  if (!user) {
    return { success: false, error: 'Unauthorized' }
  }
 
  const prisma = getPrisma()
  const restaurant = await prisma.restaurant.findUnique({
    where: { id }
  })
 
  if (!restaurant || restaurant.ownerId !== user.id) {
    return { success: false, error: 'Unauthorized' }
  }
 
  // Validate with Zod schema
  const validation = restaurantSchema.safeParse(data)
 
  Iif (!validation.success) {
    return { success: false, error: validation.error.errors[0].message }
  }
 
  try {
    // Delete old image if it's being replaced with a new uploaded image
    Iif (
      validation.data.imageUrl &&
      validation.data.imageUrl !== restaurant.imageUrl &&
      restaurant.imageUrl &&
      restaurant.imageUrl.startsWith('/uploads/')
    ) {
      await deleteImageAction(restaurant.imageUrl)
    }
 
    const updatedRestaurant = await prisma.restaurant.update({
      where: { id },
      data: {
        title: validation.data.title,
        description: validation.data.description,
        location: validation.data.location,
        cuisine: validation.data.cuisine,
        imageUrl: validation.data.imageUrl || restaurant.imageUrl || '/restaurant1.jpg',
      }
    })
 
    revalidatePath(`/owner/my-restaurants`)
    revalidatePath(`/reviewer/restaurants/${id}`)
    return { success: true, data: updatedRestaurant }
  } catch (error: any) {
    console.error('Update restaurant error:', error)
    return { success: false, error: 'Failed to update restaurant' }
  }
}
 
export async function deleteRestaurant(id: string): Promise<ActionResult> {
  const user = await getCurrentUser()
 
  if (!user) {
    return { success: false, error: 'Unauthorized' }
  }
 
  const prisma = getPrisma()
  const restaurant = await prisma.restaurant.findUnique({
    where: { id }
  })
 
  if (!restaurant || restaurant.ownerId !== user.id) {
    return { success: false, error: 'Unauthorized' }
  }
 
  try {
    await prisma.restaurant.delete({
      where: { id }
    })
 
    revalidatePath('/')
    revalidatePath(`/owner/my-restaurants`)
    return { success: true }
  } catch (error) {
    console.error('Delete restaurant error:', error)
    return { success: false, error: 'Failed to delete restaurant' }
  }
}
 
type RestaurantWithReviews = Restaurant & {
  reviews: { rating: number }[]
}
 
export async function getMyRestaurants(): Promise<RestaurantWithReviews[]> {
  const user = await getCurrentUser()
  
  if (!user || user.role !== 'OWNER') {
    return []
  }
 
  const prisma = getPrisma()
  return await prisma.restaurant.findMany({
    where: {
      ownerId: user.id
    },
    include: {
      reviews: {
        select: {
          rating: true
        }
      }
    },
    orderBy: {
      createdAt: 'desc'
    }
  })
}
 
export async function uploadImageAction(formData: FormData): Promise<ActionResult<{ imageUrl: string }>> {
  const user = await getCurrentUser()
 
  if (!user || user.role !== 'OWNER') {
    return { success: false, error: 'Unauthorized' }
  }
 
  const file = formData.get('image') as File
 
  if (!file) {
    return { success: false, error: 'No file provided' }
  }
 
  // Validate file type
  if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
    return { success: false, error: ERROR_MESSAGES.INVALID_IMAGE_TYPE }
  }
 
  // Validate file size
  if (file.size > MAX_IMAGE_SIZE) {
    return { success: false, error: ERROR_MESSAGES.IMAGE_TOO_LARGE }
  }
 
  try {
    // Generate unique filename
    const timestamp = Date.now()
    const extension = file.name.split('.').pop()
    const filename = `restaurant-${timestamp}.${extension}`
 
    // Convert file to buffer
    const bytes = await file.arrayBuffer()
    const buffer = Buffer.from(bytes)
 
    // Save to public/uploads/ directory
    const uploadDir = path.join(process.cwd(), 'public', 'uploads')
    const uploadPath = path.join(uploadDir, filename)
 
    await writeFile(uploadPath, buffer)
 
    // Return the URL path
    return { success: true, data: { imageUrl: `/uploads/${filename}` } }
  } catch (error) {
    console.error('Image upload error:', error)
    return { success: false, error: 'Failed to upload image' }
  }
}
 
export async function deleteImageAction(imageUrl: string): Promise<ActionResult> {
  const user = await getCurrentUser()
 
  if (!user || user.role !== 'OWNER') {
    return { success: false, error: 'Unauthorized' }
  }
 
  // Don't delete default images
  if (imageUrl.startsWith('/restaurant')) {
    return { success: true }
  }
 
  // Only delete images from /uploads/ directory
  if (!imageUrl.startsWith('/uploads/')) {
    return { success: true }
  }
 
  try {
    const filename = imageUrl.replace('/uploads/', '')
    const filePath = path.join(process.cwd(), 'public', 'uploads', filename)
 
    await unlink(filePath)
 
    return { success: true }
  } catch (error) {
    console.error('Image delete error:', error)
    // Don't fail if file doesn't exist
    return { success: true }
  }
}

All files / src/app/actions reviews.ts

96% Statements 48/50
91.66% Branches 22/24
100% Functions 4/4
96% Lines 48/50

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149                            5x   5x       5x   5x 2x     3x     3x                 3x 1x     2x 2x                 1x 1x   1x 1x                 4x   4x       4x   4x 1x     3x 3x       3x 1x     2x 2x               1x 1x   1x 1x         4x   4x 1x     3x 3x       3x 1x     2x 2x       1x 1x   1x 1x         3x   3x 1x     2x 2x                
'use server'
 
import { revalidatePath } from 'next/cache'
import { getPrisma } from '@/lib/db'
import { getCurrentUser } from './auth'
import { Review } from '@prisma/client'
import { reviewSchema } from '@/lib/validators'
import { type ActionResult } from '@/types/actions'
 
export async function createReview(
  restaurantId: string,
  rating: number,
  comment?: string
): Promise<ActionResult<Review>> {
  const validated = reviewSchema.safeParse({ rating, comment })
 
  Iif (!validated.success) {
    return { success: false, error: 'Invalid input', details: validated.error.errors }
  }
 
  const user = await getCurrentUser()
 
  if (!user || user.role !== 'REVIEWER') {
    return { success: false, error: 'Unauthorized' }
  }
 
  const prisma = getPrisma()
 
  // Check if user already reviewed this restaurant
  const existingReview = await prisma.review.findUnique({
    where: {
      restaurantId_userId: {
        restaurantId,
        userId: user.id
      }
    }
  })
 
  if (existingReview) {
    return { success: false, error: 'You have already reviewed this restaurant' }
  }
 
  try {
    const review = await prisma.review.create({
      data: {
        rating: validated.data.rating,
        comment: validated.data.comment,
        restaurantId,
        userId: user.id
      }
    })
 
    revalidatePath(`/reviewer/restaurants/${restaurantId}`)
    return { success: true, data: review }
  } catch (error) {
    console.error('Create review error:', error)
    return { success: false, error: 'Failed to create review' }
  }
}
 
export async function updateReview(
  reviewId: string,
  rating: number,
  comment?: string
): Promise<ActionResult<Review>> {
  const validated = reviewSchema.safeParse({ rating, comment })
 
  Iif (!validated.success) {
    return { success: false, error: 'Invalid input', details: validated.error.errors }
  }
 
  const user = await getCurrentUser()
 
  if (!user) {
    return { success: false, error: 'Unauthorized' }
  }
 
  const prisma = getPrisma()
  const review = await prisma.review.findUnique({
    where: { id: reviewId }
  })
 
  if (!review || review.userId !== user.id) {
    return { success: false, error: 'Unauthorized' }
  }
 
  try {
    const updated = await prisma.review.update({
      where: { id: reviewId },
      data: {
        rating: validated.data.rating,
        comment: validated.data.comment
      }
    })
 
    revalidatePath(`/reviewer/restaurants/${review.restaurantId}`)
    return { success: true, data: updated }
  } catch (error) {
    console.error('Update review error:', error)
    return { success: false, error: 'Failed to update review' }
  }
}
 
export async function deleteReview(reviewId: string): Promise<ActionResult> {
  const user = await getCurrentUser()
 
  if (!user) {
    return { success: false, error: 'Unauthorized' }
  }
 
  const prisma = getPrisma()
  const review = await prisma.review.findUnique({
    where: { id: reviewId }
  })
 
  if (!review || review.userId !== user.id) {
    return { success: false, error: 'Unauthorized' }
  }
 
  try {
    await prisma.review.delete({
      where: { id: reviewId }
    })
 
    revalidatePath(`/reviewer/restaurants/${review.restaurantId}`)
    return { success: true }
  } catch (error) {
    console.error('Delete review error:', error)
    return { success: false, error: 'Failed to delete review' }
  }
}
 
export async function getMyReview(restaurantId: string): Promise<Review | null> {
  const user = await getCurrentUser()
 
  if (!user) {
    return null
  }
 
  const prisma = getPrisma()
  return await prisma.review.findUnique({
    where: {
      restaurantId_userId: {
        restaurantId,
        userId: user.id
      }
    }
  })
}

All files / src/app/actions upload.ts

81.81% Statements 18/22
75% Branches 6/8
100% Functions 1/1
81.81% Lines 18/22

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54                  3x 3x   3x 1x       2x         2x 1x       1x 1x     1x 1x         1x 1x 1x 1x     1x           1x 1x      
'use server'
 
import { writeFile, mkdir } from 'fs/promises'
import { join } from 'path'
import { existsSync } from 'fs'
import { MAX_IMAGE_SIZE, ALLOWED_IMAGE_TYPES, ERROR_MESSAGES } from '@/lib/constants'
import { type ActionResult } from '@/types/actions'
 
export async function uploadImage(formData: FormData): Promise<ActionResult<{ imageUrl: string }>> {
  try {
    const file = formData.get('file') as File
 
    if (!file) {
      return { success: false, error: ERROR_MESSAGES.REQUIRED_FIELD }
    }
 
    // Validate file size
    Iif (file.size > MAX_IMAGE_SIZE) {
      return { success: false, error: ERROR_MESSAGES.IMAGE_TOO_LARGE }
    }
 
    // Validate file type
    if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
      return { success: false, error: ERROR_MESSAGES.INVALID_IMAGE_TYPE }
    }
 
    // Convert file to buffer
    const bytes = await file.arrayBuffer()
    const buffer = Buffer.from(bytes)
 
    // Create uploads directory if it doesn't exist
    const uploadsDir = join(process.cwd(), 'public', 'uploads')
    Iif (!existsSync(uploadsDir)) {
      await mkdir(uploadsDir, { recursive: true })
    }
 
    // Generate unique filename
    const timestamp = Date.now()
    const extension = file.name.split('.').pop()
    const filename = `restaurant-${timestamp}.${extension}`
    const filepath = join(uploadsDir, filename)
 
    // Write file
    await writeFile(filepath, buffer)
 
    // Return public URL
    const imageUrl = `/uploads/${filename}`
    return { success: true, data: { imageUrl } }
  } catch (error) {
    console.error('Image upload error:', error)
    return { success: false, error: 'Failed to upload image' }
  }
}
 

All files / src/app error.tsx

100% Statements 3/3
100% Branches 0/0
100% Functions 3/3
100% Lines 3/3

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49                        9x   9x                                               1x                    
'use client'
 
import { useEffect } from 'react'
import { Button } from '@/components/ui'
 
export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    // Log error to console or error reporting service
    console.error('Application error:', error)
  }, [error])
 
  return (
    <div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
      <div className="max-w-md w-full bg-white rounded-lg shadow-md p-8 text-center">
        <div className="mb-4">
          <span className="material-symbols-outlined text-red-500 text-6xl">error</span>
        </div>
        <h2 className="text-2xl font-bold text-gray-900 mb-2">Something went wrong!</h2>
        <p className="text-gray-600 mb-6">
          We encountered an unexpected error. Please try again.
        </p>
        {error.message && (
          <div className="bg-red-50 border border-red-200 rounded-md p-4 mb-6">
            <p className="text-sm text-red-800 font-mono">{error.message}</p>
          </div>
        )}
        <div className="space-y-2">
          <Button onClick={reset} className="w-full">
            Try again
          </Button>
          <Button
            variant="outline"
            onClick={() => (window.location.href = '/')}
            className="w-full"
          >
            Go to homepage
          </Button>
        </div>
      </div>
    </div>
  )
}
 

All files / src/app global-error.tsx

0% Statements 0/0
0% Branches 0/0
0% Functions 1/1
0% Lines 0/0

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33                                                                 
'use client'
 
import React from 'react'
 
export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <html>
      <body>
        <div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
          <div className="max-w-md w-full bg-white rounded-lg shadow-md p-8 text-center">
            <h2 className="text-2xl font-bold text-gray-900 mb-2">Application Error</h2>
            <p className="text-gray-600 mb-6">
              A critical error occurred. Please refresh the page.
            </p>
            <button
              onClick={reset}
              className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700"
            >
              Refresh
            </button>
          </div>
        </div>
      </body>
    </html>
  )
}
 

All files src/app

27.27% Statements 3/11
0% Branches 0/16
57.14% Functions 4/7
27.27% Lines 3/11

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
error.tsx
100% 3/3 100% 0/0 100% 3/3 100% 3/3
global-error.tsx
0% 0/0 0% 0/0 0% 1/1 0% 0/0
layout.tsx
0% 0/2 100% 0/0 0% 0/1 0% 0/2
not-found.tsx
0% 0/0 0% 0/0 0% 0/1 0% 0/0
page.tsx
0% 0/6 0% 0/16 0% 0/1 0% 0/6

All files / src/app layout.tsx

0% Statements 0/2
100% Branches 0/0
0% Functions 0/1
0% Lines 0/2

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39                                                                             
import type { Metadata } from 'next'
import './globals.css'
import { Navigation } from '@/components/Navigation'
 
export const dynamic = 'force-dynamic';
 
export const metadata: Metadata = {
  title: 'Restaurant Reviews',
  description: 'Browse and review restaurants with our community',
}
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <head>
        <link
          rel="stylesheet"
          href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=optional"
        />
      </head>
      <body className="antialiased">
        <Navigation />
        {children}
 
        <footer className="bg-gray-800 mt-10 py-8 md:py-10">
          <div className="mx-auto max-w-6xl w-full px-4">
            <div className="text-white text-sm md:text-base mb-3 md:mb-4">Restaurant Reviews Platform</div>
            <div className="text-white text-xs md:text-sm text-gray-300">Discover and share great dining experiences</div>
          </div>
        </footer>
      </body>
    </html>
  )
}
 

All files / src/app not-found.tsx

0% Statements 0/0
0% Branches 0/0
0% Functions 0/1
0% Lines 0/0

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35                                                                     
import Link from 'next/link'
 
export default function NotFound() {
  return (
    <div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
      <div className="max-w-md w-full bg-white rounded-lg shadow-md p-8 text-center">
        <div className="mb-6">
          <h1 className="text-6xl font-bold text-gray-900 mb-2">404</h1>
          <h2 className="text-2xl font-semibold text-gray-800 mb-2">
            Page Not Found
          </h2>
          <p className="text-gray-600">
            The page you&apos;re looking for doesn&apos;t exist or has been moved.
          </p>
        </div>
 
        <div className="space-y-3">
          <Link
            href="/"
            className="block w-full bg-blue-600 text-white px-6 py-3 rounded-md hover:bg-blue-700 font-semibold transition-colors"
          >
            Browse Restaurants
          </Link>
          <Link
            href="/login"
            className="block w-full bg-white text-blue-600 px-6 py-3 rounded-md border-2 border-blue-600 hover:bg-blue-50 font-semibold transition-colors"
          >
            Sign In
          </Link>
        </div>
      </div>
    </div>
  )
}
 

All files src/app/owner/create

0% Statements 0/0
0% Branches 0/0
0% Functions 1/1
0% Lines 0/0

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
page.tsx
0% 0/0 0% 0/0 0% 1/1 0% 0/0

All files / src/app/owner/create page.tsx

0% Statements 0/0
0% Branches 0/0
0% Functions 1/1
0% Lines 0/0

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19                                     
import { RestaurantForm } from '@/components/restaurants/RestaurantForm'
 
export default function AddRestaurantPage() {
  return (
    <div className="min-h-screen bg-gray-50">
      <div className="container mx-auto px-4 py-8 max-w-3xl">
        <div className="mb-8">
          <h1 className="text-3xl md:text-4xl font-bold text-gray-900 mb-2">Add New Restaurant</h1>
          <p className="text-sm md:text-base text-gray-600">Create a new restaurant listing</p>
        </div>
 
        <div className="bg-white rounded-lg shadow-md p-4 md:p-6">
          <RestaurantForm mode="create" />
        </div>
      </div>
    </div>
  )
}
 

All files / src/app/owner/my-restaurants DeleteRestaurantButton.tsx

100% Statements 11/11
100% Branches 2/2
100% Functions 6/6
100% Lines 11/11

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59                  31x 31x 31x   31x 5x   3x 3x     2x         31x                             1x                       7x            
'use client'
 
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { deleteRestaurant } from '@/app/actions/restaurants'
import { Button, ErrorMessage } from '@/components/ui'
import { useAsyncAction } from '@/hooks/useAsyncAction';
 
export function DeleteRestaurantButton({ restaurantId }: { restaurantId: string }) {
  const router = useRouter()
  const [showConfirm, setShowConfirm] = useState(false)
  const { error, isPending, execute } = useAsyncAction(deleteRestaurant)
 
  const handleDelete = () => {
    execute({
      onSuccess: () => {
        router.refresh();
        setShowConfirm(false)
      },
      onError: () => {
        setShowConfirm(false);
      }
    })(restaurantId)
  }
 
  if (showConfirm) {
    return (
      <div className="flex gap-2">
        <Button
          size="sm"
          variant="danger"
          onClick={handleDelete}
          isLoading={isPending}
          disabled={isPending}
        >
          Confirm
        </Button>
        <Button
          size="sm"
          variant="outline"
          onClick={() => setShowConfirm(false)}
          disabled={isPending}
        >
          Cancel
        </Button>
      </div>
    )
  }
 
  return (
    <div className="flex flex-col gap-2">
      {error && <ErrorMessage message={error} />}
      <Button size="sm" variant="danger" onClick={() => setShowConfirm(true)}>
        Delete
      </Button>
    </div>
  )
}
 

All files src/app/owner/my-restaurants

78.57% Statements 11/14
100% Branches 2/2
75% Functions 6/8
84.61% Lines 11/13

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
DeleteRestaurantButton.tsx
100% 11/11 100% 2/2 100% 6/6 100% 11/11
page.tsx
0% 0/3 100% 0/0 0% 0/2 0% 0/2

All files / src/app/owner/my-restaurants page.tsx

0% Statements 0/3
100% Branches 0/0
0% Functions 0/2
0% Lines 0/2

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114                                                                                                                                                                                                                                   
import Link from 'next/link'
import Image from 'next/image'
import { getMyRestaurants } from '@/app/actions/restaurants'
import { Button, StarRating } from '@/components/ui'
import { DeleteRestaurantButton } from './DeleteRestaurantButton'
import { calculateAverageRating } from '@/lib/utils'
 
export default async function MyRestaurantsPage() {
  const restaurants = await getMyRestaurants()
 
  // Calculate ratings for each restaurant
  const restaurantsWithRatings = restaurants.map((restaurant) => ({
    ...restaurant,
    averageRating: calculateAverageRating(restaurant.reviews),
    reviewCount: restaurant.reviews.length,
  }))
 
  return (
    <div className="min-h-screen bg-gray-50">
      <div className="container mx-auto px-4 py-8">
        <div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-8 gap-4">
          <div>
            <h1 className="text-3xl md:text-4xl font-bold text-gray-900 mb-2">My Restaurants</h1>
            <p className="text-gray-600">Manage your restaurant listings</p>
          </div>
          <Link href="/owner/create">
            <Button>Create New Restaurant</Button>
          </Link>
        </div>
 
        {restaurantsWithRatings.length === 0 ? (
          <div className="bg-white rounded-lg shadow-md p-12 text-center">
            <p className="text-gray-500 text-lg mb-4">You haven&apos;t created any restaurants yet</p>
            <Link href="/owner/create">
              <Button>Create Your First Restaurant</Button>
            </Link>
          </div>
        ) : (
          <div className="grid grid-cols-1 gap-6">
            {restaurantsWithRatings.map((restaurant) => (
              <div
                key={restaurant.id}
                className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"
              >
                <div className="md:flex">
                  {restaurant.imageUrl && (
                    <div className="md:w-48 h-48 relative overflow-hidden bg-gray-200">
                      <Image
                        src={restaurant.imageUrl}
                        alt={restaurant.title}
                        fill
                        className="object-cover"
                        sizes="(max-width: 768px) 100vw, 192px"
                      />
                    </div>
                  )}
                  <div className="flex-1 p-6">
                    <div className="flex flex-col sm:flex-row sm:justify-between sm:items-start mb-4 gap-4">
                      <div className="flex-1">
                        <h2 className="text-xl md:text-2xl font-semibold mb-2">{restaurant.title}</h2>
                        <p className="text-gray-600 mb-2">{restaurant.description}</p>
                        <p className="text-sm text-gray-500">
                          <span className="material-symbols-outlined text-sm align-middle">
                            location_on
                          </span>{' '}
                          {restaurant.location}
                        </p>
                      </div>
                      <div className="flex gap-2 flex-shrink-0">
                        <Link href={`/owner/${restaurant.id}/edit`}>
                          <Button size="sm" variant="secondary">
                            Edit
                          </Button>
                        </Link>
                        <DeleteRestaurantButton restaurantId={restaurant.id} />
                      </div>
                    </div>
 
                    <div className="flex flex-wrap gap-2 mb-4">
                      {restaurant.cuisine.map((c) => (
                        <span
                          key={c}
                          className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded-full"
                        >
                          {c}
                        </span>
                      ))}
                    </div>
 
                    <div className="flex items-center justify-between">
                      <StarRating
                        rating={restaurant.averageRating}
                        size="sm"
                        showRating
                        reviewCount={restaurant.reviewCount}
                      />
 
                      <Link href={`/owner/${restaurant.id}/reviews`}>
                        <Button size="sm" variant="outline">
                          View Reviews
                        </Button>
                      </Link>
                    </div>
                  </div>
                </div>
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  )
}
 

All files src/app/owner/[id]/edit

0% Statements 0/5
0% Branches 0/8
0% Functions 0/1
0% Lines 0/5

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
page.tsx
0% 0/5 0% 0/8 0% 0/1 0% 0/5

All files / src/app/owner/[id]/edit page.tsx

0% Statements 0/5
0% Branches 0/8
0% Functions 0/1
0% Lines 0/5

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46                                                                                           
import { notFound } from 'next/navigation'
import { getRestaurant } from '@/app/actions/restaurants'
import { getCurrentUser } from '@/app/actions/auth'
import { RestaurantForm } from '@/components/restaurants/RestaurantForm'
 
export default async function EditRestaurantPage({ params }: { params: { id: string } }) {
  const [restaurant, user] = await Promise.all([
    getRestaurant(params.id),
    getCurrentUser(),
  ])
 
  if (!restaurant) {
    notFound()
  }
 
  // Verify ownership
  if (!user || restaurant.ownerId !== user.id) {
    notFound()
  }
 
  return (
    <div className="min-h-screen bg-gray-50">
      <div className="container mx-auto px-4 py-8 max-w-3xl">
        <div className="mb-8">
          <h1 className="text-3xl md:text-4xl font-bold text-gray-900 mb-2">Edit Restaurant</h1>
          <p className="text-sm md:text-base text-gray-600">Update your restaurant information</p>
        </div>
 
        <div className="bg-white rounded-lg shadow-md p-4 md:p-6">
          <RestaurantForm
            mode="edit"
            restaurantId={restaurant.id}
            initialData={{
              title: restaurant.title,
              description: restaurant.description,
              location: restaurant.location,
              cuisine: restaurant.cuisine,
              imageUrl: restaurant.imageUrl || '',
            }}
          />
        </div>
      </div>
    </div>
  )
}
 

All files src/app/owner/[id]/reviews

0% Statements 0/6
0% Branches 0/6
0% Functions 0/1
0% Lines 0/6

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
page.tsx
0% 0/6 0% 0/6 0% 0/1 0% 0/6

All files / src/app/owner/[id]/reviews page.tsx

0% Statements 0/6
0% Branches 0/6
0% Functions 0/1
0% Lines 0/6

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86                                                                                                                                                                           
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { getRestaurant } from '@/app/actions/restaurants'
import { getCurrentUser } from '@/app/actions/auth'
import { Button, StarRating } from '@/components/ui'
import { formatRelativeTime, calculateAverageRating } from '@/lib/utils'
 
export default async function RestaurantReviewsPage({ params }: { params: { id: string } }) {
  const [restaurant, user] = await Promise.all([
    getRestaurant(params.id),
    getCurrentUser(),
  ])
 
  if (!restaurant) {
    notFound()
  }
 
  // Verify ownership
  if (!user || restaurant.ownerId !== user.id) {
    notFound()
  }
 
  const averageRating = calculateAverageRating(restaurant.reviews)
 
  return (
    <div className="min-h-screen bg-gray-50">
      <div className="container mx-auto px-4 py-8 max-w-4xl">
        <div className="mb-8">
          <Link href="/owner/my-restaurants">
            <Button variant="outline" size="sm">
              ← Back to My Restaurants
            </Button>
          </Link>
        </div>
 
        <div className="bg-white rounded-lg shadow-md p-6 mb-8">
          <h1 className="text-2xl md:text-3xl font-bold text-gray-900 mb-2">{restaurant.title}</h1>
          <p className="text-gray-600 mb-4">{restaurant.description}</p>
 
          <div className="flex items-center gap-4 mb-4">
            <StarRating
              rating={averageRating}
              size="lg"
              showRating
              reviewCount={restaurant.reviews.length}
            />
          </div>
        </div>
 
        <div className="space-y-4">
          <h2 className="text-xl md:text-2xl font-bold text-gray-900 mb-4">Customer Reviews</h2>
 
          {restaurant.reviews.length === 0 ? (
            <div className="bg-white rounded-lg shadow-md p-12 text-center">
              <p className="text-gray-500 text-lg">No reviews yet</p>
              <p className="text-gray-400 text-sm mt-2">
                Your restaurant will receive reviews from customers
              </p>
            </div>
          ) : (
            restaurant.reviews.map((review) => (
              <div key={review.id} className="bg-white rounded-lg shadow-md p-6">
                <div className="flex flex-col sm:flex-row sm:justify-between sm:items-start mb-3 gap-2">
                  <div className="flex-1">
                    <h3 className="font-semibold text-gray-900">{review.user.name}</h3>
                    <p className="text-sm text-gray-500">
                      {formatRelativeTime(review.createdAt)}
                    </p>
                  </div>
                  <div className="flex-shrink-0">
                    <StarRating rating={review.rating} size="sm" showRating />
                  </div>
                </div>
 
                {review.comment && (
                  <p className="text-gray-700 leading-relaxed">{review.comment}</p>
                )}
              </div>
            ))
          )}
        </div>
      </div>
    </div>
  )
}
 

All files / src/app page.tsx

0% Statements 0/6
0% Branches 0/16
0% Functions 0/1
0% Lines 0/6

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56                                                                                                               
import { RestaurantGrid } from '@/components/restaurants/RestaurantGrid';
import { FilterPanel } from '@/components/filters/FilterPanel';
import { getRestaurants } from '@/app/actions/restaurants';
import { type CuisineType, type SortOrder } from '@/lib/constants';
 
export const dynamic = 'force-dynamic';
 
interface HomePageProps {
  searchParams: {
    cuisine?: string;
    minRating?: string;
    sort?: string;
    location?: string;
  };
}
 
export default async function HomePage({ searchParams }: HomePageProps) {
  // Parse filters from URL
  const cuisines = searchParams.cuisine?.split(',').filter(Boolean) as CuisineType[] | undefined;
  const minRating = searchParams.minRating ? Number(searchParams.minRating) : undefined;
  const sort: SortOrder | undefined =
    searchParams.sort === 'best' || searchParams.sort === 'worst'
      ? searchParams.sort
      : undefined;
  const location = searchParams.location || undefined;
 
  // Fetch restaurants using server action
  const restaurants = await getRestaurants({
    cuisines,
    minRating,
    sort,
    location
  });
 
  return (
    <div className="container mx-auto px-4 py-8">
      <div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
        {/* Sidebar with filters */}
        <aside className="lg:col-span-1">
          <FilterPanel
            initialCuisines={cuisines ?? []}
            initialMinRating={minRating ?? 0}
            initialSort={sort ?? 'best'}
            initialLocation={location ?? ''}
          />
        </aside>
 
        {/* Main content */}
        <main className="lg:col-span-3">
          <RestaurantGrid restaurants={restaurants} />
        </main>
      </div>
    </div>
  );
}
 

All files src/app/reviewer/restaurants/[id]

0% Statements 0/6
0% Branches 0/10
0% Functions 0/2
0% Lines 0/6

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
page.tsx
0% 0/6 0% 0/10 0% 0/2 0% 0/6

All files / src/app/reviewer/restaurants/[id] page.tsx

0% Statements 0/6
0% Branches 0/10
0% Functions 0/2
0% Lines 0/6

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114                                                                                                                                                                                                                                   
import Image from 'next/image';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getRestaurant } from '@/app/actions/restaurants';
import { getCurrentUser } from '@/app/actions/auth';
import { getMyReview } from '@/app/actions/reviews';
import ReviewForm from '@/components/reviews/ReviewForm';
import { Button, StarRating } from '@/components/ui';
import { calculateAverageRating } from '@/lib/utils';
 
export default async function RestaurantDetailsPage({
  params
}: {
  params: { id: string }
}) {
  const [restaurant, user, myReview] = await Promise.all([
    getRestaurant(params.id),
    getCurrentUser(),
    getCurrentUser().then(u => u ? getMyReview(params.id) : null)
  ]);
 
  if (!restaurant) {
    notFound();
  }
 
  const averageRating = calculateAverageRating(restaurant.reviews);
 
  const isOwner = user?.role === 'OWNER' && user.id === restaurant.ownerId;
 
  return (
    <div className="px-4">
      <div className="mx-auto max-w-6xl mt-8 md:mt-16 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
        <div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
          <h2 className="font-bold text-2xl md:text-3xl text-gray-800">{restaurant.title}</h2>
          <div>
            <StarRating rating={averageRating} />
          </div>
        </div>
        {isOwner && (
          <div className="flex gap-2 flex-wrap">
            <Link href={`/owner/${restaurant.id}/edit`}>
              <Button size="sm" variant="secondary">Edit Restaurant</Button>
            </Link>
            <Link href={`/owner/${restaurant.id}/reviews`}>
              <Button size="sm" variant="outline">View All Reviews</Button>
            </Link>
          </div>
        )}
      </div>
 
      <div className="mx-auto max-w-6xl text-gray-500 text-sm mb-4 font-semibold">
        {restaurant.location}
      </div>
 
      <div className="mx-auto max-w-6xl grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8">
        <div className="order-2 md:order-1">
          <p className="text-justify text-gray-800">
            {restaurant.description}
          </p>
        </div>
        <div className="order-1 md:order-2">
          <Image
            src={restaurant.imageUrl ?? '/restaurant1.jpg'}
            alt={restaurant.title}
            width={600}
            height={400}
            className="rounded-lg shadow-lg w-full h-auto"
          />
        </div>
      </div>
 
      <div className="mx-auto max-w-6xl my-6">
        <h2 className="font-semibold text-xl md:text-2xl text-gray-700 mb-4">Reviews</h2>
 
        {user?.role === 'REVIEWER' && (
          <ReviewForm
            restaurantId={params.id}
            existingReview={myReview}
          />
        )}
 
        <div className="my-6">
          {restaurant.reviews.map((review) => (
            <div key={review.id} className="p-4 my-3 shadow-sm">
              <div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2">
                <div className="leading-loose">
                  <div className="select-none">
                    <StarRating rating={review.rating} />
                  </div>
                </div>
 
                <div className="leading-loose text-xs sm:text-sm text-gray-600">
                  By <strong>{review.user.name}</strong> on{' '}
                  {new Date(review.createdAt).toLocaleDateString()}
                </div>
              </div>
 
              <div className="mt-2">
                {review.comment || 'No comment provided'}
              </div>
            </div>
          ))}
 
          { restaurant.reviews.length === 0 && (
            <div className="text-gray-500 py-8">
              No reviews yet.
            </div>
          )}
 
        </div>
      </div>
    </div>
  );
}

All files src/components/auth

100% Statements 27/27
100% Branches 10/10
100% Functions 9/9
100% Lines 27/27

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
LoginForm.tsx
100% 10/10 100% 4/4 100% 3/3 100% 10/10
LogoutButton.tsx
100% 7/7 100% 2/2 100% 3/3 100% 7/7
RegisterForm.tsx
100% 10/10 100% 4/4 100% 3/3 100% 10/10

All files / src/components/auth LoginForm.tsx

100% Statements 10/10
100% Branches 4/4
100% Functions 3/3
100% Lines 10/10

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111                      28x 28x             28x                   28x 3x 3x   3x 1x   2x 2x                                                                                                                                                  
'use client';
 
import Link from 'next/link';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { loginSchema, type LoginInput } from '@/lib/validators';
import { loginAction } from '@/app/actions/auth';
 
export function LoginForm() {
  const [isPending, startTransition] = useTransition();
  const router = useRouter();
 
  const {
    register,
    handleSubmit,
    formState: { errors },
    setError,
  } = useForm<LoginInput>({
    resolver: zodResolver(loginSchema),
    mode: 'onBlur',
    reValidateMode: 'onChange',
    defaultValues: {
      email: '',
      password: '',
    },
  });
 
  const onSubmit = (data: LoginInput) => {
    startTransition(async () => {
      const result = await loginAction(data.email, data.password);
 
      if ('error' in result) {
        setError('root', { message: result.error });
      } else {
        router.push('/');
        router.refresh();
      }
    });
  };
 
  return (
    <div className="mt-10 max-w-md mx-auto w-full shadow-2xl p-6 rounded-lg">
      <form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
        {errors.root && (
          <div className="text-red-600 text-sm text-center">{errors.root.message}</div>
        )}
 
        <div>
          <label htmlFor="email" className="block text-gray-800 text-sm font-semibold">
            Email
          </label>
          <div className="mt-2">
            <input
              id="email"
              type="email"
              autoComplete="email"
              disabled={isPending}
              {...register('email')}
              className="block w-full rounded-md border-0 px-3 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600"
            />
            {errors.email && (
              <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
            )}
          </div>
        </div>
 
        <div>
          <label htmlFor="password" className="block text-gray-800 text-sm font-semibold">
            Password
          </label>
          <div className="mt-2">
            <input
              id="password"
              type="password"
              autoComplete="current-password"
              disabled={isPending}
              {...register('password')}
              className="block w-full rounded-md border-0 px-3 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600"
            />
            {errors.password && (
              <p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
            )}
          </div>
        </div>
 
        <div>
          <button
            type="submit"
            disabled={isPending}
            className="flex w-full justify-center rounded-md bg-blue-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white disabled:opacity-50 hover:bg-blue-500 transition-colors"
          >
            {isPending ? 'Signing in...' : 'Sign in'}
          </button>
        </div>
      </form>
 
      <p className="mt-10 text-center text-sm text-gray-500">
        Don&apos;t have an account?{' '}
        <Link
          href="/register"
          className="font-semibold leading-6 text-blue-600 hover:text-blue-500"
        >
          Register
        </Link>
      </p>
    </div>
  );
}
 

All files / src/components/auth LogoutButton.tsx

100% Statements 7/7
100% Branches 2/2
100% Functions 3/3
100% Lines 7/7

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29              9x 9x   9x 2x 2x 2x 2x                            
'use client'
 
import { useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { logoutAction } from '@/app/actions/auth'
 
export function LogoutButton() {
  const router = useRouter()
  const [isPending, startTransition] = useTransition()
 
  const handleLogout = () => {
    startTransition(async () => {
      await logoutAction()
      router.push('/login')
      router.refresh()
    })
  }
 
  return (
    <button
      onClick={handleLogout}
      disabled={isPending}
      className="text-sm font-semibold text-red-600 hover:text-red-700 disabled:opacity-50"
    >
      {isPending ? 'Signing out...' : 'Sign out'}
    </button>
  )
}
 

All files / src/components/auth RegisterForm.tsx

100% Statements 10/10
100% Branches 4/4
100% Functions 3/3
100% Lines 10/10

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155                      34x 34x             34x                       34x 3x 3x   3x 1x   2x 2x                                                                                                                                                                                                                                      
'use client';
 
import Link from 'next/link';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { registerSchema, type RegisterInput } from '@/lib/validators';
import { registerAction } from '@/app/actions/auth';
 
export function RegisterForm() {
  const [isPending, startTransition] = useTransition();
  const router = useRouter();
 
  const {
    register,
    handleSubmit,
    formState: { errors },
    setError,
  } = useForm<RegisterInput>({
    resolver: zodResolver(registerSchema),
    mode: 'onBlur',
    reValidateMode: 'onChange',
    defaultValues: {
      name: '',
      email: '',
      password: '',
      role: 'REVIEWER',
    },
  });
 
  const onSubmit = (data: RegisterInput) => {
    startTransition(async () => {
      const result = await registerAction(data.email, data.password, data.name, data.role);
 
      if ('error' in result) {
        setError('root', { message: result.error });
      } else {
        router.push('/');
        router.refresh();
      }
    });
  };
 
  return (
    <div className="mt-10 max-w-md mx-auto w-full shadow-2xl p-6 rounded-lg">
      <form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
        {errors.root && (
          <div className="text-red-600 text-sm text-center">{errors.root.message}</div>
        )}
 
        <div>
          <label htmlFor="name" className="block text-gray-800 text-sm font-semibold">
            Name
          </label>
          <div className="mt-2">
            <input
              id="name"
              type="text"
              autoComplete="name"
              disabled={isPending}
              {...register('name')}
              className="block w-full rounded-md border-0 px-3 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600"
            />
            {errors.name && (
              <p className="mt-1 text-sm text-red-600">{errors.name.message}</p>
            )}
          </div>
        </div>
 
        <div>
          <label htmlFor="email" className="block text-gray-800 text-sm font-semibold">
            Email
          </label>
          <div className="mt-2">
            <input
              id="email"
              type="email"
              autoComplete="email"
              disabled={isPending}
              {...register('email')}
              className="block w-full rounded-md border-0 px-3 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600"
            />
            {errors.email && (
              <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
            )}
          </div>
        </div>
 
        <div>
          <label htmlFor="password" className="block text-gray-800 text-sm font-semibold">
            Password
          </label>
          <div className="mt-2">
            <input
              id="password"
              type="password"
              autoComplete="new-password"
              disabled={isPending}
              {...register('password')}
              className="block w-full rounded-md border-0 px-3 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600"
            />
            {errors.password && (
              <p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
            )}
            <p className="mt-1 text-xs text-gray-500">
              Must be at least 8 characters with uppercase, lowercase, and number
            </p>
          </div>
        </div>
 
        <div>
          <label htmlFor="role" className="block text-gray-800 text-sm font-semibold">
            Role
          </label>
          <div className="mt-2">
            <select
              id="role"
              disabled={isPending}
              {...register('role')}
              className="block w-full rounded-md border-0 px-3 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600"
            >
              <option value="REVIEWER">Reviewer</option>
              <option value="OWNER">Restaurant Owner</option>
            </select>
            {errors.role && (
              <p className="mt-1 text-sm text-red-600">{errors.role.message}</p>
            )}
          </div>
        </div>
 
        <div>
          <button
            type="submit"
            disabled={isPending}
            className="flex w-full justify-center rounded-md bg-blue-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white disabled:opacity-50 hover:bg-blue-500 transition-colors"
          >
            {isPending ? 'Registering...' : 'Register'}
          </button>
        </div>
      </form>
 
      <p className="mt-10 text-center text-sm text-gray-500">
        Already have an account?{' '}
        <Link
          href="/login"
          className="font-semibold leading-6 text-blue-600 hover:text-blue-500"
        >
          Sign in
        </Link>
      </p>
    </div>
  );
}
 

All files / src/components/filters FilterPanel.tsx

100% Statements 73/73
72.88% Branches 43/59
100% Functions 14/14
100% Lines 68/68

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244                1x                                           74x 74x     74x 23x 23x 23x 3x 3x   1x       74x 74x 74x 74x     74x     35x   35x 23x 23x     2x         2x   2x 2x 2x 2x     2x   2x 2x   2x 2x   2x 2x   2x 2x       2x 2x 2x               74x 10x 10x 1x         74x   5x           5x   5x 5x 5x 4x   5x 2x   5x 2x   5x   5x 5x       74x 3x 3x 3x 3x   3x   3x 3x       74x                           3x                         6x                             3x                                       10x                                                                
'use client'
 
import { useState, useTransition, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { CUISINE_TYPES, MIN_RATING_FILTERS, SORT_OPTIONS, CITIES, SortOrder, CuisineType } from '@/lib/constants'
import { Button } from '@/components/ui'
import { savedPreferencesSchema } from '@/lib/validators';
 
const FILTER_PREFERENCES_KEY = 'restaurant_filter_preferences'
 
export interface FilterPanelProps {
  initialCuisines?: CuisineType[]
  initialMinRating?: number
  initialSort?: SortOrder,
  initialLocation?: string
}
 
interface SavedFilterPreferences {
  cuisines?: CuisineType[]
  minRating?: number
  sort?: SortOrder
  location?: string
}
 
export function FilterPanel({
  initialCuisines = [],
  initialMinRating = 0,
  initialSort = 'best',
  initialLocation = '',
}: FilterPanelProps) {
  const router = useRouter()
  const [isPending, startTransition] = useTransition()
 
  // Load saved filter preferences from localStorage
  const getSavedPreferences = (): SavedFilterPreferences | null => {
    try {
      const saved = localStorage.getItem(FILTER_PREFERENCES_KEY)
      if(!saved) return null;
      const parsed = savedPreferencesSchema.safeParse(JSON.parse(saved))
      return parsed.success ? parsed.data : null
    } catch {
      return null
    }
  }
 
  const [selectedCuisines, setSelectedCuisines] = useState<CuisineType[]>(initialCuisines)
  const [minRating, setMinRating] = useState(initialMinRating)
  const [sortOrder, setSortOrder] = useState<SortOrder>(initialSort)
  const [selectedLocation, setSelectedLocation] = useState(initialLocation)
 
  // Load filter preferences on mount and apply them if no URL params present
  useEffect(() => {
 
    // Only load from localStorage if no URL params are present
    const hasUrlParams = initialCuisines.length > 0 || initialMinRating > 0 || initialSort !== 'best' || initialLocation !== ''
 
    if (!hasUrlParams) {
      const saved = getSavedPreferences()
      if (saved) {
        // Check if saved preferences exist
        const hasSavedPreferences =
          (saved.cuisines && saved.cuisines.length > 0) ||
          (saved.minRating && saved.minRating > 0) ||
          saved.sort ||
          saved.location
 
        Eif (hasSavedPreferences) {
          // Update local state with saved preferences
          Eif (saved.cuisines) setSelectedCuisines(saved.cuisines)
          Eif (saved.minRating) setMinRating(saved.minRating)
          Eif (saved.sort) setSortOrder(saved.sort)
          Eif (saved.location) setSelectedLocation(saved.location)
 
          // Build URL params from saved preferences
          const params = new URLSearchParams()
 
          Eif (saved.cuisines && saved.cuisines.length > 0) {
            params.set('cuisine', saved.cuisines.join(','))
          }
          Eif (saved.minRating && saved.minRating > 0) {
            params.set('minRating', saved.minRating.toString())
          }
          Eif (saved.location) {
            params.set('location', saved.location)
          }
          Eif (saved.sort) {
            params.set('sort', saved.sort)
          }
 
          // Navigate to URL with saved filters (only once on mount)
          const queryString = params.toString()
          Eif (queryString) {
            router.push(`/?${queryString}`)
          }
        }
      }
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []) // Only run once on mount
 
  const handleCuisineToggle = (cuisine: CuisineType) => {
    setSelectedCuisines((prev) =>
      prev.includes(cuisine)
        ? prev.filter((c) => c !== cuisine)
        : [...prev, cuisine]
    )
  }
 
  const handleApplyFilters = () => {
    // Save all filter preferences to localStorage
    const preferences: SavedFilterPreferences = {
        cuisines: selectedCuisines,
        minRating,
        sort: sortOrder,
        location: selectedLocation,
    }
    localStorage.setItem(FILTER_PREFERENCES_KEY, JSON.stringify(preferences))
 
    startTransition(() => {
      const params = new URLSearchParams()
      if (selectedCuisines.length > 0) {
        params.set('cuisine', selectedCuisines.join(','))
      }
      if (minRating > 0) {
        params.set('minRating', minRating.toString())
      }
      if (selectedLocation) {
        params.set('location', selectedLocation)
      }
      params.set('sort', sortOrder)
 
      const queryString = params.toString()
      router.push(queryString ? `/?${queryString}` : '/')
    })
  }
 
  const handleReset = () => {
    setSelectedCuisines([])
    setMinRating(0)
    setSortOrder('best')
    setSelectedLocation('')
 
    localStorage.removeItem(FILTER_PREFERENCES_KEY)
 
    startTransition(() => {
      router.push('/')
    })
  }
 
  const hasActiveFilters = selectedCuisines.length > 0 || minRating > 0 || selectedLocation !== ''
 
  return (
    <div className="bg-white rounded-lg shadow-md p-6 space-y-6">
      <div>
        <h3 className="text-base md:text-lg font-semibold mb-3">Sort By</h3>
        <div className="space-y-2">
          {SORT_OPTIONS.map((option) => (
            <label key={option.value} className="flex items-center cursor-pointer">
              <input
                type="radio"
                name="sort"
                value={option.value}
                checked={sortOrder === option.value}
                onChange={(e) => setSortOrder(e.target.value as SortOrder)}
                className="mr-2"
              />
              <span>{option.label}</span>
            </label>
          ))}
        </div>
      </div>
 
      <div>
        <h3 className="text-base md:text-lg font-semibold mb-3">Minimum Rating</h3>
        <select
          value={minRating}
          onChange={(e) => setMinRating(Number(e.target.value))}
          className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
        >
          {MIN_RATING_FILTERS.map((option) => (
            <option key={option.value} value={option.value}>
              {option.label}
            </option>
          ))}
        </select>
      </div>
 
      <div>
        <h3 className="text-base md:text-lg font-semibold mb-3">Location</h3>
        <select
          value={selectedLocation}
          onChange={(e) => setSelectedLocation(e.target.value)}
          className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
        >
          <option value="">All Locations</option>
          {CITIES.map((city) => (
            <option key={city} value={city}>
              {city}
            </option>
          ))}
        </select>
      </div>
 
      <div>
        <h3 className="text-base md:text-lg font-semibold mb-3">Cuisine</h3>
        <div className="space-y-2 max-h-64 overflow-y-auto">
          {CUISINE_TYPES.map((cuisine) => (
            <label key={cuisine} className="flex items-center cursor-pointer">
              <input
                type="checkbox"
                checked={selectedCuisines.includes(cuisine)}
                onChange={() => handleCuisineToggle(cuisine)}
                className="mr-2"
              />
              <span>{cuisine}</span>
            </label>
          ))}
        </div>
      </div>
 
      <div className="pt-4 space-y-2">
        <Button
          onClick={handleApplyFilters}
          className="w-full"
          isLoading={isPending}
          disabled={isPending}
        >
          Apply Filters
        </Button>
        {hasActiveFilters && (
          <Button
            onClick={handleReset}
            variant="outline"
            className="w-full"
            disabled={isPending}
          >
            Reset Filters
          </Button>
        )}
      </div>
    </div>
  )
}
 

All files src/components/filters

100% Statements 73/73
72.88% Branches 43/59
100% Functions 14/14
100% Lines 68/68

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
FilterPanel.tsx
100% 73/73 72.88% 43/59 100% 14/14 100% 68/68

All files src/components

0% Statements 0/1
100% Branches 0/0
0% Functions 0/1
0% Lines 0/1

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
Navigation.tsx
0% 0/1 100% 0/0 0% 0/1 0% 0/1

All files / src/components Navigation.tsx

0% Statements 0/1
100% Branches 0/0
0% Functions 0/1
0% Lines 0/1

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73                                                                                                                                                 
import Link from 'next/link'
import { getCurrentUser } from '@/app/actions/auth'
import { LogoutButton } from '@/components/auth/LogoutButton'
 
export async function Navigation() {
  const user = await getCurrentUser()
 
  return (
    <nav className="bg-white border-b border-gray-200">
      <div className="mx-auto max-w-6xl px-4">
        <div className="flex items-center justify-between py-4 flex-wrap gap-4">
          <div className="leading-loose">
            <Link href="/" className="font-bold text-2xl md:text-3xl text-gray-900 hover:text-blue-600">
              Restaurant Reviews
            </Link>
          </div>
 
          <div className="flex items-center gap-2 md:gap-4 flex-wrap">
            {user ? (
              <>
                <span className="text-xs md:text-sm text-gray-600 hidden sm:inline">
                  Welcome, <span className="font-semibold">{user.name}</span>
                </span>
 
                {user.role === 'OWNER' ? (
                  <>
                    <Link
                      href="/owner/my-restaurants"
                      className="text-xs md:text-sm font-semibold text-blue-600 hover:text-blue-700 whitespace-nowrap"
                    >
                      My Restaurants
                    </Link>
                    <Link
                      href="/owner/create"
                      className="text-xs md:text-sm font-semibold text-blue-600 hover:text-blue-700 whitespace-nowrap"
                    >
                      Add Restaurant
                    </Link>
                  </>
                ) : (
                  <Link
                    href="/"
                    className="text-xs md:text-sm font-semibold text-blue-600 hover:text-blue-700 whitespace-nowrap"
                  >
                    Browse Restaurants
                  </Link>
                )}
 
                <LogoutButton />
              </>
            ) : (
              <>
                <Link
                  href="/login"
                  className="text-xs md:text-sm font-semibold text-blue-600 hover:text-blue-700"
                >
                  Sign in
                </Link>
                <Link
                  href="/register"
                  className="text-xs md:text-sm font-semibold text-white bg-blue-600 hover:bg-blue-700 px-3 md:px-4 py-1.5 md:py-2 rounded-md whitespace-nowrap"
                >
                  Register
                </Link>
              </>
            )}
          </div>
        </div>
      </div>
    </nav>
  )
}
 

All files / src/components/restaurants ImageUploader.tsx

100% Statements 38/38
96.15% Branches 25/26
100% Functions 5/5
100% Lines 37/37

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137                              35x 35x 35x 35x   35x 11x 11x   10x     10x 1x 1x     9x 1x 1x       8x 8x 8x   8x     8x 8x 8x 8x   8x   5x 2x 2x   3x 3x     2x 2x   7x       35x 1x 1x 1x 1x                                                     1x                                                                            
'use client'
 
import { useState, useRef } from 'react'
import { uploadImageAction } from '@/app/actions/restaurants'
import { Button } from '@/components/ui'
import Image from 'next/image'
import { MAX_IMAGE_SIZE, ALLOWED_IMAGE_TYPES, ERROR_MESSAGES } from '@/lib/constants'
 
interface ImageUploaderProps {
  currentImageUrl?: string
  onImageChange: (imageUrl: string) => void
  disabled?: boolean
}
 
export function ImageUploader({ currentImageUrl, onImageChange, disabled }: ImageUploaderProps) {
  const [isUploading, setIsUploading] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const [preview, setPreview] = useState<string | null>(currentImageUrl || null)
  const fileInputRef = useRef<HTMLInputElement>(null)
 
  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return
 
    setError(null)
 
    // Client-side validation
    if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
      setError(ERROR_MESSAGES.INVALID_IMAGE_TYPE)
      return
    }
 
    if (file.size > MAX_IMAGE_SIZE) {
      setError(ERROR_MESSAGES.IMAGE_TOO_LARGE)
      return
    }
 
    // Show preview
    const reader = new FileReader()
    reader.onloadend = () => {
      setPreview(reader.result as string)
    }
    reader.readAsDataURL(file)
 
    // Upload file
    setIsUploading(true)
    try {
      const formData = new FormData()
      formData.append('image', file)
 
      const result = await uploadImageAction(formData)
 
      if (!result.success) {
        setError(result.error)
        setPreview(currentImageUrl || null)
      } else {
        onImageChange(result.data.imageUrl)
        setPreview(result.data.imageUrl)
      }
    } catch (err) {
      setError('Failed to upload image')
      setPreview(currentImageUrl || null)
    } finally {
      setIsUploading(false)
    }
  }
 
  const handleRemove = () => {
    setPreview(null)
    onImageChange('')
    Eif (fileInputRef.current) {
      fileInputRef.current.value = ''
    }
  }
 
  return (
    <div className="space-y-4">
      <div>
        <label className="block text-sm font-medium text-gray-700 mb-2">
          Restaurant Image
        </label>
 
        {preview && (
          <div className="mb-4 relative w-full h-64 bg-gray-100 rounded-lg overflow-hidden">
            <Image
              src={preview}
              alt="Restaurant preview"
              fill
              className="object-cover"
              sizes="(max-width: 768px) 100vw, 768px"
            />
          </div>
        )}
 
        <div className="flex gap-2">
          <Button
            type="button"
            variant="outline"
            onClick={() => fileInputRef.current?.click()}
            disabled={disabled || isUploading}
          >
            {isUploading ? 'Uploading...' : preview ? 'Change Image' : 'Upload Image'}
          </Button>
 
          {preview && (
            <Button
              type="button"
              variant="outline"
              onClick={handleRemove}
              disabled={disabled || isUploading}
            >
              Remove
            </Button>
          )}
        </div>
 
        <input
          ref={fileInputRef}
          type="file"
          accept="image/jpeg,image/jpg,image/png,image/webp"
          onChange={handleFileChange}
          className="hidden"
          disabled={disabled || isUploading}
        />
 
        <p className="mt-2 text-sm text-gray-500">
          Upload a restaurant image (JPEG, PNG, or WebP, max 5MB)
        </p>
 
        {error && (
          <p className="mt-2 text-sm text-red-600">{error}</p>
        )}
      </div>
    </div>
  )
}
 

All files src/components/restaurants

93.22% Statements 55/59
90% Branches 36/40
93.33% Functions 14/15
93.1% Lines 54/58

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
ImageUploader.tsx
100% 38/38 96.15% 25/26 100% 5/5 100% 37/37
RestaurantCard.tsx
0% 0/0 0% 0/0 0% 1/1 0% 0/0
RestaurantForm.tsx
80% 16/20 75% 9/12 87.5% 7/8 80% 16/20
RestaurantGrid.tsx
100% 1/1 100% 2/2 100% 1/1 100% 1/1

All files / src/components/restaurants RestaurantCard.tsx

0% Statements 0/0
0% Branches 0/0
0% Functions 1/1
0% Lines 0/0

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77                                                                                                                                                         
import Link from 'next/link'
import Image from 'next/image'
import { StarRating } from '@/components/ui'
import { type CuisineType } from '@/lib/constants';
 
export interface RestaurantCardProps {
  id: string
  title: string
  description: string
  location: string
  cuisine: CuisineType[]
  imageUrl: string | null
  averageRating: number
  reviewCount: number
}
 
export function RestaurantCard({
  id,
  title,
  description,
  location,
  cuisine,
  imageUrl,
  averageRating,
  reviewCount,
}: RestaurantCardProps) {
 
  return (
    <Link href={`/reviewer/restaurants/${id}`}>
      <div className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow cursor-pointer h-full flex flex-col">
        {imageUrl && (
          <div className="h-48 relative overflow-hidden bg-gray-200">
            <Image
              src={imageUrl}
              alt={title}
              fill
              className="object-cover"
              sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
            />
          </div>
        )}
        <div className="p-4 flex-1 flex flex-col">
          <h3 className="text-lg md:text-xl font-semibold mb-2">{title}</h3>
          <p className="text-gray-600 text-sm mb-2 line-clamp-2 flex-1">{description}</p>
          <div className="space-y-2">
            <p className="text-sm text-gray-500">
              <span className="material-symbols-outlined text-sm align-middle">location_on</span>{' '}
              {location}
            </p>
            <div className="flex flex-wrap gap-1">
              {cuisine.slice(0, 3).map((c) => (
                <span
                  key={c}
                  className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded-full"
                >
                  {c}
                </span>
              ))}
              {cuisine.length > 3 && (
                <span className="px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded-full">
                  +{cuisine.length - 3}
                </span>
              )}
            </div>
            <StarRating
              rating={averageRating}
              size="sm"
              showRating
              reviewCount={reviewCount}
            />
          </div>
        </div>
      </div>
    </Link>
  )
}
 

All files / src/components/restaurants RestaurantForm.tsx

80% Statements 16/20
75% Branches 9/12
87.5% Functions 7/8
80% Lines 16/20

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161                                      32x 32x                 32x                           32x   32x 5x 5x 1x   5x     32x 1x 1x   1x         1x                                                                                               5x                                                                                 1x                  
'use client'
 
import { useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { createRestaurant, updateRestaurant } from '@/app/actions/restaurants'
import { restaurantSchema, type RestaurantInput } from '@/lib/validators'
import { CUISINE_TYPES, type CuisineType } from '@/lib/constants'
import { Button, Input, ErrorMessage } from '@/components/ui'
import { ImageUploader } from './ImageUploader'
 
export interface RestaurantFormProps {
  mode: 'create' | 'edit'
  restaurantId?: string
  initialData?: RestaurantInput
}
 
export function RestaurantForm({ mode, restaurantId, initialData }: RestaurantFormProps) {
  const router = useRouter()
  const [isPending, startTransition] = useTransition()
 
  const {
    register,
    handleSubmit,
    formState: { errors },
    setError,
    watch,
    setValue,
  } = useForm<RestaurantInput>({
    resolver: zodResolver(restaurantSchema),
    mode: 'onBlur',
    reValidateMode: 'onChange',
    defaultValues: {
      title: '',
      description: '',
      location: '',
      imageUrl: '',
      cuisine: [],
      ...initialData
    },
  })
 
  const cuisine = watch('cuisine')
 
  const handleCuisineToggle = (cuisineType: CuisineType) => {
    const currentCuisine = cuisine || []
    const newCuisine = currentCuisine.includes(cuisineType)
      ? currentCuisine.filter((c) => c !== cuisineType)
      : [...currentCuisine, cuisineType]
    setValue('cuisine', newCuisine, { shouldValidate: true })
  }
 
  const onSubmit = (data: RestaurantInput) => {
    startTransition(async () => {
      try {
        const result =
          mode === 'create'
            ? await createRestaurant(data)
            : await updateRestaurant(restaurantId!, data)
 
        if (result.success) {
          router.push('/owner/my-restaurants')
        } else E{
          setError('root', { message: result.error })
        }
      } catch (err) {
        setError('root', { message: 'An unexpected error occurred' })
      }
    })
  }
 
  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
      {errors.root?.message && <ErrorMessage message={errors.root.message} />}
 
      <div>
        <Input
          label="Restaurant Name"
          type="text"
          {...register('title')}
          disabled={isPending}
        />
        {errors.title && (
          <p className="mt-1 text-sm text-red-600">{errors.title.message}</p>
        )}
      </div>
 
      <div>
        <Input
          label="Location"
          type="text"
          {...register('location')}
          disabled={isPending}
        />
        {errors.location && (
          <p className="mt-1 text-sm text-red-600">{errors.location.message}</p>
        )}
      </div>
 
      <div>
        <label className="block text-sm font-medium text-gray-700 mb-2">
          Cuisine Types (select at least one)
        </label>
        <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 max-h-64 overflow-y-auto border border-gray-300 rounded-md p-4">
          {CUISINE_TYPES.map((cuisineType) => (
            <label key={cuisineType} className="flex items-center cursor-pointer">
              <input
                type="checkbox"
                checked={cuisine?.includes(cuisineType) || false}
                onChange={() => handleCuisineToggle(cuisineType)}
                className="mr-2"
                disabled={isPending}
              />
              <span className="text-sm">{cuisineType}</span>
            </label>
          ))}
        </div>
        {errors.cuisine && (
          <p className="mt-1 text-sm text-red-600">{errors.cuisine.message}</p>
        )}
      </div>
 
      <div>
        <label className="block text-sm font-medium text-gray-700 mb-1">
          Description
        </label>
        <textarea
          {...register('description')}
          rows={4}
          className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
          disabled={isPending}
        />
        {errors.description && (
          <p className="mt-1 text-sm text-red-600">{errors.description.message}</p>
        )}
      </div>
 
      <ImageUploader
        currentImageUrl={watch('imageUrl')}
        onImageChange={(imageUrl) => setValue('imageUrl', imageUrl, { shouldValidate: true })}
        disabled={isPending}
      />
 
      <div className="flex gap-4">
        <Button type="submit" isLoading={isPending} disabled={isPending} className="flex-1">
          {mode === 'create' ? 'Create Restaurant' : 'Update Restaurant'}
        </Button>
        <Button
          type="button"
          variant="outline"
          onClick={() => router.back()}
          disabled={isPending}
        >
          Cancel
        </Button>
      </div>
    </form>
  )
}
 

All files / src/components/restaurants RestaurantGrid.tsx

100% Statements 1/1
100% Branches 2/2
100% Functions 1/1
100% Lines 1/1

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37                                      4x                                  
import { RestaurantCard } from './RestaurantCard'
import { type CuisineType } from '@/lib/constants'
 
export interface Restaurant {
  id: string
  title: string
  description: string
  location: string
  cuisine: CuisineType[]
  imageUrl: string | null
  averageRating: number
  reviewCount: number
}
 
export interface RestaurantGridProps {
  restaurants: Restaurant[]
}
 
export function RestaurantGrid({ restaurants }: RestaurantGridProps) {
  if (restaurants.length === 0) {
    return (
      <div className="text-center py-12">
        <p className="text-gray-500 text-lg">No restaurants found</p>
        <p className="text-gray-400 text-sm mt-2">Try adjusting your filters</p>
      </div>
    )
  }
 
  return (
    <div className="mx-auto max-w-6xl w-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 lg:gap-8">
      {restaurants.map((restaurant) => (
        <RestaurantCard key={restaurant.id} {...restaurant} />
      ))}
    </div>
  )
}
 

All files src/components/reviews

100% Statements 11/11
100% Branches 18/18
100% Functions 4/4
100% Lines 11/11

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
ReviewForm.tsx
100% 11/11 100% 18/18 100% 4/4 100% 11/11

All files / src/components/reviews ReviewForm.tsx

100% Statements 11/11
100% Branches 18/18
100% Functions 4/4
100% Lines 11/11

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119                                      55x   55x                   55x   7x     7x 2x           5x             7x 2x   5x                                 25x                                                                                                
'use client';
 
import { useTransition } from 'react';
import { createReview, updateReview } from '@/app/actions/reviews';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'
import { reviewSchema, type ReviewInput } from '@/lib/validators';
import { ErrorMessage } from '@/components/ui';
 
interface ReviewFormProps {
  restaurantId: string;
  existingReview?: {
    id: string;
    rating: number;
    comment: string | null;
  } | null;
}
 
export default function ReviewForm({ restaurantId, existingReview }: ReviewFormProps) {
  const [isPending, startTransition] = useTransition();
 
  const { register, handleSubmit, formState: { errors }, setError, reset} = useForm<ReviewInput>({
    resolver: zodResolver(reviewSchema),
     mode: 'onBlur',
     reValidateMode: 'onChange',
     defaultValues: {
      rating: existingReview?.rating ?? 5,
      comment: existingReview?.comment ?? '',
    },
  });
 
  const onSubmit = (data: ReviewInput) => {
 
    startTransition(async () => {
      let result;
 
      if (existingReview) {
        result = await updateReview(
          existingReview.id,
          data.rating,
          data.comment
        );
      } else {
        result = await createReview(
          restaurantId,
          data.rating,
          data.comment
        );
      }
 
      if (!result.success) {
         setError('root', { message: result.error })
      } else {
        reset();
      }
    });
  };
 
  return (
    <div className="shadow-md px-4 py-6 rounded-lg space-y-3">
      <form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
        {errors.root?.message && <ErrorMessage message={errors.root.message} />}
 
        <div>
          <label className="text-gray-800 text-sm font-semibold" htmlFor="comment">
            {existingReview ? 'Update your review' : 'Have you been here? How did you find it?'}
          </label>
          <textarea
            id="comment"
            {...register('comment', {
                       setValueAs: (v) => v?.trim() || undefined
             })}
            disabled={isPending}
            className="w-full rounded-lg border-0 shadow-sm p-2 h-36 resize-none ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600"
            placeholder="Write your review..."
          />
           {errors.comment && (
             <p className="mt-1 text-sm text-red-600">{errors.comment.message}</p>
           )}
        </div>
 
        <div>
          <label className="text-gray-800 text-sm font-semibold" htmlFor="rating">
            Rating
          </label>
          <select
            className="w-20 border-gray-300 border rounded-md p-1"
            id="rating"
            {...register('rating',  { valueAsNumber: true })}
            disabled={isPending}
          >
            <option value="5">5</option>
            <option value="4">4</option>
            <option value="3">3</option>
            <option value="2">2</option>
            <option value="1">1</option>
          </select>
           { errors.rating && (
             <p className="mt-1 text-sm text-red-600">{errors.rating.message}</p>
           )}
        </div>
 
        <div className="flex justify-center">
          <button
            type="submit"
            disabled={isPending}
            className="mt-4 flex w-full justify-center rounded-md bg-blue-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white disabled:opacity-50"
          >
            {isPending
              ? (existingReview ? 'Updating...' : 'Submitting...')
              : (existingReview ? 'Update review' : 'Submit review')
            }
          </button>
        </div>
      </form>
    </div>
  );
}
 

All files / src/components/ui Button.tsx

100% Statements 5/5
100% Branches 4/4
100% Functions 1/1
100% Lines 5/5

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63                  5x   176x   176x             176x                                                                                 5x  
import React from 'react'
import { cn } from '@/lib/utils'
 
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger' | 'outline'
  size?: 'sm' | 'md' | 'lg'
  isLoading?: boolean
}
 
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant = 'primary', size = 'md', isLoading, disabled, children, ...props }, ref) => {
    const baseStyles = 'inline-flex items-center justify-center font-medium rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none'
 
    const variants = {
      primary: 'bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-600',
      secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus-visible:ring-gray-500',
      danger: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-600',
      outline: 'border-2 border-gray-300 bg-transparent hover:bg-gray-50 focus-visible:ring-gray-500',
    }
 
    const sizes = {
      sm: 'text-sm px-3 py-1.5',
      md: 'text-base px-4 py-2',
      lg: 'text-lg px-6 py-3',
    }
 
    return (
      <button
        ref={ref}
        className={cn(baseStyles, variants[variant], sizes[size], className)}
        disabled={disabled || isLoading}
        {...props}
      >
        {isLoading && (
          <svg
            className="animate-spin -ml-1 mr-2 h-4 w-4"
            xmlns="http://www.w3.org/2000/svg"
            fill="none"
            viewBox="0 0 24 24"
          >
            <circle
              className="opacity-25"
              cx="12"
              cy="12"
              r="10"
              stroke="currentColor"
              strokeWidth="4"
            />
            <path
              className="opacity-75"
              fill="currentColor"
              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
            />
          </svg>
        )}
        {children}
      </button>
    )
  }
)
 
Button.displayName = 'Button'
 

All files / src/components/ui ErrorMessage.tsx

0% Statements 0/0
0% Branches 0/0
0% Functions 1/1
0% Lines 0/0

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49                                                                                                 
import React from 'react'
import { cn } from '@/lib/utils'
 
export interface ErrorMessageProps {
  message: string
  className?: string
  onRetry?: () => void
}
 
export function ErrorMessage({ message, className, onRetry }: ErrorMessageProps) {
  return (
    <div
      role="alert"
      className={cn(
        'bg-red-50 border border-red-200 rounded-md p-4',
        className
      )}
    >
      <div className="flex items-start">
        <div className="flex-shrink-0">
          <svg
            className="h-5 w-5 text-red-400"
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 20 20"
            fill="currentColor"
          >
            <path
              fillRule="evenodd"
              d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
              clipRule="evenodd"
            />
          </svg>
        </div>
        <div className="ml-3 flex-1">
          <p className="text-sm text-red-800">{message}</p>
          {onRetry && (
            <button
              onClick={onRetry}
              className="mt-2 text-sm font-medium text-red-600 hover:text-red-500"
            >
              Try again
            </button>
          )}
        </div>
      </div>
    </div>
  )
}
 

All files src/components/ui

100% Statements 13/13
94.73% Branches 18/19
100% Functions 4/4
100% Lines 13/13

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
Button.tsx
100% 5/5 100% 4/4 100% 1/1 100% 5/5
ErrorMessage.tsx
0% 0/0 0% 0/0 0% 1/1 0% 0/0
Input.tsx
100% 3/3 100% 6/6 100% 1/1 100% 3/3
StarRating.tsx
100% 5/5 88.88% 8/9 100% 1/1 100% 5/5
index.ts
0% 0/0 0% 0/0 0% 0/0 0% 0/0

All files / src/components/ui index.ts

0% Statements 0/0
0% Branches 0/0
0% Functions 0/0
0% Lines 0/0

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12                       
export { Button } from './Button'
export type { ButtonProps } from './Button'
 
export { Input } from './Input'
export type { InputProps } from './Input'
 
export { ErrorMessage } from './ErrorMessage'
export type { ErrorMessageProps } from './ErrorMessage'
 
export { StarRating } from './StarRating'
export type { StarRatingProps } from './StarRating'
 

All files / src/components/ui Input.tsx

100% Statements 3/3
100% Branches 6/6
100% Functions 1/1
100% Lines 3/3

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40                  5x   48x                                                     5x  
import React from 'react'
import { cn } from '@/lib/utils'
 
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label?: string
  error?: string
  helperText?: string
}
 
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ className, label, error, helperText, id, ...props }, ref) => {
    const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
 
    return (
      <div className="w-full">
        {label && (
          <label htmlFor={inputId} className="block text-sm font-medium text-gray-700 mb-1">
            {label}
          </label>
        )}
        <input
          ref={ref}
          id={inputId}
          className={cn(
            'w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors',
            error ? 'border-red-500' : 'border-gray-300',
            props.disabled && 'bg-gray-100 cursor-not-allowed',
            className
          )}
          {...props}
        />
        {error && <p className="mt-1 text-sm text-red-600">{error}</p>}
        {helperText && !error && <p className="mt-1 text-sm text-gray-500">{helperText}</p>}
      </div>
    )
  }
)
 
Input.displayName = 'Input'
 

All files / src/components/ui StarRating.tsx

100% Statements 5/5
88.88% Branches 8/9
100% Functions 1/1
100% Lines 5/5

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76                              10x 10x 10x   10x           10x                                                                                                    
export interface StarRatingProps {
  rating: number
  size?: 'sm' | 'md' | 'lg'
  showRating?: boolean
  reviewCount?: number
  className?: string
}
 
export function StarRating({
  rating,
  size = 'md',
  showRating = false,
  reviewCount,
  className = ''
}: StarRatingProps) {
  const fullStars = Math.floor(rating)
  const hasHalfStar = rating % 1 >= 0.5
  const emptyStars = 5 - fullStars - (hasHalfStar ? 1 : 0)
 
  const sizeClasses = {
    sm: 'text-sm',
    md: 'text-base',
    lg: 'text-2xl'
  }
 
  const textSizeClasses = {
    sm: 'text-sm',
    md: 'text-base',
    lg: 'text-lg'
  }
 
  return (
    <div className={`flex items-center gap-2 ${className}`}>
      <div className="inline-flex select-none">
        {[...Array(fullStars)].map((_, i) => (
          <span
            key={`full-${i}`}
            className={`material-symbols-outlined text-yellow-400 ${sizeClasses[size]}`}
            style={{ fontVariationSettings: "'FILL' 1" }}
          >
            star
          </span>
        ))}
        {hasHalfStar && (
          <span
            className={`material-symbols-outlined text-yellow-400 ${sizeClasses[size]}`}
            style={{ fontVariationSettings: "'FILL' 1" }}
          >
            star_half
          </span>
        )}
        {[...Array(emptyStars)].map((_, i) => (
          <span
            key={`empty-${i}`}
            className={`material-symbols-outlined text-gray-300 ${sizeClasses[size]}`}
          >
            star
          </span>
        ))}
      </div>
      {showRating && (
        <>
          <span className={`font-semibold ${textSizeClasses[size]}`}>
            {rating > 0 ? rating.toFixed(1) : 'No ratings'}
          </span>
          {reviewCount !== undefined && (
            <span className={`text-gray-500 ${textSizeClasses[size]}`}>
              ({reviewCount} {reviewCount === 1 ? 'review' : 'reviews'})
            </span>
          )}
        </>
      )}
    </div>
  )
}
 

All files src/hooks

100% Statements 19/19
100% Branches 4/4
100% Functions 5/5
100% Lines 18/18

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
useAsyncAction.ts
100% 19/19 100% 4/4 100% 5/5 100% 18/18

All files / src/hooks useAsyncAction.ts

100% Statements 19/19
100% Branches 4/4
100% Functions 5/5
100% Lines 18/18

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47                      48x 48x 48x   48x 9x 9x 9x 9x 6x 2x 2x   4x     3x 3x         48x 1x 1x     48x                
import { type ActionResult } from "@/types/actions";
import { useState, useTransition } from "react";
 
interface ExecuteOptions<TData> {
    onSuccess?: (data: TData) => void
    onError?: (error: string) => void
}
 
export function useAsyncAction<TData, TArgs extends any[]>(
    action: (...args: TArgs) => Promise<ActionResult<TData>>
) {
    const [error, setError] = useState<string | null>(null)
    const [isPending, startTransition] = useTransition()
    const [data, setData] = useState<TData | null>(null)
 
    const execute = (options?: ExecuteOptions<TData>) => (...args: TArgs): void => {
        startTransition(async () => {
            setError(null)
            const result = await action(...args)
            if (result.success) {
                if ('data' in result) {
                    setData(result.data)
                    options?.onSuccess?.(result.data)
                } else {
                    options?.onSuccess?.(undefined as TData)
                }
            } else {
                setError(result.error)
                options?.onError?.(result.error)
            }
        })
    }
 
    const reset = () => {
        setError(null)
        setData(null)
    }
 
    return {
        error,
        setError,
        isPending,
        execute,
        data,
        reset,
    }
}

All files src

100% Statements 20/20
100% Branches 21/21
100% Functions 1/1
100% Lines 20/20

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
middleware.ts
100% 20/20 100% 21/21 100% 1/1 100% 20/20

All files / src/lib auth.ts

88.88% Statements 24/27
87.5% Branches 7/8
100% Functions 7/7
88% Lines 22/25

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73                    2x 2x     23x     11x 11x           11x               12x 12x     8x 8x       8x   4x 4x         8x 8x       2x 2x                 2x 2x       6x 6x 5x  
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'
import { jwtPayloadSchema, type JWTPayload } from './validators'
 
// Re-export for consumers who import from auth.ts
export type { JWTPayload }
 
// AuthPayload is what we put INTO the token (no iat/exp - those are added by jose)
export type AuthPayload = Omit<JWTPayload, 'iat' | 'exp'>
 
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'
const TOKEN_EXPIRY = 7 * 24 * 60 * 60 // 7 days in seconds
 
// Convert secret to Uint8Array for jose
const getSecretKey = () => new TextEncoder().encode(JWT_SECRET)
 
export async function generateToken(payload: AuthPayload): Promise<string> {
  try {
    const token = await new SignJWT({ ...payload })
      .setProtectedHeader({ alg: 'HS256' })
      .setIssuedAt()
      .setExpirationTime(`${TOKEN_EXPIRY}s`)
      .sign(getSecretKey())
 
    return token
  } catch (error) {
    console.error('Error generating token:', error)
    throw new Error('Failed to generate token')
  }
}
 
export async function verifyToken(token: string): Promise<JWTPayload | null> {
  try {
    const { payload } = await jwtVerify(token, getSecretKey())
 
    // Validate payload with Zod
    const result = jwtPayloadSchema.safeParse(payload)
    Iif (!result.success) {
      return null
    }
 
    return result.data
  } catch (error) {
    console.error('Error verifying token:', error)
    return null
  }
}
 
export async function getTokenFromCookies(): Promise<string | null> {
  const cookieStore = cookies()
  return cookieStore.get('auth-token')?.value || null
}
 
export async function setTokenCookie(token: string): Promise<void> {
  const cookieStore = cookies()
  cookieStore.set('auth-token', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: TOKEN_EXPIRY,
  })
}
 
export async function clearTokenCookie(): Promise<void> {
  const cookieStore = cookies()
  cookieStore.delete('auth-token')
}
 
export async function getCurrentUser(): Promise<JWTPayload | null> {
  const token = await getTokenFromCookies()
  if (!token) return null
  return await verifyToken(token)
}

All files / src/lib constants.ts

100% Statements 15/15
100% Branches 0/0
100% Functions 0/0
100% Lines 15/15

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 929x                                     9x   9x                             9x                 9x   9x         9x 9x   9x 9x 9x 9x   9x   9x                               9x                          
export const CUISINE_TYPES = [
  'Italian',
  'French',
  'Chinese',
  'Japanese',
  'Korean',
  'Thai',
  'Vietnamese',
  'Indian',
  'Mexican',
  'American',
  'Mediterranean',
  'Greek',
  'Spanish',
  'Middle Eastern',
  'Brazilian',
  'Other',
] as const
 
export const RATING_OPTIONS = [1, 2, 3, 4, 5] as const
 
export const CITIES = [
  'New York',
  'Los Angeles',
  'Chicago',
  'San Francisco',
  'Boston',
  'Miami',
  'Seattle',
  'Austin',
  'Portland',
  'Denver',
  'Toronto',
  'Other',
] as const
 
export const MIN_RATING_FILTERS = [
  { value: 0, label: 'All Ratings' },
  { value: 1, label: '1+ Stars' },
  { value: 2, label: '2+ Stars' },
  { value: 3, label: '3+ Stars' },
  { value: 4, label: '4+ Stars' },
  { value: 5, label: '5 Stars' },
] as const
 
export const SORT_VALUES = ['best', 'worst'] as const
 
export const SORT_OPTIONS = [
  { value: 'best', label: 'Best Rated' },
  { value: 'worst', label: 'Worst Rated' },
] as const
 
export const JWT_EXPIRY_DAYS = 7
export const JWT_EXPIRY_SECONDS = JWT_EXPIRY_DAYS * 24 * 60 * 60
 
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5MB
export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
export const MIN_IMAGE_WIDTH = 400
export const MIN_IMAGE_HEIGHT = 300
 
export const DEFAULT_ITEMS_PER_PAGE = 12
 
export const ERROR_MESSAGES = {
  UNAUTHORIZED: 'You must be logged in to perform this action',
  FORBIDDEN: 'You do not have permission to perform this action',
  NOT_FOUND: 'The requested resource was not found',
  INVALID_CREDENTIALS: 'Invalid email or password',
  EMAIL_EXISTS: 'An account with this email already exists',
  WEAK_PASSWORD: 'Password must be at least 8 characters with uppercase, lowercase, and number',
  INVALID_EMAIL: 'Please enter a valid email address',
  REQUIRED_FIELD: 'This field is required',
  DUPLICATE_REVIEW: 'You have already reviewed this restaurant',
  IMAGE_TOO_LARGE: `Image must be less than ${MAX_IMAGE_SIZE / 1024 / 1024}MB`,
  INVALID_IMAGE_TYPE: 'Only JPEG, PNG, and WebP images are allowed',
  IMAGE_DIMENSIONS_TOO_SMALL: `Image must be at least ${MIN_IMAGE_WIDTH}x${MIN_IMAGE_HEIGHT}px`,
  NETWORK_ERROR: 'Network error. Please try again.',
} as const
 
export const ROUTE_PATHS = {
  HOME: '/',
  LOGIN: '/login',
  REGISTER: '/register',
  REVIEWER_DASHBOARD: '/reviewer',
  REVIEWER_RESTAURANTS: '/reviewer/restaurants',
  OWNER_DASHBOARD: '/owner',
  OWNER_MY_RESTAURANTS: '/owner/my-restaurants',
  OWNER_CREATE: '/owner/create',
} as const
 
export type SortOrder = typeof SORT_VALUES[number]
export type CuisineType = typeof CUISINE_TYPES[number]
 

All files / src/lib db.ts

0% Statements 0/22
0% Branches 0/16
0% Functions 0/3
0% Lines 0/22

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65                                                                                                                                 
import { PrismaClient } from '@prisma/client'
import { headers } from 'next/headers'
 
// Cache clients per worker for E2E testing
const workerClients = new Map<string, PrismaClient>()
 
function getDefaultClient(): PrismaClient {
  if (!globalThis.prisma) {
    // In E2E mode, use DATABASE_URL_BASE with a default database (test_0)
    // This ensures all requests go to the CI database on port 5434
    const isE2EMode = process.env.E2E_MODE === 'true'
    const baseUrl = process.env.DATABASE_URL_BASE
 
    if (isE2EMode && baseUrl) {
      globalThis.prisma = new PrismaClient({
        datasources: {
          db: { url: `${baseUrl}/test_0` },
        },
      })
    } else {
      globalThis.prisma = new PrismaClient({
        log: process.env.NODE_ENV === 'development' ? ['query'] : [],
      })
    }
  }
  return globalThis.prisma
}
 
function getWorkerClient(workerId: string): PrismaClient {
  if (!workerClients.has(workerId)) {
    const baseUrl = process.env.DATABASE_URL_BASE
    if (!baseUrl) {
      throw new Error('DATABASE_URL_BASE is not set for E2E mode')
    }
    workerClients.set(
      workerId,
      new PrismaClient({
        datasources: {
          db: { url: `${baseUrl}/test_${workerId}` },
        },
      })
    )
  }
  return workerClients.get(workerId)!
}
 
export function getPrisma(): PrismaClient {
  // Only check header in E2E mode
  if (process.env.E2E_MODE === 'true') {
    try {
      const headersList = headers()
      const workerId = headersList.get('x-worker-id')
      if (workerId) {
        return getWorkerClient(workerId)
      }
    } catch {
      // headers() throws outside of request context
    }
  }
  return getDefaultClient()
}
 
// For backward compatibility - existing code can still use prisma directly
export const prisma = getDefaultClient()
 

All files src/lib

73.07% Statements 76/104
56.25% Branches 27/48
80% Functions 12/15
69.89% Lines 65/93

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
auth.ts
88.88% 24/27 87.5% 7/8 100% 7/7 88% 22/25
constants.ts
100% 15/15 100% 0/0 100% 0/0 100% 15/15
db.ts
0% 0/22 0% 0/16 0% 0/3 0% 0/22
utils.ts
100% 21/21 93.75% 15/16 100% 4/4 100% 13/13
validators.ts
84.21% 16/19 62.5% 5/8 100% 1/1 83.33% 15/18

All files / src/lib utils.ts

100% Statements 21/21
93.75% Branches 15/16
100% Functions 4/4
100% Lines 13/13

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37              198x             7x 7x 7x   7x 6x 5x 4x 3x 2x 1x                 14x 15x    
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
 
/**
 * Merges Tailwind CSS classes with proper precedence
 */
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
 
/**
 * Formats a date to a relative time string (e.g., "2 days ago")
 */
export function formatRelativeTime(date: string | Date): string {
  const d = typeof date === 'string' ? new Date(date) : date
  const now = new Date()
  const diffInSeconds = Math.floor((now.getTime() - d.getTime()) / 1000)
 
  if (diffInSeconds < 60) return 'just now'
  if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`
  if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`
  if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} days ago`
  if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 604800)} weeks ago`
  if (diffInSeconds < 31536000) return `${Math.floor(diffInSeconds / 2592000)} months ago`
  return `${Math.floor(diffInSeconds / 31536000)} years ago`
}
 
/**
 * Calculate the average rating from an array of reviews
 * @param reviews - Array of review objects with a rating property
 * @returns Average rating (0 if no reviews)
 */
export function calculateAverageRating(reviews: { rating: number }[]): number {
  if (reviews.length === 0) return 0
  return reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length
}
 

All files / src/lib validators.ts

84.21% Statements 16/19
62.5% Branches 5/8
100% Functions 1/1
83.33% Lines 15/18

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90      7x         7x             7x             7x         7x                   9x     5x     5x         5x 5x 5x       5x                   7x         7x             7x                          
import { z } from 'zod'
import { CUISINE_TYPES, SORT_VALUES } from './constants'
 
export const emailSchema = z
  .string()
  .min(1, 'Email is required')
  .email('Invalid email format')
 
export const passwordSchema = z
  .string()
  .min(8, 'Password must be at least 8 characters')
  .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
  .regex(/[a-z]/, 'Password must contain at least one lowercase letter')
  .regex(/[0-9]/, 'Password must contain at least one number')
 
export const registerSchema = z.object({
  email: emailSchema,
  password: passwordSchema,
  name: z.string().min(1, 'Name is required'),
  role: z.enum(['REVIEWER', 'OWNER']),
})
 
export const loginSchema = z.object({
  email: emailSchema,
  password: z.string().min(1, 'Password is required'),
})
 
export const restaurantSchema = z.object({
  title: z.string().min(1, 'Title is required').max(255),
  description: z.string().min(1, 'Description is required').max(2000),
  location: z.string().min(1, 'Location is required').max(500),
  cuisine: z.array(z.enum(CUISINE_TYPES)).min(1, 'At least one cuisine is required'),
  imageUrl: z
    .string()
    .refine(
      (val) => {
        // Allow empty string
        Eif (!val) return true
 
        // Check for valid image extensions
        const validExtensions = /\.(jpg|jpeg|png|webp)$/i
 
        // For relative paths starting with /
        Iif (val.startsWith('/')) {
          return validExtensions.test(val)
        }
 
        // For full URLs (https:// or http://)
        try {
          const url = new URL(val)
          Iif (url.protocol !== 'http:' && url.protocol !== 'https:') {
            return false
          }
          // Check if URL ends with valid image extension
          return validExtensions.test(url.pathname)
        } catch {
          return false
        }
      },
      { message: 'Must be a valid image file (.jpg, .jpeg, .png, .webp)' }
    )
    .optional(),
})
 
export const reviewSchema = z.object({
  rating: z.number().min(1, 'Rating must be between 1 and 5').max(5),
  comment: z.string().max(1000, 'Comment must be 1000 characters or less').optional(),
})
 
export const savedPreferencesSchema = z.object({
  cuisines: z.array(z.enum(CUISINE_TYPES)).optional(),
  minRating: z.number().optional(),
  sort: z.enum(SORT_VALUES).optional(),
  location: z.string().optional(),
})
 
export const jwtPayloadSchema = z.object({
  userId: z.string(),
  email: z.string(),
  role: z.enum(['REVIEWER', 'OWNER']),
  iat: z.number().optional(),
  exp: z.number().optional(),
})
 
export type RegisterInput = z.infer<typeof registerSchema>
export type LoginInput = z.infer<typeof loginSchema>
export type RestaurantInput = z.infer<typeof restaurantSchema>
export type ReviewInput = z.infer<typeof reviewSchema>
export type JWTPayload = z.infer<typeof jwtPayloadSchema>
export type SavedPreferences = z.infer<typeof savedPreferencesSchema>

All files / src middleware.ts

100% Statements 20/20
100% Branches 21/21
100% Functions 1/1
100% Lines 20/20

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56          16x     16x     16x 16x 11x 11x 9x         16x 4x     4x       12x 6x 3x   3x 1x         8x 3x 1x       7x     1x                
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { verifyToken } from './lib/auth'
 
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
 
  // Get auth token from cookies
  const token = request.cookies.get('auth-token')?.value
 
  // Verify token and get user data
  let user = null
  if (token) {
    const verified = await verifyToken(token)
    if (verified) {
      user = verified
    }
  }
 
  // Redirect authenticated users away from auth pages
  if ((pathname.startsWith('/login') || pathname.startsWith('/register')) && user) {
    const redirectUrl = user.role === 'OWNER'
      ? '/owner/my-restaurants'
      : '/'
    return NextResponse.redirect(new URL(redirectUrl, request.url))
  }
 
  // Protect /owner routes - require OWNER role
  if (pathname.startsWith('/owner')) {
    if (!user) {
      return NextResponse.redirect(new URL('/login', request.url))
    }
    if (user.role !== 'OWNER') {
      return NextResponse.redirect(new URL('/', request.url))
    }
  }
 
  // Protect /reviewer routes - require authentication
  if (pathname.startsWith('/reviewer')) {
    if (!user) {
      return NextResponse.redirect(new URL('/login', request.url))
    }
  }
 
  return NextResponse.next()
}
 
export const config = {
  matcher: [
    '/owner/:path*',
    '/reviewer/:path*',
    '/login',
    '/register',
  ],
}
 

All files src/pages

0% Statements 0/3
0% Branches 0/8
0% Functions 0/2
0% Lines 0/3

Press n or j to go to the next uncovered block, b, p or k for the previous block.

File Statements Branches Functions Lines
_error.tsx
0% 0/3 0% 0/8 0% 0/2 0% 0/3

All files / src/pages _error.tsx

0% Statements 0/3
0% Branches 0/8
0% Functions 0/2
0% Lines 0/3

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16                               
function Error({ statusCode }: { statusCode?: number }) {
  return (
    <div style={{ textAlign: 'center', padding: '50px' }}>
      <h1>{statusCode || 'Error'}</h1>
      <p>{statusCode ? `An error ${statusCode} occurred` : 'An error occurred'}</p>
    </div>
  )
}
 
Error.getInitialProps = ({ res, err }: { res?: { statusCode?: number }, err?: { statusCode?: number } }) => {
  const statusCode = res ? res.statusCode : err ? err.statusCode : 404
  return { statusCode }
}
 
export default Error