Apply tenant theme on business login and polish login branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-07 18:37:13 +03:30
co-authored by Cursor
parent 56f6b23074
commit a48a24e6f2
12 changed files with 122 additions and 52 deletions
@@ -1,32 +1,24 @@
import { useEffect, type ReactNode } from 'react' import { useEffect, type ReactNode } from 'react'
import { useAuth } from './AuthContext'
import { getActiveBusinessId } from '../lib/businessContext'
import { isAbortError } from '../lib/api' import { isAbortError } from '../lib/api'
import { getSettings } from '../services/settingsService' import { getBusinessDomain } from '../lib/config'
import { resolveTenantByDomain } from '../services/tenantService'
import { applyBusinessPrimaryColor, resetBusinessPrimaryColor } from '../utils/applyBusinessTheme' import { applyBusinessPrimaryColor, resetBusinessPrimaryColor } from '../utils/applyBusinessTheme'
import { normalizeBusinessPrimaryColorId } from '../utils/businessPrimaryColors' import { normalizeBusinessPrimaryColorId } from '../utils/businessPrimaryColors'
export function BusinessThemeProvider({ children }: { children: ReactNode }) { export function BusinessThemeProvider({ children }: { children: ReactNode }) {
const { user, isLoading } = useAuth()
useEffect(() => { useEffect(() => {
if (isLoading) return
if (!user || !getActiveBusinessId()) {
resetBusinessPrimaryColor()
return
}
const controller = new AbortController() const controller = new AbortController()
const domain = getBusinessDomain()
async function loadTheme() { async function loadTheme() {
try { try {
const data = await getSettings(controller.signal) const tenant = await resolveTenantByDomain(domain, controller.signal)
if (controller.signal.aborted) return
applyBusinessPrimaryColor( applyBusinessPrimaryColor(
normalizeBusinessPrimaryColorId(data.settings.branding?.primaryColor), normalizeBusinessPrimaryColorId(tenant.primaryColor),
) )
} catch (err) { } catch (err) {
if (isAbortError(err)) return if (isAbortError(err) || controller.signal.aborted) return
applyBusinessPrimaryColor(undefined) applyBusinessPrimaryColor(undefined)
} }
} }
@@ -35,8 +27,9 @@ export function BusinessThemeProvider({ children }: { children: ReactNode }) {
return () => { return () => {
controller.abort() controller.abort()
resetBusinessPrimaryColor()
} }
}, [user, isLoading]) }, [])
return children return children
} }
@@ -18,6 +18,7 @@ import { resolveTenantByDomain } from '../services/tenantService'
interface TenantBrandingContextValue { interface TenantBrandingContextValue {
businessName: string businessName: string
businessNameEn: string
logoUrl: string | null logoUrl: string | null
faviconUrl: string | null faviconUrl: string | null
refreshBranding: () => void refreshBranding: () => void
@@ -135,9 +136,14 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
return nameEn.trim() || nameFa.trim() || getBusinessDomain() return nameEn.trim() || nameFa.trim() || getBusinessDomain()
}, [locale, nameEn, nameFa]) }, [locale, nameEn, nameFa])
const businessNameEn = useMemo(
() => nameEn.trim() || nameFa.trim() || getBusinessDomain(),
[nameEn, nameFa],
)
const value = useMemo( const value = useMemo(
() => ({ businessName, logoUrl, faviconUrl, refreshBranding }), () => ({ businessName, businessNameEn, logoUrl, faviconUrl, refreshBranding }),
[businessName, logoUrl, faviconUrl, refreshBranding], [businessName, businessNameEn, logoUrl, faviconUrl, refreshBranding],
) )
return ( return (
@@ -1,10 +1,12 @@
.page { .page {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 24px; padding: 24px;
position: relative; position: relative;
gap: 20px;
} }
.card { .card {
@@ -60,6 +62,18 @@
margin-top: 2px; margin-top: 2px;
} }
.poweredBy {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-align: center;
transition: color 0.2s;
}
.poweredBy:hover {
color: var(--primary);
}
.title { .title {
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
+10 -2
View File
@@ -23,7 +23,7 @@ type SmsStep = 'phone' | 'code'
export function LoginPage() { export function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { login, loginWithOtp } = useAuth() const { login, loginWithOtp } = useAuth()
const { businessName, logoUrl } = useTenantBranding() const { businessName, businessNameEn, logoUrl } = useTenantBranding()
const businessDomain = getBusinessDomain() const businessDomain = getBusinessDomain()
const t = useT() const t = useT()
@@ -217,7 +217,7 @@ export function LoginPage() {
{logoUrl ? <img src={logoUrl} alt="" className={styles.logo} /> : null} {logoUrl ? <img src={logoUrl} alt="" className={styles.logo} /> : null}
<div className={styles.brandText}> <div className={styles.brandText}>
<span className={styles.businessName}>{businessName || businessDomain}</span> <span className={styles.businessName}>{businessName || businessDomain}</span>
<span className={styles.appName}>{t('app.poweredBy')}</span> <span className={styles.appName}>{businessNameEn || businessDomain}</span>
</div> </div>
<div className={styles.langSelect}> <div className={styles.langSelect}>
<LanguageSelect /> <LanguageSelect />
@@ -673,6 +673,14 @@ export function LoginPage() {
</> </>
)} )}
</div> </div>
<a
className={styles.poweredBy}
href="https://meshkee.com"
target="_blank"
rel="noopener noreferrer"
>
Powered by Meshkee.app
</a>
</div> </div>
) )
} }
+4 -2
View File
@@ -1,5 +1,6 @@
import { apiRequest } from '../lib/api' import { apiRequest } from '../lib/api'
import type { DashboardLocale } from '@meshkee/dashboard-core' import type { DashboardLocale } from '@meshkee/dashboard-core'
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
export interface ResolvedTenant { export interface ResolvedTenant {
id: string id: string
@@ -7,12 +8,13 @@ export interface ResolvedTenant {
nameFa: string | null nameFa: string | null
slug: string slug: string
domain: string domain: string
primaryColor: BusinessPrimaryColorId
defaultLocale?: DashboardLocale defaultLocale?: DashboardLocale
logoUrl?: string | null logoUrl?: string | null
faviconUrl?: string | null faviconUrl?: string | null
} }
export async function resolveTenantByDomain(host: string) { export async function resolveTenantByDomain(host: string, signal?: AbortSignal) {
const encodedHost = encodeURIComponent(host) const encodedHost = encodeURIComponent(host)
return apiRequest<ResolvedTenant>(`/tenants/${encodedHost}`) return apiRequest<ResolvedTenant>(`/tenants/${encodedHost}`, { signal })
} }
@@ -16,6 +16,7 @@ import { resolveTenantByDomain } from '../services/tenantService'
interface TenantBrandingContextValue { interface TenantBrandingContextValue {
businessName: string businessName: string
businessNameEn: string
logoUrl: string | null logoUrl: string | null
faviconUrl: string | null faviconUrl: string | null
} }
@@ -35,6 +36,7 @@ function pickBusinessName(
export function TenantBrandingProvider({ children }: { children: ReactNode }) { export function TenantBrandingProvider({ children }: { children: ReactNode }) {
const { setLocale } = useLocale() const { setLocale } = useLocale()
const [businessName, setBusinessName] = useState('') const [businessName, setBusinessName] = useState('')
const [businessNameEn, setBusinessNameEn] = useState('')
const [logoUrl, setLogoUrl] = useState<string | null>(null) const [logoUrl, setLogoUrl] = useState<string | null>(null)
const [faviconUrl, setFaviconUrl] = useState<string | null>(null) const [faviconUrl, setFaviconUrl] = useState<string | null>(null)
const defaultLocaleAppliedRef = useRef(false) const defaultLocaleAppliedRef = useRef(false)
@@ -59,6 +61,7 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
tenant.name, tenant.name,
domain, domain,
) )
const nameEn = pickBusinessName(info?.name, tenant.name, domain)
const nextLogo = const nextLogo =
info?.logoUrl?.trim() || tenant.logoUrl?.trim() || null info?.logoUrl?.trim() || tenant.logoUrl?.trim() || null
@@ -69,6 +72,7 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
null null
setBusinessName(name || domain) setBusinessName(name || domain)
setBusinessNameEn(nameEn || domain)
setLogoUrl(nextLogo) setLogoUrl(nextLogo)
setFaviconUrl(nextFavicon) setFaviconUrl(nextFavicon)
applyDocumentFavicon(nextFavicon) applyDocumentFavicon(nextFavicon)
@@ -84,6 +88,7 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
} catch (err) { } catch (err) {
if (isAbortError(err) || controller.signal.aborted) return if (isAbortError(err) || controller.signal.aborted) return
setBusinessName(domain) setBusinessName(domain)
setBusinessNameEn(domain)
setLogoUrl(null) setLogoUrl(null)
setFaviconUrl(null) setFaviconUrl(null)
applyDocumentFavicon(null) applyDocumentFavicon(null)
@@ -98,8 +103,8 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
}, [domain, setLocale]) }, [domain, setLocale])
const value = useMemo( const value = useMemo(
() => ({ businessName, logoUrl, faviconUrl }), () => ({ businessName, businessNameEn, logoUrl, faviconUrl }),
[businessName, logoUrl, faviconUrl], [businessName, businessNameEn, logoUrl, faviconUrl],
) )
return ( return (
@@ -1,10 +1,12 @@
.page { .page {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 24px; padding: 24px;
position: relative; position: relative;
gap: 20px;
} }
.card { .card {
@@ -60,6 +62,18 @@
margin-top: 2px; margin-top: 2px;
} }
.poweredBy {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-align: center;
transition: color 0.2s;
}
.poweredBy:hover {
color: var(--primary);
}
.title { .title {
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
+10 -2
View File
@@ -35,7 +35,7 @@ export function LoginPage() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const redirectTo = safeRedirectPath(searchParams.get('redirect')) const redirectTo = safeRedirectPath(searchParams.get('redirect'))
const { login, loginWithOtp } = useAuth() const { login, loginWithOtp } = useAuth()
const { businessName, logoUrl } = useTenantBranding() const { businessName, businessNameEn, logoUrl } = useTenantBranding()
const tenantDomain = getTenantDomain() const tenantDomain = getTenantDomain()
const t = useT() const t = useT()
@@ -247,7 +247,7 @@ export function LoginPage() {
<img src={logoUrl || meshkeeLogo} alt="" className={styles.logo} /> <img src={logoUrl || meshkeeLogo} alt="" className={styles.logo} />
<div className={styles.brandText}> <div className={styles.brandText}>
<span className={styles.domain}>{businessName || tenantDomain}</span> <span className={styles.domain}>{businessName || tenantDomain}</span>
<span className={styles.appName}>{t('app.poweredBy')}</span> <span className={styles.appName}>{businessNameEn || tenantDomain}</span>
</div> </div>
<div className={styles.langSelect}> <div className={styles.langSelect}>
<LanguageSelect /> <LanguageSelect />
@@ -704,6 +704,14 @@ export function LoginPage() {
</> </>
)} )}
</div> </div>
<a
className={styles.poweredBy}
href="https://meshkee.com"
target="_blank"
rel="noopener noreferrer"
>
Powered by Meshkee.app
</a>
</div> </div>
) )
} }
@@ -31,11 +31,11 @@
min-width: 220px; min-width: 220px;
padding: 6px; padding: 6px;
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.55); border: 1px solid rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.28); background: rgba(255, 255, 255, 0.78);
backdrop-filter: blur(48px) saturate(1.2); backdrop-filter: blur(72px) saturate(1.35);
-webkit-backdrop-filter: blur(48px) saturate(1.2); -webkit-backdrop-filter: blur(72px) saturate(1.35);
box-shadow: var(--glass-shadow); box-shadow: 0 12px 40px rgba(15, 23, 42, 0.16);
animation: menuIn 0.14s ease; animation: menuIn 0.14s ease;
} }
@@ -86,6 +86,12 @@
color: #ef4444; color: #ef4444;
} }
.menuDivider {
height: 1px;
margin: 4px 6px;
background: rgba(148, 163, 184, 0.28);
}
@keyframes menuIn { @keyframes menuIn {
from { from {
opacity: 0; opacity: 0;
@@ -1,6 +1,13 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Building2, ExternalLink, Globe, MoreHorizontal, UserRound } from 'lucide-react' import {
Building2,
ExternalLink,
Globe,
MoreHorizontal,
Trash2,
UserRound,
} from 'lucide-react'
import styles from './BusinessActionsOverflowMenu.module.css' import styles from './BusinessActionsOverflowMenu.module.css'
type PopoverPosition = { type PopoverPosition = {
@@ -10,6 +17,7 @@ type PopoverPosition = {
interface DomainLinksOverflowMenuProps { interface DomainLinksOverflowMenuProps {
host: string host: string
onRemove: () => void
} }
function apexHost(host: string) { function apexHost(host: string) {
@@ -28,7 +36,7 @@ function openHost(host: string) {
window.open(siteUrl(host), '_blank', 'noopener,noreferrer') window.open(siteUrl(host), '_blank', 'noopener,noreferrer')
} }
export function DomainLinksOverflowMenu({ host }: DomainLinksOverflowMenuProps) { export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowMenuProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [popoverPos, setPopoverPos] = useState<PopoverPosition | null>(null) const [popoverPos, setPopoverPos] = useState<PopoverPosition | null>(null)
const wrapRef = useRef<HTMLDivElement>(null) const wrapRef = useRef<HTMLDivElement>(null)
@@ -126,8 +134,8 @@ export function DomainLinksOverflowMenu({ host }: DomainLinksOverflowMenuProps)
e.stopPropagation() e.stopPropagation()
toggleOpen() toggleOpen()
}} }}
title="Open sites" title="More actions"
aria-label={`Open sites for ${apex}`} aria-label={`More actions for ${apex}`}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={open} aria-expanded={open}
> >
@@ -141,7 +149,7 @@ export function DomainLinksOverflowMenu({ host }: DomainLinksOverflowMenuProps)
ref={popoverRef} ref={popoverRef}
className={styles.menu} className={styles.menu}
role="menu" role="menu"
aria-label={`Open sites for ${apex}`} aria-label={`More actions for ${apex}`}
style={{ style={{
top: popoverPos.top, top: popoverPos.top,
left: popoverPos.left, left: popoverPos.left,
@@ -168,6 +176,19 @@ export function DomainLinksOverflowMenu({ host }: DomainLinksOverflowMenuProps)
</span> </span>
</button> </button>
))} ))}
<div className={styles.menuDivider} role="separator" />
<button
type="button"
className={`${styles.item} ${styles.itemDanger}`}
role="menuitem"
onClick={() => {
setOpen(false)
onRemove()
}}
>
<Trash2 size={16} />
<span>Remove</span>
</button>
</div>, </div>,
document.body, document.body,
)} )}
@@ -50,11 +50,11 @@
max-width: 280px; max-width: 280px;
padding: 8px 10px; padding: 8px 10px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: 1px solid rgba(255, 255, 255, 0.55); border: 1px solid rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.28); background: rgba(255, 255, 255, 0.78);
backdrop-filter: blur(48px) saturate(1.2); backdrop-filter: blur(72px) saturate(1.35);
-webkit-backdrop-filter: blur(48px) saturate(1.2); -webkit-backdrop-filter: blur(72px) saturate(1.35);
box-shadow: var(--glass-shadow); box-shadow: 0 12px 40px rgba(15, 23, 42, 0.16);
} }
.option { .option {
+4 -11
View File
@@ -9,7 +9,6 @@ import {
Rocket, Rocket,
RotateCcw, RotateCcw,
Search, Search,
Trash2,
Unlock, Unlock,
} from 'lucide-react' } from 'lucide-react'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
@@ -590,16 +589,10 @@ export function WebsitesPage() {
> >
<Pencil size={16} /> <Pencil size={16} />
</button> </button>
<DomainLinksOverflowMenu host={domain.host} /> <DomainLinksOverflowMenu
<button host={domain.host}
type="button" onRemove={() => setRemoveTarget(domain)}
className={`${tableStyles.controlBtn} ${tableStyles.controlBtnDanger}`} />
onClick={() => setRemoveTarget(domain)}
title="Remove"
aria-label="Remove"
>
<Trash2 size={16} />
</button>
</div> </div>
</td> </td>
</tr> </tr>