Skip to content

Commit eefb857

Browse files
committed
feat: review
1 parent 7c07323 commit eefb857

File tree

10 files changed

+312
-6
lines changed

10 files changed

+312
-6
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { ModalReview } from '@/components/reviews/ModelReview'
2+
import { getBorrow } from '@/lib/api/borrow'
3+
import { Verify } from '@/lib/firebase/firebase'
4+
5+
// This is a server component use to pass data to the modal
6+
export default async function ReviewDetailsPage({
7+
params,
8+
}: {
9+
params: Promise<{ id: string }>
10+
}) {
11+
const { id } = await params
12+
13+
const headers = await Verify({ from: `/borrows/${id}/review` })
14+
15+
const [borrowRes] = await Promise.all([getBorrow({ id }, { headers })])
16+
if ('error' in borrowRes) {
17+
return <div>{JSON.stringify(borrowRes.message)}</div>
18+
}
19+
return <ModalReview borrow={borrowRes.data} />
20+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Default() {
2+
return null
3+
}

app/(protected)/borrows/[id]/layout.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import { getBorrowStatus } from '@/lib/utils'
1313
export default async function BorrowDetailsLayout({
1414
children,
1515
params,
16+
review,
1617
}: Readonly<{
1718
children: React.ReactNode
19+
review: React.ReactNode
1820
params: Promise<{ id: string }>
1921
}>) {
2022
const { id } = await params
@@ -61,6 +63,7 @@ export default async function BorrowDetailsLayout({
6163
</div>
6264
</nav>
6365
{children}
66+
{review}
6467
</>
6568
)
6669
}

app/(protected)/borrows/[id]/page.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { IsLoggedIn, Verify } from '@/lib/firebase/firebase'
22
import { getBorrow } from '@/lib/api/borrow'
33
import { redirect, RedirectType } from 'next/navigation'
44
import { DetailBorrow } from '@/components/borrows/DetailBorrow'
5+
import { Button } from '@/components/ui/button'
6+
import Link from 'next/link'
7+
import { Star } from 'lucide-react'
8+
import clsx from 'clsx'
59

610
export default async function BorrowDetailsPage({
711
params,
@@ -37,5 +41,21 @@ export default async function BorrowDetailsPage({
3741
return <div>{JSON.stringify(borrowRes.message)}</div>
3842
}
3943

40-
return <DetailBorrow borrow={borrowRes.data} />
44+
return (
45+
<DetailBorrow borrow={borrowRes.data}>
46+
<Button variant="secondary" asChild>
47+
<Link href={`/borrows/${borrowRes.data.id}/review`} className="w-full">
48+
<Star
49+
className={clsx(
50+
'size-4 mr-2',
51+
borrowRes.data.review
52+
? 'fill-yellow-400 text-yellow-400'
53+
: 'text-gray-300'
54+
)}
55+
/>
56+
{borrowRes.data.review ? 'Edit Review' : 'Write a Review'}
57+
</Link>
58+
</Button>
59+
</DetailBorrow>
60+
)
4161
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Verify } from '@/lib/firebase/firebase'
2+
import { getBorrow } from '@/lib/api/borrow'
3+
import { FormReview } from '@/components/reviews/FormReview'
4+
5+
export default async function BorrowReviewPage({
6+
params,
7+
}: {
8+
params: Promise<{ id: string }>
9+
}) {
10+
const { id } = await params
11+
12+
const headers = await Verify({ from: `/borrows/${id}/review` })
13+
14+
const [borrowRes] = await Promise.all([getBorrow({ id }, { headers })])
15+
16+
if ('error' in borrowRes) {
17+
console.log({ libRes: borrowRes })
18+
return <div>{JSON.stringify(borrowRes.message)}</div>
19+
}
20+
21+
return (
22+
<div className="grid place-items-center">
23+
<FormReview borrow={borrowRes.data} />
24+
</div>
25+
)
26+
}

components/borrows/BtnBorrowSeq.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export const BtnBorrowSeq: React.FC<{ prevID?: string; nextID?: string }> = ({
4141
aria-disabled={!prevID}
4242
>
4343
<ChevronLeft className="h-4 w-4 mr-1" />
44-
Previous
44+
<span>Previous</span>
4545
</Button>
4646
</ConditionalLink>
4747
<ConditionalLink href={getHref(nextID)}>
@@ -52,7 +52,7 @@ export const BtnBorrowSeq: React.FC<{ prevID?: string; nextID?: string }> = ({
5252
aria-disabled={!nextID}
5353
>
5454
<ChevronRight className="h-4 w-4 mr-1" />
55-
Next
55+
<span>Next</span>
5656
</Button>
5757
</ConditionalLink>
5858
</div>

components/borrows/DetailBorrow.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,6 @@ export const DetailBorrow: React.FC<
5151
<BtnBorrowSeq prevID={borrow.prev_id} nextID={borrow.next_id} />
5252
</div>
5353
<Card className="md:row-span-2 bg-(--color-light-vibrant) dark:bg-(--color-dark-vibrant)">
54-
<CardHeader>
55-
<CardTitle>Book Information</CardTitle>
56-
</CardHeader>
5754
<CardContent className="grid place-self-center md:place-self-auto md:grid-cols-2 gap-4">
5855
{/* FIXME */}
5956
<Link href={`../books/${borrow.book.id}` as Route}>

components/reviews/FormReview.tsx

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
'use client'
2+
3+
import { zodResolver } from '@hookform/resolvers/zod'
4+
import { Controller, useForm } from 'react-hook-form'
5+
import { z } from 'zod'
6+
import { Form } from '../ui/form'
7+
import { toast } from 'sonner'
8+
import { Button } from '../ui/button'
9+
import { useCallback, useTransition } from 'react'
10+
import { Spinner } from '../ui/spinner'
11+
import { BorrowDetail } from '@/lib/types/borrow'
12+
import { Field, FieldDescription, FieldError, FieldLabel } from '../ui/field'
13+
import { Textarea } from '../ui/textarea'
14+
import { reviewBorrowAction } from '@/lib/actions/review-borrow'
15+
import { Star } from 'lucide-react'
16+
17+
const REVIEW_MAX_LENGTH = 512
18+
19+
const BaseReviewSchema = z.object({
20+
rating: z.coerce.number<number>().min(0).max(5),
21+
comment: z.string().max(REVIEW_MAX_LENGTH).optional(),
22+
})
23+
const UpdateReviewSchema = BaseReviewSchema.extend({
24+
id: z.string(),
25+
})
26+
const CreateReviewSchema = BaseReviewSchema.extend({
27+
borrow_id: z.uuid(),
28+
})
29+
const FormSchema = z.union([UpdateReviewSchema, CreateReviewSchema])
30+
31+
export const FormReview: React.FC<{
32+
borrow: BorrowDetail
33+
}> = ({ borrow }) => {
34+
const form = useForm<z.infer<typeof FormSchema>>({
35+
resolver: zodResolver(FormSchema),
36+
mode: 'onChange',
37+
defaultValues: borrow.review
38+
? {
39+
id: borrow.review.id,
40+
rating: borrow.review.rating,
41+
comment: borrow.review.comment,
42+
}
43+
: {
44+
borrow_id: borrow.id,
45+
},
46+
})
47+
48+
const [isPending, startTransition] = useTransition()
49+
50+
function onSubmit(data: z.infer<typeof FormSchema>) {
51+
startTransition(async () => {
52+
const msg = await reviewBorrowAction(
53+
borrow.id,
54+
'id' in data
55+
? {
56+
id: data.id,
57+
rating: data.rating,
58+
comment: data.comment,
59+
}
60+
: {
61+
borrowing_id: data.borrow_id,
62+
rating: data.rating,
63+
comment: data.comment,
64+
}
65+
)
66+
if ('error' in msg) {
67+
toast.error(msg.error, { richColors: true })
68+
} else {
69+
toast.success(msg.message)
70+
}
71+
})
72+
}
73+
74+
const onReset = useCallback(() => {
75+
form.reset()
76+
}, [form])
77+
78+
return (
79+
<Form {...form}>
80+
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
81+
<Controller
82+
control={form.control}
83+
name="rating"
84+
render={({ field, fieldState }) => (
85+
<Field data-invalid={fieldState.invalid}>
86+
<FieldLabel>Rating</FieldLabel>
87+
<div className="flex items-center gap-1">
88+
{[1, 2, 3, 4, 5].map((star) => (
89+
<button
90+
key={star}
91+
type="button"
92+
onClick={() => field.onChange(star)}
93+
className="focus:outline-none transition-transform hover:scale-110"
94+
>
95+
<Star
96+
className={`h-6 w-6 ${
97+
star <= field.value
98+
? 'fill-yellow-400 text-yellow-400'
99+
: 'text-gray-300'
100+
}`}
101+
/>
102+
</button>
103+
))}
104+
{field.value > 0 && (
105+
<span className="ml-2 text-sm text-muted-foreground">
106+
{field.value} {field.value === 1 ? 'star' : 'stars'}
107+
</span>
108+
)}
109+
</div>
110+
</Field>
111+
)}
112+
/>
113+
114+
<Controller
115+
control={form.control}
116+
name="comment"
117+
render={({ field, fieldState }) => (
118+
<Field
119+
data-invalid={fieldState.invalid}
120+
className="flex flex-col col-span-2"
121+
>
122+
<FieldLabel>Your Review</FieldLabel>
123+
<Textarea
124+
placeholder="What did you think about the book?"
125+
{...field}
126+
onChange={field.onChange}
127+
rows={4}
128+
/>
129+
{fieldState.error && <FieldError errors={[fieldState.error]} />}
130+
<FieldDescription>
131+
{field.value?.length ?? 0}/{REVIEW_MAX_LENGTH}
132+
</FieldDescription>
133+
</Field>
134+
)}
135+
/>
136+
137+
<Button type="reset" variant="ghost" onClick={onReset}>
138+
Reset
139+
</Button>
140+
<Button type="submit" disabled={isPending}>
141+
{isPending && <Spinner />}
142+
Submit Review
143+
</Button>
144+
</form>
145+
</Form>
146+
)
147+
}

components/reviews/ModelReview.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
'use client'
2+
3+
import {
4+
Dialog,
5+
DialogContent,
6+
DialogDescription,
7+
DialogHeader,
8+
DialogTitle,
9+
} from '@/components/ui/dialog'
10+
import { BorrowDetail } from '@/lib/types/borrow'
11+
import { usePathname, useRouter } from 'next/navigation'
12+
import { useEffect, useRef, useState } from 'react'
13+
import { FormReview } from './FormReview'
14+
15+
export const ModalReview: React.FC<{ borrow: BorrowDetail }> = ({ borrow }) => {
16+
const router = useRouter()
17+
const pathname = usePathname()
18+
const [open, setOpen] = useState(true)
19+
const prevPathRef = useRef(pathname)
20+
21+
useEffect(() => {
22+
if (pathname !== prevPathRef.current) {
23+
setOpen(false)
24+
}
25+
prevPathRef.current = pathname
26+
}, [pathname])
27+
28+
return (
29+
<Dialog open={open} onOpenChange={router.back} modal={false}>
30+
<DialogContent className="bg-background/50 backdrop-blur-md">
31+
<DialogHeader>
32+
<DialogTitle>
33+
{borrow.review ? borrow.book.title : 'Write a Review'}
34+
</DialogTitle>
35+
<DialogDescription>
36+
{borrow.review
37+
? 'Your review details'
38+
: 'Let us know what you think about this book!'}
39+
</DialogDescription>
40+
</DialogHeader>
41+
42+
<FormReview borrow={borrow} />
43+
</DialogContent>
44+
</Dialog>
45+
)
46+
}

lib/actions/review-borrow.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
'use server'
2+
3+
import { revalidatePath } from 'next/cache'
4+
import { Verify } from '../firebase/firebase'
5+
import { createReview, updateReview } from '../api/review'
6+
7+
type ReviewBorrowParams =
8+
| Parameters<typeof createReview>[0]
9+
| ({
10+
id: Parameters<typeof updateReview>[0]
11+
} & Parameters<typeof updateReview>[1])
12+
13+
// server action to review a borrow
14+
export async function reviewBorrowAction(
15+
id: string,
16+
params: ReviewBorrowParams
17+
) {
18+
const headers = await Verify({
19+
from: `/borrows/${id}/review`,
20+
})
21+
22+
try {
23+
const res =
24+
'id' in params
25+
? await updateReview(params.id, params, { headers })
26+
: await createReview(params, { headers })
27+
28+
if ('error' in res) {
29+
return {
30+
error: res.error,
31+
}
32+
}
33+
return { message: 'Review submitted successfully' }
34+
} catch (e) {
35+
if (e instanceof Object && 'error' in e) {
36+
return { error: e.error as string }
37+
}
38+
return { error: 'Failed to submit review' }
39+
} finally {
40+
revalidatePath(`/admin/borrows/${id}`)
41+
revalidatePath(`/admin/borrows/${id}/review`)
42+
revalidatePath(`/admin/borrows`)
43+
}
44+
}

0 commit comments

Comments
 (0)