diff --git a/cloud/app/components/blocks/navigation/footer.tsx b/cloud/app/components/blocks/navigation/footer.tsx index 47b33b3d9b..295f0a3e03 100644 --- a/cloud/app/components/blocks/navigation/footer.tsx +++ b/cloud/app/components/blocks/navigation/footer.tsx @@ -19,7 +19,8 @@ export default function Footer() { diff --git a/cloud/app/components/page-sidebar.tsx b/cloud/app/components/page-sidebar.tsx index 581dd3c661..3123923667 100644 --- a/cloud/app/components/page-sidebar.tsx +++ b/cloud/app/components/page-sidebar.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Link, useRouterState } from "@tanstack/react-router"; +import { Link, useRouterState, type LinkProps } from "@tanstack/react-router"; import { cn } from "@/app/lib/utils"; import { ChevronDown, ChevronRight } from "lucide-react"; @@ -213,8 +213,8 @@ const SidebarLink = ({ return ( { + if (!dateString) return ""; + try { + // Validate ISO 8601 date format (YYYY-MM-DD) + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { + return dateString; + } + // Parse date components + const [year, month, day] = dateString.split("-").map(Number); + + // Create date at noon UTC to avoid DST edge cases, then format in LA timezone + // This ensures the date is preserved correctly when formatting + const date = new Date(Date.UTC(year, month - 1, day, 12, 0, 0)); + + // Check if date is valid + if (isNaN(date.getTime())) { + return dateString; + } + + // Format in Los Angeles timezone to match the source timezone + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + timeZone: "America/Los_Angeles", + }); + } catch (e) { + console.error("Error formatting date:", e); + return dateString; + } +}; + +interface PolicyPageProps { + content: PolicyContent; +} + +/** + * PolicyPage - Reusable component for rendering policy and terms pages + */ +const PolicyPage: React.FC = ({ content }) => { + // Content ID for the article element + const contentId = "policy-content"; + + const title = content?.meta?.title ?? "Loading..."; + const lastUpdated = content?.meta?.lastUpdated + ? formatDate(content.meta.lastUpdated) + : ""; + + return ( +
+ {/* Header with title and fun mode button aligned horizontally */} +
+
+

{title}

+ {lastUpdated && ( +

+ Last Updated: {lastUpdated} +

+ )} +
+
+ +
+
+ +
+
+
+ ); +}; + +export default PolicyPage; diff --git a/cloud/app/components/temp-page.tsx b/cloud/app/components/temp-page.tsx deleted file mode 100644 index 1173ff9964..0000000000 --- a/cloud/app/components/temp-page.tsx +++ /dev/null @@ -1,4 +0,0 @@ -// todo(sebastian): remove once content (md/mdx) is ported -export function TempPage({ name }: { name: string }) { - return
{name}
; -} diff --git a/cloud/app/lib/content/content-loader.ts b/cloud/app/lib/content/content-loader.ts index 31d6a932a8..036d019861 100644 --- a/cloud/app/lib/content/content-loader.ts +++ b/cloud/app/lib/content/content-loader.ts @@ -1,16 +1,20 @@ -import type { LoaderFnContext, AnyRoute } from "@tanstack/react-router"; +import type { LoaderFnContext } from "@tanstack/react-router"; import type { BlogContent, BlogMeta, DocContent, DocMeta, + PolicyContent, + PolicyMeta, } from "@/app/lib/content/types"; import type { ProcessedMDX } from "@/app/lib/mdx/types"; import { BLOG_MODULE_MAP, DOCS_MODULE_MAP, + POLICY_MODULE_MAP, getAllBlogMeta, getAllDocsMeta, + getAllPolicyMeta, type ModuleMap, } from "./virtual-module"; @@ -47,7 +51,7 @@ function createContentLoader< >(moduleMap: ModuleMap, config: ContentLoaderConfig) { return async function loader( // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: LoaderFnContext, + context: LoaderFnContext, ): Promise { // Type assertion needed due to TanStack Router's complex param type expansion const key = config.extractKey(context.params as TParams); @@ -144,3 +148,89 @@ export function docsContentLoader(version?: string, moduleMap?: ModuleMap) { }, ); } + +/* ========== POLICY CONTENT LOADER =========== */ + +/** + * Type for the data returned by policyContentLoader. + */ +export type PolicyLoaderData = PolicyContent | undefined; + +/** + * Generic policy content loader (supports custom module map for testing). + * + * @param subdirectory - Optional subdirectory within policy (e.g., "terms" for policy/terms/, + * or undefined for top-level policy files like privacy.mdx) + * @param moduleMap - Optional custom module map (for testing) + */ +export function policyContentLoader( + subdirectory: string | undefined, + moduleMap?: ModuleMap, +) { + const effectiveModuleMap = moduleMap ?? POLICY_MODULE_MAP; + + return createContentLoader( + effectiveModuleMap, + { + getMeta: getAllPolicyMeta, + extractKey: (params) => { + // Build path with "policy/" prefix, optionally including subdirectory + const basePath = subdirectory ? `policy/${subdirectory}` : "policy"; + return `${basePath}${params._splat ? `/${params._splat}` : ""}`; + }, + findMeta: (metas, path) => { + // Find metadata matching the path (e.g., "policy/privacy" or "policy/terms/service") + return metas.find((m) => m.path === path); + }, + getModuleKey: (meta) => { + // Strip "policy/" prefix to get the module key + const prefix = "policy/"; + return meta.path.startsWith(prefix) + ? meta.path.slice(prefix.length) + : meta.path; + }, + buildContent: (meta, mdx) => ({ meta, content: mdx.content, mdx }), + }, + ); +} + +/** + * Terms content loader - convenience wrapper for policy/terms/ subdirectory. + * + * @param moduleMap - Optional custom module map (for testing) + */ +export function termsContentLoader(moduleMap?: ModuleMap) { + return policyContentLoader("terms", moduleMap); +} + +/** + * Privacy content loader - convenience wrapper for top-level policy/privacy. + * This loader doesn't use _splat params since privacy is a single file. + * + * @param moduleMap - Optional custom module map (for testing) + */ +export function privacyContentLoader(moduleMap?: ModuleMap) { + const effectiveModuleMap = moduleMap ?? POLICY_MODULE_MAP; + + return createContentLoader, PolicyContent>( + effectiveModuleMap, + { + getMeta: getAllPolicyMeta, + extractKey: () => { + // Privacy is always at "policy/privacy" + return "policy/privacy"; + }, + findMeta: (metas, path) => { + return metas.find((m) => m.path === path); + }, + getModuleKey: (meta) => { + // Strip "policy/" prefix to get the module key + const prefix = "policy/"; + return meta.path.startsWith(prefix) + ? meta.path.slice(prefix.length) + : meta.path; + }, + buildContent: (meta, mdx) => ({ meta, content: mdx.content, mdx }), + }, + ); +} diff --git a/cloud/app/lib/content/types.ts b/cloud/app/lib/content/types.ts index 3405f83873..c9d6ed5a2c 100644 --- a/cloud/app/lib/content/types.ts +++ b/cloud/app/lib/content/types.ts @@ -78,6 +78,8 @@ export type DocContent = Content; * Policy-specific metadata extends the base ContentMeta */ export interface PolicyMeta extends ContentMeta { + title: string; + description: string; lastUpdated: string; // Last update date of the policy } diff --git a/cloud/app/lib/content/virtual-module.ts b/cloud/app/lib/content/virtual-module.ts index 5f3204b47c..efff24e9d3 100644 --- a/cloud/app/lib/content/virtual-module.ts +++ b/cloud/app/lib/content/virtual-module.ts @@ -1,7 +1,11 @@ -// @ts-expect-error - virtual module resolved by vite plugin -import { blogMetadata, docsMetadata } from "virtual:content-meta"; +import { + blogMetadata, + docsMetadata, + policyMetadata, + // @ts-expect-error - virtual module resolved by vite plugin +} from "virtual:content-meta"; import type { ProcessedMDX } from "@/app/lib/mdx/types"; -import type { BlogMeta, DocMeta } from "@/app/lib/content/types"; +import type { BlogMeta, DocMeta, PolicyMeta } from "@/app/lib/content/types"; export function getAllBlogMeta(): BlogMeta[] { return blogMetadata as BlogMeta[]; @@ -11,6 +15,10 @@ export function getAllDocsMeta(): DocMeta[] { return docsMetadata as DocMeta[]; } +export function getAllPolicyMeta(): PolicyMeta[] { + return policyMetadata as PolicyMeta[]; +} + /** * Maps a content key to its MDX loader function. */ @@ -41,6 +49,7 @@ function buildModuleMap( const BLOG_PATH_REGEX = /^\/content\/blog\/(.*)\.mdx$/; const DOCS_PATH_REGEX = /^\/content\/docs\/(.*)\.mdx$/; +const POLICY_PATH_REGEX = /^\/content\/policy\/(.*)\.mdx$/; const BLOG_MODULES = import.meta.glob<{ mdx: ProcessedMDX }>( "@/content/blog/*.mdx", @@ -52,6 +61,15 @@ const DOCS_MODULES = import.meta.glob<{ mdx: ProcessedMDX }>( { eager: false }, ); +const POLICY_MODULES = import.meta.glob<{ mdx: ProcessedMDX }>( + "@/content/policy/**/*.mdx", + { eager: false }, +); + // Pre-build module maps once at module initialization export const BLOG_MODULE_MAP = buildModuleMap(BLOG_MODULES, BLOG_PATH_REGEX); export const DOCS_MODULE_MAP = buildModuleMap(DOCS_MODULES, DOCS_PATH_REGEX); +export const POLICY_MODULE_MAP = buildModuleMap( + POLICY_MODULES, + POLICY_PATH_REGEX, +); diff --git a/cloud/app/routeTree.gen.ts b/cloud/app/routeTree.gen.ts index 6ae29da63d..1dec14de1c 100644 --- a/cloud/app/routeTree.gen.ts +++ b/cloud/app/routeTree.gen.ts @@ -19,7 +19,7 @@ import { Route as BlogRouteImport } from './routes/blog' import { Route as IndexRouteImport } from './routes/index' import { Route as DashboardIndexRouteImport } from './routes/dashboard/index' import { Route as BlogIndexRouteImport } from './routes/blog.index' -import { Route as TermsUseRouteImport } from './routes/terms.use' +import { Route as TermsSplatRouteImport } from './routes/terms.$' import { Route as DashboardSettingsRouteImport } from './routes/dashboard/settings' import { Route as BlogSlugRouteImport } from './routes/blog.$slug' import { Route as AuthMeRouteImport } from './routes/auth/me' @@ -87,9 +87,9 @@ const BlogIndexRoute = BlogIndexRouteImport.update({ path: '/', getParentRoute: () => BlogRoute, } as any) -const TermsUseRoute = TermsUseRouteImport.update({ - id: '/terms/use', - path: '/terms/use', +const TermsSplatRoute = TermsSplatRouteImport.update({ + id: '/terms/$', + path: '/terms/$', getParentRoute: () => rootRouteImport, } as any) const DashboardSettingsRoute = DashboardSettingsRouteImport.update({ @@ -187,7 +187,7 @@ export interface FileRoutesByFullPath { '/auth/me': typeof AuthMeRoute '/blog/$slug': typeof BlogSlugRoute '/dashboard/settings': typeof DashboardSettingsRoute - '/terms/use': typeof TermsUseRoute + '/terms/$': typeof TermsSplatRoute '/blog/': typeof BlogIndexRoute '/dashboard': typeof DashboardIndexRoute '/api/v0/$': typeof ApiV0SplatRoute @@ -215,7 +215,7 @@ export interface FileRoutesByTo { '/auth/me': typeof AuthMeRoute '/blog/$slug': typeof BlogSlugRoute '/dashboard/settings': typeof DashboardSettingsRoute - '/terms/use': typeof TermsUseRoute + '/terms/$': typeof TermsSplatRoute '/blog': typeof BlogIndexRoute '/dashboard': typeof DashboardIndexRoute '/api/v0/$': typeof ApiV0SplatRoute @@ -245,7 +245,7 @@ export interface FileRoutesById { '/auth/me': typeof AuthMeRoute '/blog/$slug': typeof BlogSlugRoute '/dashboard/settings': typeof DashboardSettingsRoute - '/terms/use': typeof TermsUseRoute + '/terms/$': typeof TermsSplatRoute '/blog/': typeof BlogIndexRoute '/dashboard/': typeof DashboardIndexRoute '/api/v0/$': typeof ApiV0SplatRoute @@ -276,7 +276,7 @@ export interface FileRouteTypes { | '/auth/me' | '/blog/$slug' | '/dashboard/settings' - | '/terms/use' + | '/terms/$' | '/blog/' | '/dashboard' | '/api/v0/$' @@ -304,7 +304,7 @@ export interface FileRouteTypes { | '/auth/me' | '/blog/$slug' | '/dashboard/settings' - | '/terms/use' + | '/terms/$' | '/blog' | '/dashboard' | '/api/v0/$' @@ -333,7 +333,7 @@ export interface FileRouteTypes { | '/auth/me' | '/blog/$slug' | '/dashboard/settings' - | '/terms/use' + | '/terms/$' | '/blog/' | '/dashboard/' | '/api/v0/$' @@ -362,7 +362,7 @@ export interface RootRouteChildren { AuthGoogleRoute: typeof AuthGoogleRouteWithChildren AuthMeRoute: typeof AuthMeRoute DashboardSettingsRoute: typeof DashboardSettingsRoute - TermsUseRoute: typeof TermsUseRoute + TermsSplatRoute: typeof TermsSplatRoute DashboardIndexRoute: typeof DashboardIndexRoute ApiV0SplatRoute: typeof ApiV0SplatRoute ApiV0DocsRoute: typeof ApiV0DocsRoute @@ -443,11 +443,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof BlogIndexRouteImport parentRoute: typeof BlogRoute } - '/terms/use': { - id: '/terms/use' - path: '/terms/use' - fullPath: '/terms/use' - preLoaderRoute: typeof TermsUseRouteImport + '/terms/$': { + id: '/terms/$' + path: '/terms/$' + fullPath: '/terms/$' + preLoaderRoute: typeof TermsSplatRouteImport parentRoute: typeof rootRouteImport } '/dashboard/settings': { @@ -630,7 +630,7 @@ const rootRouteChildren: RootRouteChildren = { AuthGoogleRoute: AuthGoogleRouteWithChildren, AuthMeRoute: AuthMeRoute, DashboardSettingsRoute: DashboardSettingsRoute, - TermsUseRoute: TermsUseRoute, + TermsSplatRoute: TermsSplatRoute, DashboardIndexRoute: DashboardIndexRoute, ApiV0SplatRoute: ApiV0SplatRoute, ApiV0DocsRoute: ApiV0DocsRoute, diff --git a/cloud/app/routes/privacy.tsx b/cloud/app/routes/privacy.tsx index 4b8b90ff1c..6391c99129 100644 --- a/cloud/app/routes/privacy.tsx +++ b/cloud/app/routes/privacy.tsx @@ -1,7 +1,41 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { TempPage } from "@/app/components/temp-page"; +import { createFileRoute, type LoaderFnContext } from "@tanstack/react-router"; +import { NotFound } from "@/app/components/not-found"; +import { + privacyContentLoader, + type PolicyLoaderData, +} from "@/app/lib/content/content-loader"; +import PolicyPage from "@/app/components/policy-page"; export const Route = createFileRoute("/privacy")({ - // todo(sebastian): temp route ahead of porting md/mdx files - component: () => , + ssr: false, + head: (ctx) => { + // todo(sebastian): simplify and add other SEO metadata + const meta = ctx.loaderData?.meta; + if (!meta) { + return { + meta: [ + { title: "Loading..." }, + { name: "description", content: "Loading privacy content" }, + ], + }; + } + return { + meta: [ + { title: meta.title }, + { name: "description", content: meta.description }, + ], + }; + }, + loader: async ( + ctx: LoaderFnContext>, + ): Promise => { + return privacyContentLoader()(ctx); + }, + component: () => { + const content: PolicyLoaderData = Route.useLoaderData(); + if (!content) { + return ; + } + return ; + }, }); diff --git a/cloud/app/routes/terms.$.tsx b/cloud/app/routes/terms.$.tsx new file mode 100644 index 0000000000..80e2c33265 --- /dev/null +++ b/cloud/app/routes/terms.$.tsx @@ -0,0 +1,56 @@ +import { + createFileRoute, + redirect, + type LoaderFnContext, +} from "@tanstack/react-router"; +import { NotFound } from "@/app/components/not-found"; +import { + termsContentLoader, + type PolicyLoaderData, +} from "@/app/lib/content/content-loader"; +import PolicyPage from "@/app/components/policy-page"; + +export const Route = createFileRoute("/terms/$")({ + ssr: false, + head: (ctx) => { + // todo(sebastian): simplify and add other SEO metadata + const meta = ctx.loaderData?.meta; + if (!meta) { + return { + meta: [ + { title: "Loading..." }, + { name: "description", content: "Loading terms content" }, + ], + }; + } + return { + meta: [ + { title: meta.title }, + { name: "description", content: meta.description }, + ], + }; + }, + loader: async ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ctx: LoaderFnContext, + ): Promise => { + // Redirect on index case (no splat) + if (!ctx.params._splat) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw redirect({ + to: "/terms/$", + params: { _splat: "use" }, + replace: true, + }); + } + return termsContentLoader()(ctx); + }, + component: () => { + const content: PolicyLoaderData = Route.useLoaderData(); + if (!content) { + return ; + } + // todo(sebastian): Port actual page + return ; + }, +}); diff --git a/cloud/app/routes/terms.use.tsx b/cloud/app/routes/terms.use.tsx deleted file mode 100644 index 65be5a4635..0000000000 --- a/cloud/app/routes/terms.use.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { TempPage } from "@/app/components/temp-page"; - -export const Route = createFileRoute("/terms/use")({ - // todo(sebastian): temp route ahead of porting md/mdx files - component: () => , -}); diff --git a/cloud/vite.config.ts b/cloud/vite.config.ts index 6fba3bf5fc..f601291c79 100644 --- a/cloud/vite.config.ts +++ b/cloud/vite.config.ts @@ -33,7 +33,10 @@ export default defineConfig(() => { maxRedirects: 5, failOnError: true, filter: (page: { path: string }) => - page.path.startsWith("/docs") || page.path.startsWith("/blog"), + page.path.startsWith("/docs") || + page.path.startsWith("/blog") || + page.path.startsWith("/terms") || + page.path.startsWith("/privacy"), // todo(sebastian): Consider post-processing sitemap/pages to set the changefreq. // When using autoStaticPathsDiscovery, you can't set the sitemap changefreq or // other sitemap options per page—frequency can only be set on a per-page basis if you provide