Skip to content

Conversation

@pranavjana
Copy link
Contributor

lego-hunter

Live Demo: https://lego-hunter.vercel.app/

Overview

Live Demo: https://lego-hunter.vercel.app/

The Lego Restock Hunter is a powerful inventory search tool designed to find rare or sold-out Lego sets across 15+ global retailers simultaneously. It uses AI to discover the best retailers for a specific set, deploys parallel Mino browser agents to check stock and pricing, and finishes with a Gemini-powered analysis to recommend the single best deal (balancing price and shipping).

Mino API Integration

This use case demonstrates Mino API usage for browser automation.

Tech Stack

  • Next.js (TypeScript)
  • Mino API
  • AI

Contributor: Pranav Janakiraman (@pranavjana)

- ## Demo
- Live demo: https://lego-hunter.vercel.app/
- Contributor: Pranav Janakiraman (@pranavjana)
@coderabbitai
Copy link

coderabbitai bot commented Jan 25, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a complete new Next.js application for the Lego Restock Hunter—a multi-retailer stock search and deal analysis tool. The application includes API routes for URL generation and parallel retailer scraping, a React frontend for user interaction and results display, comprehensive global styling with Lego-themed CSS, TypeScript type definitions, utility libraries for Mino API integration and Gemini AI interactions, and reusable UI components. Supporting configuration files for Next.js, TypeScript, ESLint, PostCSS, and npm dependencies are also included, along with documentation in the README describing the project's functionality and architecture.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client Browser
    participant API as /api/generate-urls
    participant Gemini as Gemini API

    Client->>API: POST { legoSetName }
    API->>Gemini: Generate retailer URLs for set
    Gemini-->>API: Return 15 retailer search URLs
    API-->>Client: { retailers: [Retailer...] }
Loading
sequenceDiagram
    participant Client as Client Browser
    participant SearchAPI as /api/search-lego
    participant Mino as Mino API
    participant Retailers as Multiple Retailers
    participant Gemini as Gemini API
    
    Client->>SearchAPI: POST { legoSetName, maxBudget, retailers }
    SearchAPI->>SearchAPI: Create SSE stream
    SearchAPI-->>Client: Streaming response (ReadableStream)
    
    par Parallel Retailer Tasks
        SearchAPI->>Mino: scrapeRetailer(retailer1)
        SearchAPI->>Mino: scrapeRetailer(retailer2)
        SearchAPI->>Mino: scrapeRetailer(retailer3)
    end
    
    par SSE Events from Mino
        Mino->>Retailers: Browser automation tasks
        Retailers-->>Mino: Product data & screenshots
        Mino-->>SearchAPI: STEP/COMPLETION events
        SearchAPI-->>Client: retailer_start, retailer_step, retailer_stock_found events
    end
    
    SearchAPI->>Gemini: analyzeBestDeal(results, maxBudget)
    Gemini-->>SearchAPI: DealAnalysis { bestRetailer, reason, savings }
    SearchAPI-->>Client: analysis_complete event
    SearchAPI->>SearchAPI: Close SSE stream
Loading
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add lego-hunter - Mino Use Case' clearly and concisely describes the main change: adding a new Lego Restock Hunter project that demonstrates Mino API usage.
Description check ✅ Passed The description is directly related to the changeset, providing context about the Lego Restock Hunter project, its functionality, tech stack, and demonstrating Mino API integration as intended.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🤖 Fix all issues with AI agents
In `@lego-hunter/app/api/search-lego/route.ts`:
- Around line 10-19: Wrap the request.json() call inside a try/catch in the POST
function to return a 400 Response.json with a clear parse error when JSON is
malformed (catch any exception from request.json()); after parsing, validate
maxBudget from the body (the maxBudget variable) to ensure it is either
undefined/null or a finite number greater than or equal to zero (otherwise
return a 400 Response.json with an appropriate error message); update any
early-return checks (the existing legoSetName and retailers validation) to run
after parsing and include the new maxBudget validation so downstream code that
uses maxBudget can assume it is a valid number.

In `@lego-hunter/app/page.tsx`:
- Around line 34-87: The SSE handler (handleSSEEvent) currently spreads
prev[event.retailer!] which can be undefined for unseen retailers; update each
retailer-related case (retailer_start, retailer_step, retailer_stock_found,
retailer_complete, retailer_error) to first ensure a base retailer object exists
(e.g., read const existing = prev[event.retailer!] || { status: 'idle', steps:
[], streamingUrl: undefined, stockFound: false, error: undefined, data:
undefined }) and then call setRetailers with [event.retailer!] merged into that
base so you never spread undefined and you always preserve/initialize fields
like steps, streamingUrl, stockFound, error, and data; apply this pattern inside
handleSSEEvent for all retailer_* branches and keep the other logic
(triggerLegoConfetti, setResults, etc.) unchanged.

In `@lego-hunter/components/best-deal-card.tsx`:
- Around line 8-36: The "Try Another Set" button in BestDealCard is a dead CTA;
update BestDealCard to accept a callback prop (e.g., onTryAnother: () => void)
or a navigation prop and wire the button's onClick to call that prop (or render
a Link/navigation action) so the button triggers an action instead of doing
nothing; update the BestDealCardProps interface to include the new prop and use
it in the JSX button (or replace the button with a Link) so consumers can
navigate or retry when no stock is found.
- Around line 79-88: The anchor currently uses bestProduct.productUrl directly
which can be untrusted; validate the URL before rendering an external link by
constructing a URL object from bestProduct.productUrl and ensuring url.protocol
is either "http:" or "https:" (if construction fails or protocol is invalid,
treat as unsafe). If valid, render the <a> with href, target and rel as now; if
invalid, render a non-clickable fallback (e.g., a button or span with the same
styling, no href/target/rel and aria-disabled) so users cannot be navigated to
unsafe schemes. Reference: bestProduct and productUrl in the best-deal-card
component and the BUILD YOUR SET anchor.

In `@lego-hunter/components/browser-preview.tsx`:
- Around line 44-54: Validate the streamingUrl before rendering the iframe:
inside the component where isInView and streamingUrl are used (the JSX block
that sets src={streamingUrl}, onLoad={() => setHasLoaded(true)}, and title using
retailerName), parse streamingUrl with the URL constructor (or equivalent) and
ensure its protocol is 'http:' or 'https:'; if the check fails, do not render
the iframe (render a fallback or null) to guard against non-http(s) schemes from
external sources and avoid loading unsafe content.

In `@lego-hunter/components/results-table.tsx`:
- Around line 93-118: Replace the clickable <th> elements for the "Retailer",
"Status", and "Price" headers with focusable button elements (inside the th)
that call handleSort('<key>') on click and key activation, and add aria-sort
attributes reflecting current sort state; ensure the ArrowUpDown icon remains
inside the button and that the button has an accessible label (e.g., "Sort by
Retailer") so keyboard users can focus and toggle sorting. Update the component
state/logic that determines sort direction so aria-sort is set to "ascending",
"descending", or "none" for each header and ensure handleSort is used unchanged
to perform sorting.
- Around line 125-179: The mapped results rendering in ResultsTable uses
result.productUrl directly when rendering the <a> link (inside the
sortedResults.map callback), which can expose non-http(s) schemes; validate each
productUrl before rendering by parsing it with new URL(...) (catching
exceptions) and only allow links with protocol 'http:' or 'https:'; for invalid
or unsafe URLs render the Notify/disabled button or a non-clickable element
instead of an anchor, and ensure any allowed href is the validated URL string so
only safe http(s) links are used in the anchor.

In `@lego-hunter/components/retailer-card.tsx`:
- Around line 104-115: Validate data.productUrl's scheme before rendering the
anchor: ensure it is a well-formed URL whose protocol is "http:" or "https:"
(use the URL constructor in a try/catch or a small isValidExternalUrl helper)
and only render the <a href={data.productUrl} ...>View Product</a> when that
check passes; if invalid, render a safe fallback (e.g., plain text/span for
data.productUrl or omit the link) so the ExternalLink icon and target=_blank are
not used with non-http(s) schemes.

In `@lego-hunter/lib/gemini-client.ts`:
- Around line 37-73: The code in generateRetailerUrls currently instantiates the
model with google('gemini-2.0-flash-exp'); update this to use the stable
recommended model name google('gemini-2.5-flash') instead, i.e., change the
model assignment in the generateRetailerUrls function (and any other places
using 'gemini-2.0-flash-exp') to 'gemini-2.5-flash' so the function uses the
non-experimental model going forward.

In `@lego-hunter/README.md`:
- Line 7: Update the README line that currently shows a bare URL by replacing it
with proper Markdown link syntax; change the "Live Demo:" line to use a labeled
link like [Live Demo](https://lego-hunter.vercel.app/) so the URL renders
consistently across Markdown processors.
- Around line 3-17: Remove the duplicate "## Demo" block that contains only the
placeholder "*[Demo video/screenshot to be added]*": locate the second "## Demo"
heading and the trailing separator and delete that entire section (the heading
and the placeholder text) so only the first Demo section with the image and live
demo link remains; ensure the top-level separators ("---") remain intact around
the kept content.
🧹 Nitpick comments (9)
lego-hunter/app/api/generate-urls/route.ts (1)

4-21: Consider distinguishing JSON parse errors from internal errors.

If the request body contains malformed JSON, request.json() throws and the catch block returns a 500 error. A 400 response would be more appropriate for client-side input errors.

Proposed improvement
 export async function POST(request: Request) {
   try {
-    const body: GenerateUrlsRequest = await request.json()
+    let body: GenerateUrlsRequest
+    try {
+      body = await request.json()
+    } catch {
+      return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
+    }
 
     if (!body.legoSetName) {
       return Response.json({ error: 'legoSetName is required' }, { status: 400 })
     }
 
     const retailers = await generateRetailerUrls(body.legoSetName)
 
     return Response.json({ retailers })
   } catch (error) {
     console.error('Error generating URLs:', error)
     return Response.json(
       { error: 'Failed to generate retailer URLs' },
       { status: 500 }
     )
   }
 }
lego-hunter/lib/retailers.ts (1)

101-107: Harden path-based search URL construction.
If a future retailer entry forgets the trailing slash, URLs will be malformed. Consider normalizing the separator.

♻️ Suggested fix
-  if (!retailer.searchQueryParam) {
-    return `${retailer.baseSearchUrl}${encodedTerm}`
-  }
+  if (!retailer.searchQueryParam) {
+    const base = retailer.baseSearchUrl.endsWith('/')
+      ? retailer.baseSearchUrl
+      : `${retailer.baseSearchUrl}/`
+    return `${base}${encodedTerm}`
+  }
lego-hunter/lib/gemini-client.ts (1)

129-137: Consider handling edge cases in price parsing.

The regex [^0-9.] will keep multiple decimal points (e.g., "1.2.3" → "1.2.3"), which would produce NaN and fall back to the original string. This is acceptable but worth noting.

lego-hunter/app/api/search-lego/route.ts (1)

131-167: Consider adding a timeout for the Mino API request.

The fetch call to the Mino API has no timeout configured. Long-running or stalled requests could block resources indefinitely.

♻️ Proposed fix using AbortController
+  const controller = new AbortController()
+  const timeout = setTimeout(() => controller.abort(), 120000) // 2 minute timeout
+
   const minoResponse = await fetch('https://mino.ai/v1/automation/run-sse', {
     method: 'POST',
     headers: {
       'X-API-Key': MINO_API_KEY,
       'Content-Type': 'application/json'
     },
     body: JSON.stringify({
       url: retailer.url,
       goal: `Search for "${legoSetName}" Lego set...`,
       browser_profile: 'lite'
-    })
+    }),
+    signal: controller.signal
   })
+
+  clearTimeout(timeout)
lego-hunter/lib/mino-client.ts (2)

166-172: Unsafe type cast for proxy country code.

The proxy string is cast directly to the union type without validation, which could allow invalid country codes.

♻️ Proposed fix with validation
+  const VALID_COUNTRY_CODES = ["US", "GB", "CA", "DE", "FR", "JP", "AU"] as const;
+  type CountryCode = typeof VALID_COUNTRY_CODES[number];
+
   if (options?.proxy) {
-    const countryCode = options.proxy as "US" | "GB" | "CA" | "DE" | "FR" | "JP" | "AU";
-    config.proxy_config = {
-      enabled: true,
-      country_code: countryCode,
-    };
+    const countryCode = options.proxy.toUpperCase();
+    if (VALID_COUNTRY_CODES.includes(countryCode as CountryCode)) {
+      config.proxy_config = {
+        enabled: true,
+        country_code: countryCode as CountryCode,
+      };
+    } else {
+      throw new Error(`Invalid proxy country code: ${options.proxy}. Valid codes: ${VALID_COUNTRY_CODES.join(", ")}`);
+    }
   }

147-185: Consider documenting the expected return type.

The scrape function returns Promise<unknown>, which is accurate but provides no type guidance to consumers. Consider documenting the expected shape or using a generic type parameter.

lego-hunter/app/page.tsx (3)

560-563: Currency field is ignored; price always displayed with $.

ProductData includes a currency field, but the price display hardcodes the $ symbol. Given that DEFAULT_RETAILERS includes UK-based stores (Smyths Toys, John Lewis, Argos) that may return prices in GBP, this could display misleading information.

♻️ Suggested fix
-                    <span className="font-black text-white text-lg">
-                      ${retailer.data.price}
-                    </span>
+                    <span className="font-black text-white text-lg">
+                      {retailer.data.currency === 'USD' ? '$' : retailer.data.currency}
+                      {retailer.data.price}
+                    </span>

676-678: "Notify Me" button has no functionality.

The button lacks an onClick handler, so clicking it does nothing. Consider adding a placeholder action or tooltip to set user expectations:

♻️ Suggested fix
-                    <button className="text-xs text-[var(--lego-gray-400)] hover:text-[var(--lego-gray-500)]">
+                    <button
+                      className="text-xs text-[var(--lego-gray-400)] hover:text-[var(--lego-gray-500)]"
+                      onClick={() => alert('Notification feature coming soon!')}
+                    >
                       Notify Me
                     </button>

628-688: Remove duplicate ResultsTable component.

The local ResultsTable function duplicates functionality from lego-hunter/components/results-table.tsx, which provides additional features including user-controlled sorting, an out-of-stock filter toggle, and improved UI. Replace the local implementation with the external component:

+import { ResultsTable } from '@/components/results-table'

Then remove the local ResultsTable function definition (lines 628-688).

Comment on lines +10 to +19
export async function POST(request: Request) {
const body: SearchLegoRequest = await request.json()
const { legoSetName, maxBudget, retailers } = body

if (!legoSetName || !retailers || retailers.length === 0) {
return Response.json(
{ error: 'legoSetName and retailers are required' },
{ status: 400 }
)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add error handling for JSON parsing and validate maxBudget.

request.json() can throw on malformed JSON. Additionally, maxBudget is used downstream but not validated.

🐛 Proposed fix
 export async function POST(request: Request) {
-  const body: SearchLegoRequest = await request.json()
-  const { legoSetName, maxBudget, retailers } = body
+  let body: SearchLegoRequest
+  try {
+    body = await request.json()
+  } catch {
+    return Response.json(
+      { error: 'Invalid JSON body' },
+      { status: 400 }
+    )
+  }
+  const { legoSetName, maxBudget, retailers } = body

-  if (!legoSetName || !retailers || retailers.length === 0) {
+  if (!legoSetName || !retailers || retailers.length === 0 || typeof maxBudget !== 'number') {
     return Response.json(
-      { error: 'legoSetName and retailers are required' },
+      { error: 'legoSetName, maxBudget (number), and retailers are required' },
       { status: 400 }
     )
   }
🤖 Prompt for AI Agents
In `@lego-hunter/app/api/search-lego/route.ts` around lines 10 - 19, Wrap the
request.json() call inside a try/catch in the POST function to return a 400
Response.json with a clear parse error when JSON is malformed (catch any
exception from request.json()); after parsing, validate maxBudget from the body
(the maxBudget variable) to ensure it is either undefined/null or a finite
number greater than or equal to zero (otherwise return a 400 Response.json with
an appropriate error message); update any early-return checks (the existing
legoSetName and retailers validation) to run after parsing and include the new
maxBudget validation so downstream code that uses maxBudget can assume it is a
valid number.

Comment on lines +34 to +87
const handleSSEEvent = useCallback((event: SSEEvent) => {
switch (event.type) {
case 'retailer_start':
setRetailers(prev => ({
...prev,
[event.retailer!]: {
...prev[event.retailer!],
status: 'searching',
streamingUrl: event.streamingUrl || prev[event.retailer!]?.streamingUrl
}
}))
break
case 'retailer_step':
setRetailers(prev => ({
...prev,
[event.retailer!]: {
...prev[event.retailer!],
steps: [...(prev[event.retailer!]?.steps || []).slice(-10), event.step!]
}
}))
break
case 'retailer_stock_found':
triggerLegoConfetti()
setRetailers(prev => ({
...prev,
[event.retailer!]: { ...prev[event.retailer!], stockFound: true }
}))
break
case 'retailer_complete':
setRetailers(prev => ({
...prev,
[event.retailer!]: { ...prev[event.retailer!], status: 'complete', data: event.data }
}))
if (event.data) setResults(prev => [...prev, event.data!])
break
case 'retailer_error':
setRetailers(prev => ({
...prev,
[event.retailer!]: { ...prev[event.retailer!], status: 'error', error: event.error }
}))
break
case 'analysis_complete':
setBestDeal(event.bestDeal || null)
setIsSearching(false)
if (event.bestDeal && event.bestDeal.bestRetailer !== 'None') {
triggerVictoryConfetti()
}
break
case 'error':
setError(event.error || 'An error occurred')
setIsSearching(false)
break
}
}, [])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential runtime issue: spreading undefined retailer state.

If an SSE event arrives with a retailer name that wasn't initialized via initializeRetailers(), prev[event.retailer!] will be undefined, and spreading it will silently produce incomplete state objects (e.g., missing steps array).

Consider adding a guard to ensure the retailer exists before updating:

🛡️ Suggested defensive approach
 case 'retailer_start':
+  if (!prev[event.retailer!]) {
+    console.warn(`Unknown retailer: ${event.retailer}`)
+    return prev
+  }
   setRetailers(prev => ({
     ...prev,
     [event.retailer!]: {
       ...prev[event.retailer!],
       status: 'searching',
       streamingUrl: event.streamingUrl || prev[event.retailer!]?.streamingUrl
     }
   }))
   break

Apply similar guards to other cases (retailer_step, retailer_stock_found, retailer_complete, retailer_error).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleSSEEvent = useCallback((event: SSEEvent) => {
switch (event.type) {
case 'retailer_start':
setRetailers(prev => ({
...prev,
[event.retailer!]: {
...prev[event.retailer!],
status: 'searching',
streamingUrl: event.streamingUrl || prev[event.retailer!]?.streamingUrl
}
}))
break
case 'retailer_step':
setRetailers(prev => ({
...prev,
[event.retailer!]: {
...prev[event.retailer!],
steps: [...(prev[event.retailer!]?.steps || []).slice(-10), event.step!]
}
}))
break
case 'retailer_stock_found':
triggerLegoConfetti()
setRetailers(prev => ({
...prev,
[event.retailer!]: { ...prev[event.retailer!], stockFound: true }
}))
break
case 'retailer_complete':
setRetailers(prev => ({
...prev,
[event.retailer!]: { ...prev[event.retailer!], status: 'complete', data: event.data }
}))
if (event.data) setResults(prev => [...prev, event.data!])
break
case 'retailer_error':
setRetailers(prev => ({
...prev,
[event.retailer!]: { ...prev[event.retailer!], status: 'error', error: event.error }
}))
break
case 'analysis_complete':
setBestDeal(event.bestDeal || null)
setIsSearching(false)
if (event.bestDeal && event.bestDeal.bestRetailer !== 'None') {
triggerVictoryConfetti()
}
break
case 'error':
setError(event.error || 'An error occurred')
setIsSearching(false)
break
}
}, [])
const handleSSEEvent = useCallback((event: SSEEvent) => {
switch (event.type) {
case 'retailer_start':
setRetailers(prev => {
if (!prev[event.retailer!]) {
console.warn(`Unknown retailer: ${event.retailer}`)
return prev
}
return {
...prev,
[event.retailer!]: {
...prev[event.retailer!],
status: 'searching',
streamingUrl: event.streamingUrl || prev[event.retailer!]?.streamingUrl
}
}
})
break
case 'retailer_step':
setRetailers(prev => {
if (!prev[event.retailer!]) {
console.warn(`Unknown retailer: ${event.retailer}`)
return prev
}
return {
...prev,
[event.retailer!]: {
...prev[event.retailer!],
steps: [...(prev[event.retailer!]?.steps || []).slice(-10), event.step!]
}
}
})
break
case 'retailer_stock_found':
triggerLegoConfetti()
setRetailers(prev => {
if (!prev[event.retailer!]) {
console.warn(`Unknown retailer: ${event.retailer}`)
return prev
}
return {
...prev,
[event.retailer!]: { ...prev[event.retailer!], stockFound: true }
}
})
break
case 'retailer_complete':
setRetailers(prev => {
if (!prev[event.retailer!]) {
console.warn(`Unknown retailer: ${event.retailer}`)
return prev
}
return {
...prev,
[event.retailer!]: { ...prev[event.retailer!], status: 'complete', data: event.data }
}
})
if (event.data) setResults(prev => [...prev, event.data!])
break
case 'retailer_error':
setRetailers(prev => {
if (!prev[event.retailer!]) {
console.warn(`Unknown retailer: ${event.retailer}`)
return prev
}
return {
...prev,
[event.retailer!]: { ...prev[event.retailer!], status: 'error', error: event.error }
}
})
break
case 'analysis_complete':
setBestDeal(event.bestDeal || null)
setIsSearching(false)
if (event.bestDeal && event.bestDeal.bestRetailer !== 'None') {
triggerVictoryConfetti()
}
break
case 'error':
setError(event.error || 'An error occurred')
setIsSearching(false)
break
}
}, [])
🤖 Prompt for AI Agents
In `@lego-hunter/app/page.tsx` around lines 34 - 87, The SSE handler
(handleSSEEvent) currently spreads prev[event.retailer!] which can be undefined
for unseen retailers; update each retailer-related case (retailer_start,
retailer_step, retailer_stock_found, retailer_complete, retailer_error) to first
ensure a base retailer object exists (e.g., read const existing =
prev[event.retailer!] || { status: 'idle', steps: [], streamingUrl: undefined,
stockFound: false, error: undefined, data: undefined }) and then call
setRetailers with [event.retailer!] merged into that base so you never spread
undefined and you always preserve/initialize fields like steps, streamingUrl,
stockFound, error, and data; apply this pattern inside handleSSEEvent for all
retailer_* branches and keep the other logic (triggerLegoConfetti, setResults,
etc.) unchanged.

Comment on lines +8 to +36
interface BestDealCardProps {
deal: DealAnalysis
results: ProductData[]
}

export function BestDealCard({ deal, results }: BestDealCardProps) {
// Find the product data for the best retailer
const bestProduct = results.find(r => r.retailer === deal.bestRetailer)

// Trigger confetti on mount
useEffect(() => {
if (deal.bestRetailer !== 'None') {
triggerVictoryConfetti()
}
}, [deal.bestRetailer])

if (deal.bestRetailer === 'None') {
return (
<div className="lego-card p-8 text-center border-[var(--lego-red)]">
<div className="text-6xl mb-4">😢</div>
<h3 className="text-2xl font-bold text-[var(--lego-black)] mb-2">
No Stock Found
</h3>
<p className="text-[var(--lego-black)]/60 max-w-md mx-auto">
{deal.reason}
</p>
<button className="lego-button mt-6 px-8 py-3">
Try Another Set
</button>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Wire the “Try Another Set” button to an action or link.
Right now it does nothing, which is a dead-end CTA.

✅ Suggested fix
 interface BestDealCardProps {
   deal: DealAnalysis
   results: ProductData[]
+  onRetry?: () => void
 }
 ...
-        <button className="lego-button mt-6 px-8 py-3">
+        <button
+          type="button"
+          className="lego-button mt-6 px-8 py-3"
+          onClick={onRetry}
+          disabled={!onRetry}
+        >
           Try Another Set
         </button>
🤖 Prompt for AI Agents
In `@lego-hunter/components/best-deal-card.tsx` around lines 8 - 36, The "Try
Another Set" button in BestDealCard is a dead CTA; update BestDealCard to accept
a callback prop (e.g., onTryAnother: () => void) or a navigation prop and wire
the button's onClick to call that prop (or render a Link/navigation action) so
the button triggers an action instead of doing nothing; update the
BestDealCardProps interface to include the new prop and use it in the JSX button
(or replace the button with a Link) so consumers can navigate or retry when no
stock is found.

Comment on lines +79 to +88
{bestProduct && (
<a
href={bestProduct.productUrl}
target="_blank"
rel="noopener noreferrer"
className="lego-button inline-flex items-center gap-2 px-8 py-4 text-lg"
>
BUILD YOUR SET
<ExternalLink className="w-5 h-5" />
</a>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate best-deal product URL before linking out.
productUrl is external input; restrict to http(s) schemes.

🔒 Suggested fix
+const isSafeExternalUrl = (url: string) => {
+  try {
+    const parsed = new URL(url)
+    return parsed.protocol === 'http:' || parsed.protocol === 'https:'
+  } catch {
+    return false
+  }
+}
+
 export function BestDealCard({ deal, results }: BestDealCardProps) {
   // Find the product data for the best retailer
   const bestProduct = results.find(r => r.retailer === deal.bestRetailer)
+  const safeProductUrl =
+    bestProduct && isSafeExternalUrl(bestProduct.productUrl)
+      ? bestProduct.productUrl
+      : undefined
 ...
-            {bestProduct && (
+            {safeProductUrl && (
               <a
-                href={bestProduct.productUrl}
+                href={safeProductUrl}
                 target="_blank"
                 rel="noopener noreferrer"
                 className="lego-button inline-flex items-center gap-2 px-8 py-4 text-lg"
               >
                 BUILD YOUR SET
                 <ExternalLink className="w-5 h-5" />
               </a>
             )}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{bestProduct && (
<a
href={bestProduct.productUrl}
target="_blank"
rel="noopener noreferrer"
className="lego-button inline-flex items-center gap-2 px-8 py-4 text-lg"
>
BUILD YOUR SET
<ExternalLink className="w-5 h-5" />
</a>
const isSafeExternalUrl = (url: string) => {
try {
const parsed = new URL(url)
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
} catch {
return false
}
}
export function BestDealCard({ deal, results }: BestDealCardProps) {
// Find the product data for the best retailer
const bestProduct = results.find(r => r.retailer === deal.bestRetailer)
const safeProductUrl =
bestProduct && isSafeExternalUrl(bestProduct.productUrl)
? bestProduct.productUrl
: undefined
// ... other component code ...
{safeProductUrl && (
<a
href={safeProductUrl}
target="_blank"
rel="noopener noreferrer"
className="lego-button inline-flex items-center gap-2 px-8 py-4 text-lg"
>
BUILD YOUR SET
<ExternalLink className="w-5 h-5" />
</a>
)}
🤖 Prompt for AI Agents
In `@lego-hunter/components/best-deal-card.tsx` around lines 79 - 88, The anchor
currently uses bestProduct.productUrl directly which can be untrusted; validate
the URL before rendering an external link by constructing a URL object from
bestProduct.productUrl and ensuring url.protocol is either "http:" or "https:"
(if construction fails or protocol is invalid, treat as unsafe). If valid,
render the <a> with href, target and rel as now; if invalid, render a
non-clickable fallback (e.g., a button or span with the same styling, no
href/target/rel and aria-disabled) so users cannot be navigated to unsafe
schemes. Reference: bestProduct and productUrl in the best-deal-card component
and the BUILD YOUR SET anchor.

Comment on lines +44 to +54
{isInView && streamingUrl ? (
<>
<iframe
src={streamingUrl}
className={`w-full h-full border-0 transition-opacity duration-500 ${
hasLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={() => setHasLoaded(true)}
title={`Browser preview for ${retailerName}`}
sandbox="allow-same-origin allow-scripts"
/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate streamingUrl before loading iframe.
Guard against non-http(s) schemes from external sources.

🔒 Suggested fix
+const isSafeExternalUrl = (url: string) => {
+  try {
+    const parsed = new URL(url)
+    return parsed.protocol === 'http:' || parsed.protocol === 'https:'
+  } catch {
+    return false
+  }
+}
+
 export function BrowserPreview({
   streamingUrl,
   retailerName,
   status
 }: BrowserPreviewProps) {
   const [isInView, setIsInView] = useState(false)
   const [hasLoaded, setHasLoaded] = useState(false)
   const containerRef = useRef<HTMLDivElement>(null)
+  const safeStreamingUrl =
+    streamingUrl && isSafeExternalUrl(streamingUrl) ? streamingUrl : undefined
 ...
-      {isInView && streamingUrl ? (
+      {isInView && safeStreamingUrl ? (
         <>
           <iframe
-            src={streamingUrl}
+            src={safeStreamingUrl}
             className={`w-full h-full border-0 transition-opacity duration-500 ${
               hasLoaded ? 'opacity-100' : 'opacity-0'
             }`}
🤖 Prompt for AI Agents
In `@lego-hunter/components/browser-preview.tsx` around lines 44 - 54, Validate
the streamingUrl before rendering the iframe: inside the component where
isInView and streamingUrl are used (the JSX block that sets src={streamingUrl},
onLoad={() => setHasLoaded(true)}, and title using retailerName), parse
streamingUrl with the URL constructor (or equivalent) and ensure its protocol is
'http:' or 'https:'; if the check fails, do not render the iframe (render a
fallback or null) to guard against non-http(s) schemes from external sources and
avoid loading unsafe content.

Comment on lines +125 to +179
{sortedResults.map((result, index) => (
<tr
key={`${result.retailer}-${index}`}
className={`border-b border-[var(--lego-gray-dark)] ${
!result.inStock ? 'out-of-stock' : ''
}`}
>
<td className="px-4 py-3">
<span className="font-medium">{result.retailer}</span>
</td>
<td className="px-4 py-3">
{result.inStock ? (
<span className="inline-flex items-center gap-1 text-[var(--lego-green)] font-bold">
<Package className="w-4 h-4" />
In Stock
</span>
) : (
<span className="inline-flex items-center gap-1 text-[var(--lego-red)]">
<PackageX className="w-4 h-4" />
Out of Stock
</span>
)}
</td>
<td className="px-4 py-3">
{result.inStock && result.price !== '0' ? (
<span className="font-bold">
{result.currency === 'USD' ? '$' : result.currency}
{result.price}
</span>
) : (
<span className="text-[var(--lego-black)]/40">-</span>
)}
</td>
<td className="px-4 py-3 text-sm">
{result.inStock ? result.shipping : '-'}
</td>
<td className="px-4 py-3 text-center">
{result.inStock ? (
<a
href={result.productUrl}
target="_blank"
rel="noopener noreferrer"
className="lego-button-blue inline-flex items-center gap-1 px-3 py-1.5 text-sm"
>
View
<ExternalLink className="w-3 h-3" />
</a>
) : (
<button
className="px-3 py-1.5 text-sm border-2 border-[var(--lego-gray-dark)] rounded text-[var(--lego-black)]/60 hover:bg-[var(--lego-gray)]"
onClick={() => alert('Notification feature coming soon!')}
>
Notify Me
</button>
)}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate external product URLs before rendering links.
productUrl may be untrusted; guard against non-http(s) schemes.

🔒 Suggested fix
+const isSafeExternalUrl = (url: string) => {
+  try {
+    const parsed = new URL(url)
+    return parsed.protocol === 'http:' || parsed.protocol === 'https:'
+  } catch {
+    return false
+  }
+}
+
 ...
-            {sortedResults.map((result, index) => (
-              <tr
-                key={`${result.retailer}-${index}`}
-                className={`border-b border-[var(--lego-gray-dark)] ${
-                  !result.inStock ? 'out-of-stock' : ''
-                }`}
-              >
+            {sortedResults.map((result, index) => {
+              const safeProductUrl = isSafeExternalUrl(result.productUrl)
+                ? result.productUrl
+                : undefined
+
+              return (
+                <tr
+                  key={`${result.retailer}-${index}`}
+                  className={`border-b border-[var(--lego-gray-dark)] ${
+                    !result.inStock ? 'out-of-stock' : ''
+                  }`}
+                >
 ...
-                  {result.inStock ? (
+                  {result.inStock && safeProductUrl ? (
                     <a
-                      href={result.productUrl}
+                      href={safeProductUrl}
                       target="_blank"
                       rel="noopener noreferrer"
                       className="lego-button-blue inline-flex items-center gap-1 px-3 py-1.5 text-sm"
                     >
                       View
                       <ExternalLink className="w-3 h-3" />
                     </a>
                   ) : (
                     <button
                       className="px-3 py-1.5 text-sm border-2 border-[var(--lego-gray-dark)] rounded text-[var(--lego-black)]/60 hover:bg-[var(--lego-gray)]"
                       onClick={() => alert('Notification feature coming soon!')}
                     >
                       Notify Me
                     </button>
                   )}
-                </td>
-              </tr>
-            ))}
+                </td>
+              </tr>
+              )
+            })}
🤖 Prompt for AI Agents
In `@lego-hunter/components/results-table.tsx` around lines 125 - 179, The mapped
results rendering in ResultsTable uses result.productUrl directly when rendering
the <a> link (inside the sortedResults.map callback), which can expose
non-http(s) schemes; validate each productUrl before rendering by parsing it
with new URL(...) (catching exceptions) and only allow links with protocol
'http:' or 'https:'; for invalid or unsafe URLs render the Notify/disabled
button or a non-clickable element instead of an anchor, and ensure any allowed
href is the validated URL string so only safe http(s) links are used in the
anchor.

Comment on lines +104 to +115
<>
<p className="text-xs text-[var(--lego-black)]/60">
{data.shipping}
</p>
<a
href={data.productUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-[var(--lego-blue)] hover:underline mt-1"
>
View Product <ExternalLink className="w-3 h-3" />
</a>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard product links against non-http(s) URLs.
data.productUrl is external input; validate scheme before rendering.

🔒 Suggested fix
+const isSafeExternalUrl = (url: string) => {
+  try {
+    const parsed = new URL(url)
+    return parsed.protocol === 'http:' || parsed.protocol === 'https:'
+  } catch {
+    return false
+  }
+}
+
 export function RetailerCard({ retailerStatus, logo }: RetailerCardProps) {
   const cardRef = useRef<HTMLDivElement>(null)
   const hasTriggeredConfetti = useRef(false)
 
   const { name, status, streamingUrl, data, stockFound, error, steps } =
     retailerStatus
+  const safeProductUrl =
+    data?.productUrl && isSafeExternalUrl(data.productUrl)
+      ? data.productUrl
+      : undefined
 ...
-            {data.inStock && (
+            {data.inStock && safeProductUrl && (
               <>
                 <p className="text-xs text-[var(--lego-black)]/60">
                   {data.shipping}
                 </p>
                 <a
-                  href={data.productUrl}
+                  href={safeProductUrl}
                   target="_blank"
                   rel="noopener noreferrer"
                   className="inline-flex items-center gap-1 text-xs text-[var(--lego-blue)] hover:underline mt-1"
                 >
                   View Product <ExternalLink className="w-3 h-3" />
                 </a>
               </>
             )}
🤖 Prompt for AI Agents
In `@lego-hunter/components/retailer-card.tsx` around lines 104 - 115, Validate
data.productUrl's scheme before rendering the anchor: ensure it is a well-formed
URL whose protocol is "http:" or "https:" (use the URL constructor in a
try/catch or a small isValidExternalUrl helper) and only render the <a
href={data.productUrl} ...>View Product</a> when that check passes; if invalid,
render a safe fallback (e.g., plain text/span for data.productUrl or omit the
link) so the ExternalLink icon and target=_blank are not used with non-http(s)
schemes.

Comment on lines +37 to +73
export async function generateRetailerUrls(legoSetName: string): Promise<Retailer[]> {
const model = google('gemini-2.0-flash-exp')

const prompt = `You are a Lego shopping expert. Generate 15 specific product search URLs for finding "${legoSetName}" Lego set.

Include these retailers and create direct search URLs that would find this specific set:
1. LEGO.com official store (lego.com/en-us/search)
2. Amazon US (amazon.com/s)
3. Target (target.com/s)
4. Walmart (walmart.com/search)
5. BrickLink (bricklink.com/v2/search.page)
6. Zavvi (zavvi.com)
7. Toys R Us (toysrus.com)
8. Barnes & Noble (barnesandnoble.com)
9. Kohls (kohls.com)
10. Best Buy (bestbuy.com)
11. GameStop (gamestop.com)
12. Smyths Toys UK (smythstoys.com)
13. John Lewis UK (johnlewis.com)
14. Argos UK (argos.co.uk)
15. Entertainment Earth (entertainmentearth.com)

For each retailer:
- Use their actual search URL format
- Include the Lego set name/number in the search query
- Make the URL valid and properly encoded

Return exactly 15 retailers with their search URLs.`

const { object } = await generateObject({
model,
schema: retailerUrlsSchema,
prompt
})

return object.retailers
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Google Gemini 2.0 flash exp model availability 2025 2026

💡 Result:

  • gemini-2.0-flash-exp (experimental) became available in Google AI Studio / Gemini API during the Gemini 2.0 Flash rollout (latest update noted: Feb 2025) and is listed as the Experimental variant alongside gemini-2.0-flash (Latest) and gemini-2.0-flash-001 (Stable). (ai.google.dev)
  • As of March 12, 2025, Google specifically documented that developers could experiment with native image generation using gemini-2.0-flash-exp in Google AI Studio and via the Gemini API. (developers.googleblog.com)
  • As of the docs last updated Dec 18, 2025, Google’s deprecation table gives “earliest February 2026” shutdown timing for the non-exp Gemini 2.0 Flash models (e.g., gemini-2.0-flash, gemini-2.0-flash-001) and recommends migrating to gemini-2.5-flash—but it does not list gemini-2.0-flash-exp in that shutdown table. (ai.google.dev)
  • A related experimental image-generation variant (gemini-2.0-flash-exp-image-generation) was announced as shutting down Nov 14, 2025. (ai.google.dev)

Citations:


Consider migrating to gemini-2.5-flash instead of using the experimental model.

The functions use gemini-2.0-flash-exp, which is actively maintained but still marked as experimental. While the experimental variant is available and not currently deprecated, Google recommends migrating to gemini-2.5-flash as the stable, future-proof alternative. The standard gemini-2.0-flash releases are scheduled for deprecation by early February 2026, making gemini-2.5-flash the recommended production choice.

🤖 Prompt for AI Agents
In `@lego-hunter/lib/gemini-client.ts` around lines 37 - 73, The code in
generateRetailerUrls currently instantiates the model with
google('gemini-2.0-flash-exp'); update this to use the stable recommended model
name google('gemini-2.5-flash') instead, i.e., change the model assignment in
the generateRetailerUrls function (and any other places using
'gemini-2.0-flash-exp') to 'gemini-2.5-flash' so the function uses the
non-experimental model going forward.

Comment on lines +3 to +17
## Demo

![lego-hunter Demo](./75339b8c-4e68-490d-89cf-96c62334598a.jpg)

**Live Demo:** https://lego-hunter.vercel.app/

The Lego Restock Hunter is a powerful inventory search tool designed to find rare or sold-out Lego sets across 15+ global retailers simultaneously. It uses AI to discover the best retailers for a specific set, deploys parallel Mino browser agents to check stock and pricing, and finishes with a Gemini-powered analysis to recommend the single best deal (balancing price and shipping).

---

---

## Demo

*[Demo video/screenshot to be added]*
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove duplicate Demo section.

There are two "## Demo" sections (lines 3-9 and lines 15-17). The second section at lines 15-17 contains placeholder text and appears redundant. Consider removing lines 13-17 (including the extra separator).

Proposed fix
 ![lego-hunter Demo](./75339b8c-4e68-490d-89cf-96c62334598a.jpg)
 
 **Live Demo:** https://lego-hunter.vercel.app/
 
 The Lego Restock Hunter is a powerful inventory search tool designed to find rare or sold-out Lego sets across 15+ global retailers simultaneously. It uses AI to discover the best retailers for a specific set, deploys parallel Mino browser agents to check stock and pricing, and finishes with a Gemini-powered analysis to recommend the single best deal (balancing price and shipping).
 
 ---
 
----
-
-## Demo
-
-*[Demo video/screenshot to be added]*
-
----
-
 ## How Mino API is Used
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

7-7: Bare URL used

(MD034, no-bare-urls)


15-15: Multiple headings with the same content

(MD024, no-duplicate-heading)

🤖 Prompt for AI Agents
In `@lego-hunter/README.md` around lines 3 - 17, Remove the duplicate "## Demo"
block that contains only the placeholder "*[Demo video/screenshot to be
added]*": locate the second "## Demo" heading and the trailing separator and
delete that entire section (the heading and the placeholder text) so only the
first Demo section with the image and live demo link remains; ensure the
top-level separators ("---") remain intact around the kept content.


![lego-hunter Demo](./75339b8c-4e68-490d-89cf-96c62334598a.jpg)

**Live Demo:** https://lego-hunter.vercel.app/
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use Markdown link syntax for the URL.

The bare URL should use proper Markdown link syntax for better compatibility and rendering across different Markdown processors.

Proposed fix
-**Live Demo:** https://lego-hunter.vercel.app/
+**Live Demo:** [https://lego-hunter.vercel.app/](https://lego-hunter.vercel.app/)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
**Live Demo:** https://lego-hunter.vercel.app/
**Live Demo:** [https://lego-hunter.vercel.app/](https://lego-hunter.vercel.app/)
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

7-7: Bare URL used

(MD034, no-bare-urls)

🤖 Prompt for AI Agents
In `@lego-hunter/README.md` at line 7, Update the README line that currently shows
a bare URL by replacing it with proper Markdown link syntax; change the "Live
Demo:" line to use a labeled link like [Live
Demo](https://lego-hunter.vercel.app/) so the URL renders consistently across
Markdown processors.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant