Skip to content
Open
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
5 changes: 4 additions & 1 deletion apps/web/src/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -243,12 +244,14 @@ function DrawerButton({
}

function Logo() {
const branding = useBranding();

return (
<Link
to="/"
className="font-semibold text-2xl font-serif hover:scale-105 transition-transform mr-4"
>
<img src="/api/images/hyprnote/logo.svg" alt="Hyprnote" className="h-6" />
<img src={branding.logo} alt={branding.productName} className="h-6" />
</Link>
);
}
Expand Down
67 changes: 67 additions & 0 deletions apps/web/src/contexts/BrandingProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<BrandingContextValue | null>(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 (
<BrandingContext.Provider value={value}>
{children}
</BrandingContext.Provider>
);
}

/**
* 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;
}
73 changes: 73 additions & 0 deletions apps/web/src/lib/branding.ts
Original file line number Diff line number Diff line change
@@ -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<Brand, BrandingConfig> = {
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 "";
}
}
22 changes: 15 additions & 7 deletions apps/web/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -18,14 +20,20 @@ export function getRouter() {
scrollRestoration: true,
trailingSlash: "always",
Wrap: (props: { children: React.ReactNode }) => {
const hostname = getCurrentHostname();
return (
<PostHogProvider>
<OutlitProvider publicKey={env.VITE_OUTLIT_PUBLIC_KEY} trackPageviews>
<QueryClientProvider client={queryClient}>
{props.children}
</QueryClientProvider>
</OutlitProvider>
</PostHogProvider>
<BrandingProvider hostname={hostname}>
<PostHogProvider>
<OutlitProvider
publicKey={env.VITE_OUTLIT_PUBLIC_KEY}
trackPageviews
>
<QueryClientProvider client={queryClient}>
{props.children}
</QueryClientProvider>
</OutlitProvider>
</PostHogProvider>
</BrandingProvider>
);
},
});
Expand Down