Improve portfolio title images, branding, and super-admin domains UI.
Add aspect-ratio crop presets for portfolio title images, fixed card image placeholders with contained centering, Meshkee favicon/logo fallbacks, login branding updates, category FAB alignment, and super-admin website/domain SSL tooling. Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -2,6 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 12 KiB |
@@ -55,6 +55,12 @@
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.previewImgNatural {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.removeBtn {
|
.removeBtn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
@@ -85,9 +91,15 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 160px;
|
min-height: 160px;
|
||||||
|
max-height: 420px;
|
||||||
background: #1e293b;
|
background: #1e293b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cropAreaPortrait {
|
||||||
|
max-height: none;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.cropControls {
|
.cropControls {
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -141,6 +153,11 @@
|
|||||||
background: var(--primary-dark);
|
background: var(--primary-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.applyBtn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.changeBtn {
|
.changeBtn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import Cropper, { type Area } from 'react-easy-crop'
|
import Cropper, { type Area } from 'react-easy-crop'
|
||||||
import { ImagePlus, X } from 'lucide-react'
|
import { ImagePlus, X } from 'lucide-react'
|
||||||
import { getCroppedImage } from '../utils/cropImage'
|
import { getCroppedImage } from '../utils/cropImage'
|
||||||
@@ -7,6 +7,7 @@ import styles from './ImageCropper.module.css'
|
|||||||
interface ImageCropperProps {
|
interface ImageCropperProps {
|
||||||
value: string | null
|
value: string | null
|
||||||
onChange: (value: string | null) => void
|
onChange: (value: string | null) => void
|
||||||
|
/** Crop aspect ratio. Defaults to square (1). */
|
||||||
aspect?: number
|
aspect?: number
|
||||||
outputFormat?: 'jpeg' | 'png'
|
outputFormat?: 'jpeg' | 'png'
|
||||||
accept?: string
|
accept?: string
|
||||||
@@ -30,6 +31,12 @@ export function ImageCropper({
|
|||||||
const [zoom, setZoom] = useState(1)
|
const [zoom, setZoom] = useState(1)
|
||||||
const [croppedArea, setCroppedArea] = useState<Area | null>(null)
|
const [croppedArea, setCroppedArea] = useState<Area | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCrop({ x: 0, y: 0 })
|
||||||
|
setZoom(1)
|
||||||
|
setCroppedArea(null)
|
||||||
|
}, [aspect])
|
||||||
|
|
||||||
const onCropComplete = useCallback((_: Area, pixels: Area) => {
|
const onCropComplete = useCallback((_: Area, pixels: Area) => {
|
||||||
setCroppedArea(pixels)
|
setCroppedArea(pixels)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -38,7 +45,12 @@ export function ImageCropper({
|
|||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = () => setImageSrc(reader.result as string)
|
reader.onload = () => {
|
||||||
|
setImageSrc(reader.result as string)
|
||||||
|
setCrop({ x: 0, y: 0 })
|
||||||
|
setZoom(1)
|
||||||
|
setCroppedArea(null)
|
||||||
|
}
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
@@ -62,13 +74,26 @@ export function ImageCropper({
|
|||||||
onChange(null)
|
onChange(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const frameStyle = { aspectRatio: `${aspect}` as const }
|
const isPortrait = aspect < 1
|
||||||
|
const frameStyle: React.CSSProperties = isPortrait
|
||||||
|
? {
|
||||||
|
aspectRatio: `${aspect}`,
|
||||||
|
height: 'min(480px, 65vh)',
|
||||||
|
width: 'auto',
|
||||||
|
maxWidth: '100%',
|
||||||
|
marginInline: 'auto',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
aspectRatio: `${aspect}`,
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: 'min(420px, 55vh)',
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
{value && !imageSrc && (
|
{value && !imageSrc && (
|
||||||
<div className={styles.preview} style={frameStyle}>
|
<div className={styles.preview}>
|
||||||
<img src={value} alt="Thumbnail preview" className={styles.previewImg} />
|
<img src={value} alt="Thumbnail preview" className={styles.previewImgNatural} />
|
||||||
<button type="button" className={styles.removeBtn} onClick={removeThumbnail} aria-label="Remove thumbnail">
|
<button type="button" className={styles.removeBtn} onClick={removeThumbnail} aria-label="Remove thumbnail">
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -86,8 +111,9 @@ export function ImageCropper({
|
|||||||
|
|
||||||
{imageSrc && (
|
{imageSrc && (
|
||||||
<div className={styles.cropPanel}>
|
<div className={styles.cropPanel}>
|
||||||
<div className={styles.cropArea} style={frameStyle}>
|
<div className={`${styles.cropArea} ${isPortrait ? styles.cropAreaPortrait : ''}`} style={frameStyle}>
|
||||||
<Cropper
|
<Cropper
|
||||||
|
key={String(aspect)}
|
||||||
image={imageSrc}
|
image={imageSrc}
|
||||||
crop={crop}
|
crop={crop}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
@@ -113,7 +139,12 @@ export function ImageCropper({
|
|||||||
<button type="button" className={styles.cancelBtn} onClick={cancelCrop}>
|
<button type="button" className={styles.cancelBtn} onClick={cancelCrop}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={styles.applyBtn} onClick={applyCrop}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.applyBtn}
|
||||||
|
onClick={() => void applyCrop()}
|
||||||
|
disabled={!croppedArea}
|
||||||
|
>
|
||||||
Apply Crop
|
Apply Crop
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,14 +34,17 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 3 / 2;
|
aspect-ratio: 3 / 2;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: rgba(148, 163, 184, 0.08);
|
background: transparent;
|
||||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.image {
|
.image {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.imagePlaceholder {
|
.imagePlaceholder {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { resolveTenantByDomain } from '../services/tenantService'
|
|||||||
|
|
||||||
interface TenantBrandingContextValue {
|
interface TenantBrandingContextValue {
|
||||||
businessName: string
|
businessName: string
|
||||||
|
logoUrl: string | null
|
||||||
faviconUrl: string | null
|
faviconUrl: string | null
|
||||||
refreshBranding: () => void
|
refreshBranding: () => void
|
||||||
}
|
}
|
||||||
@@ -34,6 +35,7 @@ function pickBusinessName(
|
|||||||
|
|
||||||
export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
||||||
const [businessName, setBusinessName] = useState('')
|
const [businessName, setBusinessName] = useState('')
|
||||||
|
const [logoUrl, setLogoUrl] = useState<string | null>(null)
|
||||||
const [faviconUrl, setFaviconUrl] = useState<string | null>(null)
|
const [faviconUrl, setFaviconUrl] = useState<string | null>(null)
|
||||||
const [refreshToken, setRefreshToken] = useState(0)
|
const [refreshToken, setRefreshToken] = useState(0)
|
||||||
|
|
||||||
@@ -51,6 +53,7 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
|||||||
if (controller.signal.aborted) return
|
if (controller.signal.aborted) return
|
||||||
|
|
||||||
let name = pickBusinessName(tenant.name, tenant.nameFa, domain)
|
let name = pickBusinessName(tenant.name, tenant.nameFa, domain)
|
||||||
|
let nextLogo = tenant.logoUrl?.trim() || null
|
||||||
let nextFavicon =
|
let nextFavicon =
|
||||||
tenant.faviconUrl?.trim() || tenant.logoUrl?.trim() || null
|
tenant.faviconUrl?.trim() || tenant.logoUrl?.trim() || null
|
||||||
|
|
||||||
@@ -63,6 +66,7 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
|||||||
name,
|
name,
|
||||||
domain,
|
domain,
|
||||||
)
|
)
|
||||||
|
nextLogo = profile.profile.logoUrl?.trim() || nextLogo
|
||||||
nextFavicon =
|
nextFavicon =
|
||||||
profile.profile.faviconUrl?.trim() ||
|
profile.profile.faviconUrl?.trim() ||
|
||||||
profile.profile.logoUrl?.trim() ||
|
profile.profile.logoUrl?.trim() ||
|
||||||
@@ -76,12 +80,14 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
setBusinessName(name || domain)
|
setBusinessName(name || domain)
|
||||||
|
setLogoUrl(nextLogo)
|
||||||
setFaviconUrl(nextFavicon)
|
setFaviconUrl(nextFavicon)
|
||||||
applyDocumentFavicon(nextFavicon)
|
applyDocumentFavicon(nextFavicon)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isAbortError(err) || controller.signal.aborted) return
|
if (isAbortError(err) || controller.signal.aborted) return
|
||||||
setBusinessName(domain)
|
setBusinessName(domain)
|
||||||
|
setLogoUrl(null)
|
||||||
setFaviconUrl(null)
|
setFaviconUrl(null)
|
||||||
applyDocumentFavicon(null)
|
applyDocumentFavicon(null)
|
||||||
}
|
}
|
||||||
@@ -101,8 +107,8 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [refreshToken, refreshBranding])
|
}, [refreshToken, refreshBranding])
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({ businessName, faviconUrl, refreshBranding }),
|
() => ({ businessName, logoUrl, faviconUrl, refreshBranding }),
|
||||||
[businessName, faviconUrl, refreshBranding],
|
[businessName, logoUrl, faviconUrl, refreshBranding],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -28,6 +28,19 @@ import { flattenCategories } from '../utils/categories'
|
|||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
import styles from './AddNewProductPage.module.css'
|
import styles from './AddNewProductPage.module.css'
|
||||||
|
|
||||||
|
type TitleImageAspectId = 'square' | '3:2' | '16:9' | '9:16'
|
||||||
|
|
||||||
|
const TITLE_IMAGE_ASPECTS: {
|
||||||
|
id: TitleImageAspectId
|
||||||
|
label: string
|
||||||
|
aspect: number
|
||||||
|
}[] = [
|
||||||
|
{ id: 'square', label: 'Square', aspect: 1 },
|
||||||
|
{ id: '3:2', label: '3:2', aspect: 3 / 2 },
|
||||||
|
{ id: '16:9', label: '16:9', aspect: 16 / 9 },
|
||||||
|
{ id: '9:16', label: '9:16 (Reel)', aspect: 9 / 16 },
|
||||||
|
]
|
||||||
|
|
||||||
export function AddNewPortfolioPage() {
|
export function AddNewPortfolioPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -40,6 +53,7 @@ export function AddNewPortfolioPage() {
|
|||||||
const [abstract, setAbstract] = useState('')
|
const [abstract, setAbstract] = useState('')
|
||||||
const [mainTextHtml, setMainTextHtml] = useState('')
|
const [mainTextHtml, setMainTextHtml] = useState('')
|
||||||
const [titleImage, setTitleImage] = useState<string | null>(null)
|
const [titleImage, setTitleImage] = useState<string | null>(null)
|
||||||
|
const [titleImageAspect, setTitleImageAspect] = useState<TitleImageAspectId>('3:2')
|
||||||
const [images, setImages] = useState<string[]>([])
|
const [images, setImages] = useState<string[]>([])
|
||||||
const [tags, setTags] = useState<string[]>([])
|
const [tags, setTags] = useState<string[]>([])
|
||||||
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
|
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
|
||||||
@@ -49,6 +63,8 @@ export function AddNewPortfolioPage() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const categoryOptions = flattenCategories(categories)
|
const categoryOptions = flattenCategories(categories)
|
||||||
|
const selectedAspect =
|
||||||
|
TITLE_IMAGE_ASPECTS.find((option) => option.id === titleImageAspect)?.aspect ?? 3 / 2
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -199,19 +215,49 @@ export function AddNewPortfolioPage() {
|
|||||||
|
|
||||||
<form className={styles.form} onSubmit={handleSubmit}>
|
<form className={styles.form} onSubmit={handleSubmit}>
|
||||||
<div className={styles.formGrid}>
|
<div className={styles.formGrid}>
|
||||||
<div className={`${styles.field} ${styles.col3} ${styles.rowSpan3}`}>
|
<div className={`${styles.field} ${styles.col3} ${styles.thumbnailField}`}>
|
||||||
<label>Title Image</label>
|
<label>Title Image</label>
|
||||||
|
<div
|
||||||
|
className={styles.aspectOptions}
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Title image aspect ratio"
|
||||||
|
>
|
||||||
|
{TITLE_IMAGE_ASPECTS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={titleImageAspect === option.id}
|
||||||
|
className={`${styles.aspectChip} ${
|
||||||
|
titleImageAspect === option.id ? styles.aspectChipSelected : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (option.id === titleImageAspect) return
|
||||||
|
setTitleImageAspect(option.id)
|
||||||
|
// Aspect only applies during crop — clear so the user re-crops
|
||||||
|
if (titleImage) {
|
||||||
|
setTitleImage(null)
|
||||||
|
setFeaturedMediaId(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<ImageCropper
|
<ImageCropper
|
||||||
value={titleImage}
|
value={titleImage}
|
||||||
onChange={setTitleImage}
|
onChange={setTitleImage}
|
||||||
aspect={3 / 2}
|
aspect={selectedAspect}
|
||||||
uploadLabel="Upload title image"
|
uploadLabel="Upload title image"
|
||||||
hint="Click to select, then crop"
|
hint="Click to select, then crop"
|
||||||
changeLabel="Change title image"
|
changeLabel="Change title image"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${styles.field} ${styles.col6Span}`}>
|
<div className={styles.formColumn}>
|
||||||
|
<div className={styles.field}>
|
||||||
<label>Category</label>
|
<label>Category</label>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={categoryOptions}
|
options={categoryOptions}
|
||||||
@@ -221,7 +267,7 @@ export function AddNewPortfolioPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${styles.field} ${styles.col9}`}>
|
<div className={styles.field}>
|
||||||
<label htmlFor="portfolio-title">Title</label>
|
<label htmlFor="portfolio-title">Title</label>
|
||||||
<input
|
<input
|
||||||
id="portfolio-title"
|
id="portfolio-title"
|
||||||
@@ -235,7 +281,7 @@ export function AddNewPortfolioPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${styles.field} ${styles.col9}`}>
|
<div className={styles.field}>
|
||||||
<label htmlFor="portfolio-abstract">Abstract</label>
|
<label htmlFor="portfolio-abstract">Abstract</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="portfolio-abstract"
|
id="portfolio-abstract"
|
||||||
@@ -249,16 +295,17 @@ export function AddNewPortfolioPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${styles.field} ${styles.col12}`}>
|
<div className={styles.field}>
|
||||||
<label>Main Text</label>
|
<label>Main Text</label>
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
value={mainTextHtml}
|
value={mainTextHtml}
|
||||||
onChange={setMainTextHtml}
|
onChange={setMainTextHtml}
|
||||||
placeholder="Full portfolio content with formatting and images..."
|
placeholder="Full portfolio content with formatting and images..."
|
||||||
allowImages
|
allowImages
|
||||||
editorMinHeight={320}
|
editorMinHeight={280}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={`${styles.field} ${styles.col12}`}>
|
<div className={`${styles.field} ${styles.col12}`}>
|
||||||
<label>Image Gallery</label>
|
<label>Image Gallery</label>
|
||||||
|
|||||||
@@ -26,6 +26,14 @@
|
|||||||
.col9 { grid-column: span 9; }
|
.col9 { grid-column: span 9; }
|
||||||
.col12 { grid-column: span 12; }
|
.col12 { grid-column: span 12; }
|
||||||
|
|
||||||
|
.formColumn {
|
||||||
|
grid-column: span 9;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.thumbnailField {
|
.thumbnailField {
|
||||||
align-self: start;
|
align-self: start;
|
||||||
}
|
}
|
||||||
@@ -176,6 +184,41 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aspectOptions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aspectChip {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
border-radius: 50px;
|
||||||
|
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aspectChip:hover:not(:disabled) {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aspectChip:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aspectChipSelected {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.12);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.form {
|
.form {
|
||||||
padding: 20px 16px;
|
padding: 20px 16px;
|
||||||
@@ -192,7 +235,8 @@
|
|||||||
.col10,
|
.col10,
|
||||||
.col4Start,
|
.col4Start,
|
||||||
.col9,
|
.col9,
|
||||||
.col12 {
|
.col12,
|
||||||
|
.formColumn {
|
||||||
grid-column: span 12;
|
grid-column: span 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,14 +155,6 @@ export function BlogCategoriesPage() {
|
|||||||
Organize your blog posts into categories and subcategories.
|
Organize your blog posts into categories and subcategories.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
className={styles.addBtn}
|
|
||||||
onClick={() => openCreateModal()}
|
|
||||||
aria-label="Add category"
|
|
||||||
>
|
|
||||||
<Plus size={22} strokeWidth={2.5} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -217,6 +209,17 @@ export function BlogCategoriesPage() {
|
|||||||
onConfirm={confirmDelete}
|
onConfirm={confirmDelete}
|
||||||
onCancel={() => !isSubmitting && setDeleteTarget(null)}
|
onCancel={() => !isSubmitting && setDeleteTarget(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className={styles.fabDock}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.addFab}
|
||||||
|
onClick={() => openCreateModal()}
|
||||||
|
aria-label="Add category"
|
||||||
|
>
|
||||||
|
<Plus size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,3 +24,45 @@
|
|||||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fabDock {
|
||||||
|
position: fixed;
|
||||||
|
right: 32px;
|
||||||
|
bottom: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addFab {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||||
|
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addFab:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.fabDock {
|
||||||
|
right: 20px;
|
||||||
|
bottom: 20px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addFab {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
updateProductCategory,
|
updateProductCategory,
|
||||||
} from '../services/productCategoryService'
|
} from '../services/productCategoryService'
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
import fabStyles from './MyProductsPage.module.css'
|
|
||||||
import aiStyles from '../styles/ai.module.css'
|
import aiStyles from '../styles/ai.module.css'
|
||||||
import styles from './CategoriesPage.module.css'
|
import styles from './CategoriesPage.module.css'
|
||||||
|
|
||||||
@@ -534,7 +533,7 @@ export function CategoriesPage() {
|
|||||||
isRunning={aiGenerating}
|
isRunning={aiGenerating}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={fabStyles.fabDock}>
|
<div className={styles.fabDock}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={aiStyles.aiFabStrip}
|
className={aiStyles.aiFabStrip}
|
||||||
@@ -545,7 +544,7 @@ export function CategoriesPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={fabStyles.addFab}
|
className={styles.addFab}
|
||||||
onClick={() => openCreateModal()}
|
onClick={() => openCreateModal()}
|
||||||
aria-label="Add category"
|
aria-label="Add category"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.domain {
|
.businessName {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -275,13 +275,6 @@
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.domainHint {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin: -12px 0 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldRow {
|
.fieldRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide-react'
|
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide-react'
|
||||||
import { useAuth, BUSINESS_ACCESS_MESSAGE } from '../context/AuthContext'
|
import { useAuth, BUSINESS_ACCESS_MESSAGE } from '../context/AuthContext'
|
||||||
|
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||||
import { ApiError } from '../lib/api'
|
import { ApiError } from '../lib/api'
|
||||||
import { toE164CellNumber } from '../lib/cellNumber'
|
import { toE164CellNumber } from '../lib/cellNumber'
|
||||||
import { getBusinessDomain } from '../lib/config'
|
import { getBusinessDomain } from '../lib/config'
|
||||||
@@ -21,6 +22,7 @@ type SmsStep = 'phone' | 'code'
|
|||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
|
const { businessName, logoUrl } = useTenantBranding()
|
||||||
const businessDomain = getBusinessDomain()
|
const businessDomain = getBusinessDomain()
|
||||||
|
|
||||||
const [view, setView] = useState<AuthView>('login')
|
const [view, setView] = useState<AuthView>('login')
|
||||||
@@ -217,15 +219,13 @@ export function LoginPage() {
|
|||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
<div className={styles.brand}>
|
<div className={styles.brand}>
|
||||||
<img src={meshkeeLogo} alt="Meshkee" className={styles.logo} />
|
<img src={logoUrl || meshkeeLogo} alt="" className={styles.logo} />
|
||||||
<div className={styles.brandText}>
|
<div className={styles.brandText}>
|
||||||
<span className={styles.domain}>Sanihome.ir</span>
|
<span className={styles.businessName}>{businessName || businessDomain}</span>
|
||||||
<span className={styles.appName}>Meshkee.app</span>
|
<span className={styles.appName}>powered by Meshkee.app</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className={styles.domainHint}>Business domain: {businessDomain}</p>
|
|
||||||
|
|
||||||
{view === 'login' && (
|
{view === 'login' && (
|
||||||
<>
|
<>
|
||||||
<h1 className={styles.title}>Welcome back</h1>
|
<h1 className={styles.title}>Welcome back</h1>
|
||||||
|
|||||||
@@ -155,14 +155,6 @@ export function PortfolioCategoriesPage() {
|
|||||||
Organize your portfolio items into categories and subcategories.
|
Organize your portfolio items into categories and subcategories.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
className={styles.addBtn}
|
|
||||||
onClick={() => openCreateModal()}
|
|
||||||
aria-label="Add category"
|
|
||||||
>
|
|
||||||
<Plus size={22} strokeWidth={2.5} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -217,6 +209,17 @@ export function PortfolioCategoriesPage() {
|
|||||||
onConfirm={confirmDelete}
|
onConfirm={confirmDelete}
|
||||||
onCancel={() => !isSubmitting && setDeleteTarget(null)}
|
onCancel={() => !isSubmitting && setDeleteTarget(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className={styles.fabDock}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.addFab}
|
||||||
|
onClick={() => openCreateModal()}
|
||||||
|
aria-label="Add category"
|
||||||
|
>
|
||||||
|
<Plus size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,23 +7,26 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
margin: 0 auto 20px;
|
margin: 0 auto 20px;
|
||||||
aspect-ratio: 3 / 2;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: rgba(148, 163, 184, 0.08);
|
background: rgba(148, 163, 184, 0.08);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.heroImageWrapEmpty {
|
||||||
|
aspect-ratio: 3 / 2;
|
||||||
|
}
|
||||||
|
|
||||||
.heroImage {
|
.heroImage {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: auto;
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroPlaceholder {
|
.heroPlaceholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-height: 180px;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
rgba(148, 163, 184, 0.12) 0%,
|
rgba(148, 163, 184, 0.12) 0%,
|
||||||
|
|||||||
@@ -100,7 +100,11 @@ export function PortfolioDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<article className={styles.portfolioDetail}>
|
<article className={styles.portfolioDetail}>
|
||||||
<div className={styles.heroImageWrap}>
|
<div
|
||||||
|
className={`${styles.heroImageWrap} ${
|
||||||
|
portfolio.titleImageUrl ? '' : styles.heroImageWrapEmpty
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{portfolio.titleImageUrl ? (
|
{portfolio.titleImageUrl ? (
|
||||||
<img
|
<img
|
||||||
src={portfolio.titleImageUrl}
|
src={portfolio.titleImageUrl}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 12 KiB |
@@ -5,6 +5,7 @@ import { useAuth } from '../context/AuthContext'
|
|||||||
import { getActiveBusinessDomain } from '../lib/businessContext'
|
import { getActiveBusinessDomain } from '../lib/businessContext'
|
||||||
import { isAbortError } from '../lib/api'
|
import { isAbortError } from '../lib/api'
|
||||||
import { getWebsiteBusinessInfo } from '../services/websiteService'
|
import { getWebsiteBusinessInfo } from '../services/websiteService'
|
||||||
|
import meshkeeLogo from '../assets/meshkee-logo.png'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -25,7 +26,6 @@ export function Sidebar() {
|
|||||||
const businessDomain = getActiveBusinessDomain()
|
const businessDomain = getActiveBusinessDomain()
|
||||||
const fallbackBusinessName = user?.customerBusinesses[0]?.name ?? 'Store'
|
const fallbackBusinessName = user?.customerBusinesses[0]?.name ?? 'Store'
|
||||||
const displayName = brandName || fallbackBusinessName
|
const displayName = brandName || fallbackBusinessName
|
||||||
const initial = displayName.trim().charAt(0) || businessDomain.charAt(0) || 'S'
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -35,7 +35,7 @@ export function Sidebar() {
|
|||||||
const info = await getWebsiteBusinessInfo(businessDomain, controller.signal)
|
const info = await getWebsiteBusinessInfo(businessDomain, controller.signal)
|
||||||
if (controller.signal.aborted) return
|
if (controller.signal.aborted) return
|
||||||
setBrandName(info.nameFa?.trim() || info.name.trim() || fallbackBusinessName)
|
setBrandName(info.nameFa?.trim() || info.name.trim() || fallbackBusinessName)
|
||||||
setLogoUrl(info.logoUrl)
|
setLogoUrl(info.logoUrl?.trim() || null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isAbortError(err)) return
|
if (isAbortError(err)) return
|
||||||
setBrandName(fallbackBusinessName)
|
setBrandName(fallbackBusinessName)
|
||||||
@@ -53,17 +53,11 @@ export function Sidebar() {
|
|||||||
return (
|
return (
|
||||||
<aside className={styles.sidebar}>
|
<aside className={styles.sidebar}>
|
||||||
<div className={styles.brand}>
|
<div className={styles.brand}>
|
||||||
{logoUrl ? (
|
|
||||||
<img
|
<img
|
||||||
src={logoUrl}
|
src={logoUrl || meshkeeLogo}
|
||||||
alt={displayName}
|
alt={displayName}
|
||||||
className={styles.brandLogo}
|
className={styles.brandLogo}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<span className={styles.brandFallback} aria-hidden>
|
|
||||||
{initial.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className={styles.brandText}>
|
<div className={styles.brandText}>
|
||||||
<span className={styles.brandDomain}>{businessDomain}</span>
|
<span className={styles.brandDomain}>{businessDomain}</span>
|
||||||
<span className={styles.brandName}>{displayName}</span>
|
<span className={styles.brandName}>{displayName}</span>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { isAbortError } from '../../lib/api'
|
|||||||
import { getTenantDomain } from '../../lib/config'
|
import { getTenantDomain } from '../../lib/config'
|
||||||
import { CheckoutProvider } from '../../context/CheckoutContext'
|
import { CheckoutProvider } from '../../context/CheckoutContext'
|
||||||
import { getWebsiteBusinessInfo, getWebsiteUrl } from '../../services/websiteService'
|
import { getWebsiteBusinessInfo, getWebsiteUrl } from '../../services/websiteService'
|
||||||
|
import meshkeeLogo from '../../assets/meshkee-logo.png'
|
||||||
import styles from './CheckoutLayout.module.css'
|
import styles from './CheckoutLayout.module.css'
|
||||||
|
|
||||||
export function CheckoutLayout() {
|
export function CheckoutLayout() {
|
||||||
@@ -20,7 +21,7 @@ export function CheckoutLayout() {
|
|||||||
const info = await getWebsiteBusinessInfo(tenantDomain, controller.signal)
|
const info = await getWebsiteBusinessInfo(tenantDomain, controller.signal)
|
||||||
if (controller.signal.aborted) return
|
if (controller.signal.aborted) return
|
||||||
setBrandName(info.nameFa?.trim() || info.name.trim() || tenantDomain)
|
setBrandName(info.nameFa?.trim() || info.name.trim() || tenantDomain)
|
||||||
setLogoUrl(info.logoUrl)
|
setLogoUrl(info.logoUrl?.trim() || null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isAbortError(err)) return
|
if (isAbortError(err)) return
|
||||||
setBrandName(tenantDomain)
|
setBrandName(tenantDomain)
|
||||||
@@ -33,24 +34,17 @@ export function CheckoutLayout() {
|
|||||||
}, [tenantDomain])
|
}, [tenantDomain])
|
||||||
|
|
||||||
const displayName = brandName || tenantDomain
|
const displayName = brandName || tenantDomain
|
||||||
const initial = displayName.trim().charAt(0) || 'S'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CheckoutProvider>
|
<CheckoutProvider>
|
||||||
<div className={styles.checkoutPage} lang="fa" dir="rtl">
|
<div className={styles.checkoutPage} lang="fa" dir="rtl">
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<a href={websiteUrl} className={styles.brandLink}>
|
<a href={websiteUrl} className={styles.brandLink}>
|
||||||
{logoUrl ? (
|
|
||||||
<img
|
<img
|
||||||
src={logoUrl}
|
src={logoUrl || meshkeeLogo}
|
||||||
alt={displayName}
|
alt={displayName}
|
||||||
className={styles.logo}
|
className={styles.logo}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<span className={styles.logoFallback} aria-hidden>
|
|
||||||
{initial.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className={styles.brandText}>
|
<div className={styles.brandText}>
|
||||||
<span className={styles.brandTitle}>{displayName}</span>
|
<span className={styles.brandTitle}>{displayName}</span>
|
||||||
<span className={styles.subtitle}>سبد خرید</span>
|
<span className={styles.subtitle}>سبد خرید</span>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { resolveTenantByDomain } from '../services/tenantService'
|
|||||||
|
|
||||||
interface TenantBrandingContextValue {
|
interface TenantBrandingContextValue {
|
||||||
businessName: string
|
businessName: string
|
||||||
|
logoUrl: string | null
|
||||||
faviconUrl: string | null
|
faviconUrl: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ function pickBusinessName(
|
|||||||
|
|
||||||
export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
||||||
const [businessName, setBusinessName] = useState('')
|
const [businessName, setBusinessName] = useState('')
|
||||||
|
const [logoUrl, setLogoUrl] = useState<string | null>(null)
|
||||||
const [faviconUrl, setFaviconUrl] = useState<string | null>(null)
|
const [faviconUrl, setFaviconUrl] = useState<string | null>(null)
|
||||||
const domain = getTenantDomain()
|
const domain = getTenantDomain()
|
||||||
|
|
||||||
@@ -54,19 +56,22 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
|||||||
domain,
|
domain,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const nextLogo =
|
||||||
|
info?.logoUrl?.trim() || tenant.logoUrl?.trim() || null
|
||||||
const nextFavicon =
|
const nextFavicon =
|
||||||
info?.faviconUrl?.trim() ||
|
info?.faviconUrl?.trim() ||
|
||||||
tenant.faviconUrl?.trim() ||
|
tenant.faviconUrl?.trim() ||
|
||||||
info?.logoUrl?.trim() ||
|
nextLogo ||
|
||||||
tenant.logoUrl?.trim() ||
|
|
||||||
null
|
null
|
||||||
|
|
||||||
setBusinessName(name || domain)
|
setBusinessName(name || domain)
|
||||||
|
setLogoUrl(nextLogo)
|
||||||
setFaviconUrl(nextFavicon)
|
setFaviconUrl(nextFavicon)
|
||||||
applyDocumentFavicon(nextFavicon)
|
applyDocumentFavicon(nextFavicon)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isAbortError(err) || controller.signal.aborted) return
|
if (isAbortError(err) || controller.signal.aborted) return
|
||||||
setBusinessName(domain)
|
setBusinessName(domain)
|
||||||
|
setLogoUrl(null)
|
||||||
setFaviconUrl(null)
|
setFaviconUrl(null)
|
||||||
applyDocumentFavicon(null)
|
applyDocumentFavicon(null)
|
||||||
}
|
}
|
||||||
@@ -80,8 +85,8 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [domain])
|
}, [domain])
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({ businessName, faviconUrl }),
|
() => ({ businessName, logoUrl, faviconUrl }),
|
||||||
[businessName, faviconUrl],
|
[businessName, logoUrl, faviconUrl],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
CUSTOMER_ACCESS_MESSAGE,
|
CUSTOMER_ACCESS_MESSAGE,
|
||||||
VERIFICATION_REQUIRED_MESSAGE,
|
VERIFICATION_REQUIRED_MESSAGE,
|
||||||
} from '../context/AuthContext'
|
} from '../context/AuthContext'
|
||||||
|
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||||
import { ApiError } from '../lib/api'
|
import { ApiError } from '../lib/api'
|
||||||
import { toE164CellNumber } from '../lib/cellNumber'
|
import { toE164CellNumber } from '../lib/cellNumber'
|
||||||
import { getTenantDomain } from '../lib/config'
|
import { getTenantDomain } from '../lib/config'
|
||||||
@@ -33,6 +34,7 @@ export function LoginPage() {
|
|||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const redirectTo = safeRedirectPath(searchParams.get('redirect'))
|
const redirectTo = safeRedirectPath(searchParams.get('redirect'))
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
|
const { businessName, logoUrl } = useTenantBranding()
|
||||||
const tenantDomain = getTenantDomain()
|
const tenantDomain = getTenantDomain()
|
||||||
|
|
||||||
const [view, setView] = useState<AuthView>('login')
|
const [view, setView] = useState<AuthView>('login')
|
||||||
@@ -236,15 +238,13 @@ export function LoginPage() {
|
|||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
<div className={styles.brand}>
|
<div className={styles.brand}>
|
||||||
<img src={meshkeeLogo} alt="Meshkee" className={styles.logo} />
|
<img src={logoUrl || meshkeeLogo} alt="" className={styles.logo} />
|
||||||
<div className={styles.brandText}>
|
<div className={styles.brandText}>
|
||||||
<span className={styles.domain}>{tenantDomain}</span>
|
<span className={styles.domain}>{businessName || tenantDomain}</span>
|
||||||
<span className={styles.appName}>Customer Dashboard</span>
|
<span className={styles.appName}>powered by Meshkee.app</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className={styles.domainHint}>Store domain: {tenantDomain}</p>
|
|
||||||
|
|
||||||
{view === 'login' && (
|
{view === 'login' && (
|
||||||
<>
|
<>
|
||||||
<h1 className={styles.title}>Welcome back</h1>
|
<h1 className={styles.title}>Welcome back</h1>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 12 KiB |
@@ -287,6 +287,14 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sslIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 0;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
.sslOk {
|
.sslOk {
|
||||||
color: #16a34a;
|
color: #16a34a;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -319,12 +327,18 @@
|
|||||||
transition: background 0.2s, color 0.2s;
|
transition: background 0.2s, color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controlBtn:hover {
|
.controlBtn:hover:not(:disabled) {
|
||||||
background: rgba(var(--primary-rgb) / 0.1);
|
background: rgba(var(--primary-rgb) / 0.1);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.controlBtnDanger:hover {
|
.controlBtn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlBtnDanger:hover:not(:disabled) {
|
||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -601,13 +601,25 @@ export function BusinessesPage() {
|
|||||||
<div className={styles.domainCell}>
|
<div className={styles.domainCell}>
|
||||||
<span>{b.domain}</span>
|
<span>{b.domain}</span>
|
||||||
{b.sslEnabled ? (
|
{b.sslEnabled ? (
|
||||||
<Lock size={16} className={styles.sslOk} aria-label="SSL enabled" />
|
<span
|
||||||
|
className={styles.sslIcon}
|
||||||
|
title="SSL enabled"
|
||||||
|
aria-label="SSL enabled"
|
||||||
|
>
|
||||||
|
<Lock size={16} className={styles.sslOk} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
<span
|
||||||
|
className={styles.sslIcon}
|
||||||
|
title="SSL not enabled"
|
||||||
|
aria-label="SSL not enabled"
|
||||||
|
>
|
||||||
<AlertTriangle
|
<AlertTriangle
|
||||||
size={16}
|
size={16}
|
||||||
className={styles.sslWarn}
|
className={styles.sslWarn}
|
||||||
aria-label="SSL not enabled"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -16,3 +16,41 @@
|
|||||||
.inactiveRow {
|
.inactiveRow {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
display: block;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deployBusy {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deployCell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deployStatus {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deployOk {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deployFail {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { AlertTriangle, Lock, Pencil, RotateCcw, Search, Trash2, Unlock } from 'lucide-react'
|
import { flushSync } from 'react-dom'
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Loader2,
|
||||||
|
Lock,
|
||||||
|
Pencil,
|
||||||
|
Rocket,
|
||||||
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Unlock,
|
||||||
|
} from 'lucide-react'
|
||||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||||
import { Modal } from '../components/Modal'
|
import { Modal } from '../components/Modal'
|
||||||
import { ToggleSwitch } from '../components/ToggleSwitch'
|
import { ToggleSwitch } from '../components/ToggleSwitch'
|
||||||
@@ -7,6 +18,7 @@ import type { DomainListItem, DomainsListResponse } from '../types/domain'
|
|||||||
import type { ListDomainsParams } from '../services/domainService'
|
import type { ListDomainsParams } from '../services/domainService'
|
||||||
import { ApiError, isAbortError } from '../lib/api'
|
import { ApiError, isAbortError } from '../lib/api'
|
||||||
import {
|
import {
|
||||||
|
deployDomain,
|
||||||
listDomains,
|
listDomains,
|
||||||
removeDomain,
|
removeDomain,
|
||||||
setDomainActive,
|
setDomainActive,
|
||||||
@@ -51,6 +63,31 @@ function daysLeftClass(expiresAt: string | null) {
|
|||||||
return styles.daysOk
|
return styles.daysOk
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDeployAt(iso: string | null) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return '—'
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function deployStatusLabel(status: DomainListItem['lastDeployStatus']) {
|
||||||
|
if (status === 'started') return 'Started'
|
||||||
|
if (status === 'failed') return 'Failed'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function deployStatusClass(status: DomainListItem['lastDeployStatus']) {
|
||||||
|
if (status === 'started') return styles.deployOk
|
||||||
|
if (status === 'failed') return styles.deployFail
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
export function WebsitesPage() {
|
export function WebsitesPage() {
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
const [data, setData] = useState<DomainsListResponse | null>(null)
|
const [data, setData] = useState<DomainsListResponse | null>(null)
|
||||||
@@ -70,6 +107,7 @@ export function WebsitesPage() {
|
|||||||
const [removeTarget, setRemoveTarget] = useState<DomainListItem | null>(null)
|
const [removeTarget, setRemoveTarget] = useState<DomainListItem | null>(null)
|
||||||
const [togglingActiveId, setTogglingActiveId] = useState<number | null>(null)
|
const [togglingActiveId, setTogglingActiveId] = useState<number | null>(null)
|
||||||
const [togglingSslId, setTogglingSslId] = useState<number | null>(null)
|
const [togglingSslId, setTogglingSslId] = useState<number | null>(null)
|
||||||
|
const [deployingId, setDeployingId] = useState<DomainListItem['id'] | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -229,6 +267,59 @@ export function WebsitesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDeploy(domain: DomainListItem) {
|
||||||
|
if (!domain.deploySlug) return
|
||||||
|
if (deployingId != null) return
|
||||||
|
|
||||||
|
// Paint spinner + disabled state before the network call starts
|
||||||
|
flushSync(() => {
|
||||||
|
setDeployingId(domain.id)
|
||||||
|
})
|
||||||
|
setError('')
|
||||||
|
showToast(`Deploying "${domain.host}"…`, 'info')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await deployDomain(domain.id)
|
||||||
|
setData((prev) => {
|
||||||
|
if (!prev) return prev
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
items: prev.items.map((item) =>
|
||||||
|
item.id === domain.id
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
lastDeployedAt: result.lastDeployedAt,
|
||||||
|
lastDeployStatus: result.lastDeployStatus,
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
showToast(`Deploy started for "${result.host}".`, 'success')
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof ApiError ? err.message : 'Deploy failed to start.'
|
||||||
|
setError(message)
|
||||||
|
setData((prev) => {
|
||||||
|
if (!prev) return prev
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
items: prev.items.map((item) =>
|
||||||
|
item.id === domain.id
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
lastDeployedAt: new Date().toISOString(),
|
||||||
|
lastDeployStatus: 'failed',
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
showToast(message, 'error')
|
||||||
|
} finally {
|
||||||
|
setDeployingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmRemove() {
|
async function confirmRemove() {
|
||||||
if (!removeTarget) return
|
if (!removeTarget) return
|
||||||
setError('')
|
setError('')
|
||||||
@@ -330,13 +421,14 @@ export function WebsitesPage() {
|
|||||||
<th className={tableStyles.th}>Owner business</th>
|
<th className={tableStyles.th}>Owner business</th>
|
||||||
<th className={tableStyles.th}>Days to expire</th>
|
<th className={tableStyles.th}>Days to expire</th>
|
||||||
<th className={tableStyles.th}>SSL</th>
|
<th className={tableStyles.th}>SSL</th>
|
||||||
|
<th className={tableStyles.th}>Latest deploy</th>
|
||||||
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
|
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading && (
|
{loading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className={tableStyles.td} colSpan={5}>
|
<td className={tableStyles.td} colSpan={6}>
|
||||||
Loading...
|
Loading...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -344,7 +436,7 @@ export function WebsitesPage() {
|
|||||||
|
|
||||||
{!loading && data?.items?.length === 0 && (
|
{!loading && data?.items?.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className={tableStyles.td} colSpan={5}>
|
<td className={tableStyles.td} colSpan={6}>
|
||||||
No results found.
|
No results found.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -366,19 +458,49 @@ export function WebsitesPage() {
|
|||||||
<td className={tableStyles.td}>
|
<td className={tableStyles.td}>
|
||||||
<div className={tableStyles.domainCell}>
|
<div className={tableStyles.domainCell}>
|
||||||
{domain.sslEnabled ? (
|
{domain.sslEnabled ? (
|
||||||
<Lock size={16} className={tableStyles.sslOk} aria-label="SSL enabled" />
|
<span
|
||||||
|
className={tableStyles.sslIcon}
|
||||||
|
title="SSL enabled"
|
||||||
|
aria-label="SSL enabled"
|
||||||
|
>
|
||||||
|
<Lock size={16} className={tableStyles.sslOk} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
<span
|
||||||
|
className={tableStyles.sslIcon}
|
||||||
|
title="SSL not enabled"
|
||||||
|
aria-label="SSL not enabled"
|
||||||
|
>
|
||||||
<AlertTriangle
|
<AlertTriangle
|
||||||
size={16}
|
size={16}
|
||||||
className={tableStyles.sslWarn}
|
className={tableStyles.sslWarn}
|
||||||
aria-label="SSL not enabled"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className={tableStyles.subText}>
|
<span className={tableStyles.subText}>
|
||||||
{domain.sslEnabled ? 'Enabled' : 'Disabled'}
|
{domain.sslEnabled ? 'Enabled' : 'Disabled'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className={tableStyles.td}>
|
||||||
|
{domain.lastDeployedAt ? (
|
||||||
|
<div className={styles.deployCell}>
|
||||||
|
<span className={tableStyles.subText}>
|
||||||
|
{formatDeployAt(domain.lastDeployedAt)}
|
||||||
|
</span>
|
||||||
|
{deployStatusLabel(domain.lastDeployStatus) ? (
|
||||||
|
<span
|
||||||
|
className={`${styles.deployStatus} ${deployStatusClass(domain.lastDeployStatus) ?? ''}`}
|
||||||
|
>
|
||||||
|
{deployStatusLabel(domain.lastDeployStatus)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className={tableStyles.subText}>—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
|
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
|
||||||
<div className={tableStyles.rowActions}>
|
<div className={tableStyles.rowActions}>
|
||||||
<span className={tableStyles.toggleInActions}>
|
<span className={tableStyles.toggleInActions}>
|
||||||
@@ -389,6 +511,32 @@ export function WebsitesPage() {
|
|||||||
onChange={(isActive) => void handleToggleActive(domain, isActive)}
|
onChange={(isActive) => void handleToggleActive(domain, isActive)}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
{domain.deploySlug ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${tableStyles.controlBtn} ${
|
||||||
|
deployingId === domain.id ? styles.deployBusy : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (deployingId != null) return
|
||||||
|
void handleDeploy(domain)
|
||||||
|
}}
|
||||||
|
disabled={deployingId === domain.id}
|
||||||
|
title={
|
||||||
|
deployingId === domain.id ? 'Deploying…' : 'Deploy website'
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
deployingId === domain.id ? 'Deploying' : 'Deploy website'
|
||||||
|
}
|
||||||
|
aria-busy={deployingId === domain.id}
|
||||||
|
>
|
||||||
|
{deployingId === domain.id ? (
|
||||||
|
<Loader2 size={16} className={styles.spin} aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Rocket size={16} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={tableStyles.controlBtn}
|
className={tableStyles.controlBtn}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiRequest } from '../lib/api'
|
import { apiRequest } from '../lib/api'
|
||||||
import type { DomainsListResponse } from '../types/domain'
|
import type { DeployDomainResponse, DomainsListResponse } from '../types/domain'
|
||||||
|
|
||||||
export interface ListDomainsParams {
|
export interface ListDomainsParams {
|
||||||
page?: number
|
page?: number
|
||||||
@@ -53,3 +53,10 @@ export async function removeDomain(domainId: number | string) {
|
|||||||
auth: true,
|
auth: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deployDomain(domainId: number | string) {
|
||||||
|
return apiRequest<DeployDomainResponse>(`/domains/${domainId}/deploy`, {
|
||||||
|
method: 'POST',
|
||||||
|
auth: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export type DomainDeployStatus = 'started' | 'failed'
|
||||||
|
|
||||||
export interface DomainListItem {
|
export interface DomainListItem {
|
||||||
id: number
|
id: number
|
||||||
host: string
|
host: string
|
||||||
@@ -7,6 +9,10 @@ export interface DomainListItem {
|
|||||||
isActive: boolean
|
isActive: boolean
|
||||||
expiresAt: string | null
|
expiresAt: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
/** Present when this apex has a storefront on the websites VM */
|
||||||
|
deploySlug: string | null
|
||||||
|
lastDeployedAt: string | null
|
||||||
|
lastDeployStatus: DomainDeployStatus | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DomainsListResponse {
|
export interface DomainsListResponse {
|
||||||
@@ -15,3 +21,12 @@ export interface DomainsListResponse {
|
|||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeployDomainResponse {
|
||||||
|
status: 'accepted'
|
||||||
|
slug: string
|
||||||
|
host: string
|
||||||
|
message: string
|
||||||
|
lastDeployedAt: string | null
|
||||||
|
lastDeployStatus: DomainDeployStatus | null
|
||||||
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
@@ -1,4 +1,5 @@
|
|||||||
const FAVICON_ATTR = 'data-business-favicon'
|
const FAVICON_ATTR = 'data-business-favicon'
|
||||||
|
const DEFAULT_FAVICON_HREF = '/favicon.png'
|
||||||
|
|
||||||
function removeIconLinks(root: ParentNode = document.head) {
|
function removeIconLinks(root: ParentNode = document.head) {
|
||||||
root
|
root
|
||||||
@@ -8,24 +9,19 @@ function removeIconLinks(root: ParentNode = document.head) {
|
|||||||
.forEach((node) => node.remove())
|
.forEach((node) => node.remove())
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sets browser tab favicon links for the current tenant. Pass null to clear. */
|
function appendIconLinks(href: string, cacheBust: boolean) {
|
||||||
export function applyDocumentFavicon(url: string | null | undefined) {
|
const resolved =
|
||||||
if (typeof document === 'undefined') return
|
cacheBust
|
||||||
|
? href.includes('?')
|
||||||
removeIconLinks()
|
? `${href}&v=${Date.now()}`
|
||||||
|
: `${href}?v=${Date.now()}`
|
||||||
const href = url?.trim()
|
: href
|
||||||
if (!href) return
|
|
||||||
|
|
||||||
// Bust browser favicon cache when the logo/favicon media URL changes.
|
|
||||||
const cacheBusted =
|
|
||||||
href.includes('?') ? `${href}&v=${Date.now()}` : `${href}?v=${Date.now()}`
|
|
||||||
|
|
||||||
for (const rel of ['icon', 'apple-touch-icon'] as const) {
|
for (const rel of ['icon', 'apple-touch-icon'] as const) {
|
||||||
const link = document.createElement('link')
|
const link = document.createElement('link')
|
||||||
link.setAttribute(FAVICON_ATTR, 'true')
|
link.setAttribute(FAVICON_ATTR, 'true')
|
||||||
link.rel = rel
|
link.rel = rel
|
||||||
link.href = cacheBusted
|
link.href = resolved
|
||||||
if (rel === 'icon') {
|
if (rel === 'icon') {
|
||||||
link.type = 'image/png'
|
link.type = 'image/png'
|
||||||
link.sizes = '48x48'
|
link.sizes = '48x48'
|
||||||
@@ -33,3 +29,19 @@ export function applyDocumentFavicon(url: string | null | undefined) {
|
|||||||
document.head.appendChild(link)
|
document.head.appendChild(link)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sets browser tab favicon links for the current tenant. Pass null to restore the app default. */
|
||||||
|
export function applyDocumentFavicon(url: string | null | undefined) {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
|
||||||
|
removeIconLinks()
|
||||||
|
|
||||||
|
const href = url?.trim()
|
||||||
|
if (!href) {
|
||||||
|
appendIconLinks(DEFAULT_FAVICON_HREF, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bust browser favicon cache when the logo/favicon media URL changes.
|
||||||
|
appendIconLinks(href, true)
|
||||||
|
}
|
||||||
|
|||||||