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