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 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<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" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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;
}
.sslIcon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
cursor: help;
}
.sslOk {
color: #16a34a;
flex-shrink: 0;
@@ -319,12 +327,18 @@
transition: background 0.2s, color 0.2s;
}
.controlBtn:hover {
.controlBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
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);
color: #ef4444;
}
+17 -5
View File
@@ -601,13 +601,25 @@ export function BusinessesPage() {
<div className={styles.domainCell}>
<span>{b.domain}</span>
{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>
) : (
<AlertTriangle
size={16}
className={styles.sslWarn}
<span
className={styles.sslIcon}
title="SSL not enabled"
aria-label="SSL not enabled"
/>
>
<AlertTriangle
size={16}
className={styles.sslWarn}
aria-hidden="true"
/>
</span>
)}
</div>
) : (
@@ -16,3 +16,41 @@
.inactiveRow {
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;
}
+156 -8
View File
@@ -1,5 +1,16 @@
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 { Modal } from '../components/Modal'
import { ToggleSwitch } from '../components/ToggleSwitch'
@@ -7,6 +18,7 @@ import type { DomainListItem, DomainsListResponse } from '../types/domain'
import type { ListDomainsParams } from '../services/domainService'
import { ApiError, isAbortError } from '../lib/api'
import {
deployDomain,
listDomains,
removeDomain,
setDomainActive,
@@ -51,6 +63,31 @@ function daysLeftClass(expiresAt: string | null) {
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() {
const { showToast } = useToast()
const [data, setData] = useState<DomainsListResponse | null>(null)
@@ -70,6 +107,7 @@ export function WebsitesPage() {
const [removeTarget, setRemoveTarget] = useState<DomainListItem | null>(null)
const [togglingActiveId, setTogglingActiveId] = useState<number | null>(null)
const [togglingSslId, setTogglingSslId] = useState<number | null>(null)
const [deployingId, setDeployingId] = useState<DomainListItem['id'] | null>(null)
useEffect(() => {
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() {
if (!removeTarget) return
setError('')
@@ -330,13 +421,14 @@ export function WebsitesPage() {
<th className={tableStyles.th}>Owner business</th>
<th className={tableStyles.th}>Days to expire</th>
<th className={tableStyles.th}>SSL</th>
<th className={tableStyles.th}>Latest deploy</th>
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
</tr>
</thead>
<tbody>
{loading && (
<tr>
<td className={tableStyles.td} colSpan={5}>
<td className={tableStyles.td} colSpan={6}>
Loading...
</td>
</tr>
@@ -344,7 +436,7 @@ export function WebsitesPage() {
{!loading && data?.items?.length === 0 && (
<tr>
<td className={tableStyles.td} colSpan={5}>
<td className={tableStyles.td} colSpan={6}>
No results found.
</td>
</tr>
@@ -366,19 +458,49 @@ export function WebsitesPage() {
<td className={tableStyles.td}>
<div className={tableStyles.domainCell}>
{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>
) : (
<AlertTriangle
size={16}
className={tableStyles.sslWarn}
<span
className={tableStyles.sslIcon}
title="SSL not enabled"
aria-label="SSL not enabled"
/>
>
<AlertTriangle
size={16}
className={tableStyles.sslWarn}
aria-hidden="true"
/>
</span>
)}
<span className={tableStyles.subText}>
{domain.sslEnabled ? 'Enabled' : 'Disabled'}
</span>
</div>
</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}`}>
<div className={tableStyles.rowActions}>
<span className={tableStyles.toggleInActions}>
@@ -389,6 +511,32 @@ export function WebsitesPage() {
onChange={(isActive) => void handleToggleActive(domain, isActive)}
/>
</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
type="button"
className={tableStyles.controlBtn}
@@ -1,5 +1,5 @@
import { apiRequest } from '../lib/api'
import type { DomainsListResponse } from '../types/domain'
import type { DeployDomainResponse, DomainsListResponse } from '../types/domain'
export interface ListDomainsParams {
page?: number
@@ -53,3 +53,10 @@ export async function removeDomain(domainId: number | string) {
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 {
id: number
host: string
@@ -7,6 +9,10 @@ export interface DomainListItem {
isActive: boolean
expiresAt: string | null
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 {
@@ -15,3 +21,12 @@ export interface DomainsListResponse {
page: number
pageSize: number
}
export interface DeployDomainResponse {
status: 'accepted'
slug: string
host: string
message: string
lastDeployedAt: string | null
lastDeployStatus: DomainDeployStatus | null
}