From 0095abfafe360baa3b510a4ec952ff5fce22790c Mon Sep 17 00:00:00 2001 From: Duncan Crawbuck Date: Tue, 28 Apr 2026 18:09:53 -0700 Subject: [PATCH 1/2] Improve docs 404 page --- src/components/SearchDialog.tsx | 20 ++- src/components/not-found.tsx | 258 ++++++++++++++++++++++++++++++-- src/lib/docs-search-events.ts | 15 ++ src/routeTree.gen.ts | 21 --- src/routes/api/404-report.ts | 69 --------- 5 files changed, 277 insertions(+), 106 deletions(-) create mode 100644 src/lib/docs-search-events.ts delete mode 100644 src/routes/api/404-report.ts diff --git a/src/components/SearchDialog.tsx b/src/components/SearchDialog.tsx index 906057ab..4bae8b69 100644 --- a/src/components/SearchDialog.tsx +++ b/src/components/SearchDialog.tsx @@ -27,6 +27,7 @@ import { createStaticSearchClient, preloadStaticSearch } from "@/lib/static-sear import { closeSuperchat, isSuperchatOpen, openSuperchat } from "@/lib/superchat"; import { cn } from "@/lib/cn"; import { buildDocsApiPath } from "@/lib/url-base"; +import { PREFILL_DOCS_SEARCH_EVENT, type PrefillDocsSearchDetail } from "@/lib/docs-search-events"; const SEARCH_DELAY_MS = 100; @@ -38,8 +39,8 @@ function scheduleIdle(callback: () => void) { return () => window.cancelIdleCallback(handle); } - const handle = window.setTimeout(callback, 1_200); - return () => window.clearTimeout(handle); + const handle = globalThis.setTimeout(callback, 1_200); + return () => globalThis.clearTimeout(handle); } export function CustomSearchDialog(props: SharedProps) { @@ -90,6 +91,21 @@ export function CustomSearchDialog(props: SharedProps) { return () => window.clearTimeout(handle); }, [search]); + useEffect(() => { + const onPrefillSearch = (event: Event) => { + const { query } = (event as CustomEvent).detail ?? {}; + const nextSearch = typeof query === "string" ? query.trim() : ""; + if (nextSearch) { + setSearch(nextSearch); + setDebouncedSearch(nextSearch); + } + props.onOpenChange(true); + }; + + window.addEventListener(PREFILL_DOCS_SEARCH_EVENT, onPrefillSearch); + return () => window.removeEventListener(PREFILL_DOCS_SEARCH_EVENT, onPrefillSearch); + }, [props.onOpenChange, setSearch]); + useEffect(() => { if (hasConfirmedNoResults) { setIsAskAIHintActive(true); diff --git a/src/components/not-found.tsx b/src/components/not-found.tsx index 339722ca..2d034b6b 100644 --- a/src/components/not-found.tsx +++ b/src/components/not-found.tsx @@ -1,23 +1,253 @@ +"use client"; + +import { useEffect, useState } from "react"; import { baseOptions } from "@/lib/layout.shared"; -import { Link } from "@tanstack/react-router"; +import { buildDocsApiPath, toRouterPath } from "@/lib/url-base"; +import { Link, useLocation } from "@tanstack/react-router"; import { HomeLayout } from "fumadocs-ui/layouts/home"; +import { useSearchContext } from "fumadocs-ui/contexts/search"; +import { fetchClient } from "fumadocs-core/search/client/fetch"; +import type { SortedResult } from "fumadocs-core/search"; +import { ArrowRight, CircleHelp, Home, LayoutDashboard, Search, Smartphone } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { prefillDocsSearch } from "@/lib/docs-search-events"; +import { createStaticSearchClient } from "@/lib/static-search-client"; +import { SEARCH_INDEX_PATH } from "@/lib/search.shared"; + +type HelpfulLink = { + description: string; + icon: LucideIcon; + title: string; + to: string; +}; + +const helpfulLinks: HelpfulLink[] = [ + { + title: "SDK", + description: "Start with installation and platform setup.", + icon: Smartphone, + to: "/sdk", + }, + { + title: "Dashboard", + description: "Manage paywalls, campaigns, products, and analytics.", + icon: LayoutDashboard, + to: "/dashboard", + }, + { + title: "Support", + description: "Find troubleshooting articles and FAQs.", + icon: CircleHelp, + to: "/support", + }, +]; + +type SearchSuggestion = { + label: string; + to: string; + url: string; +}; + +type SearchStatus = "loading" | "ready"; + +function parseSearchQueryFromUrl(pathname: string, hash: string): string { + const rawPath = `${pathname}${hash ? `/${hash.replace(/^#/, "")}` : ""}`; + const withoutDocsPrefix = rawPath.replace(/^\/?docs\/?/, ""); + + try { + return decodeURIComponent(withoutDocsPrefix) + .replace(/\.[a-z0-9]+$/i, "") + .replace(/[^a-zA-Z0-9]+/g, " ") + .trim() + .replace(/\s+/g, " ") + .toLowerCase(); + } catch { + return withoutDocsPrefix + .replace(/\.[a-z0-9]+$/i, "") + .replace(/[^a-zA-Z0-9]+/g, " ") + .trim() + .replace(/\s+/g, " ") + .toLowerCase(); + } +} + +function getSearchClient() { + if (process.env.NODE_ENV === "production") { + return createStaticSearchClient({ + from: SEARCH_INDEX_PATH, + }); + } + + return fetchClient({ + api: buildDocsApiPath("search"), + }); +} + +function cleanSearchText(value: string): string { + return value + .replace(/<\/?mark>/g, "") + .replace(/[`*_]+/g, "") + .trim(); +} + +function getSuggestionLabel(result: SortedResult): string { + const title = cleanSearchText(result.content); + const breadcrumbs = result.breadcrumbs?.map(cleanSearchText).filter(Boolean) ?? []; + + if (result.type === "page" || breadcrumbs.length === 0) { + return title; + } + + return [...breadcrumbs, title].join(" / "); +} + +function getSearchSuggestions(results: SortedResult[]): SearchSuggestion[] { + return results.slice(0, 5).map((result) => ({ + label: getSuggestionLabel(result) || result.url.replace(/^\/docs\/?/, ""), + to: toRouterPath(result.url), + url: result.url, + })); +} + +function SearchSuggestionsSkeleton() { + return ( + + ); +} export function NotFound() { + const { setOpenSearch } = useSearchContext(); + const { hash, pathname } = useLocation(); + const [searchQuery, setSearchQuery] = useState(""); + const [suggestions, setSuggestions] = useState([]); + const [searchStatus, setSearchStatus] = useState("loading"); + + useEffect(() => { + const nextSearchQuery = parseSearchQueryFromUrl(pathname, hash); + let isActive = true; + + setSearchQuery(nextSearchQuery); + setSearchStatus("loading"); + setSuggestions([]); + + if (!nextSearchQuery) { + setSearchStatus("ready"); + return () => { + isActive = false; + }; + } + + Promise.resolve(getSearchClient().search(nextSearchQuery)) + .then((results) => { + if (!isActive) return; + setSuggestions(getSearchSuggestions(results)); + }) + .catch(() => { + if (!isActive) return; + setSuggestions([]); + }) + .finally(() => { + if (!isActive) return; + setSearchStatus("ready"); + }); + + return () => { + isActive = false; + }; + }, [hash, pathname]); + + const handleSearchClick = () => { + if (searchQuery) { + setOpenSearch(true); + window.setTimeout(() => prefillDocsSearch(searchQuery), 0); + return; + } + + setOpenSearch(true); + }; + return ( -
-

404

-

Page Not Found

-

- The page you are looking for might have been removed, had its name changed, or is - temporarily unavailable. -

- - Back to Home - +
+
+

+ Page not found +

+
+ + + + Docs home + +
+ +
+

Related Docs

+ {searchStatus === "loading" ? ( + + ) : suggestions.length > 0 ? ( +
    + {suggestions.map((suggestion, index) => ( +
  • + + {suggestion.label} + +
  • + ))} +
+ ) : ( +

No related docs found.

+ )} +
+
+ +
+ {helpfulLinks.map((link) => { + const Icon = link.icon; + + return ( + +
+ +
+
+ {link.title} + +
+

+ {link.description} +

+ + ); + })} +
); diff --git a/src/lib/docs-search-events.ts b/src/lib/docs-search-events.ts new file mode 100644 index 00000000..962a90bc --- /dev/null +++ b/src/lib/docs-search-events.ts @@ -0,0 +1,15 @@ +"use client"; + +export const PREFILL_DOCS_SEARCH_EVENT = "superwall:prefill-docs-search"; + +export type PrefillDocsSearchDetail = { + query: string; +}; + +export function prefillDocsSearch(query: string) { + window.dispatchEvent( + new CustomEvent(PREFILL_DOCS_SEARCH_EVENT, { + detail: { query }, + }), + ); +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index d58db003..f9050ae7 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -21,7 +21,6 @@ import { Route as SdkIndexRouteImport } from './routes/sdk/index' import { Route as SdkSplatRouteImport } from './routes/sdk/$' import { Route as ApiSearchRouteImport } from './routes/api/search' import { Route as ApiFeedbackRouteImport } from './routes/api/feedback' -import { Route as Api404ReportRouteImport } from './routes/api/404-report' import { Route as LlmsDotmdxDocsSplatRouteImport } from './routes/llms[.]mdx.docs.$' import { Route as ApiRawSplatRouteImport } from './routes/api/raw/$' @@ -87,11 +86,6 @@ const ApiFeedbackRoute = ApiFeedbackRouteImport.update({ path: '/api/feedback', getParentRoute: () => rootRouteImport, } as any) -const Api404ReportRoute = Api404ReportRouteImport.update({ - id: '/api/404-report', - path: '/api/404-report', - getParentRoute: () => rootRouteImport, -} as any) const LlmsDotmdxDocsSplatRoute = LlmsDotmdxDocsSplatRouteImport.update({ id: '/llms.mdx/docs/$', path: '/llms.mdx/docs/$', @@ -112,7 +106,6 @@ export interface FileRoutesByFullPath { '/llms.txt': typeof LlmsDottxtRoute '/robots.txt': typeof RobotsDottxtRoute '/sitemap.xml': typeof SitemapDotxmlRoute - '/api/404-report': typeof Api404ReportRoute '/api/feedback': typeof ApiFeedbackRoute '/api/search': typeof ApiSearchRoute '/sdk/$': typeof SdkSplatRoute @@ -129,7 +122,6 @@ export interface FileRoutesByTo { '/llms.txt': typeof LlmsDottxtRoute '/robots.txt': typeof RobotsDottxtRoute '/sitemap.xml': typeof SitemapDotxmlRoute - '/api/404-report': typeof Api404ReportRoute '/api/feedback': typeof ApiFeedbackRoute '/api/search': typeof ApiSearchRoute '/sdk/$': typeof SdkSplatRoute @@ -147,7 +139,6 @@ export interface FileRoutesById { '/llms.txt': typeof LlmsDottxtRoute '/robots.txt': typeof RobotsDottxtRoute '/sitemap.xml': typeof SitemapDotxmlRoute - '/api/404-report': typeof Api404ReportRoute '/api/feedback': typeof ApiFeedbackRoute '/api/search': typeof ApiSearchRoute '/sdk/$': typeof SdkSplatRoute @@ -166,7 +157,6 @@ export interface FileRouteTypes { | '/llms.txt' | '/robots.txt' | '/sitemap.xml' - | '/api/404-report' | '/api/feedback' | '/api/search' | '/sdk/$' @@ -183,7 +173,6 @@ export interface FileRouteTypes { | '/llms.txt' | '/robots.txt' | '/sitemap.xml' - | '/api/404-report' | '/api/feedback' | '/api/search' | '/sdk/$' @@ -200,7 +189,6 @@ export interface FileRouteTypes { | '/llms.txt' | '/robots.txt' | '/sitemap.xml' - | '/api/404-report' | '/api/feedback' | '/api/search' | '/sdk/$' @@ -218,7 +206,6 @@ export interface RootRouteChildren { LlmsDottxtRoute: typeof LlmsDottxtRoute RobotsDottxtRoute: typeof RobotsDottxtRoute SitemapDotxmlRoute: typeof SitemapDotxmlRoute - Api404ReportRoute: typeof Api404ReportRoute ApiFeedbackRoute: typeof ApiFeedbackRoute ApiSearchRoute: typeof ApiSearchRoute SdkSplatRoute: typeof SdkSplatRoute @@ -313,13 +300,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiFeedbackRouteImport parentRoute: typeof rootRouteImport } - '/api/404-report': { - id: '/api/404-report' - path: '/api/404-report' - fullPath: '/api/404-report' - preLoaderRoute: typeof Api404ReportRouteImport - parentRoute: typeof rootRouteImport - } '/llms.mdx/docs/$': { id: '/llms.mdx/docs/$' path: '/llms.mdx/docs/$' @@ -347,7 +327,6 @@ const rootRouteChildren: RootRouteChildren = { LlmsDottxtRoute: LlmsDottxtRoute, RobotsDottxtRoute: RobotsDottxtRoute, SitemapDotxmlRoute: SitemapDotxmlRoute, - Api404ReportRoute: Api404ReportRoute, ApiFeedbackRoute: ApiFeedbackRoute, ApiSearchRoute: ApiSearchRoute, SdkSplatRoute: SdkSplatRoute, diff --git a/src/routes/api/404-report.ts b/src/routes/api/404-report.ts deleted file mode 100644 index 733aa907..00000000 --- a/src/routes/api/404-report.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; - -export const Route = createFileRoute("/api/404-report")({ - server: { - handlers: { - POST: ({ request }) => handle404ReportPost(request), - }, - }, -}); - -export async function handle404ReportPost(request: Request) { - try { - const webhookUrl = process.env.SLACK_404_WEBHOOK_URL; - - if (!webhookUrl) { - console.warn("SLACK_404_WEBHOOK_URL not configured"); - return new Response(JSON.stringify({ success: false, error: "Webhook not configured" }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); - } - - const { url, userAgent: _userAgent, referrer, email } = await request.json(); - - const isResourceRequest = - /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot|pdf|zip|mp4|webm|ogg)$/i.test(url); - - if (isResourceRequest) { - return new Response(JSON.stringify({ success: true, message: "Resource request ignored" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - if (url.includes("localhost")) { - return new Response(JSON.stringify({ success: true, message: "Localhost request ignored" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - - const response = await fetch(webhookUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - text: [ - "*Docs 404 Report*", - `*URL*: ${url}`, - `*Referrer*: ${referrer}`, - ...(email ? [`*User*: ${email}`] : []), - ].join("\n"), - }), - }); - - if (!response.ok) { - throw new Error(`Slack webhook failed: ${response.status}`); - } - - return new Response(JSON.stringify({ success: true }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } catch (error) { - console.error("Error reporting 404 to Slack:", error); - return new Response(JSON.stringify({ success: false, error: "Failed to report 404" }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); - } -} From 9354e07cdba2a7b056d96d58935db4af35eb4f2f Mon Sep 17 00:00:00 2001 From: Duncan Crawbuck Date: Tue, 28 Apr 2026 18:13:07 -0700 Subject: [PATCH 2/2] Center 404 heading actions --- src/components/not-found.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/not-found.tsx b/src/components/not-found.tsx index 2d034b6b..c9130406 100644 --- a/src/components/not-found.tsx +++ b/src/components/not-found.tsx @@ -179,10 +179,10 @@ export function NotFound() {
-

+

Page not found

-
+