feat: reject inbound hold requests

This commit is contained in:
Yehoshua Sandler 2025-05-03 16:41:47 -05:00
parent 7a52261b1e
commit bce3a7c23e
7 changed files with 231 additions and 106 deletions

View File

@ -86,8 +86,6 @@ export default function BookByIdPageClient(props: Props) {
const { user } = useGlobal() const { user } = useGlobal()
console.log(user)
const [isRequestingCopy, setIsRequestingCopy] = useState(false) const [isRequestingCopy, setIsRequestingCopy] = useState(false)
const [selectedRepository, setSelectedRepository] = useState<Repository | null>( const [selectedRepository, setSelectedRepository] = useState<Repository | null>(
repos.length ? repos[0] : null, repos.length ? repos[0] : null,

View File

@ -90,32 +90,32 @@ export default async function HomePage() {
return ( return (
<div className="home"> <div className="home">
<HomeHero user={user} /> <HomeHero user={user} />
<div id="homeContent" className='flex flex-col justify-around min-h-[90vh]'> <div id="homeContent" className="flex flex-col justify-around min-h-[90vh]">
{user ? ( {user ? (
<Tabs id="tabs" defaultValue="feed" className="p-4"> <Tabs id="tabs" defaultValue="feed" className="p-4">
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="feed">Your Feed</TabsTrigger> <TabsTrigger value="feed">Your Feed</TabsTrigger>
<TabsTrigger value="search">Search</TabsTrigger> <TabsTrigger value="search">Search</TabsTrigger>
<TabsTrigger value="manage">Manage</TabsTrigger> <TabsTrigger value="manage">Manage</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="feed"> <TabsContent value="feed">
{user && <UserFeed user={user} repos={userRepos} />} {user && <UserFeed user={user} repos={userRepos} />}
</TabsContent> </TabsContent>
<TabsContent value="search"> <TabsContent value="search">
<SearchBooks initBrowseBooks={initBrowseBooks} /> <SearchBooks initBrowseBooks={initBrowseBooks} />
</TabsContent> </TabsContent>
<TabsContent value="manage"> <TabsContent value="manage">
<Manage repos={userRepos} checkouts={userCheckouts} /> <Manage repos={userRepos} user={user} checkouts={userCheckouts} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
) : ( ) : (
<div className="flex w-full max-w-sm flex-col gap-6 mx-auto my-6"> <div className="flex w-full max-w-sm flex-col gap-6 mx-auto my-6">
<LoginForm /> <LoginForm />
</div> </div>
)} )}
</div> </div>
</div> </div>
) )

View File

@ -42,6 +42,9 @@ const UserFeed = async (props: Props) => {
isCheckedOut: { isCheckedOut: {
not_equals: true, not_equals: true,
}, },
isRejected: {
not_equals: true,
},
}, },
})) as PaginatedDocs<HoldRequest> })) as PaginatedDocs<HoldRequest>

View File

@ -3,101 +3,151 @@ import { Author, Book, HoldRequest, Repository, User } from '@/payload-types'
import { Button } from '../ui/button' import { Button } from '../ui/button'
import Image from 'next/image' import Image from 'next/image'
import ApproveHoldRequestModal from './ApproveHoldRequestModal' import ApproveHoldRequestModal from './ApproveHoldRequestModal'
import { useState } from 'react' import { useCallback, useState } from 'react'
import CheckoutFromHoldModal from './CheckoutFromHoldModal' import CheckoutFromHoldModal from './CheckoutFromHoldModal'
import rejectHoldRequest from '@/serverActions/RejectHoldRequests'
import { getUserRepos } from '@/serverActions/GetUserRepos'
import { Loader2 } from 'lucide-react'
import { toast } from 'sonner'
type Props = { type Props = {
repos: PaginatedDocs<Repository> | null repos: PaginatedDocs<Repository> | null
user: User
} }
const HoldRequestNotifications = (props: Props) => { const HoldRequestNotifications = (props: Props) => {
const { repos } = props const [repos, setRepos] = useState<PaginatedDocs<Repository> | null>(props.repos)
const [openedModalId, setOpenedModalId] = useState<number | null>(null) const [openedModalId, setOpenedModalId] = useState<number | null>(null)
const totalHoldNotifications = repos?.docs.flatMap((r) => r.holdRequests?.docs).length || 0 const totalHoldNotifications = repos?.docs.flatMap((r) => r.holdRequests?.docs).length || 0
const [isRejectingId, setIsRejectingId] = useState<number | null>()
const handleRejectHoldClick = useCallback(
async (holdRequestId: number) => {
if (isRejectingId) return
setIsRejectingId(holdRequestId)
const rejectRequest = await rejectHoldRequest({ holdRequestId })
if (rejectRequest?.isRejected) {
const updatedRepos = await getUserRepos({ userId: props.user.id })
setRepos(updatedRepos)
toast('Request was rejected')
} else {
toast('Error rejecting request')
}
setIsRejectingId(null)
},
[isRejectingId, setIsRejectingId, props.user.id],
)
const holdRequestsByRepoElements = repos?.docs.map((r) => { const holdRequestsByRepoElements = repos?.docs.map((r) => {
return ( return (
<ul key={r.id} role="list" className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> <ul key={r.id} role="list" className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{r.holdRequests?.docs?.map((h) => { {r.holdRequests?.docs
const hold = h as HoldRequest ?.map((h) => {
const book = hold.book as Book const hold = h as HoldRequest
const authors = book.authors as Author[] const book = hold.book as Book
const dateRequested = hold.dateRequested ? new Date(hold.dateRequested) : new Date() const authors = book.authors as Author[]
const holdingUntilDate = hold.holdingUntilDate const dateRequested = hold.dateRequested ? new Date(hold.dateRequested) : new Date()
? new Date(hold.holdingUntilDate) const holdingUntilDate = hold.holdingUntilDate
: new Date() ? new Date(hold.holdingUntilDate)
const userRequested = hold.userRequested as User : new Date()
const userName = `${userRequested.firstName} ${userRequested.lastName}` const userRequested = hold.userRequested as User
const userName = `${userRequested.firstName} ${userRequested.lastName}`
return ( if (hold.isRejected) return null
<li key={hold.id} className="col-span-1 rounded-lg shadow-sm border border-accent">
<div className="flex w-full items-center justify-between space-x-6 p-6"> return (
<div className="flex-1 truncate"> <li key={hold.id} className="col-span-1 rounded-lg shadow-sm border border-accent">
<div className="flex items-center space-x-3"> <div className="flex w-full items-center justify-between space-x-6 p-6">
<h3 className="truncate text-sm font-medium text-foreground">{book.title}</h3> <div className="flex-1 truncate">
<div className="flex items-center space-x-3">
<h3 className="truncate text-sm font-medium text-foreground">{book.title}</h3>
</div>
<p className="mt-1 truncate text-sm text-gray-500">
{authors.map((a) => a.lf).join(' | ')}
</p>
{hold.isHolding ? (
<span className="text-wrap">
<span className="mr-0.5 text-xs">{userName} Until</span>
<time className="inline-flex shrink-0 items-center rounded-full bg-background/20 px-1.5 py-0.5 text-xs font-medium text-emerald-600 ring-1 ring-emerald-600/20 ring-inset">
{holdingUntilDate.toLocaleDateString()}
</time>
</span>
) : (
<span>
<time className="inline-flex shrink-0 items-center rounded-full bg-background/20 px-1.5 py-0.5 text-xs font-medium text-amber-500 ring-1 ring-amber-600/20 ring-inset">
{dateRequested.toLocaleDateString()}
</time>
<span className="text-xs">{userName}</span>
</span>
)}
</div> </div>
<p className="mt-1 truncate text-sm text-gray-500"> <img
{authors.map((a) => a.lf).join(' | ')} alt=""
</p> className="h-16 shrink-0 rounded bg-gray-300"
{hold.isHolding ? ( src={
<span className="text-wrap"> book.isbn
<span className="mr-0.5 text-xs">{userName} Until</span> ? `https://covers.openlibrary.org/b/isbn/${book.isbn}-M.jpg`
<time className="inline-flex shrink-0 items-center rounded-full bg-background/20 px-1.5 py-0.5 text-xs font-medium text-emerald-600 ring-1 ring-emerald-600/20 ring-inset"> : '/images/book-48.svg'
{holdingUntilDate.toLocaleDateString()} }
</time> />
</span>
) : (
<span>
<time className="inline-flex shrink-0 items-center rounded-full bg-background/20 px-1.5 py-0.5 text-xs font-medium text-amber-500 ring-1 ring-amber-600/20 ring-inset">
{dateRequested.toLocaleDateString()}
</time>
<span className="text-xs">{userName}</span>
</span>
)}
</div> </div>
<img <div>
alt="" <div className="flex gap-2 justify-around">
className="h-16 shrink-0 rounded bg-gray-300" <Button
src={ className="inline-flex flex-1 items-center justify-center gap-3 rounded-bl-lg border border-transparent py-4 text-sm font-semibold text-foreground bg-foreground/5 hover:bg-red-400/10 cursor-pointer [&:not(:disabled)]:hover:scale-105"
book.isbn disabled={!!isRejectingId}
? `https://covers.openlibrary.org/b/isbn/${book.isbn}-M.jpg` onClick={() => handleRejectHoldClick(hold.id)}
: '/images/book-48.svg' >
} {isRejectingId === hold.id ? (
/> <>
</div> <Loader2 className="animate-spin size-[24px] text-red-200 dark:text-red-700" />
<div> <span className="text-red-200 dark:text-red-700">Rejecting</span>
<div className="flex gap-2 justify-around"> </>
<Button className="inline-flex flex-1 items-center justify-center gap-3 rounded-bl-lg border border-transparent py-4 text-sm font-semibold text-foreground bg-foreground/5 hover:bg-red-400/10 cursor-pointer hover:scale-105"> ) : (
<Image width={24} height={24} src="/images/reject.svg" alt="approve hold" /> <>
<span>Decline</span> <Image
</Button> width={24}
<Button height={24}
className="inline-flex flex-1 items-center justify-center gap-3 rounded-br-lg border border-transparent py-4 text-sm font-semibold text-foreground bg-emerald-400/30 hover:bg-emerald-300/60 cursor-pointer hover:scale-105" src="/images/reject.svg"
onClick={() => setOpenedModalId(hold.id)} alt="approve hold"
> />
<Image width={24} height={24} src="/images/approve.svg" alt="approve hold" /> <span>Reject</span>
<span>{hold.isHolding ? 'Checkout' : 'Approve'}</span> </>
</Button> )}
</Button>
{hold.isHolding ? ( <Button
<CheckoutFromHoldModal className="inline-flex flex-1 items-center justify-center gap-3 rounded-br-lg border border-transparent py-4 text-sm font-semibold text-foreground bg-emerald-400/30 hover:bg-emerald-300/60 cursor-pointer hover:scale-105"
isOpen={openedModalId === hold.id} disabled={!!isRejectingId || !!openedModalId}
onOpenChange={() => setOpenedModalId(null)} onClick={() => setOpenedModalId(hold.id)}
holdRequest={hold} >
/> <Image width={24} height={24} src="/images/approve.svg" alt="approve hold" />
) : ( <span>{hold.isHolding ? 'Checkout' : 'Approve'}</span>
<ApproveHoldRequestModal </Button>
isOpen={openedModalId === hold.id}
onOpenChange={() => setOpenedModalId(null)} {hold.isHolding ? (
holdRequest={hold} <CheckoutFromHoldModal
/> isOpen={openedModalId === hold.id}
)} onOpenChange={() => setOpenedModalId(null)}
holdRequest={hold}
/>
) : (
<ApproveHoldRequestModal
isOpen={openedModalId === hold.id}
onOpenChange={() => setOpenedModalId(null)}
holdRequest={hold}
/>
)}
</div>
</div> </div>
</div> </li>
</li> )
) })
})} .filter((element) => !!element)}
</ul> </ul>
) )
}) })

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { Checkout, Repository } from '@/payload-types' import { Checkout, Repository, User } from '@/payload-types'
import { PaginatedDocs } from 'payload' import { PaginatedDocs } from 'payload'
import RepoList from './RepoList' import RepoList from './RepoList'
import HoldRequestNotifications from './HoldRequests' import HoldRequestNotifications from './HoldRequests'
@ -9,9 +9,10 @@ import CheckedOutBooks from './CheckedOutBooks'
type Props = { type Props = {
repos: PaginatedDocs<Repository> | null repos: PaginatedDocs<Repository> | null
checkouts: PaginatedDocs<Checkout> | null checkouts: PaginatedDocs<Checkout> | null
user: User | null
} }
const Manage = (props: Props) => { const Manage = (props: Props) => {
const { repos, checkouts } = props const { repos, checkouts, user } = props
return ( return (
<section> <section>
@ -19,9 +20,7 @@ const Manage = (props: Props) => {
<RepoList repos={repos} /> <RepoList repos={repos} />
</div> </div>
<div> <div>{user && <HoldRequestNotifications repos={repos} user={user} />}</div>
<HoldRequestNotifications repos={repos} />
</div>
<div> <div>
<CheckedOutBooks initialCheckoutPage={checkouts} /> <CheckedOutBooks initialCheckoutPage={checkouts} />

View File

@ -0,0 +1,44 @@
'use server'
import { getPayload } from 'payload'
import config from '@/payload.config'
import { PaginatedDocs } from "payload"
import { Repository } from '@/payload-types'
type Props = {
userId: number
}
export const getUserRepos = async (props: Props) => {
const { userId } = props
const payloadConfig = await config
const payload = await getPayload({ config: payloadConfig })
let userRepos: PaginatedDocs<Repository> | null = null
if (userId)
userRepos = (await payload.find({
collection: 'repositories',
depth: 3,
limit: 10,
select: {
name: true,
abbreviation: true,
image: true,
description: true,
dateOpenToPublic: true,
holdRequests: true,
},
where: {
'owner.id': {
equals: userId,
},
},
joins: {
holdRequests: {
limit: 100,
},
},
})) as PaginatedDocs<Repository>
return userRepos
}

View File

@ -0,0 +1,31 @@
'use server'
import { getPayload } from 'payload'
import config from '@/payload.config'
import { HoldRequest } from '@/payload-types'
type Props = {
holdRequestId: number,
}
export const rejectHoldRequest = async (props: Props): Promise<HoldRequest | null> => {
const { holdRequestId } = props
const payloadConfig = await config
const payload = await getPayload({ config: payloadConfig })
try {
const updatedHold = await payload.update({
collection: 'holdRequests',
id: holdRequestId,
data: {
isRejected: true,
isHolding: false,
}
})
return updatedHold
} catch (_) {
return null
}
}
export default rejectHoldRequest