Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions src/components/SearchDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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) {
Expand Down Expand Up @@ -90,6 +91,21 @@ export function CustomSearchDialog(props: SharedProps) {
return () => window.clearTimeout(handle);
}, [search]);

useEffect(() => {
const onPrefillSearch = (event: Event) => {
const { query } = (event as CustomEvent<PrefillDocsSearchDetail>).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);
Expand Down
258 changes: 244 additions & 14 deletions src/components/not-found.tsx
Original file line number Diff line number Diff line change
@@ -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\/?/, "");
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Strip docs prefix only when it is a full path segment

The prefix removal in parseSearchQueryFromUrl uses rawPath.replace(/^\/?docs\/?/, ""), which also matches URLs whose first segment merely starts with docs (for example, /docs-as-code or /docsify/...). In those cases the generated query is truncated (-as-code, ify ...), so the 404 page suggests unrelated docs and pre-fills the search dialog with the wrong text. This should only remove /docs when it is an entire segment (e.g., followed by / or end-of-string).

Useful? React with 👍 / 👎.


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 (
<ul className="list-disc space-y-3 pl-5" aria-label="Loading search suggestions">
{Array.from({ length: 5 }).map((_, index) => (
<li key={index} className="text-fd-muted-foreground">
<span
className="inline-block h-3 animate-pulse rounded bg-fd-muted align-middle"
style={{ width: `${Math.max(38, 86 - index * 9)}%` }}
/>
</li>
))}
</ul>
);
}

export function NotFound() {
const { setOpenSearch } = useSearchContext();
const { hash, pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState("");
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([]);
const [searchStatus, setSearchStatus] = useState<SearchStatus>("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 (
<HomeLayout {...baseOptions()}>
<div className="flex flex-col justify-center flex-1 text-center items-center gap-4">
<h1 className="text-6xl font-bold text-fd-muted-foreground">404</h1>
<h2 className="text-2xl font-semibold">Page Not Found</h2>
<p className="text-fd-muted-foreground max-w-md">
The page you are looking for might have been removed, had its name changed, or is
temporarily unavailable.
</p>
<Link
to="/"
className="mt-4 px-4 py-2 rounded-lg bg-fd-primary text-fd-primary-foreground font-medium text-sm hover:opacity-90 transition-opacity"
>
Back to Home
</Link>
<div className="mx-auto flex min-h-[calc(100vh-9rem)] w-full max-w-4xl flex-col justify-center gap-8 px-6 py-16">
<div className="w-full">
<h1 className="mx-auto max-w-2xl text-center text-4xl font-semibold tracking-tight text-fd-foreground sm:text-5xl">
Page not found
</h1>
<div className="mt-7 flex flex-col items-center justify-center gap-3 sm:flex-row">
<button
type="button"
onClick={handleSearchClick}
title="Search"
className="inline-flex h-10 min-w-0 max-w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-transparent bg-fd-primary px-4 text-sm font-medium text-fd-primary-foreground transition-colors hover:bg-fd-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring"
>
<Search className="size-4 shrink-0" />
<span className="truncate">Search</span>
</button>
<Link
to="/"
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border px-4 text-sm font-medium transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring"
>
<Home className="size-4" />
Docs home
</Link>
</div>

<div className="mt-7 rounded-lg border bg-fd-card p-4">
<h2 className="mb-3 text-sm font-medium text-fd-foreground">Related Docs</h2>
{searchStatus === "loading" ? (
<SearchSuggestionsSkeleton />
) : suggestions.length > 0 ? (
<ul className="list-disc space-y-2 pl-5 text-sm leading-6">
{suggestions.map((suggestion, index) => (
<li key={`${suggestion.url}-${index}`}>
<Link
to={suggestion.to}
className="font-medium text-fd-foreground underline underline-offset-4 transition-colors hover:text-fd-primary"
>
{suggestion.label}
</Link>
</li>
))}
</ul>
) : (
<p className="text-sm text-fd-muted-foreground">No related docs found.</p>
)}
</div>
</div>

<div className="grid gap-3 sm:grid-cols-3">
{helpfulLinks.map((link) => {
const Icon = link.icon;

return (
<Link
key={link.to}
to={link.to}
className="group rounded-lg border bg-fd-card p-4 text-left transition-colors hover:border-fd-primary/60 hover:bg-fd-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring"
>
<div className="mb-3 flex size-9 items-center justify-center rounded-md bg-fd-muted text-fd-muted-foreground transition-colors group-hover:text-fd-foreground">
<Icon className="size-4" />
</div>
<div className="flex items-center gap-2 text-sm font-medium text-fd-foreground">
{link.title}
<ArrowRight className="size-3.5 opacity-0 transition-opacity group-hover:opacity-100" />
</div>
<p className="mt-2 text-sm leading-6 text-fd-muted-foreground">
{link.description}
</p>
</Link>
);
})}
</div>
</div>
</HomeLayout>
);
Expand Down
15 changes: 15 additions & 0 deletions src/lib/docs-search-events.ts
Original file line number Diff line number Diff line change
@@ -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<PrefillDocsSearchDetail>(PREFILL_DOCS_SEARCH_EVENT, {
detail: { query },
}),
);
}
Loading
Loading