-
Notifications
You must be signed in to change notification settings - Fork 106
Add scholarship finder to Tiny Fish cookbook #37
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
📝 WalkthroughWalkthroughThis pull request introduces a complete Vite + React + TypeScript web application for a Scholarship Finder system. It establishes a full-stack architecture with a frontend UI layer built using React Router, shadcn UI components, and Tailwind CSS; a middleware integration layer connecting to Supabase; and serverless backend functions (Deno) orchestrating AI-driven scholarship discovery through Lovable and Mino automation. The application enables users to search for scholarships by type, university, and region through a form-based interface, visualizes real-time search progress with agent-based scraping, displays results in comparable cards, and persists configuration through environment variables. No breaking changes to existing systems are introduced; this is a new project scaffolding. Sequence DiagramsequenceDiagram
actor User
participant Frontend as React App
participant Backend as Deno Function<br/>(search-scholarships)
participant AI as Lovable AI
participant Mino as Mino Agent Pool
participant Websites as Scholarship Sites
User->>Frontend: Submit search form<br/>(type, university, region)
Frontend->>Frontend: Initialize loading state<br/>with SearchState
Frontend->>Backend: POST /search with params
Backend->>Backend: STEP 1: Generate URLs
Backend->>AI: Request 5-8 provider URLs<br/>based on criteria
AI-->>Backend: Return ScholarshipUrl list
Backend->>Frontend: URLS_FOUND SSE event
Backend->>Backend: STEP 2: Launch parallel agents
par Agent Execution
Backend->>Mino: POST scrape goal for Site A
Mino->>Websites: Fetch & extract scholarships
Backend->>Frontend: AGENT_STARTED for Site A
Backend->>Frontend: AGENT_STREAMING with URL
Mino-->>Backend: Progressive SSE updates
Backend->>Frontend: AGENT_PROGRESS events
Backend->>Backend: Parse resultJson
Backend->>Frontend: AGENT_COMPLETE with data
and
Backend->>Mino: POST scrape goal for Site B
Mino->>Websites: Fetch & extract scholarships
Backend->>Frontend: AGENT_STARTED for Site B
Backend->>Frontend: AGENT_STREAMING with URL
Mino-->>Backend: Progressive SSE updates
Backend->>Frontend: AGENT_PROGRESS events
Backend->>Backend: Parse resultJson
Backend->>Frontend: AGENT_COMPLETE with data
and
note over Backend: Additional agents...
end
Backend->>Backend: Aggregate all<br/>scholarships
Backend->>Frontend: ALL_COMPLETE with<br/>full results
Frontend->>Frontend: Update results state<br/>& display SearchResults
Frontend->>User: Show scholarship cards<br/>with compare button
🚥 Pre-merge checks | ✅ 1 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 14
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🤖 Fix all issues with AI agents
In `@scholarship-finder/.env`:
- Around line 1-3: Summary: Sensitive Supabase credentials were committed in
.env; remove them and replace with safe placeholders. Fix: delete the committed
.env from the repository (stop tracking it and remove from the commit/branch),
add .env to .gitignore and also .env.local, create a .env.example containing the
keys VITE_SUPABASE_PROJECT_ID, VITE_SUPABASE_PUBLISHABLE_KEY, and
VITE_SUPABASE_URL with placeholder values (no quoted values) for documentation,
and commit only the .env.example; ensure any real keys are rotated/invalidated
outside the repo and confirm no credentials remain in history or subsequent
commits.
In `@scholarship-finder/.gitignore`:
- Around line 10-13: The repository .gitignore is missing environment file
patterns; update the .gitignore to ignore environment/credential files by adding
patterns like .env, .env.local, .env.* and .env.*.local (and any other
project-specific env variants) so that Supabase keys and other secrets are not
committed; ensure these patterns are appended to the existing entries
(node_modules, dist, dist-ssr, *.local) so all common .env files are excluded
from version control.
In `@scholarship-finder/eslint.config.js`:
- Around line 11-15: The ESLint config currently sets languageOptions.globals to
globals.browser for all TypeScript files, causing Deno globals (e.g.,
Deno.serve, Deno.env.get) to be flagged; add an override entry in
eslint.config.js that targets the backend functions (use files pattern like
"supabase/functions/**/*.ts") and set languageOptions.globals to globals.deno
for that override so those files recognize Deno globals; keep the existing
browser globals for the general files setting and ensure the override object is
appended to the top-level overrides array.
In `@scholarship-finder/src/components/CompareDashboard.tsx`:
- Around line 177-190: The Apply link in CompareDashboard renders
s.applicationLink directly inside the anchor, which allows unsafe schemes like
javascript:; update CompareDashboard to reuse the same http/https
allowlist/sanitizer used by ScholarshipCard (or call the existing utility such
as isSafeUrl/sanitizeUrl if present) before rendering: validate
s.applicationLink in the scholarships.map iteration and only render the anchor
href when the URL passes the allowlist (or replace with a safe fallback/disable
the Apply button), ensuring the anchor inside Button uses the
sanitized/validated URL and preserves target="_blank" rel="noopener noreferrer".
In `@scholarship-finder/src/components/ScholarshipCard.tsx`:
- Around line 8-18: The ScholarshipCard component currently renders
scholarship.applicationLink directly; validate and allowlist only http and https
schemes before enabling any link action: in ScholarshipCard, check
scholarship.applicationLink (and any place using it) for a safe URL (startsWith
"http://" or "https://" or use a URL parser to inspect protocol) and only render
an actionable anchor/button or set Card's action when the scheme is allowed; if
the URL is missing or unsafe (e.g., "javascript:"/"data:"), render a
disabled/unchangeable fallback (e.g., non-clickable text or disabled button) and
avoid setting href/onClick that would navigate to the raw value so clicks cannot
execute unsafe code.
In `@scholarship-finder/src/components/SearchForm.tsx`:
- Around line 71-119: Generate unique IDs via React's useId() (e.g., const
idScholarshipType = useId(), idUniversity = useId(), idRegion = useId()) and
attach them to each form control and label: set the label htmlFor to the
matching id and pass the id into the corresponding control (the Input for
university and the SelectTrigger for scholarshipType and region). Update the
Scholarship Type Select (value={scholarshipType} ...), the University Input
(value={university}, onChange={setUniversity}), and the Region Select
(value={region} ...) to accept the id prop so the labels are properly associated
for screen readers. Ensure nothing else about behavior changes (keep
disabled={isLoading} and existing props).
In `@scholarship-finder/src/components/ui/carousel.tsx`:
- Around line 70-81: The handleKeyDown callback currently prevents arrow keys
globally and ignores vertical carousels; update handleKeyDown to first
early-return when the event target is a form input (e.g., tagName 'INPUT',
'TEXTAREA', 'SELECT') or has isContentEditable true to avoid blocking cursor
movement, and then branch on an orientation prop (e.g., orientation ===
'horizontal' vs 'vertical') so ArrowLeft/ArrowRight only trigger
scrollPrev/scrollNext for horizontal carousels and ArrowUp/ArrowDown trigger
scrollPrev/scrollNext for vertical ones; keep calling event.preventDefault()
only when you actually handle the key and preserve the existing dependency array
[scrollPrev, scrollNext] (add orientation if needed).
In `@scholarship-finder/src/components/ui/chart.tsx`:
- Around line 61-88: The CSS output is vulnerable to injection because the
ChartStyle component interpolates id and raw color strings into a stylesheet;
update ChartStyle to escape the id when building the selector (e.g., use
CSS.escape(id) or otherwise wrap/escape the value so the selector becomes
[data-chart="..."] safely) and validate/sanitize each computed color before
emitting a --color-<key> rule (e.g., implement a small isValidCssColor check or
whitelist/normalize values and skip or fallback on invalid values). Ensure you
apply these fixes where ChartStyle uses id and where it computes color from
itemConfig.theme/itemConfig.color (and keep THEMES mapping usage intact).
In `@scholarship-finder/src/components/ui/collapsible.tsx`:
- Around line 5-7: The local exports CollapsibleTrigger and CollapsibleContent
are assigned non-existent Radix members; replace
CollapsiblePrimitive.CollapsibleTrigger with CollapsiblePrimitive.Trigger and
CollapsiblePrimitive.CollapsibleContent with CollapsiblePrimitive.Content so
CollapsibleTrigger = CollapsiblePrimitive.Trigger and CollapsibleContent =
CollapsiblePrimitive.Content (update any references to
CollapsibleTrigger/CollapsibleContent accordingly).
In `@scholarship-finder/src/components/ui/input-otp.tsx`:
- Line 44: The component uses a custom CSS animation class "animate-caret-blink"
(seen in input-otp.tsx as the div with className "animate-caret-blink") but the
keyframes/animation are not defined; fix by adding a "caret-blink" keyframes
entry and an "caret-blink" animation in your Tailwind config's extend.keyframes
and extend.animation (tailwind.config.ts) so "animate-caret-blink" maps to
"caret-blink 1.2s steps(2, start) infinite" (or switch to the tw-animate-css
plugin which already includes this animation).
In `@scholarship-finder/src/components/ui/sonner.tsx`:
- Around line 1-7: The file uses React.ComponentProps (type alias) but never
imports React, causing a missing symbol; add an import for React so the
ComponentProps type resolves (e.g., import React from "react" or import type
React from "react") at the top of the file alongside existing imports so the
Toaster type alias (type ToasterProps = React.ComponentProps<typeof Sonner>) and
the Toaster component compile correctly.
In `@scholarship-finder/src/integrations/supabase/client.ts`:
- Around line 5-16: Add a module-load guard that validates SUPABASE_URL and
SUPABASE_PUBLISHABLE_KEY before calling createClient: check the imported
constants (SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY) and if either is missing or
falsy throw an explicit Error with a clear message (e.g., "Missing
VITE_SUPABASE_URL or VITE_SUPABASE_PUBLISHABLE_KEY") so
createClient<Database>(...) is never invoked with undefined values; update the
top of the file where SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY are declared to
perform this validation prior to exporting the supabase client.
In `@scholarship-finder/supabase/functions/search-scholarships/index.ts`:
- Around line 108-172: The AI-returned scholarshipUrls are untrusted and
currently mapped into agentPromises without validation or limits, which can
spawn unbounded parallel agents; after the JSON.parse of content
(scholarshipUrls) validate that scholarshipUrls is an Array, filter out entries
missing required fields (e.g., name, url, description), slice the array to a
safe concurrency cap (e.g., 8), and throw an error if no valid entries remain so
agentPromises.map(...) (the agentPromises creation and subsequent Mino calls)
only iterates a validated, bounded list.
- Around line 26-291: The ReadableStream lacks cancellation so downstream
Lovable and Mino fetches keep running; create an AbortController (e.g.,
abortController) before calling req.json(), pass abortController.signal into the
Lovable AI fetch (the fetch to
"https://ai.gateway.lovable.dev/v1/chat/completions") and into each Mino fetch
(the fetch to "https://mino.ai/v1/automation/run-sse" inside the agentPromises),
and implement a cancel() handler on the ReadableStream that calls
abortController.abort() to terminate all in-flight requests when the client
disconnects; ensure error handling in your existing try/catch (used around
parsing and agent work) gracefully handles AbortError cases.
🟡 Minor comments (19)
scholarship-finder/src/components/ui/drawer.tsx-25-41 (1)
25-41:⚠️ Potential issue | 🟡 MinorMark the grab handle as decorative.
The grab indicator is purely visual; hide it from screen readers to reduce noise.✅ Suggested fix
- <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" /> + <div + aria-hidden="true" + className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" + />scholarship-finder/src/components/ui/menubar.tsx-185-188 (1)
185-188:⚠️ Potential issue | 🟡 MinorTypo:
displaynameshould bedisplayName.The property name uses lowercase
ninstead of the correct camelCasedisplayName. This prevents React DevTools from showing the component's proper name during debugging.🔧 Proposed fix
const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => { return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />; }; -MenubarShortcut.displayname = "MenubarShortcut"; +MenubarShortcut.displayName = "MenubarShortcut";scholarship-finder/src/components/ui/skeleton.tsx-1-5 (1)
1-5:⚠️ Potential issue | 🟡 MinorMissing React import for type reference.
React.HTMLAttributes<HTMLDivElement>is used without importing React. This will cause a TypeScript error. All other UI components in this project explicitly import React, includingcard.tsxwhich uses the same type pattern.🔧 Proposed fix
+import * as React from "react"; + import { cn } from "@/lib/utils"; function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {scholarship-finder/src/components/ui/breadcrumb.tsx-80-80 (1)
80-80:⚠️ Potential issue | 🟡 MinorTypo in displayName.
"BreadcrumbElipssis"should be"BreadcrumbEllipsis"(correct spelling: one 'l', two 's's in the middle).Proposed fix
-BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"; +BreadcrumbEllipsis.displayName = "BreadcrumbEllipsis";scholarship-finder/src/components/ui/breadcrumb.tsx-7-13 (1)
7-13:⚠️ Potential issue | 🟡 MinorUnused
separatorprop declared but not implemented.The
separatorprop is defined in the component's type signature (line 10) but is never used in the implementation. It gets spread into thenavelement where it has no effect. Either implement the separator functionality or remove the prop from the type.Proposed fix to remove unused prop
const Breadcrumb = React.forwardRef< HTMLElement, - React.ComponentPropsWithoutRef<"nav"> & { - separator?: React.ReactNode; - } + React.ComponentPropsWithoutRef<"nav"> >(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);scholarship-finder/src/components/ui/carousel.tsx-91-103 (1)
91-103:⚠️ Potential issue | 🟡 MinorUnsubscribe from the "reInit" listener on cleanup.
The "reInit" listener is subscribed on line 97 but never unsubscribed in the cleanup function, creating a listener leak that accumulates on unmount or when
api/onSelectchanges.Suggested fix
return () => { api?.off("select", onSelect); + api?.off("reInit", onSelect); };scholarship-finder/src/components/ui/chart.tsx-163-214 (1)
163-214:⚠️ Potential issue | 🟡 MinorUse stable React keys and render zero values in the tooltip.
The
key={item.dataKey}prop lacks a fallback and can beundefinedor duplicate across items, causing React warnings. Additionally,item.value && …skips rendering when the value is0, a valid numeric result that should display.Suggested fixes
{payload.map((item, index) => { const key = `${nameKey || item.name || item.dataKey || "value"}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); const indicatorColor = color || item.payload.fill || item.color; return ( <div - key={item.dataKey} + key={`${item.dataKey ?? item.name ?? index}`} className={cn( "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", indicator === "dot" && "items-center", )} > {...} - {item.value && ( + {item.value !== undefined && item.value !== null && ( <span className="font-mono font-medium tabular-nums text-foreground"> - {item.value.toLocaleString()} + {typeof item.value === "number" + ? item.value.toLocaleString() + : String(item.value)} </span> )}scholarship-finder/src/components/ui/chart.tsx-244-269 (1)
244-269:⚠️ Potential issue | 🟡 MinorUse stable legend keys and provide a label fallback.
key={item.value}can collide across multiple items, anditemConfig?.labelhas no fallback, resulting in blank legend entries. Useitem.dataKey(or index as fallback) for the key, and provide a fallback label value similar to how the tooltip component handles it.✅ Suggested fix
- {payload.map((item) => { + {payload.map((item, index) => { const key = `${nameKey || item.dataKey || "value"}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); + const legendKey = `${item.dataKey ?? item.value ?? index}`; + const label = itemConfig?.label ?? item.value ?? item.dataKey; return ( <div - key={item.value} + key={legendKey} className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")} > @@ - {itemConfig?.label} + {label} </div> ); })}scholarship-finder/index.html-6-19 (1)
6-19:⚠️ Potential issue | 🟡 MinorUpdate placeholder metadata to reflect the actual app.
The title/description/OG/Twitter fields still reference the “Lovable” placeholders, which will leak into SEO and social previews. Please align them with the Scholarship Finder branding.
✏️ Suggested update
- <title>Lovable App</title> - <meta name="description" content="Lovable Generated Project" /> + <title>Scholarship Finder</title> + <meta name="description" content="Find scholarships by type, university, and region." /> - <meta property="og:title" content="Lovable App" /> - <meta property="og:description" content="Lovable Generated Project" /> + <meta property="og:title" content="Scholarship Finder" /> + <meta property="og:description" content="Find scholarships by type, university, and region." />scholarship-finder/README.md-77-84 (1)
77-84:⚠️ Potential issue | 🟡 MinorAlign the “Tech Stack” section with the actual tooling.
The README lists “Next.js (TypeScript),” but the repository scaffolding is Vite + React + TS. This mismatch can confuse contributors and operators.
You can verify with:
#!/bin/bash # Confirm README tech stack and actual tooling rg -n 'Next\.js|Vite' scholarship-finder/README.md rg -n 'vite\.config|createRoot|main\.tsx' -S✏️ Suggested edit
-Next.js (TypeScript) +Vite + React (TypeScript)scholarship-finder/src/components/ui/card.tsx-17-20 (1)
17-20:⚠️ Potential issue | 🟡 MinorAlign CardTitle ref type with the rendered heading.
CardTitle renders an
<h3>(HTMLHeadingElement) but the ref is typed as HTMLParagraphElement. This type mismatch will cause TypeScript errors and incorrect type hints for consumers.🛠️ Suggested fix
-const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( +const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>( ({ className, ...props }, ref) => ( <h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} /> ),scholarship-finder/src/components/ui/alert.tsx-29-39 (1)
29-39:⚠️ Potential issue | 🟡 MinorAlign ref/props types with rendered elements.
AlertTitle and AlertDescription forwardRef generics don't match the actual elements rendered:
- AlertTitle renders
<h5>but ref type isHTMLParagraphElement(should beHTMLHeadingElement)- AlertDescription renders
<div>but both ref and props types areHTMLParagraphElement(should beHTMLDivElement)This mis-typing can mislead TypeScript tooling and consumers of these components.
Suggested fix
-const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( +const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>( ({ className, ...props }, ref) => ( <h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} /> ), ); -const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>( +const AlertDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( ({ className, ...props }, ref) => ( <div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} /> ), );scholarship-finder/src/components/ui/form.tsx-33-52 (1)
33-52:⚠️ Potential issue | 🟡 MinorGuard context usage before calling
getFieldState.
getFieldStateruns before verifying FormField/FormItem context. When misused, this yields unclear errors. CheckfieldContext.nameanditemContext.idfirst and throw explicit errors.🛡️ Suggested defensive guards
const useFormField = () => { const fieldContext = React.useContext(FormFieldContext); const itemContext = React.useContext(FormItemContext); const { getFieldState, formState } = useFormContext(); - const fieldState = getFieldState(fieldContext.name, formState); - - if (!fieldContext) { + if (!fieldContext?.name) { throw new Error("useFormField should be used within <FormField>"); } + if (!itemContext?.id) { + throw new Error("useFormField should be used within <FormItem>"); + } + + const fieldState = getFieldState(fieldContext.name, formState);scholarship-finder/src/test/setup.ts-3-15 (1)
3-15:⚠️ Potential issue | 🟡 Minor
dispatchEventmock should returnboolean.The
dispatchEventmethod in thematchMediamock returnsvoid, but per theEventTarget.dispatchEventspec it should returnboolean. This could cause issues if any code checks the return value.🔧 Proposed fix
addEventListener: () => {}, removeEventListener: () => {}, - dispatchEvent: () => {}, + dispatchEvent: () => false, }),scholarship-finder/src/hooks/use-mobile.tsx-5-18 (1)
5-18:⚠️ Potential issue | 🟡 MinorInitial render returns
falseregardless of actual viewport.The hook returns
!!isMobilewhich coercesundefinedtofalseon the initial render (before the effect runs). This causes mobile devices to briefly see the desktop layout before hydration completes.Consider initializing with a server-safe check or returning
undefinedto let consumers handle the loading state.🔧 Option: Return undefined during SSR for explicit handling
-export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined); +export function useIsMobile(): boolean | undefined { + const [isMobile, setIsMobile] = React.useState<boolean | undefined>(() => { + if (typeof window === "undefined") return undefined; + return window.innerWidth < MOBILE_BREAKPOINT; + }); React.useEffect(() => { const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); const onChange = () => { setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); }; mql.addEventListener("change", onChange); setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); return () => mql.removeEventListener("change", onChange); }, []); - return !!isMobile; + return isMobile; }scholarship-finder/src/pages/NotFound.tsx-16-18 (1)
16-18:⚠️ Potential issue | 🟡 MinorUse
<Link>instead of<a>for internal navigation.Using an anchor tag causes a full page reload, losing SPA benefits (state, faster navigation). Since you're already importing from
react-router-dom, useLinkfor client-side routing.🔧 Proposed fix
-import { useLocation } from "react-router-dom"; +import { useLocation, Link } from "react-router-dom"; import { useEffect } from "react";- <a href="/" className="text-primary underline hover:text-primary/90"> + <Link to="/" className="text-primary underline hover:text-primary/90"> Return to Home - </a> + </Link>scholarship-finder/src/components/ui/sidebar.tsx-153-169 (1)
153-169:⚠️ Potential issue | 🟡 MinorMobile branch is missing ref forwarding and className/props handling.
The mobile branch (lines 153–170) passes
...propstoSheet(a non-DOM component) and does not forwardrefor merge theclassNamepassed toSidebar, creating an inconsistency with the desktop branch. This prevents custom classes, data attributes, and ref forwarding from working correctly on mobile.Forward
reftoSheetContent, mergeclassNamewithcn(), and spread...propsonSheetContentinstead ofSheet.scholarship-finder/src/components/ui/sidebar.tsx-58-69 (1)
58-69:⚠️ Potential issue | 🟡 MinorUse previous state in functional updates for uncontrolled mode.
Line 60 resolves functional updates against
openfrom the render closure, causing back-to-back toggles (like intoggleSidebar()) to collapse into a single state change. When uncontrolled, use the setState updater form to access the actual previous state.🐛 Suggested fix
const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { + if (setOpenProp) { + const openState = typeof value === "function" ? value(open) : value; + setOpenProp(openState); + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + return; + } + - const openState = typeof value === "function" ? value(open) : value; - if (setOpenProp) { - setOpenProp(openState); - } else { - _setOpen(openState); + _setOpen((prev) => { + const openState = typeof value === "function" ? value(prev) : value; + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + return openState; + }); } - - // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; }, [setOpenProp, open], );scholarship-finder/src/components/ui/sidebar.tsx-375-392 (1)
375-392:⚠️ Potential issue | 🟡 MinorSet explicit
type="button"on sidebar action buttons to prevent unwanted form submissions.HTML buttons without an explicit type default to
type="submit"inside forms. SidebarGroupAction, SidebarMenuButton, and SidebarMenuAction currently render<button>elements without specifying a type. If these components are ever placed inside a form context, they will incorrectly trigger form submission. Settype="button"as the default whenasChildis false to ensure correct behavior.🔧 Suggested fix (default button type)
-const SidebarGroupAction = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button"> & { asChild?: boolean }>( - ({ className, asChild = false, ...props }, ref) => { +const SidebarGroupAction = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button"> & { asChild?: boolean }>( + ({ className, asChild = false, type, ...props }, ref) => { @@ - <Comp + <Comp ref={ref} data-sidebar="group-action" + type={asChild ? undefined : type ?? "button"} className={cn( "absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", @@ -const SidebarMenuButton = React.forwardRef< +const SidebarMenuButton = React.forwardRef< HTMLButtonElement, React.ComponentProps<"button"> & { asChild?: boolean; isActive?: boolean; tooltip?: string | React.ComponentProps<typeof TooltipContent>; } & VariantProps<typeof sidebarMenuButtonVariants> ->(({ asChild = false, isActive = false, variant = "default", size = "default", tooltip, className, ...props }, ref) => { +>(({ asChild = false, isActive = false, variant = "default", size = "default", tooltip, className, type, ...props }, ref) => { @@ <Comp ref={ref} data-sidebar="menu-button" data-size={size} data-active={isActive} + type={asChild ? undefined : type ?? "button"} className={cn(sidebarMenuButtonVariants({ variant, size }), className)} {...props} /> @@ -const SidebarMenuAction = React.forwardRef< +const SidebarMenuAction = React.forwardRef< HTMLButtonElement, React.ComponentProps<"button"> & { asChild?: boolean; showOnHover?: boolean; } ->(({ className, asChild = false, showOnHover = false, ...props }, ref) => { +>(({ className, asChild = false, showOnHover = false, type, ...props }, ref) => { @@ <Comp ref={ref} data-sidebar="menu-action" + type={asChild ? undefined : type ?? "button"} className={cn( "absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
🧹 Nitpick comments (16)
scholarship-finder/.gitignore (1)
1-24: Consider adding patterns for build caches and test coverage.While not critical, adding patterns for common development artifacts can help keep the repository clean as the project evolves.
📦 Optional patterns to consider
*.local +# Build caches +.cache +.vite + +# Test coverage +coverage + # Editor directories and filesscholarship-finder/src/components/ui/slider.tsx (1)
6-19: Document single-thumb limitation or prepare for multi-thumb support.The Slider component is not currently used elsewhere in the codebase. However, Radix Slider requires one
Thumbper value in the array. If this component is later used with range inputs (value as an array with length > 1), the single hardcoded thumb will render incorrectly. Either explicitly document that this component supports single-value-only sliders, or refactor to render thumbs based on thevalue/defaultValuelength to support range usage when needed (as shown in the anime-watch-hub implementation).scholarship-finder/eslint.config.js (1)
20-23: Consider re‑enabling@typescript-eslint/no-unused-varsat least aswarn.Turning this off entirely can mask real defects and leave dead code behind. A common compromise is
warnwith_-prefixed ignore patterns.Suggested rule tweak
rules: { ...reactHooks.configs.recommended.rules, "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], - "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], },scholarship-finder/src/components/ui/input-otp.tsx (1)
27-29: Consider adding defensive check for slot access.Accessing
inputOTPContext.slots[index]without validation could throw if an invalid index is passed or if the context is not properly initialized. While shadcn patterns typically trust consumers to use components correctly, a guard would improve robustness.🛡️ Optional defensive check
>(({ index, className, ...props }, ref) => { const inputOTPContext = React.useContext(OTPInputContext); - const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]; + const slot = inputOTPContext.slots[index]; + const { char, hasFakeCaret, isActive } = slot ?? {};scholarship-finder/src/components/SelectableScholarshipCard.tsx (1)
6-6: Remove unused import.
Checkboxis imported but not used. The component implements a custom checkbox UI using a styleddivwith aCheckicon (lines 29-42).Suggested fix
-import { Checkbox } from "@/components/ui/checkbox";Alternatively, consider using the imported
Checkboxcomponent for consistency with the UI library.scholarship-finder/src/components/CompareDashboard.tsx (1)
58-66: Use row header cells for feature labels.
The leftmost “Feature” column cells are row headers; use<th scope="row">so screen readers announce labels for each row’s data.♿ Example for one row (apply to each feature row)
- <td className="p-4 font-semibold bg-orange-50 text-orange-900"> + <th scope="row" className="p-4 font-semibold bg-orange-50 text-orange-900"> <div className="flex items-center gap-2"> <Building2 className="w-4 h-4" /> Provider </div> - </td> + </th>scholarship-finder/src/components/LoadingAnimation.tsx (2)
186-194: UnusedisExpandedprop in AgentCard.The
isExpandedparameter is passed toAgentCardbut never used within the component. The expanded modal is rendered in the parent component usingexpandedAgentstate directly. Consider removing this unused prop to reduce confusion.♻️ Proposed fix
function AgentCard({ agent, - isExpanded, onExpand }: { agent: AgentStatus; - isExpanded: boolean; onExpand: () => void; }) {And update the call site at line 101-106:
<AgentCard key={agent.agentId} agent={agent} - isExpanded={expandedAgent === agent.agentId} onExpand={() => setExpandedAgent(expandedAgent === agent.agentId ? null : agent.agentId)} />
14-24: Inconsistent status enum value with similar interface insummer-school-finder.This
AgentStatusinterface uses"complete"as a status value, while the similarAgentStatusinterface insummer-school-finder/src/types/summer-school.ts(lines 23-31) uses"completed". Consider aligning these for consistency across the codebase if these modules may share logic or types in the future.scholarship-finder/src/main.tsx (1)
1-5: Consider wrapping App in React.StrictMode.Adding
StrictModehelps identify potential problems during development by enabling additional checks and warnings for deprecated patterns, unsafe lifecycles, and side effects.♻️ Proposed change
-import { createRoot } from "react-dom/client"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; import App from "./App.tsx"; import "./index.css"; -createRoot(document.getElementById("root")!).render(<App />); +createRoot(document.getElementById("root")!).render( + <StrictMode> + <App /> + </StrictMode> +);scholarship-finder/src/test/example.test.ts (1)
1-7: Placeholder test provides no real coverage.This scaffold test verifies the test infrastructure works but provides no meaningful coverage. Consider adding tests for the actual components (e.g.,
ScholarshipCard,LoadingAnimation) or removing this file once real tests are added.scholarship-finder/package.json (1)
1-5: Package name doesn't match project folder.The package name
"vite_react_shadcn_ts"appears to be a template default and doesn't match the project folderscholarship-finder. Consider updating it for clarity.♻️ Proposed fix
{ - "name": "vite_react_shadcn_ts", + "name": "scholarship-finder", "private": true, "version": "0.0.0",scholarship-finder/tsconfig.json (1)
9-14: Consider enabling stricter TypeScript checks for a new project.Several important type-safety features are disabled:
strictNullChecks: false— This is the most impactful; disabling it allowsnull/undefinedto flow unchecked, which is a common source of runtime errorsnoImplicitAny: false— Permits implicitanytypes, reducing type coverageFor a greenfield project, starting with stricter settings helps catch bugs early and establishes good practices. You can always relax specific rules later if needed.
🔧 Suggested stricter configuration
"compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] }, - "noImplicitAny": false, + "noImplicitAny": true, "noUnusedParameters": false, "skipLibCheck": true, "allowJs": true, "noUnusedLocals": false, - "strictNullChecks": false + "strictNullChecks": true }scholarship-finder/src/components/CompareButton.tsx (1)
13-29: Simplify duplicate toast branches.Both branches show the same description; you can collapse the conditions for a smaller, clearer handler.
♻️ Possible simplification
- if (selectedCount === 0) { - toast({ - title: "Select Scholarships", - description: "Please select at least 2 scholarships to compare.", - variant: "destructive", - }); - return; - } - if (selectedCount < 2) { + if (selectedCount < 2) { toast({ - title: "Select More Scholarships", + title: selectedCount === 0 ? "Select Scholarships" : "Select More Scholarships", description: "Please select at least 2 scholarships to compare.", variant: "destructive", }); return; }scholarship-finder/vite.config.ts (1)
7-19: Optional: make dev host/port configurable.Hardcoding host/port can be limiting in shared dev setups; consider env overrides with sane defaults. The project already follows Vite's
VITE_prefix convention for environment variables, soVITE_DEV_SERVER_HOSTandVITE_DEV_SERVER_PORTfit naturally into the existing pattern.♻️ Possible tweak
-import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; @@ -export default defineConfig(({ mode }) => ({ - server: { - host: "::", - port: 8080, +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const host = env.VITE_DEV_SERVER_HOST ?? "::"; + const port = Number(env.VITE_DEV_SERVER_PORT ?? 8080); + return ({ + server: { + host, + port, hmr: { overlay: false, }, }, plugins: [react(), mode === "development" && componentTagger()].filter(Boolean), resolve: { alias: { "@": path.resolve(__dirname, "./src"), }, }, -})); + }); +});scholarship-finder/tsconfig.app.json (1)
18-23: Align TypeScript strictness with project standards.This project's
tsconfig.app.jsondisables strict checking while most other projects in the monorepo (bestbet, anime-watch-hub, competitor-analysis, tinyskills) enable it. While the loose settings are intentional here, tighteningstrict,noImplicitAny, andnoUnused*would improve consistency and catch issues earlier. Before re-enabling, audit the codebase for type safety violations that would need fixing.scholarship-finder/src/hooks/useScholarshipSearch.ts (1)
200-208: Close the stream whenALL_COMPLETEarrives.The reader is left open when you return early; canceling it prevents dangling connections if the server keeps the stream alive.
🔧 Suggested hardening
if (data.type === "ALL_COMPLETE") { setResults({ scholarships: data.scholarships || [], searchSummary: data.searchSummary || "", }); setIsLoading(false); + reader.cancel().catch(() => {}); return; }
| VITE_SUPABASE_PROJECT_ID="ikudbmsjgzirpyjagdgm" | ||
| VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImlrdWRibXNqZ3ppcnB5amFnZGdtIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg5NjY5ODAsImV4cCI6MjA4NDU0Mjk4MH0.pjgHkm5fniTp0Dovn4U46VCPztrYBXjWxHEaLsLP5j0" | ||
| VITE_SUPABASE_URL="https://ikudbmsjgzirpyjagdgm.supabase.co" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do not commit .env files with real credentials to version control.
This file contains actual Supabase credentials and should not be committed. While the anon key is designed to be public (it's embedded in client-side code), committing .env files sets a dangerous precedent and can lead to accidental exposure of sensitive secrets in the future.
Additionally, the values should not be quoted in .env files per dotenv conventions.
Recommended approach:
- Add
.envto.gitignore - Create a
.env.examplewith placeholder values for documentation
📁 Proposed .env.example
-VITE_SUPABASE_PROJECT_ID="ikudbmsjgzirpyjagdgm"
-VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImlrdWRibXNqZ3ppcnB5amFnZGdtIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg5NjY5ODAsImV4cCI6MjA4NDU0Mjk4MH0.pjgHkm5fniTp0Dovn4U46VCPztrYBXjWxHEaLsLP5j0"
-VITE_SUPABASE_URL="https://ikudbmsjgzirpyjagdgm.supabase.co"
+VITE_SUPABASE_PROJECT_ID=your_project_id
+VITE_SUPABASE_PUBLISHABLE_KEY=your_anon_key
+VITE_SUPABASE_URL=https://your_project_id.supabase.coAlso add to .gitignore:
.env
.env.local
🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 1-1: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
[warning] 2-2: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
[warning] 3-3: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
🪛 Gitleaks (8.30.0)
[high] 2-2: Uncovered a JSON Web Token, which may lead to unauthorized access to web applications and sensitive user data.
(jwt)
🤖 Prompt for AI Agents
In `@scholarship-finder/.env` around lines 1 - 3, Summary: Sensitive Supabase
credentials were committed in .env; remove them and replace with safe
placeholders. Fix: delete the committed .env from the repository (stop tracking
it and remove from the commit/branch), add .env to .gitignore and also
.env.local, create a .env.example containing the keys VITE_SUPABASE_PROJECT_ID,
VITE_SUPABASE_PUBLISHABLE_KEY, and VITE_SUPABASE_URL with placeholder values (no
quoted values) for documentation, and commit only the .env.example; ensure any
real keys are rotated/invalidated outside the repo and confirm no credentials
remain in history or subsequent commits.
| node_modules | ||
| dist | ||
| dist-ssr | ||
| *.local |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add .env patterns to prevent credential leakage.
Environment files containing sensitive credentials (Supabase API keys, etc.) are not ignored. This creates a risk that developers could accidentally commit secrets to the repository.
🔒 Proposed fix to add environment file patterns
node_modules
dist
dist-ssr
*.local
+
+# Environment variables
+.env
+.env.local
+.env*.local📝 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.
| node_modules | |
| dist | |
| dist-ssr | |
| *.local | |
| node_modules | |
| dist | |
| dist-ssr | |
| *.local | |
| # Environment variables | |
| .env | |
| .env.local | |
| .env*.local |
🤖 Prompt for AI Agents
In `@scholarship-finder/.gitignore` around lines 10 - 13, The repository
.gitignore is missing environment file patterns; update the .gitignore to ignore
environment/credential files by adding patterns like .env, .env.local, .env.*
and .env.*.local (and any other project-specific env variants) so that Supabase
keys and other secrets are not committed; ensure these patterns are appended to
the existing entries (node_modules, dist, dist-ssr, *.local) so all common .env
files are excluded from version control.
| files: ["**/*.{ts,tsx}"], | ||
| languageOptions: { | ||
| ecmaVersion: 2020, | ||
| globals: globals.browser, | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# First, examine the full eslint.config.js file
cd scholarship-finder
wc -l eslint.config.jsRepository: tinyfish-io/tinyfish-cookbook
Length of output: 92
🏁 Script executed:
#!/bin/bash
# Read the full eslint.config.js to understand complete configuration
cd scholarship-finder
cat -n eslint.config.jsRepository: tinyfish-io/tinyfish-cookbook
Length of output: 1019
🏁 Script executed:
#!/bin/bash
# Search for Deno/Node globals in TS/TSX files as suggested in review
cd scholarship-finder
rg -n -g '*.ts' -g '*.tsx' '\bDeno\b|\bprocess\b|\bBuffer\b' -C2Repository: tinyfish-io/tinyfish-cookbook
Length of output: 1012
🏁 Script executed:
#!/bin/bash
# Check if there are ESLint overrides for supabase/functions in the config
cd scholarship-finder
rg -n 'override|supabase|functions' eslint.config.jsRepository: tinyfish-io/tinyfish-cookbook
Length of output: 55
🏁 Script executed:
#!/bin/bash
# Check project structure to see if supabase/functions should be linted
cd scholarship-finder
ls -la | head -20
fd -t d 'supabase|functions' --max-depth 2Repository: tinyfish-io/tinyfish-cookbook
Length of output: 1324
🏁 Script executed:
#!/bin/bash
# Search for other Deno/Node globals in TS files (process, Buffer, etc.)
cd scholarship-finder
rg -n -g '*.ts' -g '*.tsx' '\bprocess\b|\bBuffer\b' --max-count 5Repository: tinyfish-io/tinyfish-cookbook
Length of output: 55
🏁 Script executed:
#!/bin/bash
# Check if there are more TS files in supabase/functions
cd scholarship-finder
find supabase/functions -name '*.ts' -type fRepository: tinyfish-io/tinyfish-cookbook
Length of output: 120
🏁 Script executed:
#!/bin/bash
# Check what other TS files might be in the project to see full scope
cd scholarship-finder
find . -name '*.ts' -o -name '*.tsx' | grep -v node_modules | head -20Repository: tinyfish-io/tinyfish-cookbook
Length of output: 732
Add ESLint override with Deno globals for serverless functions.
The config applies to all .ts/.tsx files but only defines browser globals. The repo contains Deno serverless functions (supabase/functions/search-scholarships/index.ts) that use Deno.serve() and Deno.env.get(), which will be flagged as undefined. Add an override for backend files to include globals.deno:
Suggested override
{
files: ["supabase/functions/**/*.ts"],
languageOptions: {
globals: globals.deno,
},
}🤖 Prompt for AI Agents
In `@scholarship-finder/eslint.config.js` around lines 11 - 15, The ESLint config
currently sets languageOptions.globals to globals.browser for all TypeScript
files, causing Deno globals (e.g., Deno.serve, Deno.env.get) to be flagged; add
an override entry in eslint.config.js that targets the backend functions (use
files pattern like "supabase/functions/**/*.ts") and set languageOptions.globals
to globals.deno for that override so those files recognize Deno globals; keep
the existing browser globals for the general files setting and ensure the
override object is appended to the top-level overrides array.
| {/* Apply Links */} | ||
| <tr> | ||
| <td className="p-4 font-semibold bg-orange-50 text-orange-900">Apply</td> | ||
| {scholarships.map((s) => ( | ||
| <td key={s.id} className="p-4"> | ||
| <Button | ||
| asChild | ||
| className="bg-orange-500 hover:bg-orange-600 text-white" | ||
| > | ||
| <a href={s.applicationLink} target="_blank" rel="noopener noreferrer"> | ||
| Apply Now | ||
| <ExternalLink className="w-4 h-4 ml-2" /> | ||
| </a> | ||
| </Button> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guard external apply links against unsafe URL schemes.
Line 186 renders s.applicationLink directly. Please reuse the same http/https allowlist approach as in ScholarshipCard (or sanitize before persisting) to prevent javascript: links from executing in-page.
🤖 Prompt for AI Agents
In `@scholarship-finder/src/components/CompareDashboard.tsx` around lines 177 -
190, The Apply link in CompareDashboard renders s.applicationLink directly
inside the anchor, which allows unsafe schemes like javascript:; update
CompareDashboard to reuse the same http/https allowlist/sanitizer used by
ScholarshipCard (or call the existing utility such as isSafeUrl/sanitizeUrl if
present) before rendering: validate s.applicationLink in the scholarships.map
iteration and only render the anchor href when the URL passes the allowlist (or
replace with a safe fallback/disable the Apply button), ensuring the anchor
inside Button uses the sanitized/validated URL and preserves target="_blank"
rel="noopener noreferrer".
| interface ScholarshipCardProps { | ||
| scholarship: Scholarship; | ||
| index: number; | ||
| } | ||
|
|
||
| export function ScholarshipCard({ scholarship, index }: ScholarshipCardProps) { | ||
| return ( | ||
| <Card | ||
| className="overflow-hidden border-border/50 hover:shadow-lg transition-all duration-300 animate-fade-in" | ||
| style={{ animationDelay: `${index * 100}ms` }} | ||
| > |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Validate external application links before rendering.
Line 126 uses scholarship.applicationLink directly; if data is scraped/AI-sourced, a javascript: or data: URL can execute code on click. Please allowlist http/https (or sanitize upstream) and disable the action otherwise.
🔒 Suggested guard for unsafe URL schemes
+const isSafeExternalUrl = (value: string) => {
+ try {
+ const url = new URL(value);
+ return url.protocol === "http:" || url.protocol === "https:";
+ } catch {
+ return false;
+ }
+};
export function ScholarshipCard({ scholarship, index }: ScholarshipCardProps) {
+ const applicationHref = isSafeExternalUrl(scholarship.applicationLink)
+ ? scholarship.applicationLink
+ : undefined;
return (
@@
- <a href={scholarship.applicationLink} target="_blank" rel="noopener noreferrer">
+ <a
+ href={applicationHref}
+ aria-disabled={!applicationHref}
+ onClick={(e) => {
+ if (!applicationHref) e.preventDefault();
+ }}
+ target="_blank"
+ rel="noopener noreferrer"
+ >
Apply Now
<ExternalLink className="w-4 h-4 ml-2" />
</a>Also applies to: 121-129
🤖 Prompt for AI Agents
In `@scholarship-finder/src/components/ScholarshipCard.tsx` around lines 8 - 18,
The ScholarshipCard component currently renders scholarship.applicationLink
directly; validate and allowlist only http and https schemes before enabling any
link action: in ScholarshipCard, check scholarship.applicationLink (and any
place using it) for a safe URL (startsWith "http://" or "https://" or use a URL
parser to inspect protocol) and only render an actionable anchor/button or set
Card's action when the scheme is allowed; if the URL is missing or unsafe (e.g.,
"javascript:"/"data:"), render a disabled/unchangeable fallback (e.g.,
non-clickable text or disabled button) and avoid setting href/onClick that would
navigate to the raw value so clicks cannot execute unsafe code.
| {char} | ||
| {hasFakeCaret && ( | ||
| <div className="pointer-events-none absolute inset-0 flex items-center justify-center"> | ||
| <div className="animate-caret-blink h-4 w-px bg-foreground duration-1000" /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Search for Tailwind configuration files
fd -e js -e ts -e cjs -e mjs "tailwind.config" --exec cat {} \; 2>/dev/nullRepository: tinyfish-io/tinyfish-cookbook
Length of output: 6725
🏁 Script executed:
# Check if there's a CSS or configuration file that defines the animation separately
fd -e css -e scss "input-otp" --exec cat {} \;
# Also search for any definitions of caret-blink in the codebase
rg "caret-blink" -iRepository: tinyfish-io/tinyfish-cookbook
Length of output: 481
🏁 Script executed:
# Search for CSS files that might define the animation
fd -e css | head -20
# Check package.json for dependencies
cat package.json | grep -A 20 "dependencies\|devDependencies"Repository: tinyfish-io/tinyfish-cookbook
Length of output: 420
🏁 Script executed:
# Check the CSS files for the scholarship-finder project
cat scholarship-finder/src/App.css
echo "---"
cat scholarship-finder/src/index.css
# Also search for any `@keyframes` definitions
rg "@keyframes.*caret" -iRepository: tinyfish-io/tinyfish-cookbook
Length of output: 3906
🌐 Web query:
tailwindcss-animate caret-blink animation
💡 Result:
To get a caret blink utility in a Tailwind setup:
Tailwind CSS v4 (recommended): tw-animate-css
tw-animate-css is the Tailwind v4, CSS-first replacement and includes a built-in caret-blink animation class. Install + import it, then use caret-blink on the element you’re using as the “cursor” (often a small <span>/<div>). You can tune timing with Tailwind’s animation parameter utilities like duration-*. [1]
npm i -D tw-animate-css/* app.css / globals.css */
`@import` "tailwindcss";
`@import` "tw-animate-css";<span class="caret-blink duration-700">|</span>Shadcn/ui deprecated tailwindcss-animate in favor of tw-animate-css (March 19, 2025). [2]
If you mean the actual text-input caret
Browsers already blink the input caret by default. If you’re trying to apply a custom caret animation, MDN notes you may need to disable the default blink using caret-animation: manual (this property is experimental). [3]
Sources: [1] [2] [3]
Add the missing animate-caret-blink keyframes definition.
The animate-caret-blink class is used but not defined in the Tailwind configuration or CSS files. The current tailwindcss-animate plugin does not provide this animation. Add the keyframes to tailwind.config.ts in the extend.keyframes section:
keyframes: {
"caret-blink": {
"0%, 49%, 100%": { opacity: "1" },
"50%, 99%": { opacity: "0" },
},
},
animation: {
"caret-blink": "caret-blink 1.2s steps(2, start) infinite",
},Alternatively, consider upgrading to tw-animate-css, the newer replacement for tailwindcss-animate that includes this animation built-in.
🤖 Prompt for AI Agents
In `@scholarship-finder/src/components/ui/input-otp.tsx` at line 44, The component
uses a custom CSS animation class "animate-caret-blink" (seen in input-otp.tsx
as the div with className "animate-caret-blink") but the keyframes/animation are
not defined; fix by adding a "caret-blink" keyframes entry and an "caret-blink"
animation in your Tailwind config's extend.keyframes and extend.animation
(tailwind.config.ts) so "animate-caret-blink" maps to "caret-blink 1.2s steps(2,
start) infinite" (or switch to the tw-animate-css plugin which already includes
this animation).
| import { useTheme } from "next-themes"; | ||
| import { Toaster as Sonner, toast } from "sonner"; | ||
|
|
||
| type ToasterProps = React.ComponentProps<typeof Sonner>; | ||
|
|
||
| const Toaster = ({ ...props }: ToasterProps) => { | ||
| const { theme = "system" } = useTheme(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, let's read the complete sonner.tsx file
cat -n scholarship-finder/src/components/ui/sonner.tsxRepository: tinyfish-io/tinyfish-cookbook
Length of output: 1138
🏁 Script executed:
# Check the tsconfig.json to see JSX configuration
fd tsconfig.json scholarship-finder --exec cat {} \;Repository: tinyfish-io/tinyfish-cookbook
Length of output: 441
🏁 Script executed:
# Also check if there's any type definitions or imports we might have missed
rg "import.*React" scholarship-finder/src/components/ui/sonner.tsxRepository: tinyfish-io/tinyfish-cookbook
Length of output: 55
🏁 Script executed:
# Check tsconfig.app.json for JSX transform settings
cat scholarship-finder/tsconfig.app.jsonRepository: tinyfish-io/tinyfish-cookbook
Length of output: 757
🏁 Script executed:
# Also check tsconfig.node.json in case JSX is configured there
cat scholarship-finder/tsconfig.node.jsonRepository: tinyfish-io/tinyfish-cookbook
Length of output: 553
🏁 Script executed:
# Let's also check if maybe React is imported as a side-effect or if the build actually works
rg "React" scholarship-finder/src/components/ui/sonner.tsxRepository: tinyfish-io/tinyfish-cookbook
Length of output: 129
🏁 Script executed:
# Let's check if there's a global types file or ambient declarations that might define React globally
fd "*.d.ts" scholarship-finder/src --exec cat {} \;Repository: tinyfish-io/tinyfish-cookbook
Length of output: 358
🏁 Script executed:
# Also check package.json to understand the React version and setup
rg '"react":|"typescript":' scholarship-finder/package.json -A 1Repository: tinyfish-io/tinyfish-cookbook
Length of output: 198
🏁 Script executed:
# Let's check for .d.ts files using glob pattern
fd --glob "*.d.ts" scholarship-finder/srcRepository: tinyfish-io/tinyfish-cookbook
Length of output: 109
🏁 Script executed:
# Also let's check if there's an index.d.ts or vite-env.d.ts that might declare React globally
cat scholarship-finder/src/vite-env.d.ts 2>/dev/null || cat scholarship-finder/vite-env.d.ts 2>/dev/null || echo "No ambient types found"Repository: tinyfish-io/tinyfish-cookbook
Length of output: 110
🏁 Script executed:
# Let's check if React is imported anywhere in the app setup
rg "import.*React" scholarship-finder/src --max-count=5Repository: tinyfish-io/tinyfish-cookbook
Length of output: 3648
🏁 Script executed:
# Let's check if there's an eslint config that might catch this
fd eslint.config.js scholarship-finderRepository: tinyfish-io/tinyfish-cookbook
Length of output: 108
🏁 Script executed:
# Let's also verify the TypeScript would actually error by checking if there are any type checking results
cat scholarship-finder/src/components/ui/sonner.tsx | head -20Repository: tinyfish-io/tinyfish-cookbook
Length of output: 880
🏁 Script executed:
# Check if maybe there's a .eslintrc or similar that auto-imports
rg "eslintrc|\.eslintignore" scholarship-finder --filesRepository: tinyfish-io/tinyfish-cookbook
Length of output: 4421
🏁 Script executed:
# Let's check the eslint config to see if there's auto-import settings
cat scholarship-finder/eslint.config.jsRepository: tinyfish-io/tinyfish-cookbook
Length of output: 837
🏁 Script executed:
# Let's also double-check by looking at how other components in the same directory handle React.ComponentProps
rg "ComponentProps" scholarship-finder/src/components/ui/ --max-count=10Repository: tinyfish-io/tinyfish-cookbook
Length of output: 17422
Add missing React import for ComponentProps type usage.
React.ComponentProps requires React to be in scope. Although react-jsx config allows JSX without importing React, explicit type references like React.ComponentProps still need the import. All other UI components in this directory follow this pattern.
Suggested fix
+import type { ComponentProps } from "react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, toast } from "sonner";
-type ToasterProps = React.ComponentProps<typeof Sonner>;
+type ToasterProps = ComponentProps<typeof Sonner>;📝 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.
| import { useTheme } from "next-themes"; | |
| import { Toaster as Sonner, toast } from "sonner"; | |
| type ToasterProps = React.ComponentProps<typeof Sonner>; | |
| const Toaster = ({ ...props }: ToasterProps) => { | |
| const { theme = "system" } = useTheme(); | |
| import type { ComponentProps } from "react"; | |
| import { useTheme } from "next-themes"; | |
| import { Toaster as Sonner, toast } from "sonner"; | |
| type ToasterProps = ComponentProps<typeof Sonner>; | |
| const Toaster = ({ ...props }: ToasterProps) => { | |
| const { theme = "system" } = useTheme(); |
🤖 Prompt for AI Agents
In `@scholarship-finder/src/components/ui/sonner.tsx` around lines 1 - 7, The file
uses React.ComponentProps (type alias) but never imports React, causing a
missing symbol; add an import for React so the ComponentProps type resolves
(e.g., import React from "react" or import type React from "react") at the top
of the file alongside existing imports so the Toaster type alias (type
ToasterProps = React.ComponentProps<typeof Sonner>) and the Toaster component
compile correctly.
| const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL; | ||
| const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY; | ||
|
|
||
| // Import the supabase client like this: | ||
| // import { supabase } from "@/integrations/supabase/client"; | ||
|
|
||
| export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, { | ||
| auth: { | ||
| storage: localStorage, | ||
| persistSession: true, | ||
| autoRefreshToken: true, | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add explicit validation for required Supabase environment variables.
The Supabase JavaScript client (createClient) does not validate that the URL and publishable key are defined before initialization. If VITE_SUPABASE_URL or VITE_SUPABASE_PUBLISHABLE_KEY are undefined, the client will initialize with undefined values, and authentication or database operations will fail at runtime with unclear error messages. Add a guard clause at module load time to fail fast with a clear error if either variable is missing.
🛠️ Suggested fix
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
+
+if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
+ throw new Error("Missing VITE_SUPABASE_URL or VITE_SUPABASE_PUBLISHABLE_KEY.");
+}
export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
auth: {
storage: localStorage,
persistSession: true,
autoRefreshToken: true,
}
});📝 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.
| const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL; | |
| const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY; | |
| // Import the supabase client like this: | |
| // import { supabase } from "@/integrations/supabase/client"; | |
| export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, { | |
| auth: { | |
| storage: localStorage, | |
| persistSession: true, | |
| autoRefreshToken: true, | |
| } | |
| const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL; | |
| const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY; | |
| if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) { | |
| throw new Error("Missing VITE_SUPABASE_URL or VITE_SUPABASE_PUBLISHABLE_KEY."); | |
| } | |
| // Import the supabase client like this: | |
| // import { supabase } from "@/integrations/supabase/client"; | |
| export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, { | |
| auth: { | |
| storage: localStorage, | |
| persistSession: true, | |
| autoRefreshToken: true, | |
| } | |
| }); |
🤖 Prompt for AI Agents
In `@scholarship-finder/src/integrations/supabase/client.ts` around lines 5 - 16,
Add a module-load guard that validates SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY
before calling createClient: check the imported constants (SUPABASE_URL,
SUPABASE_PUBLISHABLE_KEY) and if either is missing or falsy throw an explicit
Error with a clear message (e.g., "Missing VITE_SUPABASE_URL or
VITE_SUPABASE_PUBLISHABLE_KEY") so createClient<Database>(...) is never invoked
with undefined values; update the top of the file where SUPABASE_URL and
SUPABASE_PUBLISHABLE_KEY are declared to perform this validation prior to
exporting the supabase client.
| const stream = new ReadableStream({ | ||
| async start(controller) { | ||
| const sendEvent = (data: object) => { | ||
| controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); | ||
| }; | ||
|
|
||
| try { | ||
| const { scholarshipType, university, region }: SearchParams = await req.json(); | ||
| const MINO_API_KEY = Deno.env.get("MINO_API_KEY"); | ||
| const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY"); | ||
|
|
||
| if (!MINO_API_KEY) { | ||
| throw new Error("MINO_API_KEY not configured"); | ||
| } | ||
| if (!LOVABLE_API_KEY) { | ||
| throw new Error("LOVABLE_API_KEY not configured"); | ||
| } | ||
|
|
||
| const today = new Date(); | ||
| const currentDate = today.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); | ||
| const currentYear = today.getFullYear(); | ||
|
|
||
| const locationContext = [ | ||
| university ? `at ${university}` : "", | ||
| region ? `in ${region}` : "", | ||
| ].filter(Boolean).join(" "); | ||
|
|
||
| // STEP 1: Use Lovable AI to get scholarship URLs | ||
| sendEvent({ | ||
| type: "STEP", | ||
| step: 1, | ||
| message: "Finding scholarship websites..." | ||
| }); | ||
|
|
||
| const aiPrompt = `Find 5-8 official scholarship provider websites for ${scholarshipType} scholarships ${locationContext}. | ||
|
|
||
| Return a JSON array of scholarship websites to search. Focus on: | ||
| - Official university financial aid pages | ||
| - Well-known scholarship foundations (Fulbright, Gates, Rhodes, etc.) | ||
| - Government scholarship programs | ||
| - Reputable scholarship aggregators | ||
|
|
||
| Return ONLY a JSON array like this: | ||
| [ | ||
| { | ||
| "name": "MIT Financial Aid", | ||
| "url": "https://sfs.mit.edu/undergraduate-students/", | ||
| "description": "MIT's official financial aid office" | ||
| } | ||
| ] | ||
|
|
||
| Include diverse sources: university-specific, national programs, and international opportunities if applicable. | ||
| Make sure all URLs are real, official websites.`; | ||
|
|
||
| const aiResponse = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| "Authorization": `Bearer ${LOVABLE_API_KEY}`, | ||
| }, | ||
| body: JSON.stringify({ | ||
| model: "google/gemini-3-flash-preview", | ||
| messages: [ | ||
| { role: "system", content: "You are a scholarship research assistant. Return only valid JSON arrays." }, | ||
| { role: "user", content: aiPrompt }, | ||
| ], | ||
| temperature: 0.7, | ||
| max_tokens: 2000, | ||
| }), | ||
| }); | ||
|
|
||
| if (!aiResponse.ok) { | ||
| throw new Error(`AI Gateway error: ${aiResponse.status}`); | ||
| } | ||
|
|
||
| const aiData = await aiResponse.json(); | ||
| const content = aiData.choices?.[0]?.message?.content; | ||
|
|
||
| if (!content) { | ||
| throw new Error("No content from AI"); | ||
| } | ||
|
|
||
| // Parse the URLs from AI response | ||
| let scholarshipUrls: ScholarshipUrl[]; | ||
| try { | ||
| const cleanedContent = content.replace(/```json\n?|\n?```/g, "").trim(); | ||
| scholarshipUrls = JSON.parse(cleanedContent); | ||
| } catch { | ||
| throw new Error("Failed to parse scholarship URLs"); | ||
| } | ||
|
|
||
| sendEvent({ | ||
| type: "URLS_FOUND", | ||
| urls: scholarshipUrls, | ||
| message: `Found ${scholarshipUrls.length} scholarship sources to search` | ||
| }); | ||
|
|
||
| // STEP 2: Run Mino agents in parallel | ||
| sendEvent({ | ||
| type: "STEP", | ||
| step: 2, | ||
| message: `Launching ${scholarshipUrls.length} browser agents...` | ||
| }); | ||
|
|
||
| const goal = `You are searching for ${scholarshipType} scholarships ${locationContext}. | ||
|
|
||
| CURRENT DATE: ${currentDate} | ||
|
|
||
| For this scholarship provider, extract: | ||
| 1. Scholarship name(s) | ||
| 2. Award amounts | ||
| 3. Application deadlines (MUST be after ${currentDate}) | ||
| 4. Eligibility requirements | ||
| 5. How to apply / application link | ||
|
|
||
| Return a JSON object: | ||
| { | ||
| "scholarships": [ | ||
| { | ||
| "id": "unique-id", | ||
| "name": "Scholarship Name", | ||
| "provider": "Organization", | ||
| "amount": "$X,XXX", | ||
| "deadline": "Month Day, Year", | ||
| "eligibility": ["Requirement 1", "Requirement 2"], | ||
| "description": "Brief description", | ||
| "applicationRequirements": ["Document 1", "Document 2"], | ||
| "applicationLink": "https://...", | ||
| "region": "${region || 'International'}", | ||
| "university": "${university || 'Various'}", | ||
| "type": "${scholarshipType}" | ||
| } | ||
| ] | ||
| } | ||
|
|
||
| Only include scholarships with deadlines AFTER ${currentDate}.`; | ||
|
|
||
| // Start all Mino agents in parallel | ||
| const agentPromises = scholarshipUrls.map(async (site, index) => { | ||
| const agentId = `agent-${index}`; | ||
|
|
||
| sendEvent({ | ||
| type: "AGENT_STARTED", | ||
| agentId, | ||
| siteName: site.name, | ||
| siteUrl: site.url, | ||
| description: site.description, | ||
| }); | ||
|
|
||
| try { | ||
| const minoResponse = await fetch("https://mino.ai/v1/automation/run-sse", { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| "X-API-Key": MINO_API_KEY, | ||
| }, | ||
| body: JSON.stringify({ | ||
| url: site.url, | ||
| goal: goal, | ||
| }), | ||
| }); | ||
|
|
||
| if (!minoResponse.ok) { | ||
| throw new Error(`Mino error: ${minoResponse.status}`); | ||
| } | ||
|
|
||
| // Process SSE stream from Mino | ||
| const reader = minoResponse.body?.getReader(); | ||
| if (!reader) throw new Error("No response body"); | ||
|
|
||
| const decoder = new TextDecoder(); | ||
| let buffer = ""; | ||
|
|
||
| while (true) { | ||
| const { done, value } = await reader.read(); | ||
| if (done) break; | ||
|
|
||
| buffer += decoder.decode(value, { stream: true }); | ||
| const lines = buffer.split("\n"); | ||
| buffer = lines.pop() || ""; | ||
|
|
||
| for (const line of lines) { | ||
| if (line.startsWith("data: ")) { | ||
| const jsonStr = line.slice(6).trim(); | ||
| if (!jsonStr || jsonStr === "[DONE]") continue; | ||
|
|
||
| try { | ||
| const data = JSON.parse(jsonStr); | ||
|
|
||
| // Forward streaming URL | ||
| if (data.type === "STREAMING_URL" && data.streamingUrl) { | ||
| sendEvent({ | ||
| type: "AGENT_STREAMING", | ||
| agentId, | ||
| siteName: site.name, | ||
| streamingUrl: data.streamingUrl, | ||
| }); | ||
| } | ||
|
|
||
| // Forward progress updates | ||
| if (data.type === "PROGRESS" && data.purpose) { | ||
| sendEvent({ | ||
| type: "AGENT_PROGRESS", | ||
| agentId, | ||
| siteName: site.name, | ||
| message: data.purpose, | ||
| }); | ||
| } | ||
|
|
||
| // Handle completion | ||
| if (data.type === "COMPLETE" && data.resultJson) { | ||
| const result = typeof data.resultJson === "string" | ||
| ? JSON.parse(data.resultJson.replace(/```json\n?|\n?```/g, "").trim()) | ||
| : data.resultJson; | ||
|
|
||
| sendEvent({ | ||
| type: "AGENT_COMPLETE", | ||
| agentId, | ||
| siteName: site.name, | ||
| scholarships: result.scholarships || [], | ||
| }); | ||
|
|
||
| return { agentId, site, scholarships: result.scholarships || [] }; | ||
| } | ||
| } catch { | ||
| // Continue on parse error | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return { agentId, site, scholarships: [] }; | ||
| } catch (error) { | ||
| const errorMessage = error instanceof Error ? error.message : "Agent failed"; | ||
| sendEvent({ | ||
| type: "AGENT_ERROR", | ||
| agentId, | ||
| siteName: site.name, | ||
| error: errorMessage, | ||
| }); | ||
| return { agentId, site, scholarships: [], error: errorMessage }; | ||
| } | ||
| }); | ||
|
|
||
| // Wait for all agents to complete | ||
| const results = await Promise.all(agentPromises); | ||
|
|
||
| // Combine all scholarships | ||
| const allScholarships = results.flatMap(r => r.scholarships || []); | ||
|
|
||
| sendEvent({ | ||
| type: "ALL_COMPLETE", | ||
| totalScholarships: allScholarships.length, | ||
| scholarships: allScholarships, | ||
| searchSummary: `Found ${allScholarships.length} scholarships from ${scholarshipUrls.length} sources.`, | ||
| }); | ||
|
|
||
| controller.close(); | ||
| } catch (error) { | ||
| const errorMessage = error instanceof Error ? error.message : "Search failed"; | ||
| console.error("Error:", error); | ||
| sendEvent({ type: "ERROR", error: errorMessage }); | ||
| controller.close(); | ||
| } | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, let's check the full context of the file to see the complete function
cd scholarship-finder && git ls-files | grep -E "(search-scholarships|index\.ts)" | head -20Repository: tinyfish-io/tinyfish-cookbook
Length of output: 120
🏁 Script executed:
# Look for the full file to understand the complete implementation
find . -name "index.ts" -path "*/search-scholarships/*" -type fRepository: tinyfish-io/tinyfish-cookbook
Length of output: 141
🏁 Script executed:
# Check the full function implementation
cat -n supabase/functions/search-scholarships/index.ts | head -300Repository: tinyfish-io/tinyfish-cookbook
Length of output: 152
🏁 Script executed:
# Search for existing ReadableStream usage patterns in the codebase
rg -A 3 "ReadableStream\s*\{" --type tsRepository: tinyfish-io/tinyfish-cookbook
Length of output: 55
🏁 Script executed:
cat -n ./scholarship-finder/supabase/functions/search-scholarships/index.ts | head -300Repository: tinyfish-io/tinyfish-cookbook
Length of output: 11952
🏁 Script executed:
# Check if there's a cancel handler already defined
rg "cancel\s*\(\s*\)" ./scholarship-finder/supabase/functions/search-scholarships/index.ts -A 2Repository: tinyfish-io/tinyfish-cookbook
Length of output: 55
🏁 Script executed:
# Check the end of the ReadableStream definition to see the full structure
tail -50 ./scholarship-finder/supabase/functions/search-scholarships/index.tsRepository: tinyfish-io/tinyfish-cookbook
Length of output: 1558
Add cancel() handler to abort downstream fetch requests on client disconnect.
The ReadableStream lacks a cancel() handler, so the Lovable AI and Mino agent fetch requests continue running after the client closes the SSE connection. In a parallel execution context with 5-8+ concurrent agents, this wastes resources and can incur unexpected API costs. Add an AbortController and pass its signal to both the Lovable fetch (line 80) and Mino fetch (line 176), then implement a cancel() handler to abort the controller when the client disconnects.
Suggested fix
- const stream = new ReadableStream({
+ const abortController = new AbortController();
+ const stream = new ReadableStream({
async start(controller) {
const sendEvent = (data: object) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
};
try {
const { scholarshipType, university, region }: SearchParams = await req.json();
const MINO_API_KEY = Deno.env.get("MINO_API_KEY");
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
if (!MINO_API_KEY) {
throw new Error("MINO_API_KEY not configured");
}
if (!LOVABLE_API_KEY) {
throw new Error("LOVABLE_API_KEY not configured");
}
const today = new Date();
const currentDate = today.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
const currentYear = today.getFullYear();
const locationContext = [
university ? `at ${university}` : "",
region ? `in ${region}` : "",
].filter(Boolean).join(" ");
// STEP 1: Use Lovable AI to get scholarship URLs
sendEvent({
type: "STEP",
step: 1,
message: "Finding scholarship websites..."
});
const aiPrompt = `Find 5-8 official scholarship provider websites for ${scholarshipType} scholarships ${locationContext}.
Return a JSON array of scholarship websites to search. Focus on:
- Official university financial aid pages
- Well-known scholarship foundations (Fulbright, Gates, Rhodes, etc.)
- Government scholarship programs
- Reputable scholarship aggregators
Return ONLY a JSON array like this:
[
{
"name": "MIT Financial Aid",
"url": "https://sfs.mit.edu/undergraduate-students/",
"description": "MIT's official financial aid office"
}
]
Include diverse sources: university-specific, national programs, and international opportunities if applicable.
Make sure all URLs are real, official websites.`;
const aiResponse = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${LOVABLE_API_KEY}`,
},
+ signal: abortController.signal,
body: JSON.stringify({
model: "google/gemini-3-flash-preview",
messages: [
{ role: "system", content: "You are a scholarship research assistant. Return only valid JSON arrays." },
{ role: "user", content: aiPrompt },
],
temperature: 0.7,
max_tokens: 2000,
}),
});
if (!aiResponse.ok) {
throw new Error(`AI Gateway error: ${aiResponse.status}`);
}
const aiData = await aiResponse.json();
const content = aiData.choices?.[0]?.message?.content;
if (!content) {
throw new Error("No content from AI");
}
// Parse the URLs from AI response
let scholarshipUrls: ScholarshipUrl[];
try {
const cleanedContent = content.replace(/```json\n?|\n?```/g, "").trim();
scholarshipUrls = JSON.parse(cleanedContent);
} catch {
throw new Error("Failed to parse scholarship URLs");
}
sendEvent({
type: "URLS_FOUND",
urls: scholarshipUrls,
message: `Found ${scholarshipUrls.length} scholarship sources to search`
});
// STEP 2: Run Mino agents in parallel
sendEvent({
type: "STEP",
step: 2,
message: `Launching ${scholarshipUrls.length} browser agents...`
});
const goal = `You are searching for ${scholarshipType} scholarships ${locationContext}.
CURRENT DATE: ${currentDate}
For this scholarship provider, extract:
1. Scholarship name(s)
2. Award amounts
3. Application deadlines (MUST be after ${currentDate})
4. Eligibility requirements
5. How to apply / application link
Return a JSON object:
{
"scholarships": [
{
"id": "unique-id",
"name": "Scholarship Name",
"provider": "Organization",
"amount": "$X,XXX",
"deadline": "Month Day, Year",
"eligibility": ["Requirement 1", "Requirement 2"],
"description": "Brief description",
"applicationRequirements": ["Document 1", "Document 2"],
"applicationLink": "https://...",
"region": "${region || 'International'}",
"university": "${university || 'Various'}",
"type": "${scholarshipType}"
}
]
}
Only include scholarships with deadlines AFTER ${currentDate}.`;
// Start all Mino agents in parallel
const agentPromises = scholarshipUrls.map(async (site, index) => {
const agentId = `agent-${index}`;
sendEvent({
type: "AGENT_STARTED",
agentId,
siteName: site.name,
siteUrl: site.url,
description: site.description,
});
try {
const minoResponse = await fetch("https://mino.ai/v1/automation/run-sse", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": MINO_API_KEY,
},
+ signal: abortController.signal,
body: JSON.stringify({
url: site.url,
goal: goal,
}),
});
if (!minoResponse.ok) {
throw new Error(`Mino error: ${minoResponse.status}`);
}
// Process SSE stream from Mino
const reader = minoResponse.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const jsonStr = line.slice(6).trim();
if (!jsonStr || jsonStr === "[DONE]") continue;
try {
const data = JSON.parse(jsonStr);
// Forward streaming URL
if (data.type === "STREAMING_URL" && data.streamingUrl) {
sendEvent({
type: "AGENT_STREAMING",
agentId,
siteName: site.name,
streamingUrl: data.streamingUrl,
});
}
// Forward progress updates
if (data.type === "PROGRESS" && data.purpose) {
sendEvent({
type: "AGENT_PROGRESS",
agentId,
siteName: site.name,
message: data.purpose,
});
}
// Handle completion
if (data.type === "COMPLETE" && data.resultJson) {
const result = typeof data.resultJson === "string"
? JSON.parse(data.resultJson.replace(/```json\n?|\n?```/g, "").trim())
: data.resultJson;
sendEvent({
type: "AGENT_COMPLETE",
agentId,
siteName: site.name,
scholarships: result.scholarships || [],
});
return { agentId, site, scholarships: result.scholarships || [] };
}
} catch {
// Continue on parse error
}
}
}
}
return { agentId, site, scholarships: [] };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Agent failed";
sendEvent({
type: "AGENT_ERROR",
agentId,
siteName: site.name,
error: errorMessage,
});
return { agentId, site, scholarships: [], error: errorMessage };
}
});
// Wait for all agents to complete
const results = await Promise.all(agentPromises);
// Combine all scholarships
const allScholarships = results.flatMap(r => r.scholarships || []);
sendEvent({
type: "ALL_COMPLETE",
totalScholarships: allScholarships.length,
scholarships: allScholarships,
searchSummary: `Found ${allScholarships.length} scholarships from ${scholarshipUrls.length} sources.`,
});
controller.close();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Search failed";
console.error("Error:", error);
sendEvent({ type: "ERROR", error: errorMessage });
controller.close();
}
},
+ cancel() {
+ abortController.abort();
+ },
});🤖 Prompt for AI Agents
In `@scholarship-finder/supabase/functions/search-scholarships/index.ts` around
lines 26 - 291, The ReadableStream lacks cancellation so downstream Lovable and
Mino fetches keep running; create an AbortController (e.g., abortController)
before calling req.json(), pass abortController.signal into the Lovable AI fetch
(the fetch to "https://ai.gateway.lovable.dev/v1/chat/completions") and into
each Mino fetch (the fetch to "https://mino.ai/v1/automation/run-sse" inside the
agentPromises), and implement a cancel() handler on the ReadableStream that
calls abortController.abort() to terminate all in-flight requests when the
client disconnects; ensure error handling in your existing try/catch (used
around parsing and agent work) gracefully handles AbortError cases.
| // Parse the URLs from AI response | ||
| let scholarshipUrls: ScholarshipUrl[]; | ||
| try { | ||
| const cleanedContent = content.replace(/```json\n?|\n?```/g, "").trim(); | ||
| scholarshipUrls = JSON.parse(cleanedContent); | ||
| } catch { | ||
| throw new Error("Failed to parse scholarship URLs"); | ||
| } | ||
|
|
||
| sendEvent({ | ||
| type: "URLS_FOUND", | ||
| urls: scholarshipUrls, | ||
| message: `Found ${scholarshipUrls.length} scholarship sources to search` | ||
| }); | ||
|
|
||
| // STEP 2: Run Mino agents in parallel | ||
| sendEvent({ | ||
| type: "STEP", | ||
| step: 2, | ||
| message: `Launching ${scholarshipUrls.length} browser agents...` | ||
| }); | ||
|
|
||
| const goal = `You are searching for ${scholarshipType} scholarships ${locationContext}. | ||
|
|
||
| CURRENT DATE: ${currentDate} | ||
|
|
||
| For this scholarship provider, extract: | ||
| 1. Scholarship name(s) | ||
| 2. Award amounts | ||
| 3. Application deadlines (MUST be after ${currentDate}) | ||
| 4. Eligibility requirements | ||
| 5. How to apply / application link | ||
|
|
||
| Return a JSON object: | ||
| { | ||
| "scholarships": [ | ||
| { | ||
| "id": "unique-id", | ||
| "name": "Scholarship Name", | ||
| "provider": "Organization", | ||
| "amount": "$X,XXX", | ||
| "deadline": "Month Day, Year", | ||
| "eligibility": ["Requirement 1", "Requirement 2"], | ||
| "description": "Brief description", | ||
| "applicationRequirements": ["Document 1", "Document 2"], | ||
| "applicationLink": "https://...", | ||
| "region": "${region || 'International'}", | ||
| "university": "${university || 'Various'}", | ||
| "type": "${scholarshipType}" | ||
| } | ||
| ] | ||
| } | ||
|
|
||
| Only include scholarships with deadlines AFTER ${currentDate}.`; | ||
|
|
||
| // Start all Mino agents in parallel | ||
| const agentPromises = scholarshipUrls.map(async (site, index) => { | ||
| const agentId = `agent-${index}`; | ||
|
|
||
| sendEvent({ | ||
| type: "AGENT_STARTED", | ||
| agentId, | ||
| siteName: site.name, | ||
| siteUrl: site.url, | ||
| description: site.description, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
cat -n scholarship-finder/supabase/functions/search-scholarships/index.ts | sed -n '90,185p'Repository: tinyfish-io/tinyfish-cookbook
Length of output: 3630
🏁 Script executed:
# Also search for ReadableStream usage and cancel handlers in the file
rg -n "ReadableStream|\.cancel|abort|AbortController" scholarship-finder/supabase/functions/search-scholarships/index.tsRepository: tinyfish-io/tinyfish-cookbook
Length of output: 113
🏁 Script executed:
cat -n scholarship-finder/supabase/functions/search-scholarships/index.ts | sed -n '1,50p'Repository: tinyfish-io/tinyfish-cookbook
Length of output: 1905
🏁 Script executed:
cat -n scholarship-finder/supabase/functions/search-scholarships/index.ts | sed -n '160,220p'Repository: tinyfish-io/tinyfish-cookbook
Length of output: 2557
🏁 Script executed:
cat -n scholarship-finder/supabase/functions/search-scholarships/index.ts | sed -n '220,300p'Repository: tinyfish-io/tinyfish-cookbook
Length of output: 3409
Validate and cap AI-generated URL list before spawning agents.
AI responses are untrusted. Without validation, a malformed or oversized response can trigger uncontrolled parallel agents: line 164 maps over scholarshipUrls with no array check or length cap, creating unbounded concurrent fetch requests to the Mino API at line 176. A single response with 100+ URLs could trigger cost spikes and reliability issues.
🛠️ Suggested fix
let scholarshipUrls: ScholarshipUrl[];
try {
const cleanedContent = content.replace(/```json\n?|\n?```/g, "").trim();
scholarshipUrls = JSON.parse(cleanedContent);
} catch {
throw new Error("Failed to parse scholarship URLs");
}
+
+ if (!Array.isArray(scholarshipUrls)) {
+ throw new Error("Scholarship URLs must be an array");
+ }
+ scholarshipUrls = scholarshipUrls
+ .filter((u) => u?.name && u?.url && u?.description)
+ .slice(0, 8);
+ if (scholarshipUrls.length === 0) {
+ throw new Error("No valid scholarship URLs returned");
+ }📝 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.
| // Parse the URLs from AI response | |
| let scholarshipUrls: ScholarshipUrl[]; | |
| try { | |
| const cleanedContent = content.replace(/```json\n?|\n?```/g, "").trim(); | |
| scholarshipUrls = JSON.parse(cleanedContent); | |
| } catch { | |
| throw new Error("Failed to parse scholarship URLs"); | |
| } | |
| sendEvent({ | |
| type: "URLS_FOUND", | |
| urls: scholarshipUrls, | |
| message: `Found ${scholarshipUrls.length} scholarship sources to search` | |
| }); | |
| // STEP 2: Run Mino agents in parallel | |
| sendEvent({ | |
| type: "STEP", | |
| step: 2, | |
| message: `Launching ${scholarshipUrls.length} browser agents...` | |
| }); | |
| const goal = `You are searching for ${scholarshipType} scholarships ${locationContext}. | |
| CURRENT DATE: ${currentDate} | |
| For this scholarship provider, extract: | |
| 1. Scholarship name(s) | |
| 2. Award amounts | |
| 3. Application deadlines (MUST be after ${currentDate}) | |
| 4. Eligibility requirements | |
| 5. How to apply / application link | |
| Return a JSON object: | |
| { | |
| "scholarships": [ | |
| { | |
| "id": "unique-id", | |
| "name": "Scholarship Name", | |
| "provider": "Organization", | |
| "amount": "$X,XXX", | |
| "deadline": "Month Day, Year", | |
| "eligibility": ["Requirement 1", "Requirement 2"], | |
| "description": "Brief description", | |
| "applicationRequirements": ["Document 1", "Document 2"], | |
| "applicationLink": "https://...", | |
| "region": "${region || 'International'}", | |
| "university": "${university || 'Various'}", | |
| "type": "${scholarshipType}" | |
| } | |
| ] | |
| } | |
| Only include scholarships with deadlines AFTER ${currentDate}.`; | |
| // Start all Mino agents in parallel | |
| const agentPromises = scholarshipUrls.map(async (site, index) => { | |
| const agentId = `agent-${index}`; | |
| sendEvent({ | |
| type: "AGENT_STARTED", | |
| agentId, | |
| siteName: site.name, | |
| siteUrl: site.url, | |
| description: site.description, | |
| // Parse the URLs from AI response | |
| let scholarshipUrls: ScholarshipUrl[]; | |
| try { | |
| const cleanedContent = content.replace(/ |
🤖 Prompt for AI Agents
In `@scholarship-finder/supabase/functions/search-scholarships/index.ts` around
lines 108 - 172, The AI-returned scholarshipUrls are untrusted and currently
mapped into agentPromises without validation or limits, which can spawn
unbounded parallel agents; after the JSON.parse of content (scholarshipUrls)
validate that scholarshipUrls is an Array, filter out entries missing required
fields (e.g., name, url, description), slice the array to a safe concurrency cap
(e.g., 8), and throw an error if no valid entries remain so
agentPromises.map(...) (the agentPromises creation and subsequent Mino calls)
only iterates a validated, bounded list.
uttambharadwaj
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
really cool idea!
No description provided.