From 8fa1e4340f541ee791c179aff3952de2b754c53a Mon Sep 17 00:00:00 2001 From: Christopher Lates Date: Thu, 18 Dec 2025 09:57:56 -0500 Subject: [PATCH 1/8] feat: Replace OpenLibrary autocomplete with Google Books search for book generation and enhance metadata parsing in the API. --- app/api/books/generate/route.ts | 34 ++- app/generate/page.tsx | 387 +++++++++++++----------------- app/layout.tsx | 3 +- components/google-book-search.tsx | 3 +- next-env.d.ts | 3 +- tsconfig.json | 2 +- 6 files changed, 199 insertions(+), 233 deletions(-) diff --git a/app/api/books/generate/route.ts b/app/api/books/generate/route.ts index 104bcaf..7c5caad 100644 --- a/app/api/books/generate/route.ts +++ b/app/api/books/generate/route.ts @@ -40,9 +40,29 @@ 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 + // 4. Prepare Metadata + // The 'book' object might be an OpenLibraryDoc or a generic object from GoogleBookSearch + const bookData = book as any; + + const title = bookData.title || "Unknown Title"; + + // Handle authors: could be 'authors' (Google) or 'author_name' (OpenLibrary) or just 'author' + let author = "Unknown Author"; + if (Array.isArray(bookData.authors) && bookData.authors.length > 0) { + author = bookData.authors.join(", "); + } else if (Array.isArray(bookData.author_name) && bookData.author_name.length > 0) { + author = bookData.author_name[0]; // Keep first author for consistency with old behavior + } else if (typeof bookData.author === 'string') { + author = bookData.author; + } + + // Handle cover: use direct url or generate from OLID + let cover_link = bookData.coverUrl || bookData.cover_url; + if (!cover_link && bookData.cover_edition_key) { + cover_link = getBookCover(bookData); + } + + const isbn = bookData.isbn ? (Array.isArray(bookData.isbn) ? bookData.isbn[0] : bookData.isbn) : null; // 5. Persist Book const { data, error } = await adminSupabase @@ -51,11 +71,11 @@ 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, - location: `${lat},${long}`, + cover_url: cover_link, + location: `${lat},${long}` }) .select() .single() diff --git a/app/generate/page.tsx b/app/generate/page.tsx index 13451a8..bbe8968 100644 --- a/app/generate/page.tsx +++ b/app/generate/page.tsx @@ -1,110 +1,57 @@ -"use client" +"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 { 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 { useState, useEffect } from "react"; +import { ParchmentFrame } from "@/components/ui/parchment-frame"; +import { GoogleBookSearch } from "@/components/google-book-search"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/hooks/use-toast"; +import useLocation from "@/hooks/use-location"; +import { GenerateBookCodeRequest } from "@/lib/types"; +import { Loader2 } from "lucide-react"; export default function GeneratePage() { - const [generatedCode, setGeneratedCode] = useState("") - const [isLoading, setIsLoading] = 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 form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - author: "", - title: "", - isbn: "", - }, - }) + const [selectedBook, setSelectedBook] = useState<{ + title: string; + authors?: string[]; + coverUrl?: string; + } | null>(null); + + const [generatedCode, setGeneratedCode] = useState(""); + const [isGenerating, setIsGenerating] = useState(false); + const { toast } = useToast(); + const { latitude, longitude, error: locationError } = useLocation(); - // Helper to get cookie + // Helper to get cookie (reused from original) function getCookie(name: string) { - const value = `; ${document.cookie}` - const parts = value.split(`; ${name}=`) - if (parts.length === 2) return parts.pop()?.split(";").shift() + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop()?.split(';').shift(); } useEffect(() => { if (!getCookie("lfl_anonymous_id")) { - const newId = crypto.randomUUID() + const newId = crypto.randomUUID(); // Set cookie for 1 year - document.cookie = `lfl_anonymous_id=${newId}; path=/; max-age=31536000; SameSite=Lax; Secure` + document.cookie = `lfl_anonymous_id=${newId}; path=/; max-age=31536000; SameSite=Lax; Secure`; } - }, []) + }, []); - async function onSubmit() { - setIsLoading(true) + async function handleGenerate() { + if (!selectedBook) return; + + // Basic location check - navigator.geolocation is also checked by useLocation hook + 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") + const anonymousId = getCookie("lfl_anonymous_id"); const response = await fetch("/api/books/generate", { method: "POST", headers: { @@ -113,154 +60,154 @@ 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) + 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); } } // Format the code into ###-###-### format function formatCode(code: string) { - return `${code.slice(0, 4)}-${code.slice(4, 6)}-${code.slice(6, 9)}`.toUpperCase() + return `${code.slice(0, 4)}-${code.slice(4, 6)}-${code.slice(6, 9)}`.toUpperCase(); } 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) - }} - /> -
-
- -
- )} - /> - - -
-
- {selectedBook && ( - - - Selected Book - - -
-
-
-

{selectedBook.title}

-

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

- - {selectedBook.isbn && ( -

ISBN: {selectedBook.isbn[0]}

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

+ 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 && ( -
-

Generated Code:

-

{formatCode(generatedCode)}

+ + + {!generatedCode ? ( +
+
+

Select Your Book

+ { + setSelectedBook(book); + // Reset generated code if they search again? + // Actually keeping it simple: just select. + }} + /> +
+ + {selectedBook && ( +
+ {selectedBook.coverUrl ? ( +
+ {selectedBook.title} +
+ ) : ( +
+ No Cover +
+ )} + +
+
+

{selectedBook.title}

+

+ {selectedBook.authors?.join(", ") || "Unknown Author"} +

+
+ +
+ + {!latitude && ( +

+ Location access is required to generate a code. +

+ )} +
+
+
+ )} +
+ ) : ( +
+
+

Identity Assigned

+

Please write this code clearly on the inside cover.

+
+ +
+

+ {formatCode(generatedCode)} +

+
+ + +
+ )} +
- )} -
- ) +
+ + ); } diff --git a/app/layout.tsx b/app/layout.tsx index b5a05b4..c3943f6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -28,7 +28,6 @@ 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 { cookies: () => cookieStore }, { supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL, @@ -49,7 +48,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo return ( - + 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/next-env.d.ts b/next-env.d.ts index 7a70f65..4f11a03 100755 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,5 @@ /// /// -import "./.next/types/routes.d.ts" // NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/tsconfig.json b/tsconfig.json index b09cc80..7ec35fb 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "react-jsx", + "jsx": "preserve", "incremental": true, "plugins": [ { From 9121e3e83f3c34a9812fd346232f006c641be12c Mon Sep 17 00:00:00 2001 From: Christopher Lates Date: Fri, 19 Dec 2025 20:56:37 -0500 Subject: [PATCH 2/8] next stuff. --- next-env.d.ts | 3 ++- tsconfig.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/next-env.d.ts b/next-env.d.ts index 4f11a03..7506fe6 100755 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +import "./.next/dev/types/routes.d.ts" // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/tsconfig.json b/tsconfig.json index 7ec35fb..b09cc80 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { From 6fad924590c338906d936774ae23bef9b6f32c50 Mon Sep 17 00:00:00 2001 From: Christopher Lates Date: Fri, 19 Dec 2025 21:02:59 -0500 Subject: [PATCH 3/8] buildfix --- app/layout.tsx | 2 +- next-env.d.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index c3943f6..86aad39 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( - { cookies: () => cookieStore }, + { cookies: () => Promise.resolve(cookieStore) }, { supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL, supabaseKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, diff --git a/next-env.d.ts b/next-env.d.ts index 7506fe6..7a70f65 100755 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts" +import "./.next/types/routes.d.ts" // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 3ea9e22c35757e49afb470db7b7f1fff6ee41729 Mon Sep 17 00:00:00 2001 From: Christopher Lates Date: Fri, 19 Dec 2025 21:16:53 -0500 Subject: [PATCH 4/8] fix build --- .gitignore | 1 + app/layout.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) 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/app/layout.tsx b/app/layout.tsx index 86aad39..a432013 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -28,7 +28,8 @@ 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( - { cookies: () => Promise.resolve(cookieStore) }, + // @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, supabaseKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, From ca9c0c71d65bd2136e866edb1b9da265d72e6fd5 Mon Sep 17 00:00:00 2001 From: Christopher Lates Date: Fri, 19 Dec 2025 21:27:27 -0500 Subject: [PATCH 5/8] pr comments --- .../app/api/books/generate/route.test.ts | 70 +++++ app/api/books/generate/route.ts | 36 +-- app/generate/page.tsx | 294 +++++++++--------- jest.config.js | 2 + jest.setup.js | 114 +------ lib/book-utils.ts | 64 ++++ lib/types.ts | 14 +- 7 files changed, 321 insertions(+), 273 deletions(-) create mode 100644 __tests__/app/api/books/generate/route.test.ts create mode 100644 lib/book-utils.ts 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 7c5caad..237668e 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,29 +42,17 @@ export async function POST(request: Request) { }) // 4. Prepare Metadata - // 4. Prepare Metadata - // The 'book' object might be an OpenLibraryDoc or a generic object from GoogleBookSearch - const bookData = book as any; - - const title = bookData.title || "Unknown Title"; - - // Handle authors: could be 'authors' (Google) or 'author_name' (OpenLibrary) or just 'author' - let author = "Unknown Author"; - if (Array.isArray(bookData.authors) && bookData.authors.length > 0) { - author = bookData.authors.join(", "); - } else if (Array.isArray(bookData.author_name) && bookData.author_name.length > 0) { - author = bookData.author_name[0]; // Keep first author for consistency with old behavior - } else if (typeof bookData.author === 'string') { - author = bookData.author; - } + // The 'book' object might be an OpenLibraryDoc or a GoogleBookData object + const bookData = book as BookMetadata - // Handle cover: use direct url or generate from OLID - let cover_link = bookData.coverUrl || bookData.cover_url; - if (!cover_link && bookData.cover_edition_key) { - cover_link = getBookCover(bookData); - } + // Parse metadata using helper to handle different formats safely + const { title, author, cover_url: cover_link, isbn } = parseBookMetadata(bookData) - const isbn = bookData.isbn ? (Array.isArray(bookData.isbn) ? bookData.isbn[0] : bookData.isbn) : null; + if (!title || title === "Unknown Title") { + console.warn("Attempted to generate book with no title", bookData) + // We can iterate on this: define if text "Unknown Title" is acceptable. + // For now, allow it but log it. If stringent validation is needed, throw error. + } // 5. Persist Book const { data, error } = await adminSupabase @@ -75,7 +65,7 @@ export async function POST(request: Request) { author: author, isbn: isbn, cover_url: cover_link, - location: `${lat},${long}` + location: `${lat},${long}`, }) .select() .single() @@ -111,3 +101,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 bbe8968..27823da 100644 --- a/app/generate/page.tsx +++ b/app/generate/page.tsx @@ -1,57 +1,51 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { ParchmentFrame } from "@/components/ui/parchment-frame"; -import { GoogleBookSearch } from "@/components/google-book-search"; -import { Button } from "@/components/ui/button"; -import { useToast } from "@/hooks/use-toast"; -import useLocation from "@/hooks/use-location"; -import { GenerateBookCodeRequest } from "@/lib/types"; -import { Loader2 } from "lucide-react"; +import { useState, useEffect } from "react" +import { ParchmentFrame } from "@/components/ui/parchment-frame" +import { GoogleBookSearch } from "@/components/google-book-search" +import { Button } from "@/components/ui/button" +import { useToast } from "@/hooks/use-toast" +import useLocation from "@/hooks/use-location" +import { GoogleBookData } from "@/lib/types" +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 [isGenerating, setIsGenerating] = useState(false); - const { toast } = useToast(); - const { latitude, longitude, error: locationError } = useLocation(); - - // Helper to get cookie (reused from original) + const [selectedBook, setSelectedBook] = useState(null) + + const [generatedCode, setGeneratedCode] = useState("") + const [isGenerating, setIsGenerating] = useState(false) + const { toast } = useToast() + const { latitude, longitude } = useLocation() + + // Helper to get cookie function getCookie(name: string) { - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) return parts.pop()?.split(';').shift(); + const value = `; ${document.cookie}` + const parts = value.split(`; ${name}=`) + if (parts.length === 2) return parts.pop()?.split(";").shift() } useEffect(() => { if (!getCookie("lfl_anonymous_id")) { - const newId = crypto.randomUUID(); + const newId = crypto.randomUUID() // Set cookie for 1 year - document.cookie = `lfl_anonymous_id=${newId}; path=/; max-age=31536000; SameSite=Lax; Secure`; + document.cookie = `lfl_anonymous_id=${newId}; path=/; max-age=31536000; SameSite=Lax; Secure` } - }, []); + }, []) async function handleGenerate() { - if (!selectedBook) return; - + if (!selectedBook) return + // Basic location check - navigator.geolocation is also checked by useLocation hook if (!latitude || !longitude) { - toast({ - title: "Location Required", - description: "We need your location to generate a code.", - variant: "destructive" - }) - return; + toast({ + title: "Location Required", + description: "We need your location to generate a code.", + variant: "destructive", + }) + return } - setIsGenerating(true); + setIsGenerating(true) try { - const anonymousId = getCookie("lfl_anonymous_id"); + const anonymousId = getCookie("lfl_anonymous_id") const response = await fetch("/api/books/generate", { method: "POST", headers: { @@ -65,33 +59,33 @@ export default function GeneratePage() { }, anonymousId, }), - }); + }) if (!response.ok) { - throw new Error("Failed to generate code"); + throw new Error("Failed to generate code") } - const { code } = await response.json(); - setGeneratedCode(code); + const { code } = await response.json() + setGeneratedCode(code) toast({ title: "Success", description: "Book code generated successfully!", - }); + }) } catch (error) { - console.error(error); + console.error(error) toast({ title: "Error", description: "Failed to generate ID. Please try again.", variant: "destructive", - }); + }) } finally { - setIsGenerating(false); + setIsGenerating(false) } } // Format the code into ###-###-### format function formatCode(code: string) { - return `${code.slice(0, 4)}-${code.slice(4, 6)}-${code.slice(6, 9)}`.toUpperCase(); + return `${code.slice(0, 4)}-${code.slice(4, 6)}-${code.slice(6, 9)}`.toUpperCase() } return ( @@ -100,114 +94,120 @@ export default function GeneratePage() { className="fixed inset-0 -z-10 opacity-25 blur-[1px]" style={{ backgroundImage: "url('/images/background/lfl-background-edited.png')", - backgroundSize: 'cover', - backgroundPosition: 'center', + backgroundSize: "cover", + backgroundPosition: "center", }} /> - +
- -
-

- 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); - // Reset generated code if they search again? - // Actually keeping it simple: just select. - }} - /> -
- - {selectedBook && ( -
- {selectedBook.coverUrl ? ( -
- {selectedBook.title} -
- ) : ( -
- No Cover -
- )} - -
-
-

{selectedBook.title}

-

- {selectedBook.authors?.join(", ") || "Unknown Author"} -

-
- -
- - {!latitude && ( -

- Location access is required to generate a code. -

- )} -
-
-
+
+

+ 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} +
+ ) : ( +
+ + No Cover + +
+ )} + +
+
+

+ {selectedBook.title} +

+

+ {selectedBook.authors?.join(", ") || "Unknown Author"} +

+
+ +
+ + {!latitude && ( +

+ Location access is required to generate a code. +

)} +
- ) : ( -
-
-

Identity Assigned

-

Please write this code clearly on the inside cover.

-
- -
-

- {formatCode(generatedCode)} -

-
- - -
+
)} - +
+ ) : ( +
+
+

Identity Assigned

+

+ Please write this code clearly on the inside cover. +

+
+ +
+

+ {formatCode(generatedCode)} +

+
+ + +
+ )} +
- ); + ) } 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..916568f 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,113 +1,21 @@ -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 { 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, json: () => Promise.resolve(body) } } } } diff --git a/lib/book-utils.ts b/lib/book-utils.ts new file mode 100644 index 0000000..0f5e18d --- /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.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 } From 837a03f7acbeda093a9e38412d748abaf284bbba Mon Sep 17 00:00:00 2001 From: Christopher Lates Date: Fri, 19 Dec 2025 21:31:51 -0500 Subject: [PATCH 6/8] fixbuild --- app/generate/page.tsx | 2 ++ lib/book-utils.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/generate/page.tsx b/app/generate/page.tsx index 27823da..d223c42 100644 --- a/app/generate/page.tsx +++ b/app/generate/page.tsx @@ -1,3 +1,5 @@ +"use client" + import { useState, useEffect } from "react" import { ParchmentFrame } from "@/components/ui/parchment-frame" import { GoogleBookSearch } from "@/components/google-book-search" diff --git a/lib/book-utils.ts b/lib/book-utils.ts index 0f5e18d..281877c 100644 --- a/lib/book-utils.ts +++ b/lib/book-utils.ts @@ -41,7 +41,7 @@ export function parseBookMetadata(bookData: BookMetadata) { if ("coverUrl" in bookData && bookData.coverUrl) { cover_url = bookData.coverUrl } else if ("cover_url" in bookData && bookData.cover_url) { - cover_url = bookData.cover_url + cover_url = (bookData as any).cover_url } // OpenLibrary Logic (using getBookCover helper which expects OpenLibraryDoc) From ed2a52328f3bb8a295e16f042be6c5ae8e0ea504 Mon Sep 17 00:00:00 2001 From: Christopher Lates Date: Fri, 19 Dec 2025 21:37:10 -0500 Subject: [PATCH 7/8] fix test --- jest.setup.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/jest.setup.js b/jest.setup.js index 916568f..a15ce29 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -7,7 +7,7 @@ process.env.DB_KEY = "mock-key" if (typeof global.Request === "undefined") { global.Request = class Request { constructor(input, init) { - return { input, init, json: () => Promise.resolve({}) } + return { url: input, input, init, json: () => Promise.resolve({}) } } } } @@ -15,7 +15,16 @@ if (typeof global.Request === "undefined") { if (typeof global.Response === "undefined") { global.Response = class Response { constructor(body, init) { - return { body, init, ok: true, json: () => Promise.resolve(body) } + return { + body, + init, + ok: true, + status: init?.status || 200, + headers: { + get: (key) => init?.headers?.[key] || null, + }, + json: () => Promise.resolve(body), + } } } } From 618d29ceb92833976f49631b0ef6921e23c36453 Mon Sep 17 00:00:00 2001 From: Christopher Lates Date: Fri, 19 Dec 2025 22:06:11 -0500 Subject: [PATCH 8/8] pr comments. --- app/api/books/generate/route.ts | 5 +- app/generate/page.tsx | 106 ++++++++++++++------------------ app/layout.tsx | 2 +- 3 files changed, 50 insertions(+), 63 deletions(-) diff --git a/app/api/books/generate/route.ts b/app/api/books/generate/route.ts index 237668e..5f0586e 100644 --- a/app/api/books/generate/route.ts +++ b/app/api/books/generate/route.ts @@ -49,9 +49,8 @@ export async function POST(request: Request) { const { title, author, cover_url: cover_link, isbn } = parseBookMetadata(bookData) if (!title || title === "Unknown Title") { - console.warn("Attempted to generate book with no title", bookData) - // We can iterate on this: define if text "Unknown Title" is acceptable. - // For now, allow it but log it. If stringent validation is needed, throw error. + console.error("Attempted to generate book with no title", bookData) + return NextResponse.json({ error: "Book title is required" }, { status: 400 }) } // 5. Persist Book diff --git a/app/generate/page.tsx b/app/generate/page.tsx index d223c42..4cf5835 100644 --- a/app/generate/page.tsx +++ b/app/generate/page.tsx @@ -3,25 +3,28 @@ import { useState, useEffect } from "react" import { ParchmentFrame } from "@/components/ui/parchment-frame" import { GoogleBookSearch } from "@/components/google-book-search" -import { Button } from "@/components/ui/button" import { useToast } from "@/hooks/use-toast" import useLocation from "@/hooks/use-location" -import { GoogleBookData } from "@/lib/types" import { Loader2 } from "lucide-react" export default function GeneratePage() { - const [selectedBook, setSelectedBook] = useState(null) + const [selectedBook, setSelectedBook] = useState<{ + title: string + authors?: string[] + coverUrl?: string + } | null>(null) const [generatedCode, setGeneratedCode] = useState("") const [isGenerating, setIsGenerating] = useState(false) const { toast } = useToast() const { latitude, longitude } = useLocation() - // 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(() => { @@ -35,7 +38,6 @@ export default function GeneratePage() { async function handleGenerate() { if (!selectedBook) return - // Basic location check - navigator.geolocation is also checked by useLocation hook if (!latitude || !longitude) { toast({ title: "Location Required", @@ -47,7 +49,8 @@ export default function GeneratePage() { 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: { @@ -128,83 +131,68 @@ export default function GeneratePage() {
{selectedBook && ( -
- {selectedBook.coverUrl ? ( -
+
+
+ {selectedBook.coverUrl && ( {selectedBook.title} + )} +
+

{selectedBook.title}

+

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

- ) : ( -
- - No Cover - -
- )} - -
-
-

- {selectedBook.title} -

-

- {selectedBook.authors?.join(", ") || "Unknown Author"} -

-
+
-
- - {!latitude && ( -

- Location access is required to generate a code. -

+
+
+
)}
) : ( -
-
-

Identity Assigned

-

- Please write this code clearly on the inside cover. +

+
+

+ Your Book is Ready! +

+

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

-
-

+

+

{formatCode(generatedCode)}

- + Register another book +
)} diff --git a/app/layout.tsx b/app/layout.tsx index a432013..8b33a23 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -49,7 +49,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo return ( - +