feat: conditional UX around if logged in and hold request exists on book page

This commit is contained in:
Yehoshua Sandler 2025-05-01 14:53:57 -05:00
parent 7deb02391a
commit 9db5736487
6 changed files with 156 additions and 93 deletions

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { Book, Genre, Repository } from '@/payload-types' import { Book, Genre, HoldRequest, Repository } from '@/payload-types'
import { Combobox, ComboboxLabel, ComboboxOption, ComboboxDescription } from '@/components/combobox' import { Combobox, ComboboxLabel, ComboboxOption, ComboboxDescription } from '@/components/combobox'
import { Field, Label } from '@/components/fieldset' import { Field, Label } from '@/components/fieldset'
@ -12,11 +12,13 @@ import { Loader2 } from 'lucide-react'
import { RichText } from '@/components/RichText' import { RichText } from '@/components/RichText'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import requestHold from '../../serverCalls/requestHold' import requestHold from '../../serverCalls/requestHold'
import { useGlobal } from '@/providers/GlobalProvider'
type DropDownProps = { type DropDownProps = {
currentRepository: Repository currentRepository: Repository
repositories: Repository[] repositories: Repository[]
isDisabled: boolean isDisabled: boolean
doesHoldExist: boolean
isRequesting: boolean isRequesting: boolean
onClickRequest: () => void onClickRequest: () => void
onChange: (repo: Repository | null) => void onChange: (repo: Repository | null) => void
@ -25,38 +27,48 @@ function RepoDropdown({
currentRepository, currentRepository,
repositories, repositories,
isRequesting, isRequesting,
doesHoldExist,
isDisabled, isDisabled,
onClickRequest, onClickRequest,
onChange, onChange,
}: DropDownProps) { }: DropDownProps) {
return ( return (
<Field className=""> <Field className="">
<Label htmlFor="repositories">From Repository</Label> {doesHoldExist ? (
<div className="flex gap-2"> <span className="font-medium text-emerald-800 dark:text-emerald-300 ">
<Combobox You have requested a hold for this book
name="repositories" </span>
options={repositories} ) : (
displayValue={(repo) => repo?.name} <>
defaultValue={currentRepository} <Label htmlFor="repositories">From Repository</Label>
className="" <div className="flex gap-2">
onChange={(repo) => onChange(repo)} <Combobox
> name="repositories"
{(repo) => ( options={repositories}
<ComboboxOption value={repo}> disabled={isDisabled || doesHoldExist}
<ComboboxLabel>{repo.abbreviation}</ComboboxLabel> displayValue={(repo) => repo?.name}
<ComboboxDescription>{repo.name}</ComboboxDescription> defaultValue={currentRepository}
</ComboboxOption> className=""
)} onChange={(repo) => onChange(repo)}
</Combobox> >
<Button {(repo) => (
disabled={isDisabled} <ComboboxOption value={repo}>
onClick={onClickRequest} <ComboboxLabel>{repo.abbreviation}</ComboboxLabel>
className="hover:scale-105 bg-emerald-500 text-foreground hover:text-background cursor-pointer" <ComboboxDescription>{repo.name}</ComboboxDescription>
> </ComboboxOption>
{isRequesting && <Loader2 className="animate-spin" />} )}
<span>Request Copy</span> </Combobox>
</Button> <Button
</div> disabled={isDisabled}
onClick={onClickRequest}
className="hover:scale-105 bg-emerald-500 text-foreground hover:text-background cursor-pointer disabled:bg-muted-foreground/80"
>
{isRequesting && <Loader2 className="animate-spin" />}
<span>Request Hold</span>
</Button>
</div>
</>
)}
</Field> </Field>
) )
} }
@ -64,39 +76,41 @@ function RepoDropdown({
type Props = { type Props = {
book: Book book: Book
repositories: PaginatedDocs<Repository> repositories: PaginatedDocs<Repository>
existingHolds: PaginatedDocs<HoldRequest> | null
} }
export default function BookByIdPageClient(props: Props) { export default function BookByIdPageClient(props: Props) {
const { book, repositories } = props const { book, repositories, existingHolds } = props
const repos = repositories.docs const repos = repositories.docs
const { user } = useGlobal()
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,
) )
const [doesHoldExist, setDoesHoldExist] = useState(!!existingHolds?.totalDocs)
const isRequestDisabled = useMemo(() => { const isRequestDisabled = useMemo(() => {
return isRequestingCopy return isRequestingCopy
}, [isRequestingCopy]) }, [isRequestingCopy])
const onClickRequest = async () => { const onClickRequest = async () => {
if (isRequestingCopy || !selectedRepository || !book) return if (isRequestingCopy || !selectedRepository || !book || doesHoldExist) return
setIsRequestingCopy(true) setIsRequestingCopy(true)
const response = await requestHold({ const newHoldResponse = await requestHold({
repositoryId: selectedRepository.id, repositoryId: selectedRepository.id,
bookId: book.id, bookId: book.id,
}) })
console.log(response) if (newHoldResponse?.id) setDoesHoldExist(true)
setIsRequestingCopy(false) setIsRequestingCopy(false)
} }
return ( return (
<div className="mx-auto px-4 py-16"> <div className="mx-auto px-4 py-16">
{/* Book */}
<div className="md:grid md:grid-cols-7 md:gap-8"> <div className="md:grid md:grid-cols-7 md:gap-8">
{/* Book Cover */} {/* Book Cover */}
<div className="md:col-span-3"> <div className="md:col-span-3">
@ -113,24 +127,22 @@ export default function BookByIdPageClient(props: Props) {
{/* Book details */} {/* Book details */}
<div className="mt-14 sm:mt-16 md:col-span-4 md:mt-0"> <div className="mt-14 sm:mt-16 md:col-span-4 md:mt-0">
<div className=""> <div className="mt-4">
<div className="mt-4"> <h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl"> {book.title}
{book.title} </h1>
</h1>
<h2 id="information-heading" className="sr-only"> <h2 id="information-heading" className="sr-only">
Book information Book information
</h2> </h2>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
{book.publication && ( {book.publication && (
<span> <span>
Published:&nbsp; Published:&nbsp;
<time dateTime={book.publication}>{book.publication}</time> <time dateTime={book.publication}>{book.publication}</time>
</span> </span>
)} )}
</p> </p>
</div>
</div> </div>
<p className="my-6 text-accent-foreground">{book.summary}</p> <p className="my-6 text-accent-foreground">{book.summary}</p>
@ -139,7 +151,8 @@ export default function BookByIdPageClient(props: Props) {
currentRepository={repos[0]} currentRepository={repos[0]}
repositories={repos} repositories={repos}
isRequesting={isRequestingCopy} isRequesting={isRequestingCopy}
isDisabled={isRequestDisabled} doesHoldExist={doesHoldExist}
isDisabled={isRequestDisabled || !user}
onClickRequest={onClickRequest} onClickRequest={onClickRequest}
onChange={(repo) => setSelectedRepository(repo)} onChange={(repo) => setSelectedRepository(repo)}
/> />

View File

@ -1,6 +1,7 @@
import { Book, Copy, Repository } from '@/payload-types' import { Book, Copy, HoldRequest, Repository } from '@/payload-types'
import { getPayload, PaginatedDocs } from 'payload' import { getPayload, PaginatedDocs } from 'payload'
import configPromise from '@payload-config' import configPromise from '@payload-config'
import { headers as getHeaders } from 'next/headers.js'
import BookByIdPageClient from './page.client' import BookByIdPageClient from './page.client'
import { PageBreadCrumb, Route } from '@/components/PageBreadCrumb' import { PageBreadCrumb, Route } from '@/components/PageBreadCrumb'
@ -31,7 +32,8 @@ const BookByIdPage = async (props: Props) => {
const payload = await getPayload({ config: configPromise }) const payload = await getPayload({ config: configPromise })
const defaultRepositoryLimit = 10 const headers = await getHeaders()
const { user } = await payload.auth({ headers })
const foundBook = (await payload.findByID({ const foundBook = (await payload.findByID({
collection: 'books', collection: 'books',
@ -51,16 +53,45 @@ const BookByIdPage = async (props: Props) => {
}, },
})) as Book })) as Book
const repositoryIds = foundBook.copies?.docs?.map((c) => (c as Copy).repository) let existingHolds: PaginatedDocs<HoldRequest> | null = null
if (user?.id)
const orQueries = repositoryIds?.map((c) => { existingHolds = (await payload.find({
return { collection: 'holdRequests',
id: { depth: 1,
equals: c, limit: 1,
select: {
dateRequested: true,
holdingUntilDate: true,
}, },
} where: {
}) and: [
{
'book.id': {
equals: bookId,
},
},
{
userRequested: {
equals: user.id,
},
},
{
isCheckedOut: {
not_equals: true,
},
},
{
isRejected: {
not_equals: true,
},
},
],
},
})) as PaginatedDocs<HoldRequest>
const defaultRepositoryLimit = 10
const repositoryIds = foundBook.copies?.docs?.map((c) => (c as Copy).repository)
const repositoryOrQueries = repositoryIds?.map((c) => ({ id: { equals: c } }))
const repositories = (await payload.find({ const repositories = (await payload.find({
collection: 'repositories', collection: 'repositories',
depth: 3, depth: 3,
@ -72,28 +103,38 @@ const BookByIdPage = async (props: Props) => {
name: true, name: true,
}, },
where: { where: {
or: orQueries?.length ? orQueries : [], or: repositoryOrQueries?.length ? repositoryOrQueries : [],
}, },
})) as PaginatedDocs<Repository> })) as PaginatedDocs<Repository>
const createBreadcrumbRoutes =() => { const createBreadcrumbRoutes = () => {
const maxTitleLength = 18
let title let title
if (!foundBook?.title) title = 'Book Not Found' if (!foundBook?.title) title = 'Book Not Found'
else title = foundBook.title.length > 25 ? `${foundBook.title.slice(0, 25)}...` : foundBook.title else
title =
foundBook.title.length > maxTitleLength
? `${foundBook.title.slice(0, maxTitleLength)}...`
: foundBook.title
return [
return [...staticBreadcrumRoutes, { ...staticBreadcrumRoutes,
label: title, {
href: `/books/${bookId}` label: title,
}] href: `/books/${bookId}`,
},
]
} }
console.log(createBreadcrumbRoutes())
return ( return (
<> <>
<PageBreadCrumb routes={createBreadcrumbRoutes()} /> <PageBreadCrumb routes={createBreadcrumbRoutes()} />
<BookByIdPageClient book={foundBook} repositories={repositories} /> <BookByIdPageClient
book={foundBook}
repositories={repositories}
existingHolds={existingHolds}
/>
</> </>
) )
} }

View File

@ -12,7 +12,7 @@ const LoginPage = async () => {
if (Boolean(userResult.user)) redirect('/profile') if (Boolean(userResult.user)) redirect('/profile')
return ( return (
<div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10"> <div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10 rounded-md">
<div className="flex w-full max-w-sm flex-col gap-6"> <div className="flex w-full max-w-sm flex-col gap-6">
<a <a
href="https://beitzah.net?ref=midrashim" href="https://beitzah.net?ref=midrashim"
@ -29,7 +29,7 @@ const LoginPage = async () => {
</div> </div>
<div> <div>
<span className="block leading-3.5">Developed with 💜</span> <span className="block leading-3.5">Developed with 💜</span>
<span className="block leading-3.5">Beitzah.tech</span> <span className="block leading-3.5">by Beitzah.tech</span>
</div> </div>
</a> </a>
<LoginForm /> <LoginForm />

View File

@ -49,6 +49,10 @@ const HoldRequests: CollectionConfig = {
name: 'holdingUntilDate', name: 'holdingUntilDate',
type: 'date', type: 'date',
}, },
{
name: 'isRejected',
type: 'checkbox',
},
{ {
name: 'isCheckedOut', name: 'isCheckedOut',
type: 'checkbox', type: 'checkbox',

View File

@ -105,28 +105,31 @@ export default function SiteNavigation(props: { children: React.ReactNode }) {
<Avatar src={profilePicture?.url || ''} initials={initials} square={false} /> <Avatar src={profilePicture?.url || ''} initials={initials} square={false} />
</DropdownButton> </DropdownButton>
<DropdownMenu className="min-w-64" anchor="bottom end"> <DropdownMenu className="min-w-64" anchor="bottom end">
<DropdownItem href="/profile"> {!user ? (
<UserIcon /> <DropdownItem href="/login">
<DropdownLabel>My profile</DropdownLabel> <UserIcon />
</DropdownItem> <DropdownLabel>Login</DropdownLabel>
{/*<DropdownItem href="/settings"> </DropdownItem>
<Cog8ToothIcon /> ) : (
<DropdownLabel>Settings</DropdownLabel> <DropdownItem href="/profile">
</DropdownItem> <UserIcon />
<DropdownDivider /> <DropdownLabel>My profile</DropdownLabel>
<DropdownItem href="/privacy-policy"> </DropdownItem>
<ShieldCheckIcon /> )}
<DropdownLabel>Privacy policy</DropdownLabel>
</DropdownItem>*/}
<DropdownItem href="/feedback"> <DropdownItem href="/feedback">
<LightBulbIcon /> <LightBulbIcon />
<DropdownLabel>Share feedback</DropdownLabel> <DropdownLabel>Share feedback</DropdownLabel>
</DropdownItem> </DropdownItem>
<DropdownDivider />
<DropdownItem href="/logout"> {!!user && (
<ArrowRightStartOnRectangleIcon /> <>
<DropdownLabel>Sign out</DropdownLabel> <DropdownDivider />
</DropdownItem> <DropdownItem href="/logout">
<ArrowRightStartOnRectangleIcon />
<DropdownLabel>Sign out</DropdownLabel>
</DropdownItem>
</>
)}
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
</NavbarSection> </NavbarSection>

View File

@ -234,6 +234,7 @@ export interface HoldRequest {
dateRequested?: string | null; dateRequested?: string | null;
isHolding?: boolean | null; isHolding?: boolean | null;
holdingUntilDate?: string | null; holdingUntilDate?: string | null;
isRejected?: boolean | null;
isCheckedOut?: boolean | null; isCheckedOut?: boolean | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@ -583,6 +584,7 @@ export interface HoldRequestsSelect<T extends boolean = true> {
dateRequested?: T; dateRequested?: T;
isHolding?: T; isHolding?: T;
holdingUntilDate?: T; holdingUntilDate?: T;
isRejected?: T;
isCheckedOut?: T; isCheckedOut?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;