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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cloud/app/components/blocks/navigation/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export default function Footer() {
</span>
</Link>
<Link
to="/terms/use"
to="/terms/$"
params={{ _splat: "use" }}
className={cn("text-sm sm:text-base", "nav-text")}
>
<span className="text-sm sm:text-base font-handwriting">
Expand Down
10 changes: 5 additions & 5 deletions cloud/app/components/page-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -213,8 +213,8 @@ const SidebarLink = ({

return (
<Link
to={to}
params={params}
to={to as LinkProps["to"]}
{...(params && { params: params as LinkProps["params"] })}
style={style}
className={cn(
"font-handwriting-descent block rounded-md py-1 text-base",
Expand Down Expand Up @@ -248,8 +248,8 @@ const SectionTab = ({

return (
<Link
to={to}
params={params}
to={to as LinkProps["to"]}
{...(params && { params: params as LinkProps["params"] })}
className={cn(
"font-handwriting-descent w-full rounded-md px-3 py-1 text-base",
className,
Expand Down
83 changes: 83 additions & 0 deletions cloud/app/components/policy-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React from "react";
import { MDXRenderer } from "@/app/components/mdx/renderer";
import { type PolicyContent } from "@/app/lib/content/types";

/**
* Format a date string to "Month Day, Year" format
* The source date string (YYYY-MM-DD) represents a date in Los Angeles timezone
*/
const formatDate = (dateString: string): string => {
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<PolicyPageProps> = ({ 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 (
<div className="container mx-auto max-w-4xl px-4 py-8">
{/* Header with title and fun mode button aligned horizontally */}
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold uppercase">{title}</h1>
{lastUpdated && (
<p className="text-muted-foreground mt-1 font-medium">
Last Updated: {lastUpdated}
</p>
)}
</div>
</div>

<div
id={contentId}
className="bg-background border-border rounded-xl border p-4 shadow-sm sm:p-6"
>
<article className="prose prose-lg max-w-none">
<MDXRenderer className="mdx-content" mdx={content.mdx} />
</article>
</div>
</div>
);
};

export default PolicyPage;
4 changes: 0 additions & 4 deletions cloud/app/components/temp-page.tsx

This file was deleted.

94 changes: 92 additions & 2 deletions cloud/app/lib/content/content-loader.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -47,7 +51,7 @@ function createContentLoader<
>(moduleMap: ModuleMap, config: ContentLoaderConfig<TMeta, TParams, TContent>) {
return async function loader(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: LoaderFnContext<any, AnyRoute, any, TParams>,
context: LoaderFnContext<any, any, any, TParams>,
): Promise<TContent | undefined> {
// Type assertion needed due to TanStack Router's complex param type expansion
const key = config.extractKey(context.params as TParams);
Expand Down Expand Up @@ -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<PolicyMeta, { _splat?: string }, PolicyContent>(
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<PolicyMeta, Record<string, never>, 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 }),
},
);
}
2 changes: 2 additions & 0 deletions cloud/app/lib/content/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export type DocContent = Content<DocMeta>;
* Policy-specific metadata extends the base ContentMeta
*/
export interface PolicyMeta extends ContentMeta {
title: string;
description: string;
lastUpdated: string; // Last update date of the policy
}

Expand Down
24 changes: 21 additions & 3 deletions cloud/app/lib/content/virtual-module.ts
Original file line number Diff line number Diff line change
@@ -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[];
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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",
Expand All @@ -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,
);
Loading