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>
This commit is contained in:
Alireza Hassani
2026-07-23 12:23:02 +03:30
co-authored by Cursor
parent f566387c61
commit db52a83f98
40 changed files with 595 additions and 162 deletions
+1
View File
@@ -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 />
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

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;
+38 -7
View File
@@ -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;
} }
+11 -8
View File
@@ -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;
}
}
+2 -3
View File
@@ -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"
> >
+1 -8
View File
@@ -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;
+5 -5
View File
@@ -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}
+1
View File
@@ -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 />
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+3 -9
View File
@@ -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 (
+5 -5
View File
@@ -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>
+1 -1
View File
@@ -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 />
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

BIN
View File
Binary file not shown.

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;
} }
+14 -2
View File
@@ -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;
}
+153 -5
View File
@@ -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,
})
}
+15
View File
@@ -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
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+25 -13
View File
@@ -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)
}