diff --git a/src/app/api/audiobooks/new-releases/route.ts b/src/app/api/audiobooks/new-releases/route.ts index 78076de..5bec85d 100644 --- a/src/app/api/audiobooks/new-releases/route.ts +++ b/src/app/api/audiobooks/new-releases/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/db'; -import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; +import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher'; import { getCurrentUser } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; @@ -24,6 +24,7 @@ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const page = parseInt(searchParams.get('page') || '1', 10); const limit = parseInt(searchParams.get('limit') || '20', 10); + const hideAvailable = searchParams.get('hideAvailable') === 'true'; // Validate pagination parameters if (page < 1 || limit < 1 || limit > 100) { @@ -38,12 +39,22 @@ export async function GET(request: NextRequest) { const skip = (page - 1) * limit; + // When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests + let excludedAsins: string[] = []; + if (hideAvailable) { + const availableSet = await getAvailableAsins(); + excludedAsins = [...availableSet]; + } + + const whereClause = { + isNewRelease: true, + ...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}), + }; + // Query audible_cache for new release audiobooks const [audiobooks, totalCount] = await Promise.all([ prisma.audibleCache.findMany({ - where: { - isNewRelease: true, - }, + where: whereClause, orderBy: { newReleaseRank: 'asc', }, @@ -66,9 +77,7 @@ export async function GET(request: NextRequest) { }, }), prisma.audibleCache.count({ - where: { - isNewRelease: true, - }, + where: whereClause, }), ]); diff --git a/src/app/api/audiobooks/popular/route.ts b/src/app/api/audiobooks/popular/route.ts index 3bab399..8c46913 100644 --- a/src/app/api/audiobooks/popular/route.ts +++ b/src/app/api/audiobooks/popular/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/db'; -import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; +import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher'; import { getCurrentUser } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; @@ -24,6 +24,7 @@ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const page = parseInt(searchParams.get('page') || '1', 10); const limit = parseInt(searchParams.get('limit') || '20', 10); + const hideAvailable = searchParams.get('hideAvailable') === 'true'; // Validate pagination parameters if (page < 1 || limit < 1 || limit > 100) { @@ -38,12 +39,22 @@ export async function GET(request: NextRequest) { const skip = (page - 1) * limit; + // When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests + let excludedAsins: string[] = []; + if (hideAvailable) { + const availableSet = await getAvailableAsins(); + excludedAsins = [...availableSet]; + } + + const whereClause = { + isPopular: true, + ...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}), + }; + // Query audible_cache for popular audiobooks const [audiobooks, totalCount] = await Promise.all([ prisma.audibleCache.findMany({ - where: { - isPopular: true, - }, + where: whereClause, orderBy: { popularRank: 'asc', }, @@ -66,9 +77,7 @@ export async function GET(request: NextRequest) { }, }), prisma.audibleCache.count({ - where: { - isPopular: true, - }, + where: whereClause, }), ]); diff --git a/src/app/page.tsx b/src/app/page.tsx index af8429d..da71faa 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,12 +5,12 @@ 'use client'; -import { useState, useRef, useMemo } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { Header } from '@/components/layout/Header'; import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid'; -import { useAudiobooks, Audiobook } from '@/lib/hooks/useAudiobooks'; +import { useAudiobooks } from '@/lib/hooks/useAudiobooks'; import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; -import { StickyPagination } from '@/components/ui/StickyPagination'; +import { UnifiedPagination } from '@/components/ui/UnifiedPagination'; import { SectionToolbar } from '@/components/ui/SectionToolbar'; import { usePreferences } from '@/contexts/PreferencesContext'; @@ -29,24 +29,20 @@ export default function HomePage() { isLoading: loadingPopular, totalPages: popularTotalPages, message: popularMessage, - } = useAudiobooks('popular', 20, popularPage); + } = useAudiobooks('popular', 20, popularPage, hideAvailable); const { audiobooks: newReleases, isLoading: loadingNewReleases, totalPages: newReleasesTotalPages, message: newReleasesMessage, - } = useAudiobooks('new-releases', 20, newReleasesPage); + } = useAudiobooks('new-releases', 20, newReleasesPage, hideAvailable); - // Filter out available titles when hideAvailable is enabled - const filteredPopular = useMemo( - () => hideAvailable ? popular.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : popular, - [popular, hideAvailable] - ); - const filteredNewReleases = useMemo( - () => hideAvailable ? newReleases.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : newReleases, - [newReleases, hideAvailable] - ); + // Reset to page 1 when hideAvailable changes (total pages may differ) + useEffect(() => { + setPopularPage(1); + setNewReleasesPage(1); + }, [hideAvailable]); // Handle page changes with auto-scroll to section top const handlePopularPageChange = (page: number) => { @@ -100,7 +96,7 @@ export default function HomePage() { ) : ( ) : ( - {/* Sticky Pagination Controls */} - - + popularSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), + }, + { + label: 'New Releases', + accentColor: 'bg-emerald-500', + currentPage: newReleasesPage, + totalPages: newReleasesTotalPages, + onPageChange: handleNewReleasesPageChange, + sectionRef: newReleasesSectionRef, + onScrollToSection: () => + newReleasesSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), + }, + ]} /> diff --git a/src/components/ui/StickyPagination.tsx b/src/components/ui/StickyPagination.tsx deleted file mode 100644 index a4c7e2a..0000000 --- a/src/components/ui/StickyPagination.tsx +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Component: Sticky Pagination with Progress Bar - * Documentation: documentation/frontend/components.md - */ - -'use client'; - -import React, { useState, useEffect, useRef } from 'react'; -import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; - -interface StickyPaginationProps { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; - sectionRef: React.RefObject; - label: string; // e.g., "Popular Audiobooks" - footerRef?: React.RefObject; // Optional footer ref to avoid overlap -} - -export function StickyPagination({ - currentPage, - totalPages, - onPageChange, - sectionRef, - label, - footerRef, -}: StickyPaginationProps) { - const [isVisible, setIsVisible] = useState(false); - const [isFooterVisible, setIsFooterVisible] = useState(false); - const [jumpPage, setJumpPage] = useState(currentPage.toString()); - - // Update jump page input when current page changes externally - useEffect(() => { - setJumpPage(currentPage.toString()); - }, [currentPage]); - - // Intersection Observer to show/hide pagination based on section visibility - useEffect(() => { - if (!sectionRef.current) return; - - const observer = new IntersectionObserver( - ([entry]) => { - // Show pagination when section is in viewport - setIsVisible(entry.isIntersecting && entry.intersectionRatio > 0.1); - }, - { - threshold: [0, 0.1, 0.5, 1], - rootMargin: '-60px 0px -60px 0px', // Account for header/footer - } - ); - - observer.observe(sectionRef.current); - - return () => observer.disconnect(); - }, [sectionRef]); - - // Footer observer to hide pagination when footer is visible - useEffect(() => { - if (!footerRef?.current) return; - - const observer = new IntersectionObserver( - ([entry]) => { - // Hide pagination when footer is in viewport - setIsFooterVisible(entry.isIntersecting); - }, - { - threshold: [0, 0.1], - rootMargin: '0px', - } - ); - - observer.observe(footerRef.current); - - return () => observer.disconnect(); - }, [footerRef]); - - if (totalPages <= 1) { - return null; - } - - const handlePrevious = () => { - if (currentPage > 1) { - onPageChange(currentPage - 1); - } - }; - - const handleNext = () => { - if (currentPage < totalPages) { - onPageChange(currentPage + 1); - } - }; - - const handleJumpSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const page = parseInt(jumpPage, 10); - if (!isNaN(page) && page >= 1 && page <= totalPages) { - onPageChange(page); - } else { - // Reset to current page if invalid - setJumpPage(currentPage.toString()); - } - }; - - // Final visibility: show when section is visible AND footer is not visible - const shouldShow = isVisible && !isFooterVisible; - - return ( -
-
-
- {/* Section Label - Hidden on small screens */} -
- {label} -
- - {/* Previous Button */} - - - {/* Page Info & Jump */} -
- - Page - -
- setJumpPage(e.target.value)} - onBlur={handleJumpSubmit} - className="w-10 px-1.5 py-0.5 text-center text-sm font-medium rounded - bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 - border border-gray-300 dark:border-gray-600 - focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent" - aria-label="Current page" - /> -
- - of {totalPages} - -
- - {/* Next Button */} - -
-
-
- ); -} diff --git a/src/components/ui/UnifiedPagination.tsx b/src/components/ui/UnifiedPagination.tsx new file mode 100644 index 0000000..b44230b --- /dev/null +++ b/src/components/ui/UnifiedPagination.tsx @@ -0,0 +1,325 @@ +/** + * Component: Unified Pagination — context-aware floating paginator + * Documentation: documentation/frontend/components.md + * + * Replaces two overlapping StickyPagination instances with a single pill + * that automatically tracks which section dominates the viewport and shows + * controls for that section. Transitions smoothly when the dominant section + * changes. Includes a two-dot section indicator for manual switching. + */ + +'use client'; + +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; + +export interface PaginationSection { + /** Display label, e.g. "Popular Audiobooks" */ + label: string; + /** Tailwind color class applied to the active accent dot, e.g. "bg-blue-500" */ + accentColor: string; + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + /** Ref to the section element — used for intersection tracking */ + sectionRef: React.RefObject; + /** Called when user clicks this section's dot while it's inactive — should scroll to section */ + onScrollToSection: () => void; +} + +interface UnifiedPaginationProps { + sections: [PaginationSection, PaginationSection]; + footerRef?: React.RefObject; +} + +// --------------------------------------------------------------------------- +// Small page-jump form — isolated to prevent key re-mounts on section switch +// --------------------------------------------------------------------------- + +interface PageJumpProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +function PageJump({ currentPage, totalPages, onPageChange }: PageJumpProps) { + const [value, setValue] = useState(currentPage.toString()); + + // Sync when page changes externally (e.g. after scrollIntoView + setState) + useEffect(() => { + setValue(currentPage.toString()); + }, [currentPage]); + + const commit = useCallback( + (e?: React.FormEvent) => { + e?.preventDefault(); + const parsed = parseInt(value, 10); + if (!isNaN(parsed) && parsed >= 1 && parsed <= totalPages) { + onPageChange(parsed); + } else { + setValue(currentPage.toString()); + } + }, + [value, currentPage, totalPages, onPageChange] + ); + + return ( +
+ + Page + +
+ setValue(e.target.value)} + onBlur={commit} + className="w-10 px-1.5 py-0.5 text-center text-sm font-medium rounded-md + bg-black/[0.04] dark:bg-white/[0.08] + text-gray-900 dark:text-gray-100 + border border-gray-300/60 dark:border-white/10 + focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent + transition-all duration-150" + aria-label="Jump to page" + /> +
+ + of {totalPages} + +
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProps) { + // Index of the currently dominant section (0 or 1) + const [activeIndex, setActiveIndex] = useState<0 | 1>(0); + // Whether the label+controls area is mid-transition (drives opacity fade) + const [isTransitioning, setIsTransitioning] = useState(false); + + const [footerVisible, setFooterVisible] = useState(false); + // Per-section raw intersection ratios [0,1] + const ratiosRef = useRef<[number, number]>([0, 0]); + // Whether each section has any meaningful intersection + const [sectionVisible, setSectionVisible] = useState<[boolean, boolean]>([false, false]); + + const transitionTimerRef = useRef | null>(null); + + // Determine if the pill should be shown at all: + // - at least one section is meaningfully visible + // - footer is not visible + // - the active section has >1 page + const activeSectionHasPages = sections[activeIndex].totalPages > 1; + const eitherSectionVisible = sectionVisible[0] || sectionVisible[1]; + const shouldShow = eitherSectionVisible && !footerVisible && activeSectionHasPages; + + // ------------------------------------------------------------------ + // Track which section each instance belongs to via intersection ratio + // ------------------------------------------------------------------ + useEffect(() => { + const observers: IntersectionObserver[] = []; + + sections.forEach((section, idx) => { + if (!section.sectionRef.current) return; + + const observer = new IntersectionObserver( + ([entry]) => { + ratiosRef.current[idx as 0 | 1] = entry.intersectionRatio; + const isVisible = entry.isIntersecting && entry.intersectionRatio > 0.05; + + setSectionVisible((prev) => { + const next: [boolean, boolean] = [...prev] as [boolean, boolean]; + next[idx as 0 | 1] = isVisible; + return next; + }); + + // Determine dominant section (whichever has more viewport coverage) + const [r0, r1] = ratiosRef.current; + const dominant: 0 | 1 = r0 >= r1 ? 0 : 1; + + setActiveIndex((current) => { + if (current !== dominant) { + // Trigger cross-fade transition + setIsTransitioning(true); + + if (transitionTimerRef.current) { + clearTimeout(transitionTimerRef.current); + } + transitionTimerRef.current = setTimeout(() => { + setIsTransitioning(false); + }, 320); + + return dominant; + } + return current; + }); + }, + { + // Dense threshold array gives us smooth ratio tracking + threshold: Array.from({ length: 21 }, (_, i) => i / 20), + rootMargin: '-60px 0px -80px 0px', + } + ); + + observer.observe(section.sectionRef.current); + observers.push(observer); + }); + + return () => { + observers.forEach((o) => o.disconnect()); + if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sections[0].sectionRef, sections[1].sectionRef]); + + // ------------------------------------------------------------------ + // Footer observer + // ------------------------------------------------------------------ + useEffect(() => { + if (!footerRef?.current) return; + const observer = new IntersectionObserver( + ([entry]) => setFooterVisible(entry.isIntersecting), + { threshold: [0, 0.01] } + ); + observer.observe(footerRef.current); + return () => observer.disconnect(); + }, [footerRef]); + + // ------------------------------------------------------------------ + // Derived values for the currently active section + // ------------------------------------------------------------------ + const active = sections[activeIndex]; + + const handlePrev = () => { + if (active.currentPage > 1) active.onPageChange(active.currentPage - 1); + }; + const handleNext = () => { + if (active.currentPage < active.totalPages) active.onPageChange(active.currentPage + 1); + }; + + // ------------------------------------------------------------------ + // Render + // ------------------------------------------------------------------ + return ( +
+ {/* Pill surface */} +
+ {/* Section selector dots — left side */} +
+ {sections.map((section, idx) => { + const isActive = idx === activeIndex; + return ( +
+ + {/* Divider */} +
+ + {/* Label + controls — cross-fades on section switch */} +
+ {/* Section label — hidden on small screens */} + + {active.label} + + + {/* Previous */} + + + {/* Page jump */} + + + {/* Next */} + +
+ + {/* Right padding balance */} +
+
+
+ ); +} diff --git a/src/lib/hooks/useAudiobooks.ts b/src/lib/hooks/useAudiobooks.ts index 8018ab4..b181708 100644 --- a/src/lib/hooks/useAudiobooks.ts +++ b/src/lib/hooks/useAudiobooks.ts @@ -35,11 +35,12 @@ export interface Audiobook { hasReportedIssue?: boolean; // True if an open issue exists for this audiobook } -export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1) { +export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) { + const hideParam = hideAvailable ? '&hideAvailable=true' : ''; const endpoint = type === 'popular' - ? `/api/audiobooks/popular?page=${page}&limit=${limit}` - : `/api/audiobooks/new-releases?page=${page}&limit=${limit}`; + ? `/api/audiobooks/popular?page=${page}&limit=${limit}${hideParam}` + : `/api/audiobooks/new-releases?page=${page}&limit=${limit}${hideParam}`; const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, { revalidateOnFocus: false, diff --git a/src/lib/utils/audiobook-matcher.ts b/src/lib/utils/audiobook-matcher.ts index ec86f45..1181552 100644 --- a/src/lib/utils/audiobook-matcher.ts +++ b/src/lib/utils/audiobook-matcher.ts @@ -272,6 +272,44 @@ export async function enrichAudiobooksWithMatches( return results; } +/** + * Get all ASINs that are considered "available" — present in library or have completed requests. + * Used by paginated API routes to exclude available items at the DB level. + */ +export async function getAvailableAsins(): Promise> { + const [libraryItems, completedRequests] = await Promise.all([ + // ASINs present in the library (Plex or Audiobookshelf) + prisma.plexLibrary.findMany({ + where: { asin: { not: null } }, + select: { asin: true }, + distinct: ['asin'], + }), + // ASINs with completed audiobook requests + prisma.audiobook.findMany({ + where: { + audibleAsin: { not: null }, + requests: { + some: { + status: 'completed', + type: 'audiobook', + deletedAt: null, + }, + }, + }, + select: { audibleAsin: true }, + }), + ]); + + const asins = new Set(); + for (const item of libraryItems) { + if (item.asin) asins.add(item.asin); + } + for (const item of completedRequests) { + if (item.audibleAsin) asins.add(item.audibleAsin); + } + return asins; +} + /** * Normalize ISBN for comparison (remove dashes and spaces) */ diff --git a/tests/app/home.page.test.tsx b/tests/app/home.page.test.tsx index f84f7c0..6a009e4 100644 --- a/tests/app/home.page.test.tsx +++ b/tests/app/home.page.test.tsx @@ -47,17 +47,22 @@ vi.mock('@/components/ui/CardSizeControls', () => ({ CardSizeControls: ({ size }: { size: number }) =>
, })); -vi.mock('@/components/ui/StickyPagination', () => ({ - StickyPagination: ({ - label, - onPageChange, +vi.mock('@/components/ui/UnifiedPagination', () => ({ + UnifiedPagination: ({ + sections, }: { - label: string; - onPageChange: (page: number) => void; + sections: Array<{ + label: string; + onPageChange: (page: number) => void; + }>; }) => ( - +
+ {sections.map((s) => ( + + ))} +
), })); @@ -113,7 +118,7 @@ describe('HomePage', () => { fireEvent.click(screen.getByRole('button', { name: 'Popular Audiobooks next' })); await waitFor(() => { - expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2); + expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2, undefined); }); }); }); diff --git a/tests/components/ui/StickyPagination.test.tsx b/tests/components/ui/StickyPagination.test.tsx deleted file mode 100644 index 4c2206a..0000000 --- a/tests/components/ui/StickyPagination.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Component: Sticky Pagination Tests - * Documentation: documentation/frontend/components.md - */ - -// @vitest-environment jsdom - -import React from 'react'; -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { StickyPagination } from '@/components/ui/StickyPagination'; - -type ObserverEntry = { - isIntersecting: boolean; - intersectionRatio: number; - target: Element; -}; - -describe('StickyPagination', () => { - const observers: { callback: IntersectionObserverCallback }[] = []; - - beforeEach(() => { - observers.length = 0; - class MockIntersectionObserver { - callback: IntersectionObserverCallback; - observe = vi.fn(); - unobserve = vi.fn(); - disconnect = vi.fn(); - takeRecords = vi.fn(); - - constructor(callback: IntersectionObserverCallback) { - this.callback = callback; - observers.push(this); - } - } - - (global as any).IntersectionObserver = MockIntersectionObserver; - }); - - it('returns null when there is only one page', () => { - const sectionRef = { current: document.createElement('div') }; - const { container } = render( - - ); - - expect(container.firstChild).toBeNull(); - }); - - it('shows and hides based on section and footer visibility', () => { - const sectionRef = { current: document.createElement('div') }; - const footerRef = { current: document.createElement('div') }; - - const { container } = render( - - ); - - const root = container.querySelector('div.fixed') as HTMLElement; - expect(root).toHaveClass('opacity-0'); - - act(() => { - observers[0].callback( - [ - { - isIntersecting: true, - intersectionRatio: 0.2, - target: sectionRef.current as Element, - } as ObserverEntry, - ], - observers[0] as unknown as IntersectionObserver - ); - }); - - expect(root).toHaveClass('opacity-100'); - - act(() => { - observers[1].callback( - [ - { - isIntersecting: true, - intersectionRatio: 0.2, - target: footerRef.current as Element, - } as ObserverEntry, - ], - observers[1] as unknown as IntersectionObserver - ); - }); - - expect(root).toHaveClass('opacity-0'); - }); - - it('handles navigation and jump input updates', () => { - const sectionRef = { current: document.createElement('div') }; - const onPageChange = vi.fn(); - - render( - - ); - - fireEvent.click(screen.getByLabelText('Next page')); - expect(onPageChange).toHaveBeenCalledWith(3); - - fireEvent.click(screen.getByLabelText('Previous page')); - expect(onPageChange).toHaveBeenCalledWith(1); - - const input = screen.getByLabelText('Current page') as HTMLInputElement; - fireEvent.change(input, { target: { value: '4' } }); - fireEvent.blur(input); - expect(onPageChange).toHaveBeenCalledWith(4); - - fireEvent.change(input, { target: { value: '99' } }); - fireEvent.blur(input); - expect(input.value).toBe('2'); - }); -}); diff --git a/tests/components/ui/UnifiedPagination.test.tsx b/tests/components/ui/UnifiedPagination.test.tsx new file mode 100644 index 0000000..6d38ba8 --- /dev/null +++ b/tests/components/ui/UnifiedPagination.test.tsx @@ -0,0 +1,203 @@ +/** + * Component: Unified Pagination Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UnifiedPagination, PaginationSection } from '@/components/ui/UnifiedPagination'; + +type ObserverEntry = { + isIntersecting: boolean; + intersectionRatio: number; + target: Element; +}; + +function makeSections( + overrides?: Partial[] +): [PaginationSection, PaginationSection] { + const defaults: [PaginationSection, PaginationSection] = [ + { + label: 'Popular', + accentColor: 'bg-blue-500', + currentPage: 1, + totalPages: 3, + onPageChange: vi.fn(), + sectionRef: { current: document.createElement('section') }, + onScrollToSection: vi.fn(), + }, + { + label: 'New Releases', + accentColor: 'bg-emerald-500', + currentPage: 1, + totalPages: 2, + onPageChange: vi.fn(), + sectionRef: { current: document.createElement('section') }, + onScrollToSection: vi.fn(), + }, + ]; + + if (overrides) { + overrides.forEach((o, i) => { + if (o) Object.assign(defaults[i], o); + }); + } + + return defaults; +} + +describe('UnifiedPagination', () => { + const observers: { callback: IntersectionObserverCallback; observe: ReturnType; disconnect: ReturnType }[] = []; + + beforeEach(() => { + observers.length = 0; + + class MockIntersectionObserver { + callback: IntersectionObserverCallback; + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + takeRecords = vi.fn(); + + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + observers.push(this); + } + } + + (global as any).IntersectionObserver = MockIntersectionObserver; + }); + + it('renders nothing when both sections have only one page', () => { + const sections = makeSections([{ totalPages: 1 }, { totalPages: 1 }]); + const { container } = render(); + // The pill should be hidden (pointer-events-none, opacity-0) + const root = container.querySelector('div.fixed') as HTMLElement; + expect(root).toHaveClass('pointer-events-none'); + }); + + it('shows pagination when the dominant section is visible and has pages', () => { + const sections = makeSections(); + const { container } = render(); + + const root = container.querySelector('div.fixed') as HTMLElement; + expect(root).toHaveClass('opacity-0'); + + // Simulate first section becoming visible with high ratio + act(() => { + observers[0].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.5, + target: sections[0].sectionRef.current as Element, + } as ObserverEntry, + ], + observers[0] as unknown as IntersectionObserver + ); + }); + + expect(root).toHaveClass('opacity-100'); + }); + + it('hides when footer becomes visible', () => { + const sections = makeSections(); + const footerRef = { current: document.createElement('footer') }; + const { container } = render( + + ); + + const root = container.querySelector('div.fixed') as HTMLElement; + + // Make section visible + act(() => { + observers[0].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.5, + target: sections[0].sectionRef.current as Element, + } as ObserverEntry, + ], + observers[0] as unknown as IntersectionObserver + ); + }); + + expect(root).toHaveClass('opacity-100'); + + // Footer observer is the 3rd (index 2): section0, section1, footer + act(() => { + observers[2].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.1, + target: footerRef.current as Element, + } as ObserverEntry, + ], + observers[2] as unknown as IntersectionObserver + ); + }); + + expect(root).toHaveClass('opacity-0'); + }); + + it('calls onPageChange for prev/next buttons', () => { + const sections = makeSections([{ currentPage: 2, totalPages: 4 }]); + const { container } = render(); + + // Make section visible so controls render interactably + act(() => { + observers[0].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.5, + target: sections[0].sectionRef.current as Element, + } as ObserverEntry, + ], + observers[0] as unknown as IntersectionObserver + ); + }); + + fireEvent.click(screen.getByLabelText('Next page')); + expect(sections[0].onPageChange).toHaveBeenCalledWith(3); + + fireEvent.click(screen.getByLabelText('Previous page')); + expect(sections[0].onPageChange).toHaveBeenCalledWith(1); + }); + + it('handles page jump input', () => { + const sections = makeSections([{ currentPage: 2, totalPages: 5 }]); + render(); + + // Make section visible + act(() => { + observers[0].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.5, + target: sections[0].sectionRef.current as Element, + } as ObserverEntry, + ], + observers[0] as unknown as IntersectionObserver + ); + }); + + const input = screen.getByLabelText('Jump to page') as HTMLInputElement; + fireEvent.change(input, { target: { value: '4' } }); + fireEvent.blur(input); + expect(sections[0].onPageChange).toHaveBeenCalledWith(4); + }); + + it('uses pointer-events-none when hidden', () => { + const sections = makeSections(); + const { container } = render(); + const root = container.querySelector('div.fixed') as HTMLElement; + expect(root).toHaveClass('pointer-events-none'); + }); +});