import i18n from "i18next" import { ReactNode, useMemo } from "react" import { Helmet } from "react-helmet-async" import { UIMatch, useMatches } from "react-router-dom" const DEFAULT_TITLE = "Medusa" export type RouteHandle = { breadcrumb?: (match?: UIMatch) => string | ReactNode /** * Resolves the document title for the route. * * Each route declares its own resolver, co-located with the route, so the * title logic lives next to the loader data shape it depends on (rather than * being centralised in this hook). Return `undefined` to let a parent route's * title or the default apply. */ seo?: (match: UIMatch) => { title?: string } /** * A static label for the route, sourced from `defineRouteConfig` on custom * admin pages. When `translationNs` is set, the label is treated as an i18n * key and resolved against that namespace. Used for document title fallback. */ label?: string translationNs?: string } /** * Resolves a single matched route to a title string, or `null` when the route * does not contribute a title. * * Resolution order: * 1. An explicit `seo` resolver (used by detail pages to surface entity names). * 2. A static `label` from `defineRouteConfig` (custom pages' sidebar label). * 3. A `breadcrumb` that returns a plain string (used by list/section pages). * * React-element breadcrumbs (rendered by detail pages) carry no extractable * text and are intentionally ignored here; those routes rely on `seo`. */ export function getTitleFromMatch( match: UIMatch ): string | null { const handle = match.handle if (handle?.seo) { try { const title = handle.seo(match)?.title if (typeof title === "string" && title.trim() !== "") { return title } } catch { // Fall through to the label / breadcrumb / default below. } } if (handle?.label?.trim()?.length) { try { const label = handle.translationNs ? (i18n.t as any)(handle.label, { ns: handle.translationNs }) : handle.label if (label?.trim()?.length) { return label } } catch { // Fall through to the breadcrumb / default below. } } if (handle?.breadcrumb) { try { const result = handle.breadcrumb(match) if (typeof result === "string" && result.trim() !== "") { return result } } catch { // Ignore and let a parent route or the default apply. } } return null } /** * Hook that returns the document title based on the current route. * * It walks the matched routes from root to leaf and uses the most specific * (last) route that yields a title. The format is `"Page Title - Medusa"`, * falling back to `"Medusa"` when no route contributes a title. */ export function useDocumentTitle() { const matches = useMatches() as UIMatch[] const title = useMemo(() => { let pageTitle = "" for (let indx = matches.length - 1; indx >= 0; indx--) { const resolvedTitle = getTitleFromMatch(matches[indx]) if (resolvedTitle) { pageTitle = resolvedTitle break } } if (!pageTitle) { return DEFAULT_TITLE } return `${pageTitle} - Medusa` }, [matches]) return title } /** * Component that renders the document title using react-helmet-async. * Should be used within a HelmetProvider. */ export function DocumentTitle() { const title = useDocumentTitle() return ( {title} ) }