All files
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)
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
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 page.tsx
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 page.tsx
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
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
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
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
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
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
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
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
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
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
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're looking for doesn'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 page.tsx
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
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
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
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'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 page.tsx
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 page.tsx
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
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] page.tsx
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
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
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'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
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
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
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
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
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/restaurants ImageUploader.tsx
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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 | 9x 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
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
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
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
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
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
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
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 |