Share customer session cookies with the storefront and polish business settings.
Keep JWT login mirrored on the tenant cookie domain, improve e-payment settings UX, and add a storefront link from the customer sidebar. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
c289de5d2e
commit
dc570c1e7f
@@ -21,6 +21,19 @@
|
||||
margin-bottom: 24px;
|
||||
padding: 4px 8px 12px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.brand:hover {
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.brand:hover .brandName {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.brandLogo {
|
||||
@@ -109,6 +122,10 @@
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.navItem:hover {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { Home, User, MapPin, ShoppingBag, Heart, Package, HelpCircle, LogOut } from 'lucide-react'
|
||||
import { Home, User, MapPin, ShoppingBag, Heart, Package, HelpCircle, LogOut, Globe } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { getActiveBusinessDomain } from '../lib/businessContext'
|
||||
import { isAbortError } from '../lib/api'
|
||||
import { getWebsiteBusinessInfo } from '../services/websiteService'
|
||||
import { getWebsiteBusinessInfo, getWebsiteUrl } from '../services/websiteService'
|
||||
import { hasBusinessModule } from '../utils/businessModules'
|
||||
import meshkeeLogo from '../assets/meshkee-logo.png'
|
||||
import styles from './Sidebar.module.css'
|
||||
@@ -26,6 +26,7 @@ export function Sidebar({ open = false }: SidebarProps) {
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null)
|
||||
|
||||
const businessDomain = getActiveBusinessDomain()
|
||||
const websiteUrl = getWebsiteUrl(businessDomain)
|
||||
const fallbackBusinessName = user?.customerBusinesses[0]?.name ?? t('app.storeFallback')
|
||||
const displayName = brandName || fallbackBusinessName
|
||||
const showMyProducts = hasBusinessModule(enabledModules, 'customer_products')
|
||||
@@ -73,17 +74,23 @@ export function Sidebar({ open = false }: SidebarProps) {
|
||||
id="dashboard-sidebar"
|
||||
className={`${styles.sidebar} ${open ? styles.open : ''}`}
|
||||
>
|
||||
<div className={styles.brand}>
|
||||
<a
|
||||
href={websiteUrl}
|
||||
className={styles.brand}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t('nav.goToWebsite')}
|
||||
>
|
||||
<img
|
||||
src={logoUrl || meshkeeLogo}
|
||||
alt={displayName}
|
||||
className={styles.brandLogo}
|
||||
alt=""
|
||||
className={logoUrl ? `${styles.brandLogo} ${styles.brandLogoUploaded}` : styles.brandLogo}
|
||||
/>
|
||||
<div className={styles.brandText}>
|
||||
<span className={styles.brandName}>{displayName}</span>
|
||||
<span className={styles.brandDomain}>{businessDomain}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<nav className={styles.nav}>
|
||||
{navItems.map((item) => (
|
||||
@@ -102,6 +109,15 @@ export function Sidebar({ open = false }: SidebarProps) {
|
||||
</nav>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<a
|
||||
href={websiteUrl}
|
||||
className={styles.navItem}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Globe size={20} />
|
||||
<span>{t('nav.goToWebsite')}</span>
|
||||
</a>
|
||||
<button type="button" className={styles.navItem}>
|
||||
<HelpCircle size={20} />
|
||||
<span>{t('nav.help')}</span>
|
||||
|
||||
@@ -16,6 +16,7 @@ const en = {
|
||||
'nav.favorites': 'My Favorites',
|
||||
'nav.myProducts': 'My Products',
|
||||
'nav.help': 'Help Center',
|
||||
'nav.goToWebsite': 'Go to website',
|
||||
'nav.logout': 'Logout',
|
||||
|
||||
'header.toggleMenu': 'Toggle menu',
|
||||
@@ -413,6 +414,7 @@ const fa: Record<MessageKey, string> = {
|
||||
'nav.favorites': 'علاقهمندیها',
|
||||
'nav.myProducts': 'محصولات من',
|
||||
'nav.help': 'مرکز راهنما',
|
||||
'nav.goToWebsite': 'رفتن به وبسایت',
|
||||
'nav.logout': 'خروج',
|
||||
|
||||
'header.toggleMenu': 'باز و بسته کردن منو',
|
||||
|
||||
@@ -1,20 +1,70 @@
|
||||
import { createApiClient } from '@meshkee/dashboard-core'
|
||||
import {
|
||||
clearSharedAuthCookies,
|
||||
hydrateLocalTokensFromSharedCookies,
|
||||
writeSharedAuthCookies,
|
||||
} from './sharedAuthCookie'
|
||||
|
||||
const API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api/v1'
|
||||
|
||||
export const api = createApiClient({
|
||||
const ACCESS_KEY = 'meshkee_customer_access_token'
|
||||
const REFRESH_KEY = 'meshkee_customer_refresh_token'
|
||||
|
||||
const base = createApiClient({
|
||||
baseUrl: API_BASE_URL,
|
||||
accessTokenKey: 'meshkee_customer_access_token',
|
||||
refreshTokenKey: 'meshkee_customer_refresh_token',
|
||||
accessTokenKey: ACCESS_KEY,
|
||||
refreshTokenKey: REFRESH_KEY,
|
||||
})
|
||||
|
||||
export const {
|
||||
apiRequest,
|
||||
hydrateLocalTokensFromSharedCookies()
|
||||
|
||||
// Keep cookies aligned with an existing localStorage session (pre-cookie logins / refresh)
|
||||
function mirrorLocalTokensToCookies() {
|
||||
const access = localStorage.getItem(ACCESS_KEY)
|
||||
if (!access) return
|
||||
writeSharedAuthCookies(access, localStorage.getItem(REFRESH_KEY) ?? undefined)
|
||||
}
|
||||
|
||||
mirrorLocalTokensToCookies()
|
||||
|
||||
export function getAccessToken() {
|
||||
hydrateLocalTokensFromSharedCookies()
|
||||
return base.getAccessToken()
|
||||
}
|
||||
|
||||
export function getRefreshToken() {
|
||||
return base.getRefreshToken()
|
||||
}
|
||||
|
||||
export function setTokens(accessToken: string, refreshToken: string) {
|
||||
base.setTokens(accessToken, refreshToken)
|
||||
writeSharedAuthCookies(accessToken, refreshToken)
|
||||
}
|
||||
|
||||
export function clearTokens() {
|
||||
base.clearTokens()
|
||||
clearSharedAuthCookies()
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(
|
||||
path: string,
|
||||
options?: Parameters<typeof base.apiRequest<T>>[1],
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await base.apiRequest<T>(path, options)
|
||||
} finally {
|
||||
mirrorLocalTokensToCookies()
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
...base,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
setTokens,
|
||||
clearTokens,
|
||||
} = api
|
||||
apiRequest,
|
||||
}
|
||||
|
||||
export { ApiError, isAbortError } from '@meshkee/dashboard-core'
|
||||
|
||||
@@ -106,9 +106,10 @@ export function ingestGuestCartFromLocation(): GuestCartItem[] {
|
||||
if (typeof window === 'undefined') return []
|
||||
|
||||
const hash = window.location.hash.replace(/^#/, '')
|
||||
const search = window.location.search.replace(/^\?/, '')
|
||||
const params = new URLSearchParams(hash.includes('=') ? hash : search)
|
||||
const encoded = params.get(GUEST_CART_URL_PARAM)
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
const hashParams = new URLSearchParams(hash.includes('=') ? hash : '')
|
||||
const encoded =
|
||||
hashParams.get(GUEST_CART_URL_PARAM) || searchParams.get(GUEST_CART_URL_PARAM)
|
||||
if (!encoded) return []
|
||||
|
||||
const items = decodeGuestCartFromUrl(encoded)
|
||||
@@ -116,8 +117,10 @@ export function ingestGuestCartFromLocation(): GuestCartItem[] {
|
||||
|
||||
saveGuestCart(items)
|
||||
|
||||
// Clean the payload from the address bar
|
||||
const nextUrl = `${window.location.pathname}${window.location.search}`
|
||||
// Clean guestCart from address bar (keep other query params)
|
||||
searchParams.delete(GUEST_CART_URL_PARAM)
|
||||
const nextSearch = searchParams.toString()
|
||||
const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}`
|
||||
window.history.replaceState(null, '', nextUrl)
|
||||
|
||||
return items
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/** Shared auth cookies so storefront + customer.{domain} share one login. */
|
||||
|
||||
export const SHARED_ACCESS_TOKEN_KEY = 'meshkee_customer_access_token'
|
||||
export const SHARED_REFRESH_TOKEN_KEY = 'meshkee_customer_refresh_token'
|
||||
|
||||
const CHUNK_SIZE = 2800
|
||||
const MAX_CHUNKS = 24
|
||||
|
||||
function cookieDomain(): string | undefined {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
const host = window.location.hostname.toLowerCase()
|
||||
if (host === 'localhost' || host === '127.0.0.1') return undefined
|
||||
const parts = host.split('.').filter(Boolean)
|
||||
if (parts.length < 2) return undefined
|
||||
return `.${parts.slice(-2).join('.')}`
|
||||
}
|
||||
|
||||
function readRawCookie(name: string): string | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
const prefix = `${name}=`
|
||||
const match = document.cookie.split('; ').find((row) => row.startsWith(prefix))
|
||||
if (!match) return null
|
||||
return match.slice(prefix.length)
|
||||
}
|
||||
|
||||
function writeRawCookie(name: string, value: string | null) {
|
||||
if (typeof document === 'undefined') return
|
||||
const domain = cookieDomain()
|
||||
if (value == null) {
|
||||
let cookie = `${name}=; path=/; max-age=0; SameSite=Lax`
|
||||
if (domain) cookie += `; domain=${domain}`
|
||||
document.cookie = cookie
|
||||
return
|
||||
}
|
||||
const maxAge = 60 * 60 * 24 * 30
|
||||
let cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax`
|
||||
if (domain) cookie += `; domain=${domain}`
|
||||
document.cookie = cookie
|
||||
}
|
||||
|
||||
function clearChunkedCookie(name: string) {
|
||||
writeRawCookie(name, null)
|
||||
const n = Number(readRawCookie(`${name}_n`) || '0')
|
||||
writeRawCookie(`${name}_n`, null)
|
||||
for (let i = 0; i < Math.max(n, MAX_CHUNKS); i++) {
|
||||
writeRawCookie(`${name}_${i}`, null)
|
||||
}
|
||||
}
|
||||
|
||||
function writeCookie(name: string, value: string | null) {
|
||||
clearChunkedCookie(name)
|
||||
if (value == null || value === '') return
|
||||
|
||||
const encoded = encodeURIComponent(value)
|
||||
if (encoded.length <= 3500) {
|
||||
writeRawCookie(name, encoded)
|
||||
return
|
||||
}
|
||||
|
||||
const chunks: string[] = []
|
||||
for (let i = 0; i < encoded.length; i += CHUNK_SIZE) {
|
||||
chunks.push(encoded.slice(i, i + CHUNK_SIZE))
|
||||
}
|
||||
writeRawCookie(`${name}_n`, String(chunks.length))
|
||||
chunks.forEach((chunk, i) => writeRawCookie(`${name}_${i}`, chunk))
|
||||
}
|
||||
|
||||
function readCookie(name: string): string | null {
|
||||
const n = Number(readRawCookie(`${name}_n`) || '0')
|
||||
if (n > 0) {
|
||||
let encoded = ''
|
||||
for (let i = 0; i < n; i++) {
|
||||
const part = readRawCookie(`${name}_${i}`)
|
||||
if (part == null) return null
|
||||
encoded += part
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(encoded)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const raw = readRawCookie(name)
|
||||
if (raw == null) return null
|
||||
try {
|
||||
return decodeURIComponent(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSharedAuthCookies(accessToken: string, refreshToken?: string) {
|
||||
writeCookie(SHARED_ACCESS_TOKEN_KEY, accessToken)
|
||||
if (refreshToken) writeCookie(SHARED_REFRESH_TOKEN_KEY, refreshToken)
|
||||
}
|
||||
|
||||
export function clearSharedAuthCookies() {
|
||||
writeCookie(SHARED_ACCESS_TOKEN_KEY, null)
|
||||
writeCookie(SHARED_REFRESH_TOKEN_KEY, null)
|
||||
}
|
||||
|
||||
export function readSharedAccessToken(): string | null {
|
||||
return readCookie(SHARED_ACCESS_TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function readSharedRefreshToken(): string | null {
|
||||
return readCookie(SHARED_REFRESH_TOKEN_KEY)
|
||||
}
|
||||
|
||||
/** Copy shared cookies into localStorage when this origin has no tokens yet. */
|
||||
export function hydrateLocalTokensFromSharedCookies(
|
||||
accessKey = SHARED_ACCESS_TOKEN_KEY,
|
||||
refreshKey = SHARED_REFRESH_TOKEN_KEY,
|
||||
): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
if (localStorage.getItem(accessKey)) return false
|
||||
const access = readSharedAccessToken()
|
||||
if (!access) return false
|
||||
localStorage.setItem(accessKey, access)
|
||||
const refresh = readSharedRefreshToken()
|
||||
if (refresh) localStorage.setItem(refreshKey, refresh)
|
||||
return true
|
||||
}
|
||||
@@ -16,7 +16,18 @@ export async function getWebsiteBusinessInfo(host: string, signal?: AbortSignal)
|
||||
})
|
||||
}
|
||||
|
||||
/** Public storefront URL for the tenant (e.g. https://sanihome.ir). */
|
||||
/** Public storefront URL for the tenant (e.g. https://sanihome.com). */
|
||||
export function getWebsiteUrl(domain = getTenantDomain()) {
|
||||
return `${window.location.protocol}//${domain}`
|
||||
const configured = (import.meta.env.VITE_WEBSITE_ORIGIN as string | undefined)?.replace(
|
||||
/\/$/,
|
||||
'',
|
||||
)
|
||||
if (configured) return configured
|
||||
|
||||
const { protocol, port } = window.location
|
||||
// Local customer app is :5175; Sanihome Next storefront runs on :3001
|
||||
if (import.meta.env.DEV && (port === '5175' || port === '')) {
|
||||
return `${protocol}//${domain}:3001`
|
||||
}
|
||||
return `${protocol}//${domain}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user