diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index fae8462eaf..fa13de7ccb 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -9,6 +9,7 @@ import { import { useEffect, useRef, useState } from "react"; import { SearchTrigger } from "@/components/search"; +import { useBranding } from "@/contexts/BrandingProvider"; import { useDocsDrawer } from "@/hooks/use-docs-drawer"; import { useHandbookDrawer } from "@/hooks/use-handbook-drawer"; import { getPlatformCTA, usePlatform } from "@/hooks/use-platform"; @@ -243,12 +244,14 @@ function DrawerButton({ } function Logo() { + const branding = useBranding(); + return ( - Hyprnote + {branding.productName} ); } diff --git a/apps/web/src/contexts/BrandingProvider.tsx b/apps/web/src/contexts/BrandingProvider.tsx new file mode 100644 index 0000000000..665059f89c --- /dev/null +++ b/apps/web/src/contexts/BrandingProvider.tsx @@ -0,0 +1,67 @@ +import { createContext, type ReactNode, useContext, useMemo } from "react"; + +import { type Brand, BRANDING, detectBrand } from "@/lib/branding"; + +interface BrandingContextValue { + brand: Brand; + logo: string; + productName: string; + domain: string; + twitterHandle: string; + ogImage: string; +} + +const BrandingContext = createContext(null); + +interface BrandingProviderProps { + hostname: string; + children: ReactNode; +} + +/** + * Provides branding configuration based on the current domain. + * Should be initialized with the hostname from the request or window.location. + */ +export function BrandingProvider({ + hostname, + children, +}: BrandingProviderProps) { + const brand = detectBrand(hostname); + const branding = BRANDING[brand]; + + const value = useMemo( + () => ({ + brand, + logo: branding.logo, + productName: branding.productName, + domain: branding.domain, + twitterHandle: branding.twitterHandle, + ogImage: branding.ogImage, + }), + [brand, branding], + ); + + return ( + + {children} + + ); +} + +/** + * Hook to access branding configuration. + * + * Returns an object with stable primitive values. Components only re-render + * when the brand changes, not on every context update. Destructure only the + * values you need: + * + * @example + * const { logo, productName } = useBranding(); + */ +export function useBranding(): BrandingContextValue { + const context = useContext(BrandingContext); + if (!context) { + throw new Error("useBranding must be used within BrandingProvider"); + } + return context; +} diff --git a/apps/web/src/lib/branding.ts b/apps/web/src/lib/branding.ts new file mode 100644 index 0000000000..c95d709236 --- /dev/null +++ b/apps/web/src/lib/branding.ts @@ -0,0 +1,73 @@ +/** + * Branding System + * + * This module provides domain-based branding configuration. + */ +import { env } from "@/env"; + +export type Brand = "hyprnote" | "char"; + +export interface BrandingConfig { + logo: string; + productName: string; + domain: string; + twitterHandle: string; + ogImage: string; +} + +export const BRANDING: Record = { + hyprnote: { + logo: "/api/images/hyprnote/logo.svg", + productName: "Hyprnote", + domain: "hyprnote.com", + twitterHandle: "@tryhyprnote", + ogImage: "/api/images/hyprnote/og-image.jpg", + }, + char: { + logo: "/api/images/char/logo.svg", + productName: "Char", + domain: "char.com", + twitterHandle: "@trychar", + ogImage: "/api/images/char/og-image.jpg", + }, +}; + +/** + * Detect brand from hostname - works server-side and client-side + */ +export function detectBrand(hostname: string): Brand { + const normalized = hostname.toLowerCase(); + + if (normalized.includes("char.com")) { + return "char"; + } + + // Default to hyprnote (including localhost, staging, etc.) + return "hyprnote"; +} + +/** + * Get branding config from hostname + */ +export function getBrandingFromHostname(hostname: string): BrandingConfig { + const brand = detectBrand(hostname); + return BRANDING[brand]; +} + +/** + * Get current hostname - works in browser and during SSR + * Uses VITE_APP_URL as fallback during SSR to avoid hydration mismatch + */ +export function getCurrentHostname(): string { + if (typeof window !== "undefined") { + return window.location.hostname; + } + + // During SSR, extract hostname from VITE_APP_URL to avoid hydration mismatch + try { + const url = new URL(env.VITE_APP_URL); + return url.hostname; + } catch { + return ""; + } +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 20b4accb3b..1557755657 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -4,7 +4,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRouter } from "@tanstack/react-router"; import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query"; +import { BrandingProvider } from "./contexts/BrandingProvider"; import { env } from "./env"; +import { getCurrentHostname } from "./lib/branding"; import { PostHogProvider } from "./providers/posthog"; import { routeTree } from "./routeTree.gen"; @@ -18,14 +20,20 @@ export function getRouter() { scrollRestoration: true, trailingSlash: "always", Wrap: (props: { children: React.ReactNode }) => { + const hostname = getCurrentHostname(); return ( - - - - {props.children} - - - + + + + + {props.children} + + + + ); }, });