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()
console.log(user)
const [isRequestingCopy, setIsRequestingCopy] = useState(false)
const [selectedRepository, setSelectedRepository] = useState<Repository | null>(
repos.length ? repos[0] : null,

View File

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

View File

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

View File

@ -3,101 +3,151 @@ import { Author, Book, HoldRequest, Repository, User } from '@/payload-types'
import { Button } from '../ui/button'
import Image from 'next/image'
import ApproveHoldRequestModal from './ApproveHoldRequestModal'
import { useState } from 'react'
import { useCallback, useState } from 'react'
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 = {
repos: PaginatedDocs<Repository> | null
user: User
}
const HoldRequestNotifications = (props: Props) => {
const { repos } = props
const [repos, setRepos] = useState<PaginatedDocs<Repository> | null>(props.repos)
const [openedModalId, setOpenedModalId] = useState<number | null>(null)
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) => {
return (
<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) => {
const hold = h as HoldRequest
const book = hold.book as Book
const authors = book.authors as Author[]
const dateRequested = hold.dateRequested ? new Date(hold.dateRequested) : new Date()
const holdingUntilDate = hold.holdingUntilDate
? new Date(hold.holdingUntilDate)
: new Date()
const userRequested = hold.userRequested as User
const userName = `${userRequested.firstName} ${userRequested.lastName}`
{r.holdRequests?.docs
?.map((h) => {
const hold = h as HoldRequest
const book = hold.book as Book
const authors = book.authors as Author[]
const dateRequested = hold.dateRequested ? new Date(hold.dateRequested) : new Date()
const holdingUntilDate = hold.holdingUntilDate
? new Date(hold.holdingUntilDate)
: new Date()
const userRequested = hold.userRequested as User
const userName = `${userRequested.firstName} ${userRequested.lastName}`
return (
<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">
<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>
if (hold.isRejected) return null
return (
<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">
<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>
<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>
)}
<img
alt=""
className="h-16 shrink-0 rounded bg-gray-300"
src={
book.isbn
? `https://covers.openlibrary.org/b/isbn/${book.isbn}-M.jpg`
: '/images/book-48.svg'
}
/>
</div>
<img
alt=""
className="h-16 shrink-0 rounded bg-gray-300"
src={
book.isbn
? `https://covers.openlibrary.org/b/isbn/${book.isbn}-M.jpg`
: '/images/book-48.svg'
}
/>
</div>
<div>
<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>
</Button>
<Button
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"
onClick={() => setOpenedModalId(hold.id)}
>
<Image width={24} height={24} src="/images/approve.svg" alt="approve hold" />
<span>{hold.isHolding ? 'Checkout' : 'Approve'}</span>
</Button>
<div>
<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 [&:not(:disabled)]:hover:scale-105"
disabled={!!isRejectingId}
onClick={() => handleRejectHoldClick(hold.id)}
>
{isRejectingId === hold.id ? (
<>
<Loader2 className="animate-spin size-[24px] text-red-200 dark:text-red-700" />
<span className="text-red-200 dark:text-red-700">Rejecting</span>
</>
) : (
<>
<Image
width={24}
height={24}
src="/images/reject.svg"
alt="approve hold"
/>
<span>Reject</span>
</>
)}
</Button>
{hold.isHolding ? (
<CheckoutFromHoldModal
isOpen={openedModalId === hold.id}
onOpenChange={() => setOpenedModalId(null)}
holdRequest={hold}
/>
) : (
<ApproveHoldRequestModal
isOpen={openedModalId === hold.id}
onOpenChange={() => setOpenedModalId(null)}
holdRequest={hold}
/>
)}
<Button
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"
disabled={!!isRejectingId || !!openedModalId}
onClick={() => setOpenedModalId(hold.id)}
>
<Image width={24} height={24} src="/images/approve.svg" alt="approve hold" />
<span>{hold.isHolding ? 'Checkout' : 'Approve'}</span>
</Button>
{hold.isHolding ? (
<CheckoutFromHoldModal
isOpen={openedModalId === hold.id}
onOpenChange={() => setOpenedModalId(null)}
holdRequest={hold}
/>
) : (
<ApproveHoldRequestModal
isOpen={openedModalId === hold.id}
onOpenChange={() => setOpenedModalId(null)}
holdRequest={hold}
/>
)}
</div>
</div>
</div>
</li>
)
})}
</li>
)
})
.filter((element) => !!element)}
</ul>
)
})

View File

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