diff --git a/config/tsconfig/confs/base.json b/config/tsconfig/confs/base.json index 9b9d570a0..00999d58f 100644 --- a/config/tsconfig/confs/base.json +++ b/config/tsconfig/confs/base.json @@ -2,6 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "paths": { + "@safe-global/brand/*": ["../../packages/brand/src/*"], "@safe-global/store/*": ["../../packages/store/src/*"], "@safe-global/utils/*": ["../../packages/utils/src/*"], "@safe-global/test/*": ["../../config/test/*"] diff --git a/packages/brand/README.md b/packages/brand/README.md new file mode 100644 index 000000000..b4c1e5834 --- /dev/null +++ b/packages/brand/README.md @@ -0,0 +1,54 @@ +# @safe-global/brand + +White-label brand tokens for the Safe wallet monorepo. + +## Why + +The Safe wallet codebase is the upstream OSS reference. Lux Safe, Hanzo Vault, +and future partner builds all ship from the same source. To keep them in lock- +step we keep brand strings (product name, URLs, support contacts, asset paths, +primary color) out of the source — they live here, in one file, behind one +type — and per-brand builds override them via env vars. + +## Shape + +```ts +import { brand } from '@safe-global/brand' + +brand.name // "Safe{Wallet}" (or "Lux Safe", "Hanzo Vault", …) +brand.domain // "safe.global" +brand.email // "support@safe.global" +brand.helpUrl // "https://help.safe.global" +brand.logoUrl // "/images/safe-logo-green.png" +brand.primaryColor // "#12FF80" +``` + +See [`src/index.ts`](src/index.ts) for the full `Brand` interface. + +## Setting brand values at build time + +Web uses `NEXT_PUBLIC_BRAND_*`, mobile uses `EXPO_PUBLIC_BRAND_*`. The brand +module reads whichever is set, so a single env file can drive both apps. + +```bash +# apps/web/.env.lux +NEXT_PUBLIC_BRAND_NAME="Lux Safe" +NEXT_PUBLIC_BRAND_DOMAIN="safe.lux.network" +NEXT_PUBLIC_BRAND_EMAIL="support@lux.network" +NEXT_PUBLIC_BRAND_HELP_URL="https://help.lux.network" +NEXT_PUBLIC_BRAND_LOGO_URL="/brand/lux/logo.svg" +``` + +The repo ships three sample env files in `apps/web/`: + +- `.env.safe` — upstream Safe defaults (sanity check) +- `.env.lux` — Lux Safe +- `.env.hanzo` — Hanzo Vault + +`scripts/select-brand.sh ` copies the selected env file and the matching +asset directory into place, then the normal Next build picks them up. + +## Rule + +There is exactly one way to render the product name. It is `brand.name`. There +is no `'Safe'` string literal in source files. If you find one, replace it. diff --git a/packages/brand/eslint.config.mjs b/packages/brand/eslint.config.mjs new file mode 100644 index 000000000..ce1b22128 --- /dev/null +++ b/packages/brand/eslint.config.mjs @@ -0,0 +1,20 @@ +import js from '@eslint/js' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { + ignores: ['dist', 'node_modules'], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + languageOptions: { + globals: { + process: 'readonly', + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'error', + }, + }, +) diff --git a/packages/brand/jest.config.cjs b/packages/brand/jest.config.cjs new file mode 100644 index 000000000..1f87ab5dc --- /dev/null +++ b/packages/brand/jest.config.cjs @@ -0,0 +1,9 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['/src/**/*.test.ts'], + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.json' }], + }, +} diff --git a/packages/brand/package.json b/packages/brand/package.json new file mode 100644 index 000000000..3bd014fcb --- /dev/null +++ b/packages/brand/package.json @@ -0,0 +1,30 @@ +{ + "private": true, + "name": "@safe-global/brand", + "description": "White-label brand tokens. One source of truth for product name, URLs, support contacts, and asset paths across web and mobile.", + "version": "1.0.0", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./*": "./src/*" + }, + "scripts": { + "test": "jest", + "lint": "eslint src", + "lint:fix": "eslint src --fix", + "type-check": "tsc --noEmit", + "prettier": "prettier --check . --config ../../.prettierrc --ignore-path ../../.prettierignore", + "prettier:fix": "prettier --write . --config ../../.prettierrc --ignore-path ../../.prettierignore" + }, + "devDependencies": { + "@eslint/js": "^9.18.0", + "@types/jest": "^29.5.14", + "eslint": "^9.29.0", + "jest": "^29.7.0", + "prettier": "^3.6.2", + "ts-jest": "^29.4.0", + "typescript": "~5.9.2", + "typescript-eslint": "^8.31.1" + } +} diff --git a/packages/brand/src/index.test.ts b/packages/brand/src/index.test.ts new file mode 100644 index 000000000..bbfebf6bc --- /dev/null +++ b/packages/brand/src/index.test.ts @@ -0,0 +1,39 @@ +import { brand, type Brand } from './index' + +describe('brand', () => { + it('exposes the upstream Safe defaults when no env vars are set', () => { + expect(brand.name).toBe('Safe{Wallet}') + expect(brand.shortName).toBe('Safe') + expect(brand.domain).toBe('safe.global') + expect(brand.email).toBe('support@safe.global') + expect(brand.helpUrl).toBe('https://help.safe.global') + }) + + it('matches the Brand interface', () => { + const b: Brand = brand + expect(typeof b.name).toBe('string') + expect(typeof b.shortName).toBe('string') + expect(typeof b.domain).toBe('string') + expect(typeof b.email).toBe('string') + expect(typeof b.logoUrl).toBe('string') + }) + + it('exposes every documented URL', () => { + const required: Array = [ + 'helpUrl', + 'termsUrl', + 'privacyUrl', + 'imprintUrl', + 'cookieUrl', + 'licensesUrl', + 'statusUrl', + 'developerUrl', + 'gatewayUrl', + 'discordUrl', + 'twitterUrl', + ] + for (const key of required) { + expect(brand[key]).toMatch(/^https?:\/\//) + } + }) +}) diff --git a/packages/brand/src/index.ts b/packages/brand/src/index.ts new file mode 100644 index 000000000..12295142a --- /dev/null +++ b/packages/brand/src/index.ts @@ -0,0 +1,105 @@ +/** + * @safe-global/brand — white-label brand tokens + * + * One source of truth for the human-facing brand. Every wallet shipped from + * this monorepo reads these values; no `'Safe'` / `'safe.global'` literal + * lives in `apps/` or `packages/*` source. + * + * Defaults match upstream Safe values, so an unbranded build is byte-for-byte + * the same as today. Per-brand builds override via `NEXT_PUBLIC_BRAND_*` + * (web) or `EXPO_PUBLIC_BRAND_*` (mobile) env vars at build time. + */ + +export interface Brand { + /** Product name as it appears in chrome, titles, marketing copy. */ + name: string + /** Short form for tight UI (sidebar, badges). */ + shortName: string + /** Apex domain — `safe.global`, `safe.lux.network`, `vault.hanzo.ai`. */ + domain: string + /** App host — `app.safe.global`, `safe.lux.network`. */ + appHost: string + /** Full app URL with scheme. */ + appUrl: string + /** Support email. */ + email: string + /** Help center / docs URL (no trailing slash). */ + helpUrl: string + /** Terms of service URL. */ + termsUrl: string + /** Privacy policy URL. */ + privacyUrl: string + /** Imprint / legal URL. */ + imprintUrl: string + /** Cookie policy URL. */ + cookieUrl: string + /** Licenses page URL. */ + licensesUrl: string + /** Brand logo path (served by the app, swap per brand). */ + logoUrl: string + /** Favicon path. */ + faviconUrl: string + /** Primary brand color (hex). */ + primaryColor: string + /** Status page URL. */ + statusUrl: string + /** Developer portal URL. */ + developerUrl: string + /** Gateway service URL (CGW — Safe Transaction Service). */ + gatewayUrl: string + /** Staging gateway URL. */ + gatewayStagingUrl: string + /** Discord / chat invite URL. */ + discordUrl: string + /** Twitter / X URL. */ + twitterUrl: string + /** Support chat alias domain (Pylon). */ + supportChatAliasDomain: string + /** GitHub repo URL. */ + githubUrl: string + /** Apple App Store URL (mobile). */ + appStoreUrl: string + /** Google Play Store URL (mobile). */ + playStoreUrl: string + /** Help article slug prefix (legacy `articles/` path). */ + helpArticlesPath: string +} + +const env = (key: string, fallback: string): string => { + // Read NEXT_PUBLIC_* (web) and EXPO_PUBLIC_* (mobile) interchangeably so the + // same brand bundle works in both apps. + const next = `NEXT_PUBLIC_BRAND_${key}` + const expo = `EXPO_PUBLIC_BRAND_${key}` + return process.env[next] ?? process.env[expo] ?? fallback +} + +export const brand: Brand = { + name: env('NAME', 'Safe{Wallet}'), + shortName: env('SHORT_NAME', 'Safe'), + domain: env('DOMAIN', 'safe.global'), + appHost: env('APP_HOST', 'app.safe.global'), + appUrl: env('APP_URL', 'https://app.safe.global'), + email: env('EMAIL', 'support@safe.global'), + helpUrl: env('HELP_URL', 'https://help.safe.global'), + termsUrl: env('TERMS_URL', 'https://safe.global/terms'), + privacyUrl: env('PRIVACY_URL', 'https://safe.global/privacy'), + imprintUrl: env('IMPRINT_URL', 'https://safe.global/imprint'), + cookieUrl: env('COOKIE_URL', 'https://safe.global/cookie'), + licensesUrl: env('LICENSES_URL', 'https://safe.global/licenses'), + logoUrl: env('LOGO_URL', '/images/safe-logo-green.png'), + faviconUrl: env('FAVICON_URL', '/favicon.ico'), + primaryColor: env('PRIMARY_COLOR', '#12FF80'), + statusUrl: env('STATUS_URL', 'https://status.safe.global'), + developerUrl: env('DEVELOPER_URL', 'https://developer.safe.global'), + gatewayUrl: env('GATEWAY_URL', 'https://safe-client.safe.global'), + gatewayStagingUrl: env('GATEWAY_STAGING_URL', 'https://safe-client.staging.5afe.dev'), + discordUrl: env('DISCORD_URL', 'https://chat.safe.global'), + twitterUrl: env('TWITTER_URL', 'https://twitter.com/safe'), + supportChatAliasDomain: env('SUPPORT_CHAT_ALIAS_DOMAIN', 'anon.safe.global'), + githubUrl: env('GITHUB_URL', 'https://github.com/safe-global/safe-wallet-monorepo'), + appStoreUrl: env('APP_STORE_URL', 'https://apps.apple.com/app/id1515759131'), + playStoreUrl: env('PLAY_STORE_URL', 'https://play.google.com/store/apps/details?id=io.gnosis.safe'), + helpArticlesPath: env('HELP_ARTICLES_PATH', 'articles'), +} + +export default brand diff --git a/packages/brand/tsconfig.json b/packages/brand/tsconfig.json new file mode 100644 index 000000000..ad8f8bca1 --- /dev/null +++ b/packages/brand/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../config/tsconfig/confs/base.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@safe-global/brand/*": ["../../packages/brand/src/*"] + } + } +} diff --git a/yarn.lock b/yarn.lock index 20e1a59d5..581f51020 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13068,6 +13068,21 @@ __metadata: languageName: node linkType: hard +"@safe-global/brand@workspace:packages/brand": + version: 0.0.0-use.local + resolution: "@safe-global/brand@workspace:packages/brand" + dependencies: + "@eslint/js": "npm:^9.18.0" + "@types/jest": "npm:^29.5.14" + eslint: "npm:^9.29.0" + jest: "npm:^29.7.0" + prettier: "npm:^3.6.2" + ts-jest: "npm:^29.4.0" + typescript: "npm:~5.9.2" + typescript-eslint: "npm:^8.31.1" + languageName: unknown + linkType: soft + "@safe-global/mobile@workspace:apps/mobile": version: 0.0.0-use.local resolution: "@safe-global/mobile@workspace:apps/mobile" @@ -29215,7 +29230,7 @@ __metadata: languageName: node linkType: hard -"handlebars@npm:^4.7.8": +"handlebars@npm:^4.7.8, handlebars@npm:^4.7.9": version: 4.7.9 resolution: "handlebars@npm:4.7.9" dependencies: @@ -40736,6 +40751,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.7.4": + version: 7.7.4 + resolution: "semver@npm:7.7.4" + bin: + semver: bin/semver.js + checksum: 10/26bdc6d58b29528f4142d29afb8526bc335f4fc04c4a10f2b98b217f277a031c66736bf82d3d3bb354a2f6a3ae50f18fd62b053c4ac3f294a3d10a61f5075b75 + languageName: node + linkType: hard + "send@npm:0.19.0": version: 0.19.0 resolution: "send@npm:0.19.0" @@ -43327,6 +43351,46 @@ __metadata: languageName: node linkType: hard +"ts-jest@npm:^29.4.0": + version: 29.4.9 + resolution: "ts-jest@npm:29.4.9" + dependencies: + bs-logger: "npm:^0.2.6" + fast-json-stable-stringify: "npm:^2.1.0" + handlebars: "npm:^4.7.9" + json5: "npm:^2.2.3" + lodash.memoize: "npm:^4.1.2" + make-error: "npm:^1.3.6" + semver: "npm:^7.7.4" + type-fest: "npm:^4.41.0" + yargs-parser: "npm:^21.1.1" + peerDependencies: + "@babel/core": ">=7.0.0-beta.0 <8" + "@jest/transform": ^29.0.0 || ^30.0.0 + "@jest/types": ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: ">=4.3 <7" + peerDependenciesMeta: + "@babel/core": + optional: true + "@jest/transform": + optional: true + "@jest/types": + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + bin: + ts-jest: cli.js + checksum: 10/f5e81b1e13fff08da5b92d5a72f984f3393f40df73a1ae54473a780436b95dddb1452c78256e6d70a701c09ea427449657a5fbb3d142dc7e7a82eb192e80c3db + languageName: node + linkType: hard + "ts-mixer@npm:^6.0.3": version: 6.0.4 resolution: "ts-mixer@npm:6.0.4"