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
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;
}
.previewImgNatural {
width: 100%;
height: auto;
display: block;
}
.removeBtn {
position: absolute;
top: 8px;
@@ -85,9 +91,15 @@
position: relative;
width: 100%;
min-height: 160px;
max-height: 420px;
background: #1e293b;
}
.cropAreaPortrait {
max-height: none;
min-height: 0;
}
.cropControls {
padding: 14px 16px;
display: flex;
@@ -141,6 +153,11 @@
background: var(--primary-dark);
}
.applyBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.changeBtn {
display: inline-flex;
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 { ImagePlus, X } from 'lucide-react'
import { getCroppedImage } from '../utils/cropImage'
@@ -7,6 +7,7 @@ import styles from './ImageCropper.module.css'
interface ImageCropperProps {
value: string | null
onChange: (value: string | null) => void
/** Crop aspect ratio. Defaults to square (1). */
aspect?: number
outputFormat?: 'jpeg' | 'png'
accept?: string
@@ -30,6 +31,12 @@ export function ImageCropper({
const [zoom, setZoom] = useState(1)
const [croppedArea, setCroppedArea] = useState<Area | null>(null)
useEffect(() => {
setCrop({ x: 0, y: 0 })
setZoom(1)
setCroppedArea(null)
}, [aspect])
const onCropComplete = useCallback((_: Area, pixels: Area) => {
setCroppedArea(pixels)
}, [])
@@ -38,7 +45,12 @@ export function ImageCropper({
const file = e.target.files?.[0]
if (!file) return
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)
e.target.value = ''
}
@@ -62,13 +74,26 @@ export function ImageCropper({
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 (
<div className={styles.wrapper}>
{value && !imageSrc && (
<div className={styles.preview} style={frameStyle}>
<img src={value} alt="Thumbnail preview" className={styles.previewImg} />
<div className={styles.preview}>
<img src={value} alt="Thumbnail preview" className={styles.previewImgNatural} />
<button type="button" className={styles.removeBtn} onClick={removeThumbnail} aria-label="Remove thumbnail">
<X size={16} />
</button>
@@ -86,8 +111,9 @@ export function ImageCropper({
{imageSrc && (
<div className={styles.cropPanel}>
<div className={styles.cropArea} style={frameStyle}>
<div className={`${styles.cropArea} ${isPortrait ? styles.cropAreaPortrait : ''}`} style={frameStyle}>
<Cropper
key={String(aspect)}
image={imageSrc}
crop={crop}
zoom={zoom}
@@ -113,7 +139,12 @@ export function ImageCropper({
<button type="button" className={styles.cancelBtn} onClick={cancelCrop}>
Cancel
</button>
<button type="button" className={styles.applyBtn} onClick={applyCrop}>
<button
type="button"
className={styles.applyBtn}
onClick={() => void applyCrop()}
disabled={!croppedArea}
>
Apply Crop
</button>
</div>
@@ -34,14 +34,17 @@
width: 100%;
aspect-ratio: 3 / 2;
overflow: hidden;
background: rgba(148, 163, 184, 0.08);
background: transparent;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.image {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
display: block;
}
.imagePlaceholder {
@@ -16,6 +16,7 @@ import { resolveTenantByDomain } from '../services/tenantService'
interface TenantBrandingContextValue {
businessName: string
logoUrl: string | null
faviconUrl: string | null
refreshBranding: () => void
}
@@ -34,6 +35,7 @@ function pickBusinessName(
export function TenantBrandingProvider({ children }: { children: ReactNode }) {
const [businessName, setBusinessName] = useState('')
const [logoUrl, setLogoUrl] = useState<string | null>(null)
const [faviconUrl, setFaviconUrl] = useState<string | null>(null)
const [refreshToken, setRefreshToken] = useState(0)
@@ -51,6 +53,7 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
if (controller.signal.aborted) return
let name = pickBusinessName(tenant.name, tenant.nameFa, domain)
let nextLogo = tenant.logoUrl?.trim() || null
let nextFavicon =
tenant.faviconUrl?.trim() || tenant.logoUrl?.trim() || null
@@ -63,6 +66,7 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
name,
domain,
)
nextLogo = profile.profile.logoUrl?.trim() || nextLogo
nextFavicon =
profile.profile.faviconUrl?.trim() ||
profile.profile.logoUrl?.trim() ||
@@ -76,12 +80,14 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
if (!controller.signal.aborted) {
setBusinessName(name || domain)
setLogoUrl(nextLogo)
setFaviconUrl(nextFavicon)
applyDocumentFavicon(nextFavicon)
}
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
setBusinessName(domain)
setLogoUrl(null)
setFaviconUrl(null)
applyDocumentFavicon(null)
}
@@ -101,8 +107,8 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
}, [refreshToken, refreshBranding])
const value = useMemo(
() => ({ businessName, faviconUrl, refreshBranding }),
[businessName, faviconUrl, refreshBranding],
() => ({ businessName, logoUrl, faviconUrl, refreshBranding }),
[businessName, logoUrl, faviconUrl, refreshBranding],
)
return (
+93 -46
View File
@@ -28,6 +28,19 @@ import { flattenCategories } from '../utils/categories'
import pageStyles from '../components/PageContent.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() {
const { id } = useParams()
const navigate = useNavigate()
@@ -40,6 +53,7 @@ export function AddNewPortfolioPage() {
const [abstract, setAbstract] = useState('')
const [mainTextHtml, setMainTextHtml] = useState('')
const [titleImage, setTitleImage] = useState<string | null>(null)
const [titleImageAspect, setTitleImageAspect] = useState<TitleImageAspectId>('3:2')
const [images, setImages] = useState<string[]>([])
const [tags, setTags] = useState<string[]>([])
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
@@ -49,6 +63,8 @@ export function AddNewPortfolioPage() {
const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories)
const selectedAspect =
TITLE_IMAGE_ASPECTS.find((option) => option.id === titleImageAspect)?.aspect ?? 3 / 2
useEffect(() => {
const controller = new AbortController()
@@ -199,65 +215,96 @@ export function AddNewPortfolioPage() {
<form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.formGrid}>
<div className={`${styles.field} ${styles.col3} ${styles.rowSpan3}`}>
<div className={`${styles.field} ${styles.col3} ${styles.thumbnailField}`}>
<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
value={titleImage}
onChange={setTitleImage}
aspect={3 / 2}
aspect={selectedAspect}
uploadLabel="Upload title image"
hint="Click to select, then crop"
changeLabel="Change title image"
/>
</div>
<div className={`${styles.field} ${styles.col6Span}`}>
<label>Category</label>
<SearchableSelect
options={categoryOptions}
value={categoryId}
onChange={setCategoryId}
placeholder="Search and select category..."
/>
</div>
<div className={styles.formColumn}>
<div className={styles.field}>
<label>Category</label>
<SearchableSelect
options={categoryOptions}
value={categoryId}
onChange={setCategoryId}
placeholder="Search and select category..."
/>
</div>
<div className={`${styles.field} ${styles.col9}`}>
<label htmlFor="portfolio-title">Title</label>
<input
id="portfolio-title"
name="title"
type="text"
placeholder="Portfolio title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="portfolio-title">Title</label>
<input
id="portfolio-title"
name="title"
type="text"
placeholder="Portfolio title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className={`${styles.field} ${styles.col9}`}>
<label htmlFor="portfolio-abstract">Abstract</label>
<textarea
id="portfolio-abstract"
name="abstract"
rows={4}
placeholder="Short summary shown in portfolio listings"
value={abstract}
onChange={(e) => setAbstract(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="portfolio-abstract">Abstract</label>
<textarea
id="portfolio-abstract"
name="abstract"
rows={4}
placeholder="Short summary shown in portfolio listings"
value={abstract}
onChange={(e) => setAbstract(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>Main Text</label>
<RichTextEditor
value={mainTextHtml}
onChange={setMainTextHtml}
placeholder="Full portfolio content with formatting and images..."
allowImages
editorMinHeight={320}
/>
<div className={styles.field}>
<label>Main Text</label>
<RichTextEditor
value={mainTextHtml}
onChange={setMainTextHtml}
placeholder="Full portfolio content with formatting and images..."
allowImages
editorMinHeight={280}
/>
</div>
</div>
<div className={`${styles.field} ${styles.col12}`}>
@@ -26,6 +26,14 @@
.col9 { grid-column: span 9; }
.col12 { grid-column: span 12; }
.formColumn {
grid-column: span 9;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.thumbnailField {
align-self: start;
}
@@ -176,6 +184,41 @@
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) {
.form {
padding: 20px 16px;
@@ -192,7 +235,8 @@
.col10,
.col4Start,
.col9,
.col12 {
.col12,
.formColumn {
grid-column: span 12;
}
+11 -8
View File
@@ -155,14 +155,6 @@ export function BlogCategoriesPage() {
Organize your blog posts into categories and subcategories.
</p>
</div>
<button
className={styles.addBtn}
onClick={() => openCreateModal()}
aria-label="Add category"
>
<Plus size={22} strokeWidth={2.5} />
</button>
</div>
{error && (
@@ -217,6 +209,17 @@ export function BlogCategoriesPage() {
onConfirm={confirmDelete}
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>
)
}
@@ -24,3 +24,45 @@
border: 1px solid rgba(239, 68, 68, 0.25);
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,
} from '../services/productCategoryService'
import pageStyles from '../components/PageContent.module.css'
import fabStyles from './MyProductsPage.module.css'
import aiStyles from '../styles/ai.module.css'
import styles from './CategoriesPage.module.css'
@@ -534,7 +533,7 @@ export function CategoriesPage() {
isRunning={aiGenerating}
/>
<div className={fabStyles.fabDock}>
<div className={styles.fabDock}>
<button
type="button"
className={aiStyles.aiFabStrip}
@@ -545,7 +544,7 @@ export function CategoriesPage() {
</button>
<button
type="button"
className={fabStyles.addFab}
className={styles.addFab}
onClick={() => openCreateModal()}
aria-label="Add category"
>
+1 -8
View File
@@ -42,7 +42,7 @@
flex-direction: column;
}
.domain {
.businessName {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
@@ -275,13 +275,6 @@
padding: 10px 12px;
}
.domainHint {
text-align: center;
font-size: 12px;
color: var(--text-muted);
margin: -12px 0 20px;
}
.fieldRow {
display: grid;
grid-template-columns: 1fr 1fr;
+5 -5
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide-react'
import { useAuth, BUSINESS_ACCESS_MESSAGE } from '../context/AuthContext'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { ApiError } from '../lib/api'
import { toE164CellNumber } from '../lib/cellNumber'
import { getBusinessDomain } from '../lib/config'
@@ -21,6 +22,7 @@ type SmsStep = 'phone' | 'code'
export function LoginPage() {
const navigate = useNavigate()
const { login } = useAuth()
const { businessName, logoUrl } = useTenantBranding()
const businessDomain = getBusinessDomain()
const [view, setView] = useState<AuthView>('login')
@@ -217,15 +219,13 @@ export function LoginPage() {
<div className={styles.page}>
<div className={styles.card}>
<div className={styles.brand}>
<img src={meshkeeLogo} alt="Meshkee" className={styles.logo} />
<img src={logoUrl || meshkeeLogo} alt="" className={styles.logo} />
<div className={styles.brandText}>
<span className={styles.domain}>Sanihome.ir</span>
<span className={styles.appName}>Meshkee.app</span>
<span className={styles.businessName}>{businessName || businessDomain}</span>
<span className={styles.appName}>powered by Meshkee.app</span>
</div>
</div>
<p className={styles.domainHint}>Business domain: {businessDomain}</p>
{view === 'login' && (
<>
<h1 className={styles.title}>Welcome back</h1>
@@ -155,14 +155,6 @@ export function PortfolioCategoriesPage() {
Organize your portfolio items into categories and subcategories.
</p>
</div>
<button
className={styles.addBtn}
onClick={() => openCreateModal()}
aria-label="Add category"
>
<Plus size={22} strokeWidth={2.5} />
</button>
</div>
{error && (
@@ -217,6 +209,17 @@ export function PortfolioCategoriesPage() {
onConfirm={confirmDelete}
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>
)
}
@@ -7,23 +7,26 @@
width: 100%;
max-width: 640px;
margin: 0 auto 20px;
aspect-ratio: 3 / 2;
overflow: hidden;
border-radius: var(--radius);
background: rgba(148, 163, 184, 0.08);
border: 1px solid rgba(148, 163, 184, 0.12);
}
.heroImageWrapEmpty {
aspect-ratio: 3 / 2;
}
.heroImage {
width: 100%;
height: 100%;
object-fit: cover;
height: auto;
display: block;
}
.heroPlaceholder {
width: 100%;
height: 100%;
min-height: 180px;
background: linear-gradient(
135deg,
rgba(148, 163, 184, 0.12) 0%,
@@ -100,7 +100,11 @@ export function PortfolioDetailsPage() {
</div>
<article className={styles.portfolioDetail}>
<div className={styles.heroImageWrap}>
<div
className={`${styles.heroImageWrap} ${
portfolio.titleImageUrl ? '' : styles.heroImageWrapEmpty
}`}
>
{portfolio.titleImageUrl ? (
<img
src={portfolio.titleImageUrl}