Add workshop generate-link type, Email DNS for domains, and editor polish.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-01 22:00:04 +03:30
co-authored by Cursor
parent 6be867ea7a
commit 3cbadb4001
25 changed files with 467 additions and 70 deletions
@@ -91,7 +91,7 @@
justify-content: space-between; justify-content: space-between;
flex-shrink: 0; flex-shrink: 0;
width: 40px; width: 40px;
padding-bottom: 28px; padding-bottom: 22px;
box-sizing: border-box; box-sizing: border-box;
direction: ltr; direction: ltr;
} }
@@ -112,15 +112,10 @@
overflow: visible; overflow: visible;
} }
.chartWrapScroll {
overflow-x: auto;
overflow-y: visible;
}
.chartGrid { .chartGrid {
position: absolute; position: absolute;
inset-inline: 0; inset-inline: 0;
bottom: 28px; bottom: 22px;
pointer-events: none; pointer-events: none;
z-index: 0; z-index: 0;
} }
@@ -149,8 +144,8 @@
} }
.chartMonth { .chartMonth {
gap: 6px; gap: 4px;
overflow-x: auto; overflow: visible;
} }
.group { .group {
@@ -167,8 +162,8 @@
} }
.chartMonth .group { .chartMonth .group {
flex: 1; flex: 1 1 0;
min-width: 36px; min-width: 0;
} }
.bars { .bars {
@@ -177,14 +172,13 @@
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 180px; height: 180px;
gap: 1px; gap: 2px;
} }
.barWrap { .barWrap {
position: relative; position: relative;
display: flex; display: flex;
flex: 1 1 0; flex: 0 0 auto;
min-width: 0;
align-items: flex-end; align-items: flex-end;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
@@ -257,6 +251,9 @@
} }
.label { .label {
display: flex;
align-items: center;
justify-content: center;
font-size: 9px; font-size: 9px;
color: var(--text-muted); color: var(--text-muted);
font-weight: 500; font-weight: 500;
@@ -265,11 +262,22 @@
text-align: center; text-align: center;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
} }
.chartMonth .label { .chartMonth .label {
height: 68px;
}
.chartMonth .labelText {
writing-mode: vertical-rl;
transform: rotate(180deg);
white-space: nowrap;
max-height: 68px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 10px; font-size: 10px;
line-height: 1.2;
letter-spacing: 0.04em;
} }
.barPrimary { .barPrimary {
@@ -12,6 +12,8 @@ import type { BusinessMessageKey } from '../i18n/messages'
import styles from './DailyActivityChart.module.css' import styles from './DailyActivityChart.module.css'
const CHART_HEIGHT = 180 const CHART_HEIGHT = 180
const LABEL_BAND_DAY = 22
const LABEL_BAND_MONTH = 80
interface DailyActivityChartProps { interface DailyActivityChartProps {
titleKey: BusinessMessageKey titleKey: BusinessMessageKey
@@ -143,6 +145,7 @@ export function DailyActivityChart({
const yAxisTicks = useMemo(() => buildYAxisTicks(maxValue), [maxValue]) const yAxisTicks = useMemo(() => buildYAxisTicks(maxValue), [maxValue])
const chartScaleMax = yAxisTicks[yAxisTicks.length - 1] ?? 1 const chartScaleMax = yAxisTicks[yAxisTicks.length - 1] ?? 1
const labelBand = granularity === 'month' ? LABEL_BAND_MONTH : LABEL_BAND_DAY
const subtitle = useMemo(() => { const subtitle = useMemo(() => {
if (granularity === 'month') { if (granularity === 'month') {
@@ -191,7 +194,7 @@ export function DailyActivityChart({
<div <div
className={styles.yAxis} className={styles.yAxis}
aria-hidden="true" aria-hidden="true"
style={{ height: CHART_HEIGHT + 28 }} style={{ height: CHART_HEIGHT + labelBand, paddingBottom: labelBand }}
> >
{[...yAxisTicks].reverse().map((tick) => ( {[...yAxisTicks].reverse().map((tick) => (
<span key={tick} className={styles.yTick}> <span key={tick} className={styles.yTick}>
@@ -200,17 +203,10 @@ export function DailyActivityChart({
))} ))}
</div> </div>
<div <div className={styles.chartWrap}>
className={[
styles.chartWrap,
granularity === 'month' ? styles.chartWrapScroll : '',
]
.filter(Boolean)
.join(' ')}
>
<div <div
className={styles.chartGrid} className={styles.chartGrid}
style={{ height: CHART_HEIGHT }} style={{ height: CHART_HEIGHT, bottom: labelBand }}
aria-hidden="true" aria-hidden="true"
> >
{yAxisTicks.map((tick) => ( {yAxisTicks.map((tick) => (
@@ -229,7 +225,7 @@ export function DailyActivityChart({
] ]
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
style={{ height: CHART_HEIGHT + 28 }} style={{ height: CHART_HEIGHT + labelBand }}
role="img" role="img"
aria-label={t(titleKey)} aria-label={t(titleKey)}
> >
@@ -277,9 +273,9 @@ export function DailyActivityChart({
<span <span
className={styles.label} className={styles.label}
lang={locale === 'fa' ? 'fa' : 'en'} lang={locale === 'fa' ? 'fa' : 'en'}
dir="ltr" dir={locale === 'fa' ? 'rtl' : 'ltr'}
> >
{item.label} <span className={styles.labelText}>{item.label}</span>
</span> </span>
</div> </div>
) )
@@ -9,6 +9,7 @@ import { listBlogCategories } from '../services/blogCategoryService'
import { listPortfolioCategories } from '../services/portfolioCategoryService' import { listPortfolioCategories } from '../services/portfolioCategoryService'
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService' import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
import { listVideoCategories } from '../services/videoCategoryService' import { listVideoCategories } from '../services/videoCategoryService'
import { listWorkshopCategories } from '../services/workshopCategoryService'
import type { ProductCategory } from '../types/category' import type { ProductCategory } from '../types/category'
import { import {
WEBSITE_LINK_KINDS, WEBSITE_LINK_KINDS,
@@ -31,6 +32,7 @@ const KIND_LABEL: Record<WebsiteLinkKind, BusinessMessageKey> = {
blog: 'website.linkGenerator.types.blog', blog: 'website.linkGenerator.types.blog',
portfolio: 'website.linkGenerator.types.portfolio', portfolio: 'website.linkGenerator.types.portfolio',
video: 'website.linkGenerator.types.video', video: 'website.linkGenerator.types.video',
workshop: 'website.linkGenerator.types.workshop',
} }
const ANIMATION_MS = 220 const ANIMATION_MS = 220
@@ -54,6 +56,8 @@ async function loadCategories(kind: WebsiteLinkKind, signal: AbortSignal) {
return listPortfolioCategories(signal) return listPortfolioCategories(signal)
case 'video': case 'video':
return listVideoCategories(signal) return listVideoCategories(signal)
case 'workshop':
return listWorkshopCategories(signal)
case 'product': case 'product':
case 'storeItem': case 'storeItem':
return listProductCategories(signal) return listProductCategories(signal)
@@ -13,7 +13,7 @@
gap: 8px; gap: 8px;
box-sizing: border-box; box-sizing: border-box;
min-height: 0; min-height: 0;
border: 2px dashed rgba(148, 163, 184, 0.4); border: 2px dashed var(--primary);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface); background: var(--surface);
color: var(--text-secondary); color: var(--text-secondary);
@@ -56,7 +56,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
border: 2px dashed rgba(148, 163, 184, 0.4); border: 2px dashed var(--primary);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface); background: var(--surface);
color: var(--text-secondary); color: var(--text-secondary);
@@ -41,6 +41,14 @@ export function InstructionCard({
const hasAbstract = Boolean(video.abstract?.trim()) const hasAbstract = Boolean(video.abstract?.trim())
const titleDir = isFa ? 'rtl' : titleLocale.dir const titleDir = isFa ? 'rtl' : titleLocale.dir
const abstractDir = isFa ? 'rtl' : abstractLocale.dir const abstractDir = isFa ? 'rtl' : abstractLocale.dir
const hasVideo = Boolean(video.embedCode?.trim())
const providerLabel = !hasVideo
? null
: video.videoProvider === 'youtube'
? 'YouTube'
: video.videoProvider === 'aparat'
? 'Aparat'
: null
return ( return (
<article className={cardStyles.card}> <article className={cardStyles.card}>
@@ -58,9 +66,11 @@ export function InstructionCard({
<Play size={28} strokeWidth={1.5} /> <Play size={28} strokeWidth={1.5} />
</div> </div>
)} )}
<span className={`${cardStyles.statusBadge} ${cardStyles.verifiedBadge}`}> {providerLabel ? (
{video.videoProvider === 'youtube' ? 'YouTube' : 'Aparat'} <span className={`${cardStyles.statusBadge} ${cardStyles.verifiedBadge}`}>
</span> {providerLabel}
</span>
) : null}
</div> </div>
<div className={cardStyles.body} dir={isFa ? 'rtl' : 'ltr'}> <div className={cardStyles.body} dir={isFa ? 'rtl' : 'ltr'}>
@@ -89,7 +89,7 @@
justify-content: space-between; justify-content: space-between;
flex-shrink: 0; flex-shrink: 0;
width: 40px; width: 40px;
padding-bottom: 32px; padding-bottom: 80px;
box-sizing: border-box; box-sizing: border-box;
direction: ltr; direction: ltr;
} }
@@ -107,14 +107,13 @@
position: relative; position: relative;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
overflow-x: auto; overflow: visible;
overflow-y: visible;
} }
.chartGrid { .chartGrid {
position: absolute; position: absolute;
inset-inline: 0; inset-inline: 0;
bottom: 32px; bottom: 80px;
pointer-events: none; pointer-events: none;
z-index: 0; z-index: 0;
} }
@@ -132,16 +131,16 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: flex-end; align-items: flex-end;
gap: 14px; gap: 4px;
min-width: 100%; width: 100%;
padding-bottom: 4px; padding-bottom: 4px;
direction: ltr; direction: ltr;
unicode-bidi: isolate; unicode-bidi: isolate;
} }
.group { .group {
flex: 1; flex: 1 1 0;
min-width: 36px; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -151,13 +150,16 @@
.bars { .bars {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
justify-content: center;
height: 200px; height: 200px;
} }
.barWrap { .barWrap {
position: relative; position: relative;
display: flex; display: flex;
flex: 0 0 auto;
align-items: flex-end; align-items: flex-end;
justify-content: center;
height: 100%; height: 100%;
z-index: 1; z-index: 1;
} }
@@ -228,13 +230,30 @@
} }
.label { .label {
font-size: 11px; display: flex;
align-items: center;
justify-content: center;
height: 68px;
width: 100%;
overflow: hidden;
font-size: 10px;
color: var(--text-muted); color: var(--text-muted);
font-weight: 500; font-weight: 500;
font-family: var(--font-ui); font-family: var(--font-ui);
text-align: center; text-align: center;
} }
.labelText {
writing-mode: vertical-rl;
transform: rotate(180deg);
white-space: nowrap;
max-height: 68px;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
letter-spacing: 0.04em;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.header { .header {
flex-direction: column; flex-direction: column;
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useLocale } from '@meshkee/dashboard-ui' import { useLocale } from '@meshkee/dashboard-ui'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { listAllProducts } from '../services/productService' import { listAllProducts } from '../services/productService'
@@ -8,7 +8,8 @@ import { useT } from '../i18n/useT'
import styles from './ProductActivityChart.module.css' import styles from './ProductActivityChart.module.css'
const CHART_HEIGHT = 200 const CHART_HEIGHT = 200
const BAR_GAP = 6 const LABEL_BAND = 80
const BAR_GAP = 4
export function ProductActivityChart() { export function ProductActivityChart() {
const t = useT() const t = useT()
@@ -16,7 +17,6 @@ export function ProductActivityChart() {
const [data, setData] = useState<ProductMonthActivity[]>([]) const [data, setData] = useState<ProductMonthActivity[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const chartWrapRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController()
@@ -51,13 +51,6 @@ export function ProductActivityChart() {
const yAxisTicks = useMemo(() => buildYAxisTicks(maxValue), [maxValue]) const yAxisTicks = useMemo(() => buildYAxisTicks(maxValue), [maxValue])
const chartScaleMax = yAxisTicks[yAxisTicks.length - 1] ?? 1 const chartScaleMax = yAxisTicks[yAxisTicks.length - 1] ?? 1
useEffect(() => {
if (isLoading || error) return
const wrap = chartWrapRef.current
if (!wrap) return
wrap.scrollLeft = wrap.scrollWidth
}, [isLoading, error, data])
const totals = useMemo( const totals = useMemo(
() => ({ () => ({
added: data.reduce((sum, item) => sum + item.added, 0), added: data.reduce((sum, item) => sum + item.added, 0),
@@ -96,7 +89,7 @@ export function ProductActivityChart() {
<div <div
className={styles.yAxis} className={styles.yAxis}
aria-hidden="true" aria-hidden="true"
style={{ height: CHART_HEIGHT + 32 }} style={{ height: CHART_HEIGHT + LABEL_BAND, paddingBottom: LABEL_BAND }}
> >
{[...yAxisTicks].reverse().map((tick) => ( {[...yAxisTicks].reverse().map((tick) => (
<span key={tick} className={styles.yTick}> <span key={tick} className={styles.yTick}>
@@ -105,10 +98,10 @@ export function ProductActivityChart() {
))} ))}
</div> </div>
<div className={styles.chartWrap} ref={chartWrapRef}> <div className={styles.chartWrap}>
<div <div
className={styles.chartGrid} className={styles.chartGrid}
style={{ height: CHART_HEIGHT }} style={{ height: CHART_HEIGHT, bottom: LABEL_BAND }}
aria-hidden="true" aria-hidden="true"
> >
{yAxisTicks.map((tick) => ( {yAxisTicks.map((tick) => (
@@ -122,7 +115,7 @@ export function ProductActivityChart() {
<div <div
className={styles.chart} className={styles.chart}
style={{ height: CHART_HEIGHT + 32 }} style={{ height: CHART_HEIGHT + LABEL_BAND }}
role="img" role="img"
aria-label={t('products.activity.chartAria')} aria-label={t('products.activity.chartAria')}
> >
@@ -158,8 +151,12 @@ export function ProductActivityChart() {
</div> </div>
</div> </div>
</div> </div>
<span className={styles.label} dir="ltr"> <span
{item.label} className={styles.label}
lang={locale === 'fa' ? 'fa' : 'en'}
dir={locale === 'fa' ? 'rtl' : 'ltr'}
>
<span className={styles.labelText}>{item.label}</span>
</span> </span>
</div> </div>
) )
+2
View File
@@ -1701,6 +1701,7 @@ const en = {
'website.linkGenerator.types.blog': 'Blog', 'website.linkGenerator.types.blog': 'Blog',
'website.linkGenerator.types.portfolio': 'Portfolio', 'website.linkGenerator.types.portfolio': 'Portfolio',
'website.linkGenerator.types.video': 'Video', 'website.linkGenerator.types.video': 'Video',
'website.linkGenerator.types.workshop': 'Workshop',
'website.staticImages.subtitle': 'website.staticImages.subtitle':
'Websites load these by key from GET /tenants/{domain}/website/static-images', 'Websites load these by key from GET /tenants/{domain}/website/static-images',
@@ -4031,6 +4032,7 @@ const fa: Record<MessageKey, string> = {
'website.linkGenerator.types.blog': 'بلاگ', 'website.linkGenerator.types.blog': 'بلاگ',
'website.linkGenerator.types.portfolio': 'نمونه کار', 'website.linkGenerator.types.portfolio': 'نمونه کار',
'website.linkGenerator.types.video': 'ویدیو', 'website.linkGenerator.types.video': 'ویدیو',
'website.linkGenerator.types.workshop': 'کارگاه',
'website.staticImages.subtitle': 'website.staticImages.subtitle':
'وب‌سایت‌ها این تصاویر را با کلید از GET /tenants/{domain}/website/static-images می‌خوانند', 'وب‌سایت‌ها این تصاویر را با کلید از GET /tenants/{domain}/website/static-images می‌خوانند',
@@ -290,7 +290,7 @@
padding: 3px; padding: 3px;
gap: 2px; gap: 2px;
background: transparent; background: transparent;
border: 1px solid #0a1628; border: 1px solid var(--primary);
border-radius: 50px; border-radius: 50px;
} }
@@ -83,8 +83,14 @@ export function InstructionDetailsPage() {
const contentLocale = textLocaleAttrs( const contentLocale = textLocaleAttrs(
video.descriptionHtml?.replace(/<[^>]+>/g, ' ') ?? '', video.descriptionHtml?.replace(/<[^>]+>/g, ' ') ?? '',
) )
const providerLabel = const hasVideo = Boolean(video.embedCode?.trim())
video.videoProvider === 'youtube' ? 'YouTube' : 'Aparat' const providerLabel = !hasVideo
? null
: video.videoProvider === 'youtube'
? 'YouTube'
: video.videoProvider === 'aparat'
? 'Aparat'
: null
return ( return (
<main className={pageStyles.content}> <main className={pageStyles.content}>
@@ -167,7 +167,7 @@ export function InstructionListPage() {
/> />
) : ( ) : (
<> <>
<div className={pageStyles.grid}> <div className={pageStyles.gridCols4}>
{videos.map((video) => ( {videos.map((video) => (
<InstructionCard <InstructionCard
key={video.id} key={video.id}
+3 -1
View File
@@ -1,6 +1,6 @@
import { getBusinessDomain } from '../lib/config' import { getBusinessDomain } from '../lib/config'
export type WebsiteLinkKind = 'product' | 'storeItem' | 'blog' | 'portfolio' | 'video' export type WebsiteLinkKind = 'product' | 'storeItem' | 'blog' | 'portfolio' | 'video' | 'workshop'
export type WebsiteLinkFilter = 'category' | 'tag' export type WebsiteLinkFilter = 'category' | 'tag'
@@ -10,6 +10,7 @@ export const WEBSITE_LINK_KINDS: WebsiteLinkKind[] = [
'blog', 'blog',
'portfolio', 'portfolio',
'video', 'video',
'workshop',
] ]
export const WEBSITE_LINK_KIND_PATH: Record<WebsiteLinkKind, string> = { export const WEBSITE_LINK_KIND_PATH: Record<WebsiteLinkKind, string> = {
@@ -18,6 +19,7 @@ export const WEBSITE_LINK_KIND_PATH: Record<WebsiteLinkKind, string> = {
blog: '/blogs', blog: '/blogs',
portfolio: '/portfolios', portfolio: '/portfolios',
video: '/videos', video: '/videos',
workshop: '/workshops',
} }
export function getWebsiteOrigin(domain = getBusinessDomain()): string { export function getWebsiteOrigin(domain = getBusinessDomain()): string {
@@ -13,7 +13,7 @@
gap: 8px; gap: 8px;
width: 100%; width: 100%;
min-height: 0; min-height: 0;
border: 2px dashed rgba(148, 163, 184, 0.4); border: 2px dashed var(--primary);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--surface) 70%, transparent); background: color-mix(in srgb, var(--surface) 70%, transparent);
color: var(--text-secondary); color: var(--text-secondary);
@@ -62,7 +62,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
border: 2px dashed rgba(148, 163, 184, 0.4); border: 2px dashed var(--primary);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--surface) 70%, transparent); background: color-mix(in srgb, var(--surface) 70%, transparent);
color: var(--text-secondary); color: var(--text-secondary);
@@ -5,6 +5,7 @@ import {
ExternalLink, ExternalLink,
FileText, FileText,
Globe, Globe,
Mail,
MoreHorizontal, MoreHorizontal,
Trash2, Trash2,
UserRound, UserRound,
@@ -20,6 +21,7 @@ type PopoverPosition = {
interface DomainLinksOverflowMenuProps { interface DomainLinksOverflowMenuProps {
host: string host: string
onAiPrompts: () => void onAiPrompts: () => void
onEmailDns: () => void
onRemove: () => void onRemove: () => void
} }
@@ -42,6 +44,7 @@ function openHost(host: string) {
export function DomainLinksOverflowMenu({ export function DomainLinksOverflowMenu({
host, host,
onAiPrompts, onAiPrompts,
onEmailDns,
onRemove, onRemove,
}: DomainLinksOverflowMenuProps) { }: DomainLinksOverflowMenuProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@@ -61,7 +64,7 @@ export function DomainLinksOverflowMenu({
const size = popoverRef.current const size = popoverRef.current
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight } ? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
: { width: 220, height: 280 } : { width: 220, height: 320 }
setPopoverPos(fitFixedPopover(rect, size, 'end')) setPopoverPos(fitFixedPopover(rect, size, 'end'))
} }
@@ -190,6 +193,18 @@ export function DomainLinksOverflowMenu({
</button> </button>
))} ))}
<div className={styles.menuDivider} role="separator" /> <div className={styles.menuDivider} role="separator" />
<button
type="button"
className={styles.item}
role="menuitem"
onClick={() => {
setOpen(false)
onEmailDns()
}}
>
<Mail size={16} />
<span>Email DNS</span>
</button>
<button <button
type="button" type="button"
className={styles.item} className={styles.item}
@@ -0,0 +1,38 @@
.providerRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 0 0 14px;
}
.providerLabel {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.textarea {
width: 100%;
min-height: 110px;
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
font-family: var(--font-ui);
line-height: 1.45;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.65);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
resize: vertical;
box-sizing: border-box;
}
.textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.textarea:disabled {
opacity: 0.65;
}
@@ -0,0 +1,144 @@
import { useEffect, useState } from 'react'
import { Modal } from './Modal'
import { DnsProviderSwitch } from './DnsProviderSwitch'
import { ApiError } from '../lib/api'
import { applyMailDns } from '../services/domainService'
import type { DomainListItem, ParkedDnsProvider } from '../types/domain'
import { useToast } from '../context/ToastContext'
import tableStyles from '../pages/BusinessesPage.module.css'
import parkedStyles from './ParkedDomainsModal.module.css'
import styles from './EmailDnsModal.module.css'
interface EmailDnsModalProps {
domain: DomainListItem | null
onClose: () => void
}
export function EmailDnsModal({ domain, onClose }: EmailDnsModalProps) {
const { showToast } = useToast()
const [dnsProvider, setDnsProvider] = useState<ParkedDnsProvider>('arvan')
const [ed25519Key, setEd25519Key] = useState('')
const [rsaKey, setRsaKey] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!domain) return
setDnsProvider('arvan')
setEd25519Key('')
setRsaKey('')
setError('')
setSubmitting(false)
}, [domain])
if (!domain) return null
async function submit() {
setSubmitting(true)
setError('')
try {
const result = await applyMailDns(domain.id, {
dnsProvider,
ed25519Key,
rsaKey,
})
showToast(result.message, 'success')
onClose()
} catch (err) {
const message =
err instanceof ApiError ? err.message : 'Unable to update mail DNS.'
setError(message)
showToast(message, 'error')
} finally {
setSubmitting(false)
}
}
return (
<Modal
open
wide
title={`Email DNS · ${domain.host}`}
onClose={() => {
if (!submitting) onClose()
}}
>
<p className={parkedStyles.hint}>
Paste the two DKIM lines from Stalwart (Directory → Domains → ⋮ → View DNS
zone file). We upsert mail records on{' '}
{dnsProvider === 'cloudflare' ? 'Cloudflare' : 'Arvan'} (DNS-only / not
proxied): <strong>mail</strong> A → 185.214.101.41, MX → mail.meshkee.com,
SPF, both DKIM keys, autoconfig / autodiscover. Existing DMARC is left as-is.
</p>
{error ? (
<p className={tableStyles.alertError} role="alert">
{error}
</p>
) : null}
{submitting ? (
<p className={tableStyles.helperText} role="status">
Updating {dnsProvider === 'cloudflare' ? 'Cloudflare' : 'Arvan'} mail DNS…
</p>
) : null}
<div className={styles.providerRow}>
<span className={styles.providerLabel}>DNS host</span>
<DnsProviderSwitch
value={dnsProvider}
onChange={setDnsProvider}
disabled={submitting}
/>
</div>
<div className={tableStyles.field}>
<label htmlFor="mail-dkim-ed25519">Ed25519 domain key</label>
<textarea
id="mail-dkim-ed25519"
className={styles.textarea}
value={ed25519Key}
onChange={(e) => setEd25519Key(e.target.value)}
placeholder="v1-ed25519-…. _domainkey IN TXT &quot;v=DKIM1; k=ed25519; …&quot;"
disabled={submitting}
spellCheck={false}
dir="ltr"
rows={5}
/>
</div>
<div style={{ height: 12 }} />
<div className={tableStyles.field}>
<label htmlFor="mail-dkim-rsa">RSA domain key</label>
<textarea
id="mail-dkim-rsa"
className={styles.textarea}
value={rsaKey}
onChange={(e) => setRsaKey(e.target.value)}
placeholder="v1-rsa-…. _domainkey IN TXT &quot;v=DKIM1; k=rsa; …&quot;"
disabled={submitting}
spellCheck={false}
dir="ltr"
rows={7}
/>
</div>
<div style={{ height: 12 }} />
<div className={tableStyles.actionsRow}>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
onClick={onClose}
disabled={submitting}
>
Cancel
</button>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
onClick={() => void submit()}
disabled={submitting || !ed25519Key.trim() || !rsaKey.trim()}
>
Update DNS
</button>
</div>
</Modal>
)
}
@@ -17,6 +17,7 @@ import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { DomainAiPromptsModal } from '../components/DomainAiPromptsModal' import { DomainAiPromptsModal } from '../components/DomainAiPromptsModal'
import { DomainLinksOverflowMenu } from '../components/DomainLinksOverflowMenu' import { DomainLinksOverflowMenu } from '../components/DomainLinksOverflowMenu'
import { DnsProviderSwitch } from '../components/DnsProviderSwitch' import { DnsProviderSwitch } from '../components/DnsProviderSwitch'
import { EmailDnsModal } from '../components/EmailDnsModal'
import { ParkedDomainsModal } from '../components/ParkedDomainsModal' import { ParkedDomainsModal } from '../components/ParkedDomainsModal'
import { Modal } from '../components/Modal' import { Modal } from '../components/Modal'
import { ToggleSwitch } from '../components/ToggleSwitch' import { ToggleSwitch } from '../components/ToggleSwitch'
@@ -113,6 +114,7 @@ export function WebsitesPage() {
const [removeTarget, setRemoveTarget] = useState<DomainListItem | null>(null) const [removeTarget, setRemoveTarget] = useState<DomainListItem | null>(null)
const [promptsDomain, setPromptsDomain] = useState<DomainListItem | null>(null) const [promptsDomain, setPromptsDomain] = useState<DomainListItem | null>(null)
const [mailDomain, setMailDomain] = useState<DomainListItem | null>(null)
const [parkedDomain, setParkedDomain] = useState<DomainListItem | null>(null) const [parkedDomain, setParkedDomain] = useState<DomainListItem | null>(null)
const [togglingActiveId, setTogglingActiveId] = useState<number | null>(null) const [togglingActiveId, setTogglingActiveId] = useState<number | null>(null)
const [deployingId, setDeployingId] = useState<DomainListItem['id'] | null>(null) const [deployingId, setDeployingId] = useState<DomainListItem['id'] | null>(null)
@@ -778,6 +780,7 @@ export function WebsitesPage() {
<DomainLinksOverflowMenu <DomainLinksOverflowMenu
host={domain.host} host={domain.host}
onAiPrompts={() => setPromptsDomain(domain)} onAiPrompts={() => setPromptsDomain(domain)}
onEmailDns={() => setMailDomain(domain)}
onRemove={() => setRemoveTarget(domain)} onRemove={() => setRemoveTarget(domain)}
/> />
</div> </div>
@@ -908,6 +911,8 @@ export function WebsitesPage() {
</div> </div>
</Modal> </Modal>
<EmailDnsModal domain={mailDomain} onClose={() => setMailDomain(null)} />
<ParkedDomainsModal <ParkedDomainsModal
domain={parkedDomain} domain={parkedDomain}
onClose={() => setParkedDomain(null)} onClose={() => setParkedDomain(null)}
@@ -6,6 +6,7 @@ import type {
IssueWebsiteSslResponse, IssueWebsiteSslResponse,
SyncSslResponse, SyncSslResponse,
UpdateParkedHostsResponse, UpdateParkedHostsResponse,
ApplyMailDnsResponse,
} from '../types/domain' } from '../types/domain'
export interface ListDomainsParams { export interface ListDomainsParams {
@@ -106,3 +107,18 @@ export async function issueDomainSsl(domainId: number | string) {
auth: true, auth: true,
}) })
} }
export async function applyMailDns(
domainId: number | string,
payload: {
dnsProvider: 'arvan' | 'cloudflare'
ed25519Key: string
rsaKey: string
},
) {
return apiRequest<ApplyMailDnsResponse>(`/domains/${domainId}/mail-dns`, {
method: 'POST',
auth: true,
body: payload,
})
}
+10
View File
@@ -94,3 +94,13 @@ export interface UpdateParkedHostsResponse {
error?: string error?: string
}> }>
} }
export interface ApplyMailDnsResponse {
host: string
dnsProvider: ParkedDnsProvider
created: string[]
updated: string[]
skipped: string[]
removed: string[]
message: string
}
+1 -1
View File
@@ -142,7 +142,7 @@ Add to `/etc/hosts` (one line per tenant):
| `/businesses/:businessId/invoices` | Business invoices list | | `/businesses/:businessId/invoices` | Business invoices list |
| `/businesses/:businessId/invoices/new` | Issue invoice (full page) | | `/businesses/:businessId/invoices/new` | Issue invoice (full page) |
| `/users` | Users (business filter: Customer vs Manager → Admin/Editor/Viewer; Admin assignable by super-admin only; owners locked) | | `/users` | Users (business filter: Customer vs Manager → Admin/Editor/Viewer; Admin assignable by super-admin only; owners locked) |
| `/websites` | Websites / domains (Deploy when `deploy_slug` set). Edit domain can optionally update DNS (Arvan or Cloudflare, same tenant records as Businesses). Overflow ⋯ includes website/dashboard links, **AI prompts**, and remove. **Parked domains** button: alias hosts 301 to the main domain. Per alias, choose **Arvan** or **Cloudflare** for DNS (`@` + `www` only, not proxied); SSL is always Let's Encrypt on the websites VM. | | `/websites` | Websites / domains (Deploy when `deploy_slug` set). Edit domain can optionally update DNS (Arvan or Cloudflare, same tenant records as Businesses). Overflow ⋯ includes website/dashboard links, **Email DNS** (Stalwart DKIM + MX/SPF on Arvan or Cloudflare), **AI prompts**, and remove. **Parked domains** button: alias hosts 301 to the main domain. Per alias, choose **Arvan** or **Cloudflare** for DNS (`@` + `www` only, not proxied); SSL is always Let's Encrypt on the websites VM. |
| `/templates` | Global AI prompt templates. Assign from Businesses or Websites ⋯ → **AI prompts** (add from library; public pack link). | | `/templates` | Global AI prompt templates. Assign from Businesses or Websites ⋯ → **AI prompts** (add from library; public pack link). |
| `/settings` | Platform settings (invoice templates + item templates) | | `/settings` | Platform settings (invoice templates + item templates) |
| `/settings/invoice-templates/new` | Create invoice template | | `/settings/invoice-templates/new` | Create invoice template |
@@ -69,7 +69,7 @@
background-color: transparent; background-color: transparent;
border-width: 2px; border-width: 2px;
border-style: dashed; border-style: dashed;
border-color: #0a1628; border-color: var(--primary);
border-radius: 12px; border-radius: 12px;
color: var(--primary); color: var(--primary);
font-family: var(--font-ui); font-family: var(--font-ui);
@@ -89,7 +89,7 @@ button.addBox {
background-color: transparent; background-color: transparent;
border-width: 2px; border-width: 2px;
border-style: dashed; border-style: dashed;
border-color: #0a1628; border-color: var(--primary);
color: var(--primary); color: var(--primary);
} }
@@ -98,7 +98,7 @@ a.addBox:hover,
button.addBox:hover { button.addBox:hover {
background-color: rgba(var(--primary-rgb) / 0.08); background-color: rgba(var(--primary-rgb) / 0.08);
border-style: dashed; border-style: dashed;
border-color: #0a1628; border-color: var(--primary);
color: var(--primary); color: var(--primary);
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(var(--primary-rgb) / 0.18); box-shadow: 0 8px 20px rgba(var(--primary-rgb) / 0.18);
@@ -48,6 +48,11 @@
cursor: not-allowed; cursor: not-allowed;
} }
.toolbar button.toolbarBtnActive {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.dirBtn { .dirBtn {
width: auto; width: auto;
min-width: 34px; min-width: 34px;
@@ -1,5 +1,9 @@
import { useRef, useEffect, useState, useCallback, useContext } from 'react' import { useRef, useEffect, useState, useCallback, useContext } from 'react'
import { import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
Bold, Bold,
IndentDecrease, IndentDecrease,
IndentIncrease, IndentIncrease,
@@ -17,6 +21,15 @@ import styles from './RichTextEditor.module.css'
const INDENT_STEP_PX = 24 const INDENT_STEP_PX = 24
const INDENT_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4']) const INDENT_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4'])
const DEFAULT_TEXT_COLOR = '#0f172a' const DEFAULT_TEXT_COLOR = '#0f172a'
type TextAlign = 'left' | 'center' | 'right' | 'justify'
const ALIGN_COMMAND: Record<TextAlign, string> = {
left: 'justifyLeft',
center: 'justifyCenter',
right: 'justifyRight',
justify: 'justifyFull',
}
const SELECTED_IMAGE_CLASS = 'richTextImageSelected' const SELECTED_IMAGE_CLASS = 'richTextImageSelected'
const HEX_COLOR_PATTERN = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/ const HEX_COLOR_PATTERN = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/
@@ -77,6 +90,7 @@ export function RichTextEditor({
const [textColor, setTextColor] = useState(DEFAULT_TEXT_COLOR) const [textColor, setTextColor] = useState(DEFAULT_TEXT_COLOR)
const [colorOpen, setColorOpen] = useState(false) const [colorOpen, setColorOpen] = useState(false)
const [hexInput, setHexInput] = useState(DEFAULT_TEXT_COLOR) const [hexInput, setHexInput] = useState(DEFAULT_TEXT_COLOR)
const [currentAlign, setCurrentAlign] = useState<TextAlign | null>(null)
const syncChange = useCallback(() => { const syncChange = useCallback(() => {
if (editorRef.current) { if (editorRef.current) {
@@ -204,6 +218,48 @@ export function RichTextEditor({
return null return null
} }
function readBlockAlign(editor: HTMLElement): TextAlign | null {
const block = findIndentBlock(editor)
if (!block) return null
const inline = block.style.textAlign.trim().toLowerCase()
if (inline === 'left' || inline === 'center' || inline === 'right' || inline === 'justify') {
return inline
}
const computed = getComputedStyle(block).textAlign.toLowerCase()
if (computed === 'center') return 'center'
if (computed === 'justify') return 'justify'
if (computed === 'right') return 'right'
if (computed === 'left') return 'left'
const blockDir = (block.getAttribute('dir') || editor.getAttribute('dir') || 'ltr').toLowerCase()
if (computed === 'start') return blockDir === 'rtl' ? 'right' : 'left'
if (computed === 'end') return blockDir === 'rtl' ? 'left' : 'right'
return null
}
function refreshAlignFromSelection() {
const editor = editorRef.current
if (!editor) return
const selection = window.getSelection()
if (!selection || !selection.anchorNode || !editor.contains(selection.anchorNode)) return
setCurrentAlign(readBlockAlign(editor))
}
useEffect(() => {
function onSelectionChange() {
const editor = editorRef.current
if (!editor) return
const selection = window.getSelection()
if (!selection || !selection.anchorNode || !editor.contains(selection.anchorNode)) return
setCurrentAlign(readBlockAlign(editor))
}
document.addEventListener('selectionchange', onSelectionChange)
return () => document.removeEventListener('selectionchange', onSelectionChange)
}, [])
function changeIndent(direction: 1 | -1) { function changeIndent(direction: 1 | -1) {
const editor = editorRef.current const editor = editorRef.current
if (!editor) return if (!editor) return
@@ -245,6 +301,23 @@ export function RichTextEditor({
syncChange() syncChange()
} }
function setBlockAlign(align: TextAlign) {
const editor = editorRef.current
if (!editor) return
editor.focus()
document.execCommand(ALIGN_COMMAND[align], false)
const block = findIndentBlock(editor)
if (block) {
block.style.textAlign = align
block.removeAttribute('align')
}
setCurrentAlign(align)
syncChange()
}
function applyTextColor(color: string) { function applyTextColor(color: string) {
const normalized = normalizeHexColor(color) ?? DEFAULT_TEXT_COLOR const normalized = normalizeHexColor(color) ?? DEFAULT_TEXT_COLOR
setTextColor(normalized) setTextColor(normalized)
@@ -435,6 +508,51 @@ export function RichTextEditor({
LTR LTR
</button> </button>
<span className={styles.divider} /> <span className={styles.divider} />
<button
type="button"
className={currentAlign === 'left' ? styles.toolbarBtnActive : undefined}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setBlockAlign('left')}
title="Align left"
aria-label="Align left"
aria-pressed={currentAlign === 'left'}
>
<AlignLeft size={16} />
</button>
<button
type="button"
className={currentAlign === 'center' ? styles.toolbarBtnActive : undefined}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setBlockAlign('center')}
title="Align center"
aria-label="Align center"
aria-pressed={currentAlign === 'center'}
>
<AlignCenter size={16} />
</button>
<button
type="button"
className={currentAlign === 'right' ? styles.toolbarBtnActive : undefined}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setBlockAlign('right')}
title="Align right"
aria-label="Align right"
aria-pressed={currentAlign === 'right'}
>
<AlignRight size={16} />
</button>
<button
type="button"
className={currentAlign === 'justify' ? styles.toolbarBtnActive : undefined}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setBlockAlign('justify')}
title="Justify"
aria-label="Justify"
aria-pressed={currentAlign === 'justify'}
>
<AlignJustify size={16} />
</button>
<span className={styles.divider} />
<button type="button" onClick={() => exec('insertUnorderedList')} title="Bullet list" aria-label="Bullet list"> <button type="button" onClick={() => exec('insertUnorderedList')} title="Bullet list" aria-label="Bullet list">
<List size={16} /> <List size={16} />
</button> </button>
@@ -498,6 +616,8 @@ export function RichTextEditor({
data-placeholder={placeholder} data-placeholder={placeholder}
onInput={syncChange} onInput={syncChange}
onKeyDown={onEditorKeyDown} onKeyDown={onEditorKeyDown}
onKeyUp={refreshAlignFromSelection}
onMouseUp={refreshAlignFromSelection}
suppressContentEditableWarning suppressContentEditableWarning
/> />
{allowImages && selectedImageEl && handlePos && ( {allowImages && selectedImageEl && handlePos && (