From dd5951f918aa534fa60cc84271839fe7beb49a89 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 1 Feb 2026 02:45:23 +0000 Subject: [PATCH 1/3] feat(web): add domain-based feature flags for branding - Add feature-flags.ts with Brand type and BrandingConfig - Add BrandingProvider context for domain detection - Update router.tsx to wrap app with BrandingProvider - Update header.tsx Logo component to use useBranding hook This enables dynamic branding based on the current domain (hyprnote.com vs char.com) for the upcoming rebrand. Co-Authored-By: yujonglee --- apps/web/src/components/header.tsx | 5 +- apps/web/src/contexts/BrandingProvider.tsx | 68 ++++++++++++++++++++++ apps/web/src/lib/feature-flags.ts | 66 +++++++++++++++++++++ apps/web/src/router.tsx | 22 ++++--- 4 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/contexts/BrandingProvider.tsx create mode 100644 apps/web/src/lib/feature-flags.ts 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..6038fe110e --- /dev/null +++ b/apps/web/src/contexts/BrandingProvider.tsx @@ -0,0 +1,68 @@ +import { createContext, type ReactNode, useContext } from "react"; + +import { + type Brand, + BRANDING, + type BrandingConfig, + detectBrand, +} from "@/lib/feature-flags"; + +interface BrandingContextValue { + brand: Brand; + branding: BrandingConfig; +} + +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]; + + return ( + + {children} + + ); +} + +/** + * Hook to access the current brand identifier + */ +export function useBrand(): Brand { + const context = useContext(BrandingContext); + if (!context) { + throw new Error("useBrand must be used within BrandingProvider"); + } + return context.brand; +} + +/** + * Hook to access the full branding configuration + */ +export function useBranding(): BrandingConfig { + const context = useContext(BrandingContext); + if (!context) { + throw new Error("useBranding must be used within BrandingProvider"); + } + return context.branding; +} + +/** + * Hook to check if the current brand is Char + */ +export function useIsCharBrand(): boolean { + const brand = useBrand(); + return brand === "char"; +} diff --git a/apps/web/src/lib/feature-flags.ts b/apps/web/src/lib/feature-flags.ts new file mode 100644 index 0000000000..ded6734473 --- /dev/null +++ b/apps/web/src/lib/feature-flags.ts @@ -0,0 +1,66 @@ +/** + * Feature Flags System + * + * This module provides domain-based feature flags for branding. + * Designed to be extended with PostHog flags in the future. + */ + +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 only + * Returns empty string on server (use request URL instead) + */ +export function getCurrentHostname(): string { + if (typeof window !== "undefined") { + return window.location.hostname; + } + return ""; +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 20b4accb3b..9134d7167e 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/feature-flags"; 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} + + + + ); }, }); From bf13d25721fdaf3a568a3595ec6376c907799ebe Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 1 Feb 2026 03:08:45 +0000 Subject: [PATCH 2/3] refactor(web): rename feature-flags.ts to branding.ts and simplify API - Rename feature-flags.ts to branding.ts - Consolidate to single useBranding hook with flat return object - Return stable primitive values (brand, logo, productName, etc.) - Similar to TanStack Query's render optimization pattern Co-Authored-By: yujonglee --- apps/web/src/contexts/BrandingProvider.tsx | 61 +++++++++---------- .../src/lib/{feature-flags.ts => branding.ts} | 5 +- apps/web/src/router.tsx | 2 +- 3 files changed, 33 insertions(+), 35 deletions(-) rename apps/web/src/lib/{feature-flags.ts => branding.ts} (90%) diff --git a/apps/web/src/contexts/BrandingProvider.tsx b/apps/web/src/contexts/BrandingProvider.tsx index 6038fe110e..665059f89c 100644 --- a/apps/web/src/contexts/BrandingProvider.tsx +++ b/apps/web/src/contexts/BrandingProvider.tsx @@ -1,15 +1,14 @@ -import { createContext, type ReactNode, useContext } from "react"; +import { createContext, type ReactNode, useContext, useMemo } from "react"; -import { - type Brand, - BRANDING, - type BrandingConfig, - detectBrand, -} from "@/lib/feature-flags"; +import { type Brand, BRANDING, detectBrand } from "@/lib/branding"; interface BrandingContextValue { brand: Brand; - branding: BrandingConfig; + logo: string; + productName: string; + domain: string; + twitterHandle: string; + ogImage: string; } const BrandingContext = createContext(null); @@ -30,39 +29,39 @@ export function BrandingProvider({ 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 the current brand identifier + * 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 useBrand(): Brand { - const context = useContext(BrandingContext); - if (!context) { - throw new Error("useBrand must be used within BrandingProvider"); - } - return context.brand; -} - -/** - * Hook to access the full branding configuration - */ -export function useBranding(): BrandingConfig { +export function useBranding(): BrandingContextValue { const context = useContext(BrandingContext); if (!context) { throw new Error("useBranding must be used within BrandingProvider"); } - return context.branding; -} - -/** - * Hook to check if the current brand is Char - */ -export function useIsCharBrand(): boolean { - const brand = useBrand(); - return brand === "char"; + return context; } diff --git a/apps/web/src/lib/feature-flags.ts b/apps/web/src/lib/branding.ts similarity index 90% rename from apps/web/src/lib/feature-flags.ts rename to apps/web/src/lib/branding.ts index ded6734473..98b80eb0a4 100644 --- a/apps/web/src/lib/feature-flags.ts +++ b/apps/web/src/lib/branding.ts @@ -1,8 +1,7 @@ /** - * Feature Flags System + * Branding System * - * This module provides domain-based feature flags for branding. - * Designed to be extended with PostHog flags in the future. + * This module provides domain-based branding configuration. */ export type Brand = "hyprnote" | "char"; diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 9134d7167e..1557755657 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -6,7 +6,7 @@ import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query import { BrandingProvider } from "./contexts/BrandingProvider"; import { env } from "./env"; -import { getCurrentHostname } from "./lib/feature-flags"; +import { getCurrentHostname } from "./lib/branding"; import { PostHogProvider } from "./providers/posthog"; import { routeTree } from "./routeTree.gen"; From c0f2b96d3baf7aec998190d121858776e4dfa432 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 1 Feb 2026 03:14:53 +0000 Subject: [PATCH 3/3] fix(web): use VITE_APP_URL as fallback during SSR to avoid hydration mismatch During SSR, window is undefined so getCurrentHostname() now extracts the hostname from VITE_APP_URL environment variable. This ensures consistent branding between server and client rendering. Co-Authored-By: yujonglee --- apps/web/src/lib/branding.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lib/branding.ts b/apps/web/src/lib/branding.ts index 98b80eb0a4..c95d709236 100644 --- a/apps/web/src/lib/branding.ts +++ b/apps/web/src/lib/branding.ts @@ -3,6 +3,7 @@ * * This module provides domain-based branding configuration. */ +import { env } from "@/env"; export type Brand = "hyprnote" | "char"; @@ -54,12 +55,19 @@ export function getBrandingFromHostname(hostname: string): BrandingConfig { } /** - * Get current hostname - works in browser only - * Returns empty string on server (use request URL instead) + * 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; } - return ""; + + // 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 ""; + } }