- );
-}
\ No newline at end of file
diff --git a/src/components/CustomSidebarFooter.tsx b/src/components/CustomSidebarFooter.tsx
deleted file mode 100644
index a942c18a..00000000
--- a/src/components/CustomSidebarFooter.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-'use client';
-
-import { useDiscordInfo } from '@/hooks/useDiscordInfo';
-import { DiscordIcon } from '@/components/DiscordIcon';
-import type { DocsLayoutProps } from 'fumadocs-ui/layouts/docs';
-import { ThemeToggle } from 'fumadocs-ui/components/layout/theme-toggle';
-
-interface CustomSidebarFooterProps {
- i18n?: DocsLayoutProps['i18n'];
- themeSwitch?: DocsLayoutProps['themeSwitch'];
- githubUrl?: string;
-}
-
-export function CustomSidebarFooter({ i18n, themeSwitch, githubUrl }: CustomSidebarFooterProps) {
- const { discordInfo } = useDiscordInfo();
-
- return (
-
- );
-}
\ No newline at end of file
diff --git a/src/components/DiscordIcon.tsx b/src/components/DiscordIcon.tsx
index 7f0016f6..33bfce23 100644
--- a/src/components/DiscordIcon.tsx
+++ b/src/components/DiscordIcon.tsx
@@ -4,13 +4,13 @@ interface DiscordIconProps {
export function DiscordIcon({ className }: DiscordIconProps) {
return (
-
);
-}
\ No newline at end of file
+}
diff --git a/src/components/GlobalScripts.tsx b/src/components/GlobalScripts.tsx
index 2c315c8e..faee1487 100644
--- a/src/components/GlobalScripts.tsx
+++ b/src/components/GlobalScripts.tsx
@@ -1,169 +1,173 @@
-import Script from 'next/script';
+"use client";
-interface GlobalScriptsProps {
- location: 'head' | 'body-start' | 'body-end';
-}
-
-const toJsStringLiteral = (value?: string) => (value ? JSON.stringify(value) : 'undefined');
-
-export function GlobalScripts({ location }: GlobalScriptsProps) {
- if (location !== 'head') {
- return null;
- }
+import { useEffect } from "react";
- const meshSdkKey = process.env.NEXT_PUBLIC_MESH_SDK_KEY;
- const unifyScriptSrc = process.env.NEXT_PUBLIC_UNIFY_SCRIPT_SRC;
- const unifyApiKey = process.env.NEXT_PUBLIC_UNIFY_API_KEY;
- const rb2bKey = process.env.NEXT_PUBLIC_RB2B_KEY;
- const pylonAppId = process.env.NEXT_PUBLIC_PYLON_APP_ID;
+export function GlobalScripts() {
+ useEffect(() => {
+ const meshSdkKey = (window as any).__ENV__?.MESH_SDK_KEY;
+ const unifyScriptSrc = (window as any).__ENV__?.UNIFY_SCRIPT_SRC;
+ const unifyApiKey = (window as any).__ENV__?.UNIFY_API_KEY;
+ const rb2bKey = (window as any).__ENV__?.RB2B_KEY;
+ const pylonAppId = (window as any).__ENV__?.PYLON_APP_ID;
- const scriptContent = `
- (async function () {
+ (async function () {
+ try {
+ const response = await fetch("/api/auth/session", { credentials: "include" });
+ let isLoggedIn = false;
+ let sessionData: any = null;
- try {
- var response = await fetch('/api/auth/session', { credentials: 'include' });
- var isLoggedIn = true;
- var sessionData = null;
- if (response && response.ok) {
- try {
- var session = await response.json();
- isLoggedIn = !!(session && session.isLoggedIn);
- sessionData = session;
- } catch (_e) {
- console.error('Failed to authenticate user' + _e);
- return;
- }
- }
- else {
- console.error('Failed to authenticate user');
- return;
- }
-
- if (isLoggedIn) {
- // Load Pylon widget for logged-in users
- var pylonAppId = ${toJsStringLiteral(pylonAppId)};
- if (pylonAppId && sessionData.userInfo.email) {
- // Load Pylon widget script
- (function(){
- var e=window;
- var t=document;
- var n=function(){n.e(arguments)};
- n.q=[];
- n.e=function(e){n.q.push(e)};
- e.Pylon=n;
- var r=function(){
- var e=t.createElement("script");
- e.setAttribute("type","text/javascript");
- e.setAttribute("async","true");
- e.setAttribute("src","https://widget.usepylon.com/widget/" + pylonAppId);
- var n=t.getElementsByTagName("script")[0];
- n.parentNode.insertBefore(e,n);
- };
- if(t.readyState==="complete"){r()}
- else if(e.addEventListener){e.addEventListener("load",r,false)}
- })();
+ if (response && response.ok) {
+ try {
+ const session = await response.json();
+ isLoggedIn = !!(session && session.isLoggedIn);
+ sessionData = session;
+ } catch (_e) {
+ return;
+ }
+ } else {
+ return;
+ }
- // Configure Pylon with user data from session
- var userEmail = sessionData.userInfo.email;
- var userName = sessionData.userInfo.name;
- window.pylon = {
- chat_settings: {
- app_id: pylonAppId,
- email: sessionData.userInfo.email,
- name: sessionData.userInfo.name,
- email_hash: sessionData.emailHash,
- }
- };
- }
+ if (isLoggedIn && sessionData) {
+ // Load Pylon widget for logged-in users
+ if (pylonAppId && sessionData.userInfo?.email) {
+ (function () {
+ const e = window as any;
+ const t = document;
+ const n = function (...args: any[]) {
+ n.q.push(args);
+ };
+ n.q = [] as any[];
+ n.e = function (args: any) {
+ n.q.push(args);
+ };
+ e.Pylon = n;
+ const r = function () {
+ const el = t.createElement("script");
+ el.setAttribute("type", "text/javascript");
+ el.setAttribute("async", "true");
+ el.setAttribute("src", "https://widget.usepylon.com/widget/" + pylonAppId);
+ const first = t.getElementsByTagName("script")[0];
+ first?.parentNode?.insertBefore(el, first);
+ };
+ if (t.readyState === "complete") {
+ r();
} else {
- var meshSdkKey = ${toJsStringLiteral(meshSdkKey)};
- if (meshSdkKey) {
- // Avina
- (function (m, e, s, h, a, i, c) {
- m[a] = m[a] || function () {
- (m[a].q = m[a].q || []).push(arguments);
- };
- var o = e.createElement(s);
- o.type = 'text/javascript';
- o.id = 'mesh-analytics-sdk';
- o.async = true;
- o.src = h;
- o.setAttribute('data-mesh-sdk', i);
- o.setAttribute('data-mesh-sdk-attributes', JSON.stringify(c));
- var x = e.getElementsByTagName(s)[0];
- x.parentNode.insertBefore(o, x);
- })(window, document, 'script',
- 'https://cdn.jsdelivr.net/npm/@mesh-interactive/mesh-sdk@latest/dist/umd/index.js', 'mesh',
- meshSdkKey,
- { useFingerprint: false, track: { session: true, forms: true } });
- }
+ e.addEventListener("load", r, false);
+ }
+ })();
- var unifyScriptSrc = ${toJsStringLiteral(unifyScriptSrc)};
- var unifyApiKey = ${toJsStringLiteral(unifyApiKey)};
- if (unifyScriptSrc && unifyApiKey) {
- // Unify
- (function () {
- var methods = ["identify", "page", "startAutoPage", "stopAutoPage", "startAutoIdentify", "stopAutoIdentify"];
- function factory(queue) {
- return Object.assign([], methods.reduce(function (acc, name) {
- acc[name] = function () { return queue.push([name, [].slice.call(arguments)]), queue; };
- return acc;
- }, {}));
- }
- window.unify || (window.unify = factory(window.unify));
- window.unifyBrowser || (window.unifyBrowser = factory(window.unifyBrowser));
- var script = document.createElement('script');
- script.async = true;
- script.setAttribute('src', unifyScriptSrc);
- script.setAttribute('data-api-key', unifyApiKey);
- script.setAttribute('id', 'unifytag');
- (document.body || document.head).appendChild(script);
- })();
- }
+ (window as any).pylon = {
+ chat_settings: {
+ app_id: pylonAppId,
+ email: sessionData.userInfo.email,
+ name: sessionData.userInfo.name,
+ email_hash: sessionData.emailHash,
+ },
+ };
+ }
+ } else {
+ // Logged-out users: load analytics scripts
+ if (meshSdkKey) {
+ (function (m: any, e: any, s: any, h: any, a: any, i: any, c: any) {
+ m[a] =
+ m[a] ||
+ function () {
+ (m[a].q = m[a].q || []).push(arguments);
+ };
+ const o = e.createElement(s);
+ o.type = "text/javascript";
+ o.id = "mesh-analytics-sdk";
+ o.async = true;
+ o.src = h;
+ o.setAttribute("data-mesh-sdk", i);
+ o.setAttribute("data-mesh-sdk-attributes", JSON.stringify(c));
+ const x = e.getElementsByTagName(s)[0];
+ x?.parentNode?.insertBefore(o, x);
+ })(
+ window,
+ document,
+ "script",
+ "https://cdn.jsdelivr.net/npm/@mesh-interactive/mesh-sdk@latest/dist/umd/index.js",
+ "mesh",
+ meshSdkKey,
+ { useFingerprint: false, track: { session: true, forms: true } },
+ );
+ }
- var rb2bKey = ${toJsStringLiteral(rb2bKey)};
- if (rb2bKey) {
- // RB2B
- (function(key) {
- if (window.reb2b) return;
- window.reb2b = { loaded: true };
- var s = document.createElement("script");
- s.async = true;
- s.src = "https://b2bjsstore.s3.us-west-2.amazonaws.com/b/" + key + "/" + key + ".js.gz";
- document.getElementsByTagName("script")[0].parentNode.insertBefore(s, document.getElementsByTagName("script")[0]);
- })(rb2bKey);
- }
+ if (unifyScriptSrc && unifyApiKey) {
+ (function () {
+ const methods = [
+ "identify",
+ "page",
+ "startAutoPage",
+ "stopAutoPage",
+ "startAutoIdentify",
+ "stopAutoIdentify",
+ ];
+ function factory(queue: any) {
+ return Object.assign(
+ [],
+ methods.reduce(function (acc: any, name: any) {
+ acc[name] = function () {
+ return (queue.push([name, [].slice.call(arguments)]), queue);
+ };
+ return acc;
+ }, {}),
+ );
+ }
+ (window as any).unify || ((window as any).unify = factory((window as any).unify));
+ (window as any).unifyBrowser ||
+ ((window as any).unifyBrowser = factory((window as any).unifyBrowser));
+ const script = document.createElement("script");
+ script.async = true;
+ script.setAttribute("src", unifyScriptSrc);
+ script.setAttribute("data-api-key", unifyApiKey);
+ script.setAttribute("id", "unifytag");
+ (document.body || document.head).appendChild(script);
+ })();
+ }
- // CR-Relay
- (function() {
- if (typeof window === 'undefined') return;
- if (typeof window.signals !== 'undefined') return;
- var script = document.createElement('script');
- script.src = 'https://cdn.cr-relay.com/v1/site/4917ccd3-ed29-4d67-aac8-31ff4766d046/signals.js';
- script.async = true;
- window.signals = Object.assign(
- [],
- ['page', 'identify', 'form'].reduce(function (acc, method){
- acc[method] = function () {
- signals.push([method, arguments]);
- return signals;
- };
- return acc;
- }, {})
- );
- document.head.appendChild(script);
- })();
+ if (rb2bKey) {
+ (function (key: string) {
+ if ((window as any).reb2b) return;
+ (window as any).reb2b = { loaded: true };
+ const s = document.createElement("script");
+ s.async = true;
+ s.src =
+ "https://b2bjsstore.s3.us-west-2.amazonaws.com/b/" + key + "/" + key + ".js.gz";
+ document
+ .getElementsByTagName("script")[0]
+ ?.parentNode?.insertBefore(s, document.getElementsByTagName("script")[0]);
+ })(rb2bKey);
+ }
- }
- } catch (_err) {
- // On error, assume logged in (do nothing)
- }
+ // CR-Relay
+ (function () {
+ if (typeof window === "undefined") return;
+ if (typeof (window as any).signals !== "undefined") return;
+ const script = document.createElement("script");
+ script.src =
+ "https://cdn.cr-relay.com/v1/site/4917ccd3-ed29-4d67-aac8-31ff4766d046/signals.js";
+ script.async = true;
+ (window as any).signals = Object.assign(
+ [],
+ ["page", "identify", "form"].reduce(function (acc: any, method: any) {
+ acc[method] = function () {
+ (window as any).signals.push([method, arguments]);
+ return (window as any).signals;
+ };
+ return acc;
+ }, {}),
+ );
+ document.head.appendChild(script);
})();
- `;
+ }
+ } catch (_err) {
+ // On error, do nothing
+ }
+ })();
+ }, []);
- return (
-
- );
+ return null;
}
diff --git a/src/components/HideIfEmptyStub.tsx b/src/components/HideIfEmptyStub.tsx
deleted file mode 100644
index 900b9cc4..00000000
--- a/src/components/HideIfEmptyStub.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import React from 'react';
-
-// fumadocs-ui 15.3.2 added HideIfEmpty component, but it uses eval() to check if the children are empty
-// this is not allowed on cloudflare workers, so we need to replace it
-
-export function HideIfEmpty({ children }: { children: React.ReactNode }) {
- return <>{children}>;
-}
\ No newline at end of file
diff --git a/src/components/LoginStatusContext.tsx b/src/components/LoginStatusContext.tsx
index c7dfe98c..f4e77ea7 100644
--- a/src/components/LoginStatusContext.tsx
+++ b/src/components/LoginStatusContext.tsx
@@ -1,9 +1,8 @@
-'use client';
+"use client";
-import React, { createContext, useContext, useEffect, useState } from 'react';
-import { LoaderCircle } from 'lucide-react';
+import React, { createContext, useContext, useEffect, useState } from "react";
+import { LoaderCircle } from "lucide-react";
-// Context for sharing login status across components
const LoginStatusContext = createContext<{
isLoggedIn: boolean | null;
isLoading: boolean;
@@ -12,7 +11,6 @@ const LoginStatusContext = createContext<{
isLoading: true,
});
-// Provider component that checks login status once
export function LoginStatusProvider({ children }: { children: React.ReactNode }) {
const [isLoggedIn, setIsLoggedIn] = useState
(null);
const [isLoading, setIsLoading] = useState(true);
@@ -20,23 +18,22 @@ export function LoginStatusProvider({ children }: { children: React.ReactNode })
useEffect(() => {
async function checkAuth() {
try {
- const response = await fetch('/api/auth/session', {
- credentials: 'include'
+ const response = await fetch("/api/auth/session", {
+ credentials: "include",
});
-
+
if (response && response.ok) {
try {
const session = await response.json();
setIsLoggedIn(!!(session && session.isLoggedIn));
} catch (_e) {
- console.error('Failed to parse session data', _e);
+ console.error("Failed to parse session data", _e);
setIsLoggedIn(false);
}
} else {
setIsLoggedIn(false);
}
} catch (_err) {
- // On error, assume not logged in
setIsLoggedIn(false);
} finally {
setIsLoading(false);
@@ -53,12 +50,10 @@ export function LoginStatusProvider({ children }: { children: React.ReactNode })
);
}
-// Hook to use login status context
function useLoginStatus() {
return useContext(LoginStatusContext);
}
-// Loading component
function LoadingState() {
return (
@@ -68,72 +63,66 @@ function LoadingState() {
);
}
-// Helper components for MDX children syntax - these handle their own conditional rendering
export function LoggedIn({ children }: { children: React.ReactNode }) {
const { isLoggedIn, isLoading } = useLoginStatus();
-
+
if (isLoading || !isLoggedIn) {
return null;
}
-
+
return <>{children}>;
}
-LoggedIn.displayName = 'LoggedIn';
+LoggedIn.displayName = "LoggedIn";
export function LoggedOut({ children }: { children: React.ReactNode }) {
const { isLoggedIn, isLoading } = useLoginStatus();
-
+
if (isLoading || isLoggedIn) {
return null;
}
-
+
return <>{children}>;
}
-LoggedOut.displayName = 'LoggedOut';
+LoggedOut.displayName = "LoggedOut";
-// General-purpose component that conditionally renders content based on auth status
-interface BasedOnAuthProps {
+export function BasedOnAuth({
+ loggedIn,
+ loggedOut,
+ children,
+}: {
loggedIn?: React.ReactNode;
loggedOut?: React.ReactNode;
- children?: React.ReactNode; // For MDX children syntax
-}
+ children?: React.ReactNode;
+}) {
+ const { isLoading, isLoggedIn } = useLoginStatus();
-export function BasedOnAuth({ loggedIn, loggedOut, children }: BasedOnAuthProps) {
- const { isLoading } = useLoginStatus();
-
- // Show loading state while checking
if (isLoading) {
return ;
}
- // If children are provided, render them (LoggedIn/LoggedOut will handle their own conditional rendering)
if (children) {
return <>{children}>;
}
- // Fall back to props-based approach
- const { isLoggedIn } = useLoginStatus();
return <>{isLoggedIn ? loggedIn : loggedOut}>;
}
-// Component for logged-in users (for use with children syntax)
export function WhenLoggedIn({ children }: { children: React.ReactNode }) {
const { isLoggedIn, isLoading } = useLoginStatus();
-
+
if (isLoading || !isLoggedIn) {
return null;
}
-
+
return <>{children}>;
}
-// Component for logged-out users (for use with children syntax)
export function WhenLoggedOut({ children }: { children: React.ReactNode }) {
const { isLoggedIn, isLoading } = useLoginStatus();
-
+
if (isLoading || isLoggedIn) {
return null;
}
-
+
return <>{children}>;
}
diff --git a/src/components/Mermaid.tsx b/src/components/Mermaid.tsx
index b21e2060..3105c267 100644
--- a/src/components/Mermaid.tsx
+++ b/src/components/Mermaid.tsx
@@ -1,44 +1,42 @@
-"use client"
+"use client";
-import mermaid from "mermaid"
-import React from "react"
+import mermaid from "mermaid";
+import React from "react";
-let mermaidInitialized = false
+let mermaidInitialized = false;
type MermaidProps = {
- code?: string
- children?: React.ReactNode
-}
+ code?: string;
+ children?: React.ReactNode;
+};
const getDiagram = ({ code, children }: MermaidProps) => {
- if (typeof code === "string") return code.trim()
+ if (typeof code === "string") return code.trim();
if (typeof children === "string") {
- return children.trim()
+ return children.trim();
}
if (Array.isArray(children)) {
- return children.join("").trim()
+ return children.join("").trim();
}
- return ""
-}
+ return "";
+};
export function Mermaid(props: MermaidProps) {
- const ref = React.useRef(null)
- const id = React.useId().replace(/:/g, "")
- const diagram = getDiagram(props)
+ const ref = React.useRef(null);
+ const id = React.useId().replace(/:/g, "");
+ const diagram = getDiagram(props);
React.useEffect(() => {
- if (!ref.current || !diagram) return
+ if (!ref.current || !diagram) return;
if (!mermaidInitialized) {
const getCssVar = (name: string, fallback: string) => {
- const value = getComputedStyle(document.documentElement)
- .getPropertyValue(name)
- .trim()
- return value || fallback
- }
+ const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
+ return value || fallback;
+ };
mermaid.initialize({
startOnLoad: false,
@@ -57,31 +55,31 @@ export function Mermaid(props: MermaidProps) {
htmlLabels: false,
curve: "linear",
},
- })
- mermaidInitialized = true
+ });
+ mermaidInitialized = true;
}
- const container = ref.current
- container.innerHTML = ""
- let cancelled = false
+ const container = ref.current;
+ container.innerHTML = "";
+ let cancelled = false;
void mermaid
.render(`mermaid-${id}`, diagram)
.then(({ svg }) => {
if (!cancelled) {
- container.innerHTML = svg
+ container.innerHTML = svg;
}
})
.catch(() => {
if (!cancelled) {
- container.textContent = diagram
+ container.textContent = diagram;
}
- })
+ });
return () => {
- cancelled = true
- }
- }, [diagram, id])
+ cancelled = true;
+ };
+ }, [diagram, id]);
- return
+ return ;
}
diff --git a/src/components/SDKContent.tsx b/src/components/SDKContent.tsx
index 66de8ee9..366622eb 100644
--- a/src/components/SDKContent.tsx
+++ b/src/components/SDKContent.tsx
@@ -1,24 +1,24 @@
-'use client'
-import { useTreeContext } from 'fumadocs-ui/contexts/tree'
+"use client";
+import { useTreeContext } from "fumadocs-ui/contexts/tree";
-export function SDKContent({
- only,
- children,
- showWhenNone = false
-}: {
- only: string | string[];
+export function SDKContent({
+ only,
+ children,
+ showWhenNone = false,
+}: {
+ only: string | string[];
children: React.ReactNode;
showWhenNone?: boolean;
}) {
- const { root } = useTreeContext()
- const sdk = (root.name as string).toLowerCase() // "ios", "android", "react-native"
+ const { root } = useTreeContext();
+ const sdk = (root.name as string).toLowerCase();
+
+ const list = Array.isArray(only) ? only : [only];
+ const isIncluded = list.includes(sdk);
- const list = Array.isArray(only) ? only : [only]
- const isIncluded = list.includes(sdk)
-
if (showWhenNone) {
- return isIncluded ? null : <>{children}>
+ return isIncluded ? null : <>{children}>;
}
-
- return isIncluded ? <>{children}> : null
-}
\ No newline at end of file
+
+ return isIncluded ? <>{children}> : null;
+}
diff --git a/src/components/SdkLatestVersion.tsx b/src/components/SdkLatestVersion.tsx
index 28a646b5..a9da5ccf 100644
--- a/src/components/SdkLatestVersion.tsx
+++ b/src/components/SdkLatestVersion.tsx
@@ -1,34 +1,27 @@
-import Link from 'next/link'
-import { cn } from 'fumadocs-ui/utils/cn'
+import { cn } from "fumadocs-ui/utils/cn";
type SdkLatestVersionProps = {
- version: string
- repoUrl: string
- className?: string
-}
+ version: string;
+ repoUrl: string;
+ className?: string;
+};
-export function SdkLatestVersion({
- version,
- repoUrl,
- className,
-}: SdkLatestVersionProps) {
- const trimmedRepo = repoUrl.replace(/\/$/, '')
- const releaseHref = `${trimmedRepo}/releases/tag/${encodeURIComponent(version)}`
- const displayVersion = version.startsWith('v') ? version : `v${version}`
+export function SdkLatestVersion({ version, repoUrl, className }: SdkLatestVersionProps) {
+ const trimmedRepo = repoUrl.replace(/\/$/, "");
+ const releaseHref = `${trimmedRepo}/releases/tag/${encodeURIComponent(version)}`;
+ const displayVersion = version.startsWith("v") ? version : `v${version}`;
return (
-
Last updated for {displayVersion}
-
- )
+
+ );
}
-
-
diff --git a/src/components/SearchDialog.tsx b/src/components/SearchDialog.tsx
index cdeb7691..3b8781f0 100644
--- a/src/components/SearchDialog.tsx
+++ b/src/components/SearchDialog.tsx
@@ -1,860 +1,57 @@
-'use client';
-
-// modified copy of fumadocs search dialog
-
-import {
- FileText,
- Hash,
- Loader2 as LoaderCircle,
- Search as SearchIcon,
- Sparkles,
- CornerDownLeft,
- Text,
- ArrowUp,
- ArrowDown,
-} from 'lucide-react';
-import {
- type ComponentProps,
- type ReactNode,
- useCallback,
- useEffect,
- useState,
- useMemo,
- useRef,
-} from 'react';
-import { useLocalStorage } from '@/hooks/useLocalStorage';
-import { useI18n } from 'fumadocs-ui/contexts/i18n';
-import { cn } from 'fumadocs-ui/utils/cn';
-import { useSidebar } from 'fumadocs-ui/contexts/sidebar';
-import { SDK_OPTIONS } from '@/lib/mixedbread';
+"use client";
+import { useState } from "react";
import {
- Dialog,
- DialogContent,
- DialogOverlay,
- DialogTitle,
-} from '@radix-ui/react-dialog';
-import type { SortedResult } from 'fumadocs-core/search/server';
-import { cva } from 'class-variance-authority';
-import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
-import { createContext } from 'fumadocs-core/framework';
-import { useRouter } from 'next/navigation';
-import { useDocsSearch } from 'fumadocs-core/search/client';
-
-// Search debounce delay in milliseconds - increase to reduce API calls
-const SEARCH_DEBOUNCE_MS = 500;
-
-export type SearchLink = [name: string, href: string];
-
-type ReactSortedResult = Omit & {
- external?: boolean;
- content: ReactNode;
- tag?: string;
- description?: string;
-};
-
-export interface TagItem {
- name: string;
- value: string;
-}
-
-export interface SharedProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
-
- /**
- * Custom links to be displayed if search is empty
- */
- links?: SearchLink[];
-}
-
-interface SearchDialogProps extends SharedProps {
- search: string;
- onSearchChange: (v: string) => void;
- isLoading?: boolean;
- isDebouncing?: boolean;
- hideResults?: boolean;
- results: ReactSortedResult[] | 'empty';
-
- footer?: ReactNode;
-}
-
-export function SearchDialogWrapper(props: SharedProps) {
- // Use same localStorage key as AskAI
- const [selectedSdk, setSelectedSdk] = useLocalStorage('superwall-ai-selected-sdk', '');
- const [isDebouncing, setIsDebouncing] = useState(false);
-
- // When no SDK is selected (empty string), pass 'none' to filter for non-SDK docs only
- // When a specific SDK is selected, pass that SDK value
- const sdkParam = selectedSdk || 'none';
-
+ SearchDialog,
+ SearchDialogOverlay,
+ SearchDialogContent,
+ SearchDialogHeader,
+ SearchDialogIcon,
+ SearchDialogInput,
+ SearchDialogClose,
+ SearchDialogList,
+ SearchDialogFooter,
+ TagsList,
+ TagsListItem,
+} from "fumadocs-ui/components/dialog/search";
+import { useDocsSearch } from "fumadocs-core/search/client";
+import type { SharedProps } from "fumadocs-ui/contexts/search";
+
+const tags = [
+ { name: "iOS", value: "ios" },
+ { name: "Android", value: "android" },
+ { name: "Flutter", value: "flutter" },
+ { name: "Expo", value: "expo" },
+];
+
+export function CustomSearchDialog(props: SharedProps) {
+ const [tag, setTag] = useState();
const { search, setSearch, query } = useDocsSearch({
- type: 'fetch',
- api: `/docs/api/search?sdk=${sdkParam}`,
- delayMs: SEARCH_DEBOUNCE_MS,
- });
-
- // Track debouncing state - show loading only after debounce, not during typing
- useEffect(() => {
- if (search.length > 0) {
- setIsDebouncing(true);
- const timer = setTimeout(() => {
- setIsDebouncing(false);
- }, SEARCH_DEBOUNCE_MS);
- return () => clearTimeout(timer);
- } else {
- setIsDebouncing(false);
- }
- }, [search]);
-
- // Debug logging in development
- useEffect(() => {
- if (process.env.NODE_ENV === 'development' && query.data && query.data !== 'empty') {
- console.log(`[SearchDialog] Received ${Array.isArray(query.data) ? query.data.length : 0} results for query: "${search}"`);
- }
- }, [query.data, search]);
-
- // Only show loading when actually fetching (not during debounce)
- const showLoading = query.isLoading && !isDebouncing;
-
- return (
-
- );
-}
-
-interface SearchDialogWrapperInnerProps extends SharedProps {
- search: string;
- onSearchChange: (v: string) => void;
- results: ReactSortedResult[] | 'empty';
- isLoading: boolean;
- isDebouncing: boolean;
- selectedSdk: string;
- onSdkChange: (sdk: string) => void;
-}
-
-function SearchDialogWrapperInner(props: SearchDialogWrapperInnerProps) {
- const { selectedSdk, onSdkChange, ...dialogProps } = props;
- const [showSdkPanel, setShowSdkPanel] = useState(false);
- const [sdkHighlight, setSdkHighlight] = useState(0);
- const [sdkFilter, setSdkFilter] = useState('');
- const searchInputRef = useRef(null);
- const router = useRouter();
-
- // Filter SDK options based on search
- const filteredSdkOptions = useMemo(() => {
- if (!sdkFilter.trim()) return SDK_OPTIONS;
- const lower = sdkFilter.toLowerCase();
- return SDK_OPTIONS.filter(opt =>
- opt.label.toLowerCase().includes(lower)
- );
- }, [sdkFilter]);
-
- const selectSdk = (sdkValue: string) => {
- onSdkChange(sdkValue);
- setShowSdkPanel(false);
- setSdkFilter('');
- // Refocus search input after selection
- setTimeout(() => {
- searchInputRef.current?.focus();
- }, 0);
- };
-
- const getSelectedSdkLabel = () => {
- const found = SDK_OPTIONS.find(opt => opt.value === selectedSdk);
- return found?.label || 'None';
- };
-
- // Reset highlight and filter when panel opens/closes
- useEffect(() => {
- if (showSdkPanel) {
- setSdkFilter('');
- const idx = SDK_OPTIONS.findIndex(opt => opt.value === selectedSdk);
- setSdkHighlight(idx >= 0 ? idx : 0);
- }
- }, [selectedSdk, showSdkPanel]);
-
- // Reset highlight when filter changes
- useEffect(() => {
- setSdkHighlight(0);
- }, [sdkFilter]);
-
- // Keyboard shortcuts
- useEffect(() => {
- const onKeyDown = (e: KeyboardEvent) => {
- const isMeta = e.metaKey || e.ctrlKey;
-
- // ⌘I - Go to AI page
- if (isMeta && !e.shiftKey && e.key.toLowerCase() === 'i') {
- e.preventDefault();
- dialogProps.onOpenChange(false);
- router.push('/ai');
- return;
- }
-
- // ⌘K - Toggle SDK panel (when search is open)
- if (dialogProps.open && isMeta && e.key.toLowerCase() === 'k') {
- e.preventDefault();
- setShowSdkPanel(prev => !prev);
- return;
- }
-
- // SDK Panel keyboard navigation
- if (showSdkPanel) {
- if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
- e.preventDefault();
- setSdkHighlight(prev => {
- const delta = e.key === 'ArrowDown' ? 1 : -1;
- const len = filteredSdkOptions.length;
- if (len === 0) return 0;
- return (prev + delta + len) % len;
- });
- return;
- }
- if (e.key === 'Enter') {
- e.preventDefault();
- const opt = filteredSdkOptions[sdkHighlight];
- if (opt) selectSdk(opt.value);
- return;
- }
- }
- };
-
- window.addEventListener('keydown', onKeyDown);
- return () => window.removeEventListener('keydown', onKeyDown);
- }, [dialogProps.open, selectedSdk, sdkHighlight, showSdkPanel, router, dialogProps, filteredSdkOptions]);
-
- return (
- setSdkFilter('')}
- onCloseSdkPanel={() => {
- setShowSdkPanel(false);
- setSdkFilter('');
- }}
- searchInputRef={searchInputRef}
- sdkPanel={
-
- {/* Filter Input */}
-
- setSdkFilter(e.target.value)}
- placeholder="Filter SDKs..."
- className="w-full bg-transparent text-sm placeholder:text-fd-muted-foreground focus-visible:outline-none"
- autoFocus
- />
-
- {/* SDK Options */}
-
- {filteredSdkOptions.length === 0 ? (
-
- No matches
-
- ) : (
- filteredSdkOptions.map((option, index) => (
-
- ))
- )}
-
-
- }
- sdkActionButton={
-
- }
- />
- );
-}
-
-// Add new type for AI prompt
-interface AIPrompt {
- type: 'ai-prompt';
- id: 'ai-prompt';
- content: string;
-}
-
-export function SearchDialog({
- open,
- onOpenChange,
- footer,
- links = [],
- search: propSearch,
- onSearchChange: propOnSearchChange,
- isLoading: propIsLoading,
- isDebouncing: propIsDebouncing,
- results: propResults,
- showSdkPanel,
- sdkFilter,
- onClearSdkFilter,
- onCloseSdkPanel,
- sdkPanel,
- sdkActionButton,
- searchInputRef,
-}: SearchDialogProps & {
- showSdkPanel?: boolean;
- sdkFilter?: string;
- onClearSdkFilter?: () => void;
- onCloseSdkPanel?: () => void;
- sdkPanel?: ReactNode;
- sdkActionButton?: ReactNode;
- searchInputRef?: React.RefObject;
-}) {
- const { text } = useI18n();
- const [active, setActive] = useState();
-
- // Add default search functionality
- const { search: defaultSearch, setSearch: defaultSetSearch, query } = useDocsSearch({ type: 'fetch', api: '/docs/api/search' });
-
- // Use provided values or defaults
- const search = propSearch ?? defaultSearch;
- const onSearchChange = propOnSearchChange ?? defaultSetSearch;
- const isLoading = propIsLoading ?? query.isLoading;
- const isDebouncing = propIsDebouncing ?? false;
- const results = propResults ?? (query.data ?? 'empty');
-
- // Default links if none provided
- const defaultLinks: SearchLink[] = [
- ['Support', '/docs/support'],
- ['Dashboard', '/docs/dashboad'],
- ['SDK Installation', '/docs/sdk/quickstart/install'],
- ['Web Checkout', '/docs/web-checkout'],
- ];
-
- const displayLinks = links.length > 0 ? links : defaultLinks;
-
- // Add AI prompt to the items list
- const allItems = useMemo(() => {
- const items = results === 'empty'
- ? displayLinks.map(([name, link]) => ({
- type: 'page' as const,
- id: name,
- content: name,
- url: link,
- }))
- : results;
-
- // Show the AI prompt at the top
- const aiPrompt: AIPrompt = {
- type: 'ai-prompt',
- id: 'ai-prompt',
- content: 'Ask AI',
- };
- return [aiPrompt, ...items];
- }, [results, displayLinks]);
-
- // Handle AI redirect to AI page
- const { push } = useRouter();
- const handleAiSearch = () => {
- const trimmed = search.trim();
- const destination = trimmed ? `/ai?search=${encodeURIComponent(trimmed)}` : '/ai';
- onOpenChange(false); // Close search dialog
- push(destination);
- };
-
- // Show empty state when no query entered OR while debouncing (user is still typing)
- const showEmptyState = search.trim().length === 0 || isDebouncing;
-
- return (
-
- );
-}
-
-const getIcon = (type: 'text' | 'heading' | 'page', isActive: boolean) => {
- const iconClass = type === 'page' && isActive
- ? 'size-4 text-[#74F8F0]'
- : 'size-4 text-fd-muted-foreground';
-
- switch (type) {
- case 'text':
- return ;
- case 'heading':
- return ;
- case 'page':
- return ;
- }
-};
-
-function SearchResults({
- items = [],
- active = items[0]?.id,
- onActiveChange,
- onSelect,
- onAiSearch,
- disableKeyboardNav,
- ...props
-}: ComponentProps<'div'> & {
- active?: string;
- onActiveChange: (active: string | undefined) => void;
- items: (ReactSortedResult | AIPrompt)[];
- onSelect?: (value: string) => void;
- onAiSearch: () => void;
- disableKeyboardNav?: boolean;
-}) {
- const { text } = useI18n();
- const router = useRouter();
- const sidebar = useSidebar();
-
- const onOpen = ({ external, url }: ReactSortedResult) => {
- if (external) window.open(url, '_blank')?.focus();
- else router.push(url);
- onSelect?.(url);
- sidebar.setOpen(false);
- };
-
- const onKey = useEffectEvent((e: KeyboardEvent) => {
- // Skip keyboard navigation when SDK panel is open
- if (disableKeyboardNav) return;
-
- if (e.key === 'ArrowDown' || e.key == 'ArrowUp') {
- const idx = items.findIndex((item) => item.id === active);
- if (idx === -1) {
- onActiveChange(items[0]?.id);
- } else {
- onActiveChange(
- items[((e.key === 'ArrowDown' ? idx + 1 : idx - 1) % items.length)]
- ?.id,
- );
- }
-
- e.preventDefault();
- }
-
- if (e.key === 'Enter') {
- const selected = items.find((item) => item.id === active);
-
- if (selected) {
- if (selected.type === 'ai-prompt') {
- onAiSearch();
- } else {
- onOpen(selected as ReactSortedResult);
- }
- }
- e.preventDefault();
- }
+ type: "fetch",
+ api: "/docs/api/search",
+ tag,
});
- useEffect(() => {
- window.addEventListener('keydown', onKey);
- return () => {
- window.removeEventListener('keydown', onKey);
- };
- }, [onKey]);
-
- return (
-
- {items.length === 0 ? (
-
-
-
{text.searchNoResult}
-
- ) : null}
-
- {items.map((item) => {
- if (item.type === 'ai-prompt') {
- return (
-
onActiveChange(item.id)}
- onClick={() => onAiSearch()}
- >
-
- Ask AI
- {active === item.id && (
-
- )}
-
- );
- }
-
- const resultItem = item as ReactSortedResult;
- const rootFolder = resultItem.tag || '';
-
- // Format root folder name for display
- const formatRootFolder = (folder: string) => {
- switch (folder) {
- case 'ios': return 'iOS';
- case 'android': return 'Android';
- case 'flutter': return 'Flutter';
- case 'expo': return 'Expo';
- case 'dashboard': return 'Dashboard';
- default: return folder ? folder.charAt(0).toUpperCase() + folder.slice(1) : '';
- }
- };
-
- // Only show SDK badge for actual SDK folders
- const sdkFolders = ['ios', 'android', 'flutter', 'expo', 'react-native'];
- const showSdkBadge = sdkFolders.includes(rootFolder);
- const description = (resultItem as any).description;
-
- return (
-
onActiveChange(item.id)}
- onClick={() => onOpen(item as ReactSortedResult)}
- >
- {item.type !== 'page' ? (
-
- ) : null}
- {getIcon(item.type, active === item.id)}
-
-
{item.content}
- {/* Show description for pages and headings */}
- {description && (
-
- {description}
-
- )}
-
- {/* Show SDK badge only for SDK folders */}
- {item.type === 'page' && showSdkBadge && (
-
- {formatRootFolder(rootFolder)}
-
- )}
- {active === item.id && (
-
- )}
-
- );
- })}
-
- );
-}
-
-function CommandItem({
- active = false,
- ...props
-}: ComponentProps<'button'> & {
- active?: boolean;
-}) {
- return (
-
- );
-}
-
-export interface TagsListProps extends ComponentProps<'div'> {
- tag?: string;
- onTagChange: (tag: string | undefined) => void;
- allowClear?: boolean;
-}
-
-const itemVariants = cva(
- 'rounded-md border px-2 py-0.5 text-xs font-medium text-fd-muted-foreground transition-colors',
- {
- variants: {
- active: {
- true: 'bg-fd-accent text-fd-accent-foreground',
- },
- },
- },
-);
-
-const TagsListContext = createContext<{
- value?: string;
- onValueChange: (value: string | undefined) => void;
- allowClear: boolean;
-}>('TagsList');
-
-export function TagsList({
- tag,
- onTagChange,
- allowClear = false,
- ...props
-}: TagsListProps) {
return (
-
- ({
- value: tag,
- onValueChange: onTagChange,
- allowClear,
- }),
- [allowClear, onTagChange, tag],
- )}
- >
- {props.children}
-
-
+
+
+
+
+
+
+
+
+
+
+
+ {tags.map((t) => (
+
+ {t.name}
+
+ ))}
+
+
+
+
);
}
-
-export function TagsListItem({
- value,
- className,
- ...props
-}: ComponentProps<'button'> & {
- value: string;
-}) {
- const ctx = TagsListContext.use();
-
- return (
-
- );
-}
-
-export const buttonVariants = cva(
- 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background',
- {
- variants: {
- color: {
- default: 'bg-primary text-primary-foreground hover:bg-primary/90',
- outline: 'border border-input hover:bg-accent hover:text-accent-foreground',
- },
- },
- defaultVariants: {
- color: 'default',
- },
- }
-);
diff --git a/src/components/SidebarDiscordLink.tsx b/src/components/SidebarDiscordLink.tsx
new file mode 100644
index 00000000..94ef4290
--- /dev/null
+++ b/src/components/SidebarDiscordLink.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { DiscordIcon } from "./DiscordIcon";
+import { getDiscordInfo, type DiscordInfo } from "@/lib/discord";
+
+export function SidebarDiscordLink() {
+ const [discordInfo, setDiscordInfo] = useState(null);
+
+ useEffect(() => {
+ let mounted = true;
+
+ getDiscordInfo()
+ .then((info) => {
+ if (mounted) setDiscordInfo(info);
+ })
+ .catch(() => {
+ if (mounted) setDiscordInfo(null);
+ });
+
+ return () => {
+ mounted = false;
+ };
+ }, []);
+
+ if (!discordInfo?.instant_invite) return null;
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/SmartRootToggle.tsx b/src/components/SmartRootToggle.tsx
deleted file mode 100644
index c36d550f..00000000
--- a/src/components/SmartRootToggle.tsx
+++ /dev/null
@@ -1,194 +0,0 @@
-'use client'
-
-import { useMemo, useState } from 'react'
-import { usePathname } from 'next/navigation'
-import Link from 'next/link'
-import { Check, ChevronsUpDown } from 'fumadocs-ui/internal/icons'
-import { cn } from 'fumadocs-ui/utils/cn'
-import { isActive } from 'fumadocs-ui/utils/is-active'
-import { useSidebar } from 'fumadocs-ui/contexts/sidebar'
-import { useTreeContext } from 'fumadocs-ui/contexts/tree'
-import { getSidebarTabs } from 'fumadocs-ui/utils/get-sidebar-tabs'
-import { Popover, PopoverContent, PopoverTrigger } from 'fumadocs-ui/components/ui/popover'
-import { type Option } from 'fumadocs-ui/components/layout/root-toggle'
-import { getSmartNavigationUrl, parseDocsPath, type SDKType } from '@/lib/navigation-utils'
-
-interface SmartOption extends Option {
- smartUrl?: string
-}
-
-interface SmartRootToggleProps {
- options?: Option[]
- placeholder?: React.ReactNode
- className?: string
-}
-
-const GENERAL_ROOT_ORDER = ['dashboard', 'web-checkout', 'integrations', 'support', 'changelog'] as const
-const SDK_TARGETS = new Set(['ios', 'android', 'flutter', 'expo', 'react-native', 'dashboard'])
-
-const isSdkRoot = (key: string): key is SDKType => SDK_TARGETS.has(key as SDKType)
-
-function getRootKey(url: string | undefined) {
- if (!url) return ''
- const normalized = url.replace(/^\/docs/, '').replace(/^\/+/, '')
- return normalized.split('/')[0] ?? ''
-}
-
-/**
- * Smart Root Toggle that preserves current page path when switching between SDK sections
- */
-export function SmartRootToggle({ options, placeholder, ...props }: SmartRootToggleProps) {
- const [open, setOpen] = useState(false)
- const { closeOnRedirect } = useSidebar()
- const pathname = usePathname()
- const { full: tree } = useTreeContext()
-
- const resolvedOptions = useMemo(
- () => options ?? getSidebarTabs(tree),
- [options, tree]
- )
-
- // Find the currently selected root folder option
- const selected = useMemo(() => {
- return resolvedOptions.findLast((item) =>
- item.urls
- ? item.urls.has(pathname.endsWith('/') ? pathname.slice(0, -1) : pathname)
- : isActive(item.url, pathname, true)
- )
- }, [resolvedOptions, pathname])
-
- // Generate smart navigation options (only for known SDK roots)
- const smartOptions = useMemo(() => {
- const { sdk } = parseDocsPath(pathname)
-
- return resolvedOptions.map((option) => {
- const rootKey = getRootKey(option.url)
- if (!rootKey || !isSdkRoot(rootKey)) return option
-
- // Avoid unnecessary swaps when we are outside SDK docs and switching to dashboard
- if (!sdk && rootKey === 'dashboard') return option
-
- const smartUrl = getSmartNavigationUrl(rootKey, pathname)
- return { ...option, smartUrl }
- })
- }, [resolvedOptions, pathname])
-
- const groupedOptions = useMemo(() => {
- const used = new Set()
-
- const general = GENERAL_ROOT_ORDER.map((key) => {
- const found = smartOptions.find((option) => getRootKey(option.url) === key)
- if (found) used.add(found.url)
- return found
- }).filter(Boolean) as SmartOption[]
-
- const sdks = smartOptions.filter(
- (option) =>
- !used.has(option.url) &&
- !GENERAL_ROOT_ORDER.includes(getRootKey(option.url) as typeof GENERAL_ROOT_ORDER[number])
- ) as SmartOption[]
-
- return { general, sdks }
- }, [smartOptions])
-
- const onClick = () => {
- closeOnRedirect.current = false
- setOpen(false)
- }
-
- const item = selected ? : placeholder
-
- const renderOption = (option: SmartOption) => {
- const isSelected = selected && option.url === selected.url
- if (!isSelected && option.unlisted) return null
-
- return (
-
- {option.icon ? (
-
- {option.icon}
-
- ) : null}
-
-
{option.title}
- {option.description ? (
-
- {option.description}
-
- ) : null}
-
-
-
- )
- }
-
- return (
-
- {item ? (
-
- {item}
-
-
- ) : null}
-
-
- {groupedOptions.general.map(renderOption)}
-
- {groupedOptions.sdks.length > 0 && (
- <>
-
-
- SDKs
-
- {groupedOptions.sdks.map(renderOption)}
- >
- )}
-
-
-
- )
-}
-
-/**
- * Individual item component (copied from Fumadocs)
- */
-function Item(props: Option) {
- return (
- <>
- {props.icon && (
-
- {props.icon}
-
- )}
-
-
{props.title}
- {props.description ? (
-
- {props.description}
-
- ) : null}
-
- >
- )
-}
\ No newline at end of file
diff --git a/src/components/SupportFolderList.tsx b/src/components/SupportFolderList.tsx
index 745aa766..52d17d59 100644
--- a/src/components/SupportFolderList.tsx
+++ b/src/components/SupportFolderList.tsx
@@ -1,40 +1,51 @@
-import Link from 'next/link'
-import { source } from '@/lib/source'
-import { flattenTree } from 'fumadocs-core/page-tree'
+"use client";
+
+import { useTreeContext } from "fumadocs-ui/contexts/tree";
type SupportFolderListProps = {
- folder: string
+ folder: string;
+};
+
+function flattenNodes(nodes: any[]): any[] {
+ const result: any[] = [];
+ for (const node of nodes) {
+ if (node.type === "page") {
+ result.push(node);
+ }
+ if (node.children) {
+ result.push(...flattenNodes(node.children));
+ }
+ }
+ return result;
}
-// Build a list of pages under a support folder using the page tree order.
export function SupportFolderList({ folder }: SupportFolderListProps) {
- const normalized = folder.startsWith('/') ? folder : `/${folder}`
+ const { root } = useTreeContext();
+ const normalized = folder.startsWith("/") ? folder : `/${folder}`;
- // Support both with and without the /docs base path.
- const prefixes = new Set([normalized])
- if (!normalized.startsWith('/docs')) prefixes.add(`/docs${normalized}`)
- if (normalized.startsWith('/docs')) prefixes.add(normalized.replace(/^\/docs/, '') || '/')
+ const prefixes = new Set([normalized]);
+ if (!normalized.startsWith("/docs")) prefixes.add(`/docs${normalized}`);
+ if (normalized.startsWith("/docs")) prefixes.add(normalized.replace(/^\/docs/, "") || "/");
- const baseUrls = Array.from(prefixes)
+ const baseUrls = Array.from(prefixes);
- const items = flattenTree(source.pageTree.children ?? [])
+ const items = flattenNodes(root.children ?? [])
.filter(
- (node) =>
- node.type === 'page' &&
- baseUrls.some((prefix) => node.url === prefix || node.url.startsWith(`${prefix}/`)),
+ (node) =>
+ node.type === "page" &&
+ baseUrls.some((prefix) => node.url === prefix || node.url.startsWith(`${prefix}/`)),
)
- // drop the index/root page so only child articles show
- .filter((node) => !baseUrls.includes(node.url))
+ .filter((node) => !baseUrls.includes(node.url));
- if (!items.length) return null
+ if (!items.length) return null;
return (
- {items.map((item) => (
+ {items.map((item: any) => (
-
- {item.name}
+ {item.name}
))}
- )
+ );
}
diff --git a/src/components/ai-elements/conversation.tsx b/src/components/ai-elements/conversation.tsx
deleted file mode 100644
index b4c54c85..00000000
--- a/src/components/ai-elements/conversation.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-import { ComponentProps, ReactNode, RefObject, useEffect, useRef, useState } from 'react';
-import { ChevronDown } from 'lucide-react';
-import { cn } from 'fumadocs-ui/utils/cn';
-
-type ConversationProps = ComponentProps<'div'> & {
- children: ReactNode;
-};
-
-export function Conversation({ className, children, ...props }: ConversationProps) {
- return (
-
- {children}
-
- );
-}
-
-type ConversationContentProps = ComponentProps<'div'> & {
- children: ReactNode;
- contentRef?: RefObject;
-};
-
-export function ConversationContent({
- className,
- children,
- contentRef: forwardedRef,
- ...props
-}: ConversationContentProps) {
- const internalRef = useRef(null);
- const contentRef = forwardedRef || internalRef;
-
- return (
-
- {children}
-
- );
-}
-
-type ConversationEmptyStateProps = {
- icon?: ReactNode;
- title: string;
- description?: string;
-};
-
-export function ConversationEmptyState({ icon, title, description }: ConversationEmptyStateProps) {
- return (
-
- {icon}
-
{title}
- {description &&
{description}
}
-
- );
-}
-
-type ScrollButtonProps = {
- target?: HTMLElement | null;
- visible?: boolean;
-};
-
-export function ConversationScrollButton({ target, visible = true }: ScrollButtonProps) {
- const [show, setShow] = useState(false);
-
- useEffect(() => {
- if (!target) {
- setShow(false);
- return;
- }
-
- const handleScroll = () => {
- if (!target) return;
- const nearBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 120;
- setShow(!nearBottom);
- };
-
- handleScroll();
- target.addEventListener('scroll', handleScroll);
- return () => target.removeEventListener('scroll', handleScroll);
- }, [target]);
-
- if (!visible || !show) return null;
-
- return (
-
- );
-}
-
diff --git a/src/components/ai-elements/message.tsx b/src/components/ai-elements/message.tsx
deleted file mode 100644
index 627f0a02..00000000
--- a/src/components/ai-elements/message.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { ComponentProps, ReactNode } from 'react';
-import { cn } from 'fumadocs-ui/utils/cn';
-
-type MessageProps = ComponentProps<'div'> & {
- from: 'user' | 'assistant';
- children: ReactNode;
-};
-
-export function Message({ from, className, children, ...props }: MessageProps) {
- const isUser = from === 'user';
-
- return (
-
- );
-}
-
-type MessageContentProps = ComponentProps<'div'> & {
- children: ReactNode;
-};
-
-export function MessageContent({ className, children, ...props }: MessageContentProps) {
- return (
-
- {children}
-
- );
-}
-
-type MessageResponseProps = ComponentProps<'div'> & {
- children: ReactNode;
-};
-
-export function MessageResponse({ className, children, ...props }: MessageResponseProps) {
- return (
-
- {children}
-
- );
-}
diff --git a/src/components/ai-elements/prompt-input.tsx b/src/components/ai-elements/prompt-input.tsx
deleted file mode 100644
index 6ed353cd..00000000
--- a/src/components/ai-elements/prompt-input.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import { ComponentProps, ReactNode, forwardRef } from 'react';
-import { Loader2 } from 'lucide-react';
-import { cn } from 'fumadocs-ui/utils/cn';
-
-type PromptInputProps = ComponentProps<'form'> & {
- children: ReactNode;
-};
-
-export function PromptInput({ className, children, ...props }: PromptInputProps) {
- return (
-
- );
-}
-
-type PromptInputTextareaProps = ComponentProps<'textarea'>;
-
-export const PromptInputTextarea = forwardRef(
- ({ className, ...props }, ref) => {
- return (
-
- );
- },
-);
-
-PromptInputTextarea.displayName = 'PromptInputTextarea';
-
-type PromptInputFooterProps = ComponentProps<'div'> & { children: ReactNode };
-
-export function PromptInputFooter({ className, children, ...props }: PromptInputFooterProps) {
- return (
-
- {children}
-
- );
-}
-
-type PromptInputSubmitProps = ComponentProps<'button'> & {
- status?: 'ready' | 'submitting' | 'streaming';
-};
-
-export function PromptInputSubmit({
- className,
- disabled,
- status = 'ready',
- children,
- ...props
-}: PromptInputSubmitProps) {
- const isBusy = status === 'submitting' || status === 'streaming';
-
- return (
-
- );
-}
-
-// Alias matching examples
-export const Input = PromptInput;
-
diff --git a/src/components/ai/page-actions.tsx b/src/components/ai/page-actions.tsx
new file mode 100644
index 00000000..29d36849
--- /dev/null
+++ b/src/components/ai/page-actions.tsx
@@ -0,0 +1,244 @@
+"use client";
+import { useMemo, useState } from "react";
+import { Check, ChevronDown, Copy, ExternalLinkIcon, TextIcon } from "lucide-react";
+import { cn } from "@/lib/cn";
+import { useCopyButton } from "fumadocs-ui/utils/use-copy-button";
+import { buttonVariants } from "fumadocs-ui/components/ui/button";
+import { Popover, PopoverContent, PopoverTrigger } from "fumadocs-ui/components/ui/popover";
+
+const cache = new Map();
+
+export function LLMCopyButton({
+ /**
+ * A URL to fetch the raw Markdown/MDX content of page
+ */
+ markdownUrl,
+}: {
+ markdownUrl: string;
+}) {
+ const [isLoading, setLoading] = useState(false);
+ const [checked, onClick] = useCopyButton(async () => {
+ const cached = cache.get(markdownUrl);
+ if (cached) return navigator.clipboard.writeText(cached);
+
+ setLoading(true);
+
+ try {
+ await navigator.clipboard.write([
+ new ClipboardItem({
+ "text/plain": fetch(markdownUrl).then(async (res) => {
+ const content = await res.text();
+ cache.set(markdownUrl, content);
+
+ return content;
+ }),
+ }),
+ ]);
+ } finally {
+ setLoading(false);
+ }
+ });
+
+ return (
+
+ );
+}
+
+export function ViewOptions({
+ markdownUrl,
+ githubUrl,
+}: {
+ /**
+ * A URL to the raw Markdown/MDX content of page
+ */
+ markdownUrl: string;
+
+ /**
+ * Source file URL on GitHub
+ */
+ githubUrl: string;
+}) {
+ const items = useMemo(() => {
+ const pageUrl = typeof window !== "undefined" ? window.location.href : "loading";
+ const q = `Read ${pageUrl}, I want to ask questions about it.`;
+
+ return [
+ {
+ title: "Open in GitHub",
+ href: githubUrl,
+ icon: (
+
+ ),
+ },
+ {
+ title: "View as Markdown",
+ href: markdownUrl,
+ icon: ,
+ },
+ {
+ title: "Open in Scira AI",
+ href: `https://scira.ai/?${new URLSearchParams({
+ q,
+ })}`,
+ icon: (
+
+ ),
+ },
+ {
+ title: "Open in ChatGPT",
+ href: `https://chatgpt.com/?${new URLSearchParams({
+ hints: "search",
+ q,
+ })}`,
+ icon: (
+
+ ),
+ },
+ {
+ title: "Open in Claude",
+ href: `https://claude.ai/new?${new URLSearchParams({
+ q,
+ })}`,
+ icon: (
+
+ ),
+ },
+ {
+ title: "Open in Cursor",
+ icon: (
+
+ ),
+ href: `https://cursor.com/link/prompt?${new URLSearchParams({
+ text: q,
+ })}`,
+ },
+ ];
+ }, [githubUrl, markdownUrl]);
+
+ return (
+
+
+ Open
+
+
+
+ {items.map((item) => (
+
+ {item.icon}
+ {item.title}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/askai-dev.ts b/src/components/askai-dev.ts
deleted file mode 100644
index d06976db..00000000
--- a/src/components/askai-dev.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-export const IS_LOCAL_DEV =
- typeof window !== 'undefined' && window.location.hostname.includes('localhost');
-
-export const DEV_PLACEHOLDER_PROMPT = '(dev) simulated prompt';
-
-export const DEV_SAMPLE_RESPONSE =
- "Here's a sample response demonstrating how the AI would answer this question. It should include concise steps, links to relevant docs, and any important caveats.";
-
-export const DEV_TRANSCRIPT_CONTENT = [
- {
- role: 'user' as const,
- content: 'What is a good local workflow for testing paywalls?',
- },
- {
- role: 'assistant' as const,
- content: `### Local testing checklist
-
-- Use the **debug paywall** in the dashboard and set the device to *Tester*.
-- In iOS, enable StoreKit testing and ensure sandbox users are signed out of App Store.
-- Call \`Superwall.shared.debugInfo()\` after configure to verify environment.
-- In development, set \`paywallPresentation\` to manual and gate with feature flags.
-
-\`\`\`swift
-let options = SuperwallOptions()
-options.paywalls.isHapticFeedbackEnabled = true
-options.paywalls.presentationStyle = .modal
-Superwall.configure(apiKey: "sk_test_xxx", options: options)
-\`\`\`
-
-Troubleshooting:
-- No paywall? Confirm the rule matches the event name and the user is eligible.
-- Stuck spinner? Check device time drift and receipt corruption (reset StoreKit).`,
- },
- {
- role: 'user' as const,
- content: 'How do I track subscription state changes reliably?',
- },
- {
- role: 'assistant' as const,
- content: `Subscribe to \`Superwall.shared.subscriptionStatus\` and forward analytics to your data plane.
-
-| Event | When it fires | Notes |
-| --- | --- | --- |
-| \`subscriptionStatusDidChange\` | Any entitlement change | Fired after receipt validation |
-| \`paywallDeclined\` | User closes without purchasing | Good for funnel drop-off |
-| \`transactionAbandoned\` | Billing issues | Consider sending an email nudge |
-
-Also emit your own event on app launch to backfill dashboards.`,
- },
- {
- role: 'user' as const,
- content: 'Can I localize paywalls and trials?',
- },
- {
- role: 'assistant' as const,
- content: `Yes. Add locale variants in the dashboard and supply localized product metadata.
-
-- Create a *Localization* per language with copy + images.
-- In your app, pass \`localeIdentifier\` when tracking events to force a variant.
-- Keep trial messaging consistent with App Store Connect durations.
-
-Example forcing locale:
-\`\`\`swift
-Superwall.shared.track(
- event: "paywall_open",
- params: ["locale": "fr_FR"]
-)
-\`\`\``,
- },
- {
- role: 'user' as const,
- content: 'How do I integrate Superwall in React Native with Android product flavors?',
- },
- {
- role: 'assistant' as const,
- content: `For Expo/React Native:
-
-1) Install the Expo module and run \`expo prebuild\`.
-2) In \`android/app/build.gradle\`, set flavor-specific \`superwallApiKey\` via \`resValue\`.
-3) In JS, read the flavor via \`Application.android.packageName\` or a build-time env and pass the matching API key into \`configure\`.
-
-\`\`\`ts
-import Superwall from '@superwall/react-native-superwall';
-
-Superwall.configure({
- apiKey: Constants.expoConfig?.extra?.SUPERWALL_KEY,
- options: { logLevel: 'info' },
-});
-\`\`\`
-
-Remember to test purchases per flavor to avoid mixing receipts across package IDs.`,
- },
- {
- role: 'user' as const,
- content: 'What should I include when reporting an AI answer issue?',
- },
- {
- role: 'assistant' as const,
- content: `Include:
-- Conversation ID (from the Debug menu)
-- Question + expected answer
-- SDK/platform context
-- Screenshot if formatting is broken
-
-Send it to support so we can retrain quickly.`,
- },
-];
diff --git a/src/components/feedback/client.tsx b/src/components/feedback/client.tsx
new file mode 100644
index 00000000..1343c160
--- /dev/null
+++ b/src/components/feedback/client.tsx
@@ -0,0 +1,358 @@
+"use client";
+import { cn } from "../../lib/cn";
+import { buttonVariants } from "../ui/button";
+import { CornerDownRightIcon, MessageSquare, ThumbsDown, ThumbsUp } from "lucide-react";
+import {
+ ReactNode,
+ type SyntheticEvent,
+ useEffect,
+ useEffectEvent,
+ useState,
+ useTransition,
+} from "react";
+import { Collapsible, CollapsibleContent } from "../ui/collapsible";
+import { cva } from "class-variance-authority";
+import { useLocation } from "@tanstack/react-router";
+import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
+import type { FeedbackBlockProps } from "fumadocs-core/mdx-plugins/remark-feedback-block";
+import {
+ actionResponse,
+ blockFeedback,
+ pageFeedback,
+ type ActionResponse,
+ type BlockFeedback,
+ type PageFeedback,
+} from "./schema";
+import { z } from "zod/mini";
+
+const rateButtonVariants = cva(
+ "inline-flex items-center gap-2 px-3 py-2 rounded-full font-medium border text-sm [&_svg]:size-4 disabled:cursor-not-allowed",
+ {
+ variants: {
+ active: {
+ true: "bg-fd-accent text-fd-accent-foreground [&_svg]:fill-current",
+ false: "text-fd-muted-foreground",
+ },
+ },
+ },
+);
+
+const pageFeedbackResult = z.extend(pageFeedback, {
+ response: actionResponse,
+});
+
+const blockFeedbackResult = z.extend(blockFeedback, {
+ response: actionResponse,
+});
+
+/**
+ * A feedback component to be attached at the end of page
+ */
+export function Feedback({
+ onSendAction,
+}: {
+ onSendAction: (feedback: PageFeedback) => Promise;
+}) {
+ const { pathname: url } = useLocation();
+ const { previous, setPrevious } = useSubmissionStorage(url, (v) => {
+ const result = pageFeedbackResult.safeParse(v);
+ return result.success ? result.data : null;
+ });
+ const [opinion, setOpinion] = useState<"good" | "bad" | null>(null);
+ const [message, setMessage] = useState("");
+ const [isPending, startTransition] = useTransition();
+
+ function submit(e?: SyntheticEvent) {
+ if (opinion == null) return;
+
+ startTransition(async () => {
+ const feedback: PageFeedback = {
+ url,
+ opinion,
+ message,
+ };
+
+ const response = await onSendAction(feedback);
+ setPrevious({
+ response,
+ ...feedback,
+ });
+ setMessage("");
+ setOpinion(null);
+ });
+
+ e?.preventDefault();
+ }
+
+ const activeOpinion = previous?.opinion ?? opinion;
+
+ return (
+ {
+ if (!v) setOpinion(null);
+ }}
+ className="border-y py-3"
+ >
+
+
How is this guide?
+
+
+
+
+ {previous ? (
+
+
Thank you for your feedback!
+
+ {previous.response?.githubUrl ? (
+
+ View on GitHub
+
+ ) : null}
+
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+/**
+ * A feedback component for each content block in page, should be used with `remark-feedback-block`.
+ *
+ * See https://fumadocs.dev/docs/integrations/feedback.
+ */
+export function FeedbackBlock({
+ id,
+ body,
+ onSendAction,
+ children,
+}: FeedbackBlockProps & {
+ onSendAction: (feedback: BlockFeedback) => Promise;
+ children: ReactNode;
+}) {
+ const { pathname: url } = useLocation();
+ const blockId = `${url}-${id}`;
+ const { previous, setPrevious } = useSubmissionStorage(blockId, (v) => {
+ const result = blockFeedbackResult.safeParse(v);
+ if (result.success) return result.data;
+ return null;
+ });
+ const [message, setMessage] = useState("");
+ const [isPending, startTransition] = useTransition();
+ const [open, setOpen] = useState(false);
+
+ function submit(e?: SyntheticEvent) {
+ startTransition(async () => {
+ const feedback: BlockFeedback = {
+ blockId,
+ blockBody: body,
+ url,
+ message,
+ };
+
+ const response = await onSendAction(feedback);
+ setPrevious({
+ response,
+ ...feedback,
+ });
+ setMessage("");
+ });
+
+ e?.preventDefault();
+ }
+
+ return (
+
+
+
+
{
+ setOpen((prev) => !prev);
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ >
+
+ Feedback
+
+
+
{children}
+
+
+
+ {previous ? (
+
+
Thank you for your feedback!
+
+ {previous.response?.githubUrl ? (
+
+ View on GitHub
+
+ ) : null}
+
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function useSubmissionStorage(blockId: string, validate: (v: unknown) => Result | null) {
+ const storageKey = `docs-feedback-${blockId}`;
+ const [value, setValue] = useState(null);
+ const validateCallback = useEffectEvent(validate);
+
+ useEffect(() => {
+ const item = localStorage.getItem(storageKey);
+ if (item === null) return;
+ const validated = validateCallback(JSON.parse(item));
+
+ if (validated !== null) setValue(validated);
+ }, [storageKey]);
+
+ return {
+ previous: value,
+ setPrevious(result: Result | null) {
+ if (result) localStorage.setItem(storageKey, JSON.stringify(result));
+ else localStorage.removeItem(storageKey);
+
+ setValue(result);
+ },
+ };
+}
diff --git a/src/components/feedback/schema.ts b/src/components/feedback/schema.ts
new file mode 100644
index 00000000..bacb9c68
--- /dev/null
+++ b/src/components/feedback/schema.ts
@@ -0,0 +1,24 @@
+import { z } from "zod/mini";
+
+export const blockFeedback = z.object({
+ url: z.string(),
+ blockId: z.string(),
+ message: z.string(),
+
+ /** the referenced text of block */
+ blockBody: z.optional(z.string()),
+});
+
+export const pageFeedback = z.object({
+ opinion: z.enum(["good", "bad"]),
+ url: z.string(),
+ message: z.string(),
+});
+
+export const actionResponse = z.object({
+ githubUrl: z.optional(z.string()),
+});
+
+export type BlockFeedback = z.infer;
+export type PageFeedback = z.infer;
+export type ActionResponse = z.infer;
diff --git a/src/components/markdown.tsx b/src/components/markdown.tsx
new file mode 100644
index 00000000..880083b9
--- /dev/null
+++ b/src/components/markdown.tsx
@@ -0,0 +1,116 @@
+import { remark } from "remark";
+import remarkGfm from "remark-gfm";
+import remarkRehype from "remark-rehype";
+import { toJsxRuntime } from "hast-util-to-jsx-runtime";
+import {
+ Children,
+ type ComponentProps,
+ type ReactElement,
+ type ReactNode,
+ Suspense,
+ use,
+ useDeferredValue,
+} from "react";
+import { Fragment, jsx, jsxs } from "react/jsx-runtime";
+import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
+import defaultMdxComponents from "fumadocs-ui/mdx";
+import { visit } from "unist-util-visit";
+import type { ElementContent, Root, RootContent } from "hast";
+
+export interface Processor {
+ process: (content: string) => Promise;
+}
+
+export function rehypeWrapWords() {
+ return (tree: Root) => {
+ visit(tree, ["text", "element"], (node, index, parent) => {
+ if (node.type === "element" && node.tagName === "pre") return "skip";
+ if (node.type !== "text" || !parent || index === undefined) return;
+
+ const words = node.value.split(/(?=\s)/);
+
+ // Create new span nodes for each word and whitespace
+ const newNodes: ElementContent[] = words.flatMap((word) => {
+ if (word.length === 0) return [];
+
+ return {
+ type: "element",
+ tagName: "span",
+ properties: {
+ class: "animate-fd-fade-in",
+ },
+ children: [{ type: "text", value: word }],
+ };
+ });
+
+ Object.assign(node, {
+ type: "element",
+ tagName: "span",
+ properties: {},
+ children: newNodes,
+ } satisfies RootContent);
+ return "skip";
+ });
+ };
+}
+
+function createProcessor(): Processor {
+ const processor = remark().use(remarkGfm).use(remarkRehype).use(rehypeWrapWords);
+
+ return {
+ async process(content) {
+ const nodes = processor.parse({ value: content });
+ const hast = await processor.run(nodes);
+
+ return toJsxRuntime(hast, {
+ development: false,
+ jsx,
+ jsxs,
+ Fragment,
+ components: {
+ ...defaultMdxComponents,
+ pre: Pre,
+ img: undefined, // use JSX
+ },
+ });
+ },
+ };
+}
+
+function Pre(props: ComponentProps<"pre">) {
+ const code = Children.only(props.children) as ReactElement;
+ const codeProps = code.props as ComponentProps<"code">;
+ const content = codeProps.children;
+ if (typeof content !== "string") return null;
+
+ let lang =
+ codeProps.className
+ ?.split(" ")
+ .find((v) => v.startsWith("language-"))
+ ?.slice("language-".length) ?? "text";
+
+ if (lang === "mdx") lang = "md";
+
+ return ;
+}
+
+const processor = createProcessor();
+
+export function Markdown({ text }: { text: string }) {
+ const deferredText = useDeferredValue(text);
+
+ return (
+ {text}
}>
+
+
+ );
+}
+
+const cache = new Map>();
+
+function Renderer({ text }: { text: string }) {
+ const result = cache.get(text) ?? processor.process(text);
+ cache.set(text, result);
+
+ return use(result);
+}
diff --git a/src/components/not-found.tsx b/src/components/not-found.tsx
new file mode 100644
index 00000000..339722ca
--- /dev/null
+++ b/src/components/not-found.tsx
@@ -0,0 +1,24 @@
+import { baseOptions } from "@/lib/layout.shared";
+import { Link } from "@tanstack/react-router";
+import { HomeLayout } from "fumadocs-ui/layouts/home";
+
+export function NotFound() {
+ 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
+
+
+
+ );
+}
diff --git a/src/components/rate.tsx b/src/components/rate.tsx
deleted file mode 100644
index f115d186..00000000
--- a/src/components/rate.tsx
+++ /dev/null
@@ -1,251 +0,0 @@
-'use client';
-import { cn } from '@/lib/utils';
-import { buttonVariants } from 'fumadocs-ui/components/ui/button';
-import { ThumbsDown, ThumbsUp } from 'lucide-react';
-import { type SyntheticEvent, useEffect, useState, useTransition } from 'react';
-import {
- Collapsible,
- CollapsibleContent,
-} from 'fumadocs-ui/components/ui/collapsible';
-import { cva } from 'class-variance-authority';
-import { usePathname } from 'next/navigation';
-
-const rateButtonVariants = cva(
- 'inline-flex items-center gap-2 px-3 py-2 rounded-full font-medium border text-sm [&_svg]:size-4 disabled:cursor-not-allowed',
- {
- variants: {
- active: {
- true: 'bg-fd-accent text-fd-accent-foreground [&_svg]:fill-current',
- false: 'text-fd-muted-foreground',
- },
- },
- },
-);
-
-export interface Feedback {
- opinion: 'good' | 'bad';
- url?: string;
- message: string;
-}
-
-export interface ActionResponse {
- success: boolean;
- error?: string;
-}
-
-interface Result extends Feedback {
- response?: ActionResponse;
-}
-
-interface RateProps {
- githubPath?: string;
-}
-
-export function Rate({ githubPath }: RateProps = {}) {
- const [userEmail, setUserEmail] = useState(null);
-
- // Effect to fetch user session
- useEffect(() => {
- fetch('/api/auth/session')
- .then(res => res.json())
- .then(data => {
- if (data.isLoggedIn && data.userInfo?.email) {
- setUserEmail(data.userInfo.email);
- }
- })
- .catch(err => console.error('Failed to fetch session:', err));
- }, []);
-
- async function defaultRateAction(
- url: string,
- feedback: Feedback,
- ): Promise {
- try {
- const res = await fetch('/docs/api/feedback', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ url, ...feedback, email: userEmail || undefined }),
- });
-
- let data: ActionResponse | undefined;
- if (res.headers.get('content-type')?.includes('application/json')) {
- data = await res.json();
- }
-
- // non‑200
- if (!res.ok) {
- return {
- success: false,
- error: data?.error ?? 'Something went wrong. Please try again.',
- };
- }
-
- return data ?? { success: true };
- } catch (e) {
- return { success: false, error: 'Network error. Please try again.' };
- }
- }
-
- const url = usePathname();
- const [previous, setPrevious] = useState(null);
- const [opinion, setOpinion] = useState<'good' | 'bad' | null>(null);
- const [message, setMessage] = useState('');
- const [error, setError] = useState(null);
- const [isPending, startTransition] = useTransition();
-
- useEffect(() => {
- const item = localStorage.getItem(`docs-feedback-${url}`);
-
- if (item === null) return;
- setPrevious(JSON.parse(item) as Result);
- }, [url]);
-
- useEffect(() => {
- const key = `docs-feedback-${url}`;
-
- if (previous) localStorage.setItem(key, JSON.stringify(previous));
- else localStorage.removeItem(key);
- }, [previous, url]);
-
- function submit(e?: SyntheticEvent) {
- if (opinion == null) return;
-
- startTransition(async () => {
- const feedback: Feedback = {
- opinion,
- message,
- };
-
- void defaultRateAction(url, feedback).then((response) => {
- if (!response.success) {
- setError(response.error ?? 'Something went wrong');
- return;
- }
-
- setPrevious({
- response,
- ...feedback,
- });
- setMessage('');
- setOpinion(null);
- setError(null);
- });
- });
-
- e?.preventDefault();
- }
-
- const activeOpinion = previous?.opinion ?? opinion;
-
- return (
- {
- if (!v) setOpinion(null);
- }}
- className="border-y py-3"
- >
-
-
How is this guide?
-
-
- {githubPath && (
-
- Edit on GitHub
-
- )}
-
-
- {previous ? (
-
-
Thank you for your feedback!
-
-
-
-
-
- ) : (
-
- )}
-
-
- );
-}
\ No newline at end of file
diff --git a/src/components/search.tsx b/src/components/search.tsx
new file mode 100644
index 00000000..e3872247
--- /dev/null
+++ b/src/components/search.tsx
@@ -0,0 +1,838 @@
+"use client";
+import {
+ type ComponentProps,
+ createContext,
+ type ReactNode,
+ type SyntheticEvent,
+ use,
+ useEffect,
+ useEffectEvent,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import {
+ ChevronDown,
+ Copy,
+ Loader2,
+ MessageCircleIcon,
+ RefreshCw,
+ Send,
+ ThumbsDown,
+ ThumbsUp,
+ X,
+} from "lucide-react";
+import { cn } from "../lib/cn";
+import { buttonVariants } from "./ui/button";
+import { Markdown } from "./markdown";
+import { Presence } from "@radix-ui/react-presence";
+import { useLocalStorage } from "../hooks/useLocalStorage";
+import { SDK_OPTIONS } from "../lib/sdk-options";
+
+const API_URL = "https://docs-ai-api.superwall.com";
+
+type ChatMessage = {
+ id: string;
+ role: "user" | "assistant";
+ content: string;
+ isError?: boolean;
+ isStreaming?: boolean;
+ feedback?: {
+ rating: "positive" | "negative";
+ comment?: string;
+ submitted?: boolean;
+ };
+};
+
+type ChatStatus = "ready" | "submitting" | "streaming";
+
+const newId = () => crypto.randomUUID?.() ?? `id-${Date.now()}-${Math.random()}`;
+
+const Context = createContext<{
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ messages: ChatMessage[];
+ status: ChatStatus;
+ sendMessage: (text: string) => void;
+ stop: () => void;
+ setMessages: (value: ChatMessage[] | ((prev: ChatMessage[]) => ChatMessage[])) => void;
+ retryMessage: (assistantId: string) => void;
+ clearChat: () => void;
+ selectedSdk: string;
+ setSelectedSdk: (value: string) => void;
+ handleFeedback: (messageId: string, rating: "positive" | "negative") => void;
+ submitFeedbackComment: (messageId: string) => void;
+ feedbackState: Record;
+ updateFeedbackComment: (messageId: string, comment: string) => void;
+} | null>(null);
+
+// --- Provider ---
+
+export function AISearch({ children }: { children: ReactNode }) {
+ const [open, setOpen] = useState(false);
+ const [messages, setMessages] = useLocalStorage("superwall-ai-chat-history", []);
+ const [status, setStatus] = useState("ready");
+ const [selectedSdk, setSelectedSdk] = useLocalStorage("superwall-ai-selected-sdk", "");
+ const [conversationId, setConversationId] = useLocalStorage(
+ "superwall-ai-conversation-id",
+ "",
+ );
+ const [feedbackState, setFeedbackState] = useState<
+ Record
+ >({});
+
+ const abortRef = useRef(null);
+ const messagesRef = useRef(messages);
+ useEffect(() => {
+ messagesRef.current = messages;
+ }, [messages]);
+
+ // Ensure conversation ID exists
+ useEffect(() => {
+ if (!conversationId) setConversationId(newId());
+ }, [conversationId, setConversationId]);
+
+ const sendMessage = (text: string) => {
+ const trimmed = text.trim();
+ if (!trimmed) return;
+ if (status === "submitting" || status === "streaming") return;
+
+ const userMsg: ChatMessage = { id: newId(), role: "user", content: trimmed };
+ const assistantId = newId();
+ const assistantMsg: ChatMessage = {
+ id: assistantId,
+ role: "assistant",
+ content: "",
+ isStreaming: true,
+ };
+
+ setMessages((prev) => [...prev, userMsg, assistantMsg]);
+ setStatus("submitting");
+
+ const payloadMessages = [...messagesRef.current, userMsg]
+ .filter((m) => !m.isError)
+ .map((m) => ({ role: m.role, content: m.content }));
+
+ const controller = new AbortController();
+ abortRef.current = controller;
+
+ (async () => {
+ try {
+ const res = await fetch(`${API_URL}/chat`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messages: payloadMessages,
+ sdks: selectedSdk ? [selectedSdk] : undefined,
+ chatId: conversationId,
+ }),
+ signal: controller.signal,
+ });
+
+ if (!res.ok || !res.body) throw new Error("Bad response");
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let accumulated = "";
+ setStatus("streaming");
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ accumulated += decoder.decode(value, { stream: true });
+ const snapshot = accumulated;
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === assistantId ? { ...m, content: snapshot, isStreaming: true } : m,
+ ),
+ );
+ }
+
+ accumulated += decoder.decode();
+ const final = accumulated;
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === assistantId ? { ...m, content: final, isStreaming: false, isError: false } : m,
+ ),
+ );
+ setStatus("ready");
+ } catch (error) {
+ if (controller.signal.aborted) {
+ setMessages((prev) =>
+ prev.map((m) => (m.id === assistantId ? { ...m, isStreaming: false } : m)),
+ );
+ setStatus("ready");
+ return;
+ }
+ console.error("Chat error", error);
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === assistantId
+ ? {
+ ...m,
+ content: "**Error** – please try again.",
+ isError: true,
+ isStreaming: false,
+ }
+ : m,
+ ),
+ );
+ setStatus("ready");
+ } finally {
+ abortRef.current = null;
+ }
+ })();
+ };
+
+ const stop = () => {
+ abortRef.current?.abort();
+ };
+
+ const retryMessage = (assistantId: string) => {
+ let userContent: string | undefined;
+
+ setMessages((prev) => {
+ const idx = prev.findIndex((m) => m.id === assistantId);
+ if (idx === -1) return prev;
+ const prevUserIdx = prev.slice(0, idx).reverse().findIndex((m) => m.role === "user");
+ const removeFrom = prevUserIdx === -1 ? idx : idx - prevUserIdx - 1;
+ if (prevUserIdx !== -1) userContent = prev[removeFrom].content;
+ const next = [...prev];
+ next.splice(removeFrom, idx - removeFrom + 1);
+ messagesRef.current = next;
+ return next;
+ });
+
+ if (userContent) {
+ // Use setTimeout to ensure state is updated before re-sending
+ setTimeout(() => sendMessage(userContent!), 0);
+ }
+ };
+
+ const clearChat = () => {
+ abortRef.current?.abort();
+ setMessages([]);
+ setConversationId(newId());
+ setStatus("ready");
+ setFeedbackState({});
+ };
+
+ const handleFeedback = (messageId: string, rating: "positive" | "negative") => {
+ const item = messages.find((m) => m.id === messageId);
+ if (item?.feedback?.submitted) return;
+
+ if (item?.feedback?.rating === rating && !item.feedback.comment) {
+ setMessages((prev) =>
+ prev.map((m) => (m.id === messageId ? { ...m, feedback: undefined } : m)),
+ );
+ setFeedbackState((prev) => ({
+ ...prev,
+ [messageId]: { showInput: false, comment: "" },
+ }));
+ return;
+ }
+
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === messageId
+ ? { ...m, feedback: { rating, comment: feedbackState[messageId]?.comment || "" } }
+ : m,
+ ),
+ );
+ setFeedbackState((prev) => ({
+ ...prev,
+ [messageId]: { showInput: true, comment: "" },
+ }));
+ };
+
+ const submitFeedbackComment = (messageId: string) => {
+ const comment = feedbackState[messageId]?.comment || "";
+ const item = messages.find((m) => m.id === messageId);
+ if (!item?.feedback) return;
+
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === messageId && m.feedback
+ ? { ...m, feedback: { ...m.feedback, comment, submitted: true } }
+ : m,
+ ),
+ );
+ setFeedbackState((prev) => ({
+ ...prev,
+ [messageId]: { ...prev[messageId], showInput: false },
+ }));
+
+ // Find the question that preceded this answer
+ const idx = messagesRef.current.findIndex((m) => m.id === messageId);
+ const question =
+ messagesRef.current.slice(0, idx).reverse().find((m) => m.role === "user")?.content ??
+ "";
+
+ fetch("/docs/api/feedback", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ type: "ai",
+ question,
+ answer: item.content,
+ rating: item.feedback.rating,
+ comment: comment || undefined,
+ }),
+ }).catch((err) => console.error("Failed to send feedback:", err));
+ };
+
+ const updateFeedbackComment = (messageId: string, comment: string) => {
+ setFeedbackState((prev) => ({
+ ...prev,
+ [messageId]: { ...prev[messageId], comment },
+ }));
+ };
+
+ const ctx = useMemo(
+ () => ({
+ open,
+ setOpen,
+ messages,
+ status,
+ sendMessage,
+ stop,
+ setMessages,
+ retryMessage,
+ clearChat,
+ selectedSdk,
+ setSelectedSdk,
+ handleFeedback,
+ submitFeedbackComment,
+ feedbackState,
+ updateFeedbackComment,
+ }),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [open, messages, status, selectedSdk, feedbackState],
+ );
+
+ return {children};
+}
+
+// --- Hooks ---
+
+export function useAISearchContext() {
+ return use(Context)!;
+}
+
+// --- Trigger ---
+
+export function AISearchTrigger({
+ position = "default",
+ className,
+ ...props
+}: ComponentProps<"button"> & { position?: "default" | "float" }) {
+ const { open, setOpen } = useAISearchContext();
+
+ return (
+
+ );
+}
+
+// --- Panel ---
+
+export function AISearchPanel() {
+ const { open, setOpen } = useAISearchContext();
+ useHotKey();
+
+ return (
+ <>
+
+
+ setOpen(false)}
+ />
+
+
+
+
+ >
+ );
+}
+
+// --- Header ---
+
+export function AISearchPanelHeader({ className, ...props }: ComponentProps<"div">) {
+ const { setOpen, selectedSdk, setSelectedSdk } = useAISearchContext();
+ const [showDropdown, setShowDropdown] = useState(false);
+ const dropdownRef = useRef
(null);
+
+ useEffect(() => {
+ const handler = (e: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
+ setShowDropdown(false);
+ }
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, []);
+
+ const currentLabel = SDK_OPTIONS.find((o) => o.value === selectedSdk)?.label ?? "None";
+
+ return (
+
+
+
AI Chat
+
+
+ {showDropdown && (
+
+ {SDK_OPTIONS.map((option) => (
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
+
+// --- Actions ---
+
+export function AISearchInputActions() {
+ const { messages, status, retryMessage, clearChat } = useAISearchContext();
+ const isLoading = status === "streaming";
+ const nonSystemMessages = messages;
+
+ if (nonSystemMessages.length === 0) return null;
+
+ const lastAssistant = [...nonSystemMessages].reverse().find((m) => m.role === "assistant");
+
+ return (
+ <>
+ {!isLoading && lastAssistant && !lastAssistant.isStreaming && (
+
+ )}
+
+ >
+ );
+}
+
+// --- Input ---
+
+const StorageKeyInput = "__ai_search_input";
+
+export function AISearchInput(props: ComponentProps<"form">) {
+ const { status, sendMessage, stop } = useAISearchContext();
+ const [input, setInput] = useState(() => localStorage.getItem(StorageKeyInput) ?? "");
+ const isLoading = status === "streaming" || status === "submitting";
+
+ const onStart = (e?: SyntheticEvent) => {
+ e?.preventDefault();
+ sendMessage(input);
+ setInput("");
+ };
+
+ localStorage.setItem(StorageKeyInput, input);
+
+ useEffect(() => {
+ if (isLoading) document.getElementById("nd-ai-input")?.focus();
+ }, [isLoading]);
+
+ return (
+
+ );
+}
+
+// --- Message List ---
+
+export function AISearchPanelList({ className, style, ...props }: ComponentProps<"div">) {
+ const { messages } = useAISearchContext();
+ const filtered = messages;
+
+ return (
+
+ {filtered.length === 0 ? (
+
+
+
e.stopPropagation()}>Start a new chat below.
+
+ ) : (
+
+ {filtered.map((item) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+// --- Internal Components ---
+
+function List(props: Omit, "dir">) {
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ if (!containerRef.current) return;
+ function callback() {
+ const container = containerRef.current;
+ if (!container) return;
+ container.scrollTo({ top: container.scrollHeight, behavior: "instant" });
+ }
+
+ const observer = new ResizeObserver(callback);
+ callback();
+ const element = containerRef.current?.firstElementChild;
+ if (element) observer.observe(element);
+
+ return () => observer.disconnect();
+ }, []);
+
+ return (
+
+ {props.children}
+
+ );
+}
+
+function TextArea(props: ComponentProps<"textarea">) {
+ const ref = useRef(null);
+ const shared = cn("col-start-1 row-start-1", props.className);
+
+ return (
+
+
+
+ {`${props.value?.toString() ?? ""}\n`}
+
+
+ );
+}
+
+const roleName: Record = {
+ user: "you",
+ assistant: "superwall",
+};
+
+const ACTION_BTN = "rounded p-1 transition-colors hover:bg-fd-accent cursor-pointer";
+
+function Message({ message }: { message: ChatMessage }) {
+ const {
+ retryMessage,
+ handleFeedback,
+ submitFeedbackComment,
+ feedbackState,
+ updateFeedbackComment,
+ } = useAISearchContext();
+
+ return (
+ e.stopPropagation()}>
+
+ {roleName[message.role] ?? "unknown"}
+
+
+ {message.isError ? (
+
+ Error — please try again
+
+
+ ) : message.isStreaming && !message.content ? (
+
+
+ Thinking…
+
+ ) : (
+
+
+
+ )}
+
+ {/* Actions for assistant messages */}
+ {message.role === "assistant" && !message.isStreaming && !message.isError && (
+
+
+
+
+
+
+ )}
+
+ {/* Feedback comment input */}
+ {feedbackState[message.id]?.showInput && (
+
+
+ Optional feedback (helps improve AI responses)
+
+
+
+
+ )}
+
+ );
+}
+
+// --- Hotkey ---
+
+export function useHotKey() {
+ const { open, setOpen } = useAISearchContext();
+
+ const onKeyPress = useEffectEvent((e: KeyboardEvent) => {
+ if (e.key === "Escape" && open) {
+ setOpen(false);
+ e.preventDefault();
+ }
+ if (e.key === "/" && (e.metaKey || e.ctrlKey) && !open) {
+ setOpen(true);
+ e.preventDefault();
+ }
+ });
+
+ useEffect(() => {
+ window.addEventListener("keydown", onKeyPress);
+ return () => window.removeEventListener("keydown", onKeyPress);
+ }, []);
+}
diff --git a/src/components/type-table.tsx b/src/components/type-table.tsx
index bc6f6745..ea7f7334 100644
--- a/src/components/type-table.tsx
+++ b/src/components/type-table.tsx
@@ -1,15 +1,11 @@
-'use client';
+"use client";
-import { ChevronDown } from 'lucide-react';
-import Link from 'fumadocs-core/link';
-import { cva } from 'class-variance-authority';
-import { cn } from '@/lib/utils';
-import { type ReactNode, useState } from 'react';
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from './ui/collapsible';
+import { ChevronDown } from "lucide-react";
+import Link from "fumadocs-core/link";
+import { cva } from "class-variance-authority";
+import { cn } from "@/lib/utils";
+import { type ReactNode, useState } from "react";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
export interface ParameterNode {
name: string;
@@ -17,49 +13,30 @@ export interface ParameterNode {
}
export interface TypeNode {
- /**
- * Additional description of the field
- */
description?: ReactNode;
-
- /**
- * type signature (short)
- */
type: ReactNode;
-
- /**
- * type signature (full)
- */
typeDescription?: ReactNode;
-
- /**
- * Optional `href` for the type
- */
typeDescriptionLink?: string;
-
default?: ReactNode;
-
required?: boolean;
deprecated?: boolean;
-
parameters?: ParameterNode[];
-
returns?: ReactNode;
}
-const keyVariants = cva('text-fd-primary', {
+const keyVariants = cva("text-fd-primary", {
variants: {
deprecated: {
- true: 'line-through text-fd-primary/50',
+ true: "line-through text-fd-primary/50",
},
},
});
-const fieldVariants = cva('text-fd-muted-foreground not-prose pe-2');
+const fieldVariants = cva("text-fd-muted-foreground not-prose pe-2");
export function TypeTable({
type,
- propColumnWidth = '25%',
+ propColumnWidth = "25%",
}: {
type: Record;
propColumnWidth?: string;
@@ -71,12 +48,7 @@ export function TypeTable({
Type
{Object.entries(type).map(([key, value]) => (
-
+
))}