diff --git a/.gitignore b/.gitignore index 48ae54e..cefb8b8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ .backups/ *.code-workspace .eslintrc.json +next-env.d.ts \ No newline at end of file diff --git a/__tests__/app/api/books/generate/route.test.ts b/__tests__/app/api/books/generate/route.test.ts new file mode 100644 index 0000000..da2c5f0 --- /dev/null +++ b/__tests__/app/api/books/generate/route.test.ts @@ -0,0 +1,70 @@ +import { parseBookMetadata } from "@/lib/book-utils" +import { BookMetadata } from "@/lib/types" + +describe("parseBookMetadata", () => { + it("should parse Google Books data correctly", () => { + const googleBook: BookMetadata = { + title: "Google Book Title", + authors: ["Author One", "Author Two"], + coverUrl: "http://example.com/cover.jpg", + isbn: "1234567890", + googleId: "g1", + } + + const result = parseBookMetadata(googleBook) + + expect(result).toEqual({ + title: "Google Book Title", + author: "Author One, Author Two", + cover_url: "http://example.com/cover.jpg", + isbn: "1234567890", + }) + }) + + it("should parse OpenLibrary data correctly", () => { + const olBook: BookMetadata = { + title: "OL Book Title", + author_name: ["OL Author"], + cover_edition_key: "OL123M", + isbn: ["0987654321"], + key: "/works/OL123W", + // Force type to satisfy simplified mock if needed, but the structure matches + edition_key: ["OL123M"], + publish_year: [2021], + } as any // Cast as any because OpenLibraryDoc has many required fields + + const result = parseBookMetadata(olBook) + + expect(result).toEqual({ + title: "OL Book Title", + author: "OL Author", + cover_url: "https://covers.openlibrary.org/b/olid/OL123M-M.jpg", // Expecting getBookCover logic + isbn: "0987654321", + }) + }) + + it("should handle missing optional fields gracefully", () => { + const minimalBook: BookMetadata = { + title: "Minimal Book", + } as any + + const result = parseBookMetadata(minimalBook) + + expect(result).toEqual({ + title: "Minimal Book", + author: "Unknown Author", + cover_url: null, + isbn: null, + }) + }) + + it("should return defaults for null input", () => { + const result = parseBookMetadata(null as any) + expect(result).toEqual({ + title: "Unknown Title", + author: "Unknown Author", + cover_url: null, + isbn: null, + }) + }) +}) diff --git a/app/api/books/generate/route.ts b/app/api/books/generate/route.ts index 104bcaf..5f0586e 100644 --- a/app/api/books/generate/route.ts +++ b/app/api/books/generate/route.ts @@ -4,7 +4,9 @@ import { generateBookId } from "@/lib/id_generator" import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs" import { cookies } from "next/headers" import { createClient } from "@supabase/supabase-js" +import { parseBookMetadata } from "@/lib/book-utils" import { OpenLibraryDoc, getBookCover } from "@/lib/openLibrary" +import { BookMetadata } from "@/lib/types" export const dynamic = "force-dynamic" @@ -40,9 +42,16 @@ export async function POST(request: Request) { }) // 4. Prepare Metadata - const typedBook = book as OpenLibraryDoc - const cover_url = typedBook ? getBookCover(typedBook) : null - const isbn = typedBook?.isbn ? typedBook.isbn[0] : null + // The 'book' object might be an OpenLibraryDoc or a GoogleBookData object + const bookData = book as BookMetadata + + // Parse metadata using helper to handle different formats safely + const { title, author, cover_url: cover_link, isbn } = parseBookMetadata(bookData) + + if (!title || title === "Unknown Title") { + console.error("Attempted to generate book with no title", bookData) + return NextResponse.json({ error: "Book title is required" }, { status: 400 }) + } // 5. Persist Book const { data, error } = await adminSupabase @@ -51,10 +60,10 @@ export async function POST(request: Request) { code, lat, lon: long, - title: typedBook?.title || "Unknown Title", - author: typedBook?.author_name?.[0] || "Unknown Author", + title: title, + author: author, isbn: isbn, - cover_url: cover_url, + cover_url: cover_link, location: `${lat},${long}`, }) .select() @@ -91,3 +100,5 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Failed to create code" }, { status: 500 }) } } + +// Helper moved to @/lib/book-utils diff --git a/app/generate/page.tsx b/app/generate/page.tsx index 13451a8..4cf5835 100644 --- a/app/generate/page.tsx +++ b/app/generate/page.tsx @@ -1,96 +1,30 @@ "use client" -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" -import * as z from "zod" -import { Button } from "@/components/ui/button" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Input } from "@/components/ui/input" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { useEffect, useState } from "react" -import { GenerateBookCodeRequest } from "@/lib/types" +import { useState, useEffect } from "react" +import { ParchmentFrame } from "@/components/ui/parchment-frame" +import { GoogleBookSearch } from "@/components/google-book-search" import { useToast } from "@/hooks/use-toast" import useLocation from "@/hooks/use-location" -import { OpenLibraryDoc, getBookCover } from "@/lib/openLibrary" -import AutoCompleteResults from "@/components/autoCompleteResults" -import { Accordion } from "@/components/ui/accordion" - -const formSchema = z.object({ - location: z.string().min(2, "Location must be at least 2 characters"), - author: z.string().min(2, "Author must be at least 2 characters"), - title: z.string().min(2, "Title must be at least 2 characters"), - lat: z.string().min(2, "Title must be at least 2 characters"), - long: z.string().min(2, "Title must be at least 2 characters"), - isbn: z.string().min(2, "ISBN must be at least 8 characters"), -}) +import { Loader2 } from "lucide-react" export default function GeneratePage() { + const [selectedBook, setSelectedBook] = useState<{ + title: string + authors?: string[] + coverUrl?: string + } | null>(null) + const [generatedCode, setGeneratedCode] = useState("") - const [isLoading, setIsLoading] = useState(false) + const [isGenerating, setIsGenerating] = useState(false) const { toast } = useToast() - const { latitude, longitude, error } = useLocation() - const [titleInput, setTitleInput] = useState("") - const [isbnInput, setIsbnInput] = useState("") - const [titleResults, setTitleResults] = useState([]) - const [selectedBook, setSelectedBook] = useState(null) - - /** - * AutoComplete when typing in the title field. - * This will fetch results from the OpenLibrary API and display them in a list. - */ - useEffect(() => { - const fetchResults = async () => { - let searchCriteria = "" - if (titleInput.length >= 6) { - searchCriteria = `title=${titleInput}*` - } else if (isbnInput.length >= 8) { - searchCriteria = `isbn=${isbnInput}*` - } - try { - const response = await fetch( - `https://openlibrary.org/search.json?${searchCriteria}&fields=title,author_name,cover_i,isbn,ratings_count,cover_edition_key&limit=20` - ) - const data: { docs: OpenLibraryDoc[] } = await response.json() - setTitleResults(data.docs.sort((a, b) => a.ratings_count - b.ratings_count) || []) - } catch (fetchError) { - console.error("Error fetching data:", fetchError) - } - } - - // Debounce the API call by setting a timer - const debounceTimer = setTimeout(() => { - if (titleInput.length >= 6 || isbnInput.length >= 8) { - // Adjust this condition as needed - fetchResults() - } else { - setTitleResults([]) - } - }, 300) // Delay in milliseconds - - return () => clearTimeout(debounceTimer) // Cleanup the timer - }, [titleInput, isbnInput]) + const { latitude, longitude } = useLocation() - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - author: "", - title: "", - isbn: "", - }, - }) - - // Helper to get cookie function getCookie(name: string) { + if (typeof document === "undefined") return null const value = `; ${document.cookie}` const parts = value.split(`; ${name}=`) if (parts.length === 2) return parts.pop()?.split(";").shift() + return null } useEffect(() => { @@ -101,10 +35,22 @@ export default function GeneratePage() { } }, []) - async function onSubmit() { - setIsLoading(true) + async function handleGenerate() { + if (!selectedBook) return + + if (!latitude || !longitude) { + toast({ + title: "Location Required", + description: "We need your location to generate a code.", + variant: "destructive", + }) + return + } + + setIsGenerating(true) try { - const anonymousId = getCookie("lfl_anonymous_id") + // Get anonymous ID if exists + const anonymousId = getCookie("anonymousId") const response = await fetch("/api/books/generate", { method: "POST", headers: { @@ -113,31 +59,32 @@ export default function GeneratePage() { body: JSON.stringify({ book: selectedBook, location: { - lat: latitude ? latitude : 0, - long: longitude ? longitude : 0, + lat: latitude, + long: longitude, }, anonymousId, - } as GenerateBookCodeRequest), + }), }) if (!response.ok) { - throw new Error("Failed to record hit") + throw new Error("Failed to generate code") } const { code } = await response.json() setGeneratedCode(code) toast({ title: "Success", - description: "ID Generated Successfully", + description: "Book code generated successfully!", }) } catch (error) { + console.error(error) toast({ title: "Error", description: "Failed to generate ID. Please try again.", variant: "destructive", }) } finally { - setIsLoading(false) + setIsGenerating(false) } } @@ -147,120 +94,110 @@ export default function GeneratePage() { } return ( -
- - - Generate Book Code - - -
- - ( - - Title - -
- { - setTitleInput(e.target.value) - field.onChange(e) - }} - onFocus={(e) => { - setTitleInput(e.target.value) - }} - /> - { - setTitleResults([]) - setSelectedBook(book) - }} - /> -
-
- -
- )} - /> - ( - - ISBN - -
- { - setIsbnInput(e.target.value) - field.onChange(e) - }} - onFocus={(e) => { - setTitleInput(e.target.value) - }} - /> - { - setTitleResults([]) - setSelectedBook(book) - }} + <> +
+ +
+
+
+

+ Start a Journey +

+

+ Search for your book, generate a unique tracking code, and write it on the inside + cover before releasing it into the wild. +

+
+ + + {!generatedCode ? ( +
+
+

+ Select Your Book +

+ { + setSelectedBook(book) + }} + /> +
+ + {selectedBook && ( +
+
+ {selectedBook.coverUrl && ( + {selectedBook.title} + )} +
+

{selectedBook.title}

+

{selectedBook.authors?.join(", ")}

- - - +
+ +
+ +
+
)} - /> - - - - - {selectedBook && ( - - - Selected Book - - -
-
-
-

{selectedBook.title}

-

- {selectedBook.author_name?.join(", ")} +

+ ) : ( +
+
+

+ Your Book is Ready! +

+

+ Write this code clearly on the inside cover of your book:

+
- {selectedBook.isbn && ( -

ISBN: {selectedBook.isbn[0]}

- )} +
+

+ {formatCode(generatedCode)} +

- { - // Display the book cover - selectedBook.cover_edition_key && ( - - ) - } + +
- -
- - - )} - {generatedCode && ( -
-

Generated Code:

-

{formatCode(generatedCode)}

+ )} +
- )} -
+
+ ) } diff --git a/app/layout.tsx b/app/layout.tsx index b5a05b4..8b33a23 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -28,7 +28,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo const cookieStore = await cookies() // Ensure we use the ANON key for client operations to respect RLS const supabase = createServerComponentClient( - // @ts-expect-error - The library expects a Promise based on types but the implementation is synchronous + // @ts-expect-error - @supabase/auth-helpers-nextjs types are outdated for Next.js 15+ async cookies, but runtime expects sync object { cookies: () => cookieStore }, { supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL, diff --git a/components/google-book-search.tsx b/components/google-book-search.tsx index ca7f27a..ea5ff81 100644 --- a/components/google-book-search.tsx +++ b/components/google-book-search.tsx @@ -19,7 +19,7 @@ interface GoogleBook { } interface GoogleBookSearchProps { - onSelect?: (book: { title: string; coverUrl?: string }) => void + onSelect?: (book: { title: string; authors?: string[]; coverUrl?: string }) => void } export function GoogleBookSearch({ onSelect }: GoogleBookSearchProps) { @@ -65,6 +65,7 @@ export function GoogleBookSearch({ onSelect }: GoogleBookSearchProps) { const handleSelect = (book: GoogleBook) => { const payload = { title: book.volumeInfo.title, + authors: book.volumeInfo.authors, coverUrl: book.volumeInfo.imageLinks?.thumbnail, } diff --git a/jest.config.js b/jest.config.js index 21eefe0..0714cab 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,11 +7,13 @@ const createJestConfig = nextJest({ // Add any custom config to be passed to Jest const customJestConfig = { + setupFiles: ["/jest.setup.js"], setupFilesAfterEnv: ["/jest.setup.js"], testEnvironment: "jest-environment-jsdom", moduleNameMapper: { "^@/(.*)$": "/$1", }, + transformIgnorePatterns: ["node_modules/(?!(jose|@supabase|uuid)/)"], } // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async diff --git a/jest.setup.js b/jest.setup.js index ae6a735..a15ce29 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,113 +1,30 @@ -import "@testing-library/jest-dom" -import "whatwg-fetch" +// Mock Supabase environment variables +process.env.NEXT_PUBLIC_SUPABASE_URL = "https://mock.supabase.co" +process.env.SUPABASE_SERVICE_ROLE_KEY = "mock-key" +process.env.DB_KEY = "mock-key" -// Polyfill for Web APIs needed by Next.js in test environment -if (typeof global.Headers === "undefined") { - global.Headers = class Headers { - constructor(init) { - this._headers = new Map() - if (init) { - if (init instanceof Headers) { - init.forEach((value, key) => this._headers.set(key, value)) - } else if (Array.isArray(init)) { - init.forEach(([key, value]) => this._headers.set(key, value)) - } else if (typeof init === "object") { - Object.entries(init).forEach(([key, value]) => this._headers.set(key, value)) - } - } - } - get(name) { - return this._headers.get(name) || null - } - set(name, value) { - this._headers.set(name, value) - } - has(name) { - return this._headers.has(name) - } - delete(name) { - this._headers.delete(name) - } - append(name, value) { - const existing = this._headers.get(name) - // Set-Cookie headers should not be concatenated - if (name.toLowerCase() === "set-cookie") { - // For set-cookie, store as array - const cookies = existing ? (Array.isArray(existing) ? existing : [existing]) : [] - cookies.push(value) - this._headers.set(name, cookies) - } else if (existing) { - this._headers.set(name, `${existing}, ${value}`) - } else { - this._headers.set(name, value) - } - } - forEach(callback, thisArg) { - this._headers.forEach((value, key) => { - callback.call(thisArg, value, key, this) - }) - } - keys() { - return this._headers.keys() - } - values() { - return this._headers.values() - } - entries() { - return this._headers.entries() - } - getSetCookie() { - // Return array of set-cookie values - const setCookie = this._headers.get("set-cookie") - if (!setCookie) return [] - return Array.isArray(setCookie) ? setCookie : [setCookie] +// Mock global Request if missing +if (typeof global.Request === "undefined") { + global.Request = class Request { + constructor(input, init) { + return { url: input, input, init, json: () => Promise.resolve({}) } } } } if (typeof global.Response === "undefined") { global.Response = class Response { - constructor(body, init = {}) { - this.body = body - this.status = init.status || 200 - this.statusText = init.statusText || "" - this.headers = new global.Headers(init.headers) - this.ok = this.status >= 200 && this.status < 300 - } - - async json() { - return JSON.parse(this.body) - } - - async text() { - return this.body - } - } -} - -if (!Response.json) { - Response.json = function (data, init) { - return new Response(JSON.stringify(data), { - ...init, - headers: { - "Content-Type": "application/json", - ...(init && init.headers), - }, - }) - } -} - -if (typeof global.Request === "undefined") { - global.Request = class Request { - constructor(input, init = {}) { - this.url = input - this.method = init.method || "GET" - this.headers = new global.Headers(init.headers) - this.body = init.body - } - - async json() { - return JSON.parse(this.body) + constructor(body, init) { + return { + body, + init, + ok: true, + status: init?.status || 200, + headers: { + get: (key) => init?.headers?.[key] || null, + }, + json: () => Promise.resolve(body), + } } } } diff --git a/lib/book-utils.ts b/lib/book-utils.ts new file mode 100644 index 0000000..281877c --- /dev/null +++ b/lib/book-utils.ts @@ -0,0 +1,64 @@ +import { BookMetadata } from "./types" +import { OpenLibraryDoc, getBookCover } from "./openLibrary" + +/** + * Helper to parse metadata from different book sources (Google vs OpenLibrary) + */ +export function parseBookMetadata(bookData: BookMetadata) { + let title = "Unknown Title" + let author = "Unknown Author" + let cover_url: string | null = null + let isbn: string | null = null + + if (!bookData) { + return { title, author, cover_url, isbn } + } + + // Title + if ("title" in bookData && bookData.title) { + title = bookData.title + } + + // Author + // Google Books: authors[] + if ("authors" in bookData && Array.isArray(bookData.authors) && bookData.authors.length > 0) { + author = bookData.authors.join(", ") + } + // OpenLibrary: author_name[] + else if ( + "author_name" in bookData && + Array.isArray(bookData.author_name) && + bookData.author_name.length > 0 + ) { + author = bookData.author_name[0] + } + // Generic fallback if author is string (legacy/stub) + else if ("author" in bookData && typeof (bookData as any).author === "string") { + author = (bookData as any).author + } + + // Cover + if ("coverUrl" in bookData && bookData.coverUrl) { + cover_url = bookData.coverUrl + } else if ("cover_url" in bookData && bookData.cover_url) { + cover_url = (bookData as any).cover_url + } + + // OpenLibrary Logic (using getBookCover helper which expects OpenLibraryDoc) + if (!cover_url && "cover_edition_key" in bookData) { + // Cast is safe here because we checked for cover_edition_key presence which implies OpenLibraryDoc structure + cover_url = getBookCover(bookData as OpenLibraryDoc) || null + } + + // ISBN + // Google Books: isbn (string) + if ("isbn" in bookData && typeof bookData.isbn === "string") { + isbn = bookData.isbn + } + // OpenLibrary: isbn[] + else if ("isbn" in bookData && Array.isArray(bookData.isbn) && bookData.isbn.length > 0) { + isbn = bookData.isbn[0] + } + + return { title, author, cover_url, isbn } +} diff --git a/lib/types.ts b/lib/types.ts index 959e44a..9582a79 100755 --- a/lib/types.ts +++ b/lib/types.ts @@ -28,8 +28,20 @@ export type Sighting = { } } +// Google Books API / Component format +export type GoogleBookData = { + title: string + authors?: string[] + coverUrl?: string + isbn?: string + googleId?: string +} + +// Union type for the API input +export type BookMetadata = OpenLibraryDoc | GoogleBookData + export type GenerateBookCodeRequest = { - book: OpenLibraryDoc + book: BookMetadata location: { lat: string | number; long: string | number } anonymousId?: string }