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;
flex-shrink: 0;
width: 40px;
padding-bottom: 28px;
padding-bottom: 22px;
box-sizing: border-box;
direction: ltr;
}
@@ -112,15 +112,10 @@
overflow: visible;
}
.chartWrapScroll {
overflow-x: auto;
overflow-y: visible;
}
.chartGrid {
position: absolute;
inset-inline: 0;
bottom: 28px;
bottom: 22px;
pointer-events: none;
z-index: 0;
}
@@ -149,8 +144,8 @@
}
.chartMonth {
gap: 6px;
overflow-x: auto;
gap: 4px;
overflow: visible;
}
.group {
@@ -167,8 +162,8 @@
}
.chartMonth .group {
flex: 1;
min-width: 36px;
flex: 1 1 0;
min-width: 0;
}
.bars {
@@ -177,14 +172,13 @@
justify-content: center;
width: 100%;
height: 180px;
gap: 1px;
gap: 2px;
}
.barWrap {
position: relative;
display: flex;
flex: 1 1 0;
min-width: 0;
flex: 0 0 auto;
align-items: flex-end;
justify-content: center;
height: 100%;
@@ -257,6 +251,9 @@
}
.label {
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
color: var(--text-muted);
font-weight: 500;
@@ -265,11 +262,22 @@
text-align: center;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.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;
line-height: 1.2;
letter-spacing: 0.04em;
}
.barPrimary {
@@ -12,6 +12,8 @@ import type { BusinessMessageKey } from '../i18n/messages'
import styles from './DailyActivityChart.module.css'
const CHART_HEIGHT = 180
const LABEL_BAND_DAY = 22
const LABEL_BAND_MONTH = 80
interface DailyActivityChartProps {
titleKey: BusinessMessageKey
@@ -143,6 +145,7 @@ export function DailyActivityChart({
const yAxisTicks = useMemo(() => buildYAxisTicks(maxValue), [maxValue])
const chartScaleMax = yAxisTicks[yAxisTicks.length - 1] ?? 1
const labelBand = granularity === 'month' ? LABEL_BAND_MONTH : LABEL_BAND_DAY
const subtitle = useMemo(() => {
if (granularity === 'month') {
@@ -191,7 +194,7 @@ export function DailyActivityChart({
<div
className={styles.yAxis}
aria-hidden="true"
style={{ height: CHART_HEIGHT + 28 }}
style={{ height: CHART_HEIGHT + labelBand, paddingBottom: labelBand }}
>
{[...yAxisTicks].reverse().map((tick) => (
<span key={tick} className={styles.yTick}>
@@ -200,17 +203,10 @@ export function DailyActivityChart({
))}
</div>
<div
className={[
styles.chartWrap,
granularity === 'month' ? styles.chartWrapScroll : '',
]
.filter(Boolean)
.join(' ')}
>
<div className={styles.chartWrap}>
<div
className={styles.chartGrid}
style={{ height: CHART_HEIGHT }}
style={{ height: CHART_HEIGHT, bottom: labelBand }}
aria-hidden="true"
>
{yAxisTicks.map((tick) => (
@@ -229,7 +225,7 @@ export function DailyActivityChart({
]
.filter(Boolean)
.join(' ')}
style={{ height: CHART_HEIGHT + 28 }}
style={{ height: CHART_HEIGHT + labelBand }}
role="img"
aria-label={t(titleKey)}
>
@@ -277,9 +273,9 @@ export function DailyActivityChart({
<span
className={styles.label}
lang={locale === 'fa' ? 'fa' : 'en'}
dir="ltr"
dir={locale === 'fa' ? 'rtl' : 'ltr'}
>
{item.label}
<span className={styles.labelText}>{item.label}</span>
</span>
</div>
)
@@ -9,6 +9,7 @@ import { listBlogCategories } from '../services/blogCategoryService'
import { listPortfolioCategories } from '../services/portfolioCategoryService'
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
import { listVideoCategories } from '../services/videoCategoryService'
import { listWorkshopCategories } from '../services/workshopCategoryService'
import type { ProductCategory } from '../types/category'
import {
WEBSITE_LINK_KINDS,
@@ -31,6 +32,7 @@ const KIND_LABEL: Record<WebsiteLinkKind, BusinessMessageKey> = {
blog: 'website.linkGenerator.types.blog',
portfolio: 'website.linkGenerator.types.portfolio',
video: 'website.linkGenerator.types.video',
workshop: 'website.linkGenerator.types.workshop',
}
const ANIMATION_MS = 220
@@ -54,6 +56,8 @@ async function loadCategories(kind: WebsiteLinkKind, signal: AbortSignal) {
return listPortfolioCategories(signal)
case 'video':
return listVideoCategories(signal)
case 'workshop':
return listWorkshopCategories(signal)
case 'product':
case 'storeItem':
return listProductCategories(signal)
@@ -13,7 +13,7 @@
gap: 8px;
box-sizing: border-box;
min-height: 0;
border: 2px dashed rgba(148, 163, 184, 0.4);
border: 2px dashed var(--primary);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text-secondary);
@@ -56,7 +56,7 @@
align-items: center;
justify-content: center;
gap: 6px;
border: 2px dashed rgba(148, 163, 184, 0.4);
border: 2px dashed var(--primary);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text-secondary);
@@ -41,6 +41,14 @@ export function InstructionCard({
const hasAbstract = Boolean(video.abstract?.trim())
const titleDir = isFa ? 'rtl' : titleLocale.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 (
<article className={cardStyles.card}>
@@ -58,9 +66,11 @@ export function InstructionCard({
<Play size={28} strokeWidth={1.5} />
</div>
)}
<span className={`${cardStyles.statusBadge} ${cardStyles.verifiedBadge}`}>
{video.videoProvider === 'youtube' ? 'YouTube' : 'Aparat'}
</span>
{providerLabel ? (
<span className={`${cardStyles.statusBadge} ${cardStyles.verifiedBadge}`}>
{providerLabel}
</span>
) : null}
</div>
<div className={cardStyles.body} dir={isFa ? 'rtl' : 'ltr'}>
@@ -89,7 +89,7 @@
justify-content: space-between;
flex-shrink: 0;
width: 40px;
padding-bottom: 32px;
padding-bottom: 80px;
box-sizing: border-box;
direction: ltr;
}
@@ -107,14 +107,13 @@
position: relative;
flex: 1;
min-width: 0;
overflow-x: auto;
overflow-y: visible;
overflow: visible;
}
.chartGrid {
position: absolute;
inset-inline: 0;
bottom: 32px;
bottom: 80px;
pointer-events: none;
z-index: 0;
}
@@ -132,16 +131,16 @@
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 14px;
min-width: 100%;
gap: 4px;
width: 100%;
padding-bottom: 4px;
direction: ltr;
unicode-bidi: isolate;
}
.group {
flex: 1;
min-width: 36px;
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
@@ -151,13 +150,16 @@
.bars {
display: flex;
align-items: flex-end;
justify-content: center;
height: 200px;
}
.barWrap {
position: relative;
display: flex;
flex: 0 0 auto;
align-items: flex-end;
justify-content: center;
height: 100%;
z-index: 1;
}
@@ -228,13 +230,30 @@
}
.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);
font-weight: 500;
font-family: var(--font-ui);
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) {
.header {
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 { ApiError } from '../lib/api'
import { listAllProducts } from '../services/productService'
@@ -8,7 +8,8 @@ import { useT } from '../i18n/useT'
import styles from './ProductActivityChart.module.css'
const CHART_HEIGHT = 200
const BAR_GAP = 6
const LABEL_BAND = 80
const BAR_GAP = 4
export function ProductActivityChart() {
const t = useT()
@@ -16,7 +17,6 @@ export function ProductActivityChart() {
const [data, setData] = useState<ProductMonthActivity[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
const chartWrapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const controller = new AbortController()
@@ -51,13 +51,6 @@ export function ProductActivityChart() {
const yAxisTicks = useMemo(() => buildYAxisTicks(maxValue), [maxValue])
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(
() => ({
added: data.reduce((sum, item) => sum + item.added, 0),
@@ -96,7 +89,7 @@ export function ProductActivityChart() {
<div
className={styles.yAxis}
aria-hidden="true"
style={{ height: CHART_HEIGHT + 32 }}
style={{ height: CHART_HEIGHT + LABEL_BAND, paddingBottom: LABEL_BAND }}
>
{[...yAxisTicks].reverse().map((tick) => (
<span key={tick} className={styles.yTick}>
@@ -105,10 +98,10 @@ export function ProductActivityChart() {
))}
</div>
<div className={styles.chartWrap} ref={chartWrapRef}>
<div className={styles.chartWrap}>
<div
className={styles.chartGrid}
style={{ height: CHART_HEIGHT }}
style={{ height: CHART_HEIGHT, bottom: LABEL_BAND }}
aria-hidden="true"
>
{yAxisTicks.map((tick) => (
@@ -122,7 +115,7 @@ export function ProductActivityChart() {
<div
className={styles.chart}
style={{ height: CHART_HEIGHT + 32 }}
style={{ height: CHART_HEIGHT + LABEL_BAND }}
role="img"
aria-label={t('products.activity.chartAria')}
>
@@ -158,8 +151,12 @@ export function ProductActivityChart() {
</div>
</div>
</div>
<span className={styles.label} dir="ltr">
{item.label}
<span
className={styles.label}
lang={locale === 'fa' ? 'fa' : 'en'}
dir={locale === 'fa' ? 'rtl' : 'ltr'}
>
<span className={styles.labelText}>{item.label}</span>
</span>
</div>
)
+2
View File
@@ -1701,6 +1701,7 @@ const en = {
'website.linkGenerator.types.blog': 'Blog',
'website.linkGenerator.types.portfolio': 'Portfolio',
'website.linkGenerator.types.video': 'Video',
'website.linkGenerator.types.workshop': 'Workshop',
'website.staticImages.subtitle':
'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.portfolio': 'نمونه کار',
'website.linkGenerator.types.video': 'ویدیو',
'website.linkGenerator.types.workshop': 'کارگاه',
'website.staticImages.subtitle':
'وب‌سایت‌ها این تصاویر را با کلید از GET /tenants/{domain}/website/static-images می‌خوانند',
@@ -290,7 +290,7 @@
padding: 3px;
gap: 2px;
background: transparent;
border: 1px solid #0a1628;
border: 1px solid var(--primary);
border-radius: 50px;
}
@@ -83,8 +83,14 @@ export function InstructionDetailsPage() {
const contentLocale = textLocaleAttrs(
video.descriptionHtml?.replace(/<[^>]+>/g, ' ') ?? '',
)
const providerLabel =
video.videoProvider === 'youtube' ? 'YouTube' : 'Aparat'
const hasVideo = Boolean(video.embedCode?.trim())
const providerLabel = !hasVideo
? null
: video.videoProvider === 'youtube'
? 'YouTube'
: video.videoProvider === 'aparat'
? 'Aparat'
: null
return (
<main className={pageStyles.content}>
@@ -167,7 +167,7 @@ export function InstructionListPage() {
/>
) : (
<>
<div className={pageStyles.grid}>
<div className={pageStyles.gridCols4}>
{videos.map((video) => (
<InstructionCard
key={video.id}
+3 -1
View File
@@ -1,6 +1,6 @@
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'
@@ -10,6 +10,7 @@ export const WEBSITE_LINK_KINDS: WebsiteLinkKind[] = [
'blog',
'portfolio',
'video',
'workshop',
]
export const WEBSITE_LINK_KIND_PATH: Record<WebsiteLinkKind, string> = {
@@ -18,6 +19,7 @@ export const WEBSITE_LINK_KIND_PATH: Record<WebsiteLinkKind, string> = {
blog: '/blogs',
portfolio: '/portfolios',
video: '/videos',
workshop: '/workshops',
}
export function getWebsiteOrigin(domain = getBusinessDomain()): string {
@@ -13,7 +13,7 @@
gap: 8px;
width: 100%;
min-height: 0;
border: 2px dashed rgba(148, 163, 184, 0.4);
border: 2px dashed var(--primary);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--surface) 70%, transparent);
color: var(--text-secondary);
@@ -62,7 +62,7 @@
align-items: center;
justify-content: center;
gap: 6px;
border: 2px dashed rgba(148, 163, 184, 0.4);
border: 2px dashed var(--primary);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--surface) 70%, transparent);
color: var(--text-secondary);
@@ -5,6 +5,7 @@ import {
ExternalLink,
FileText,
Globe,
Mail,
MoreHorizontal,
Trash2,
UserRound,
@@ -20,6 +21,7 @@ type PopoverPosition = {
interface DomainLinksOverflowMenuProps {
host: string
onAiPrompts: () => void
onEmailDns: () => void
onRemove: () => void
}
@@ -42,6 +44,7 @@ function openHost(host: string) {
export function DomainLinksOverflowMenu({
host,
onAiPrompts,
onEmailDns,
onRemove,
}: DomainLinksOverflowMenuProps) {
const [open, setOpen] = useState(false)
@@ -61,7 +64,7 @@ export function DomainLinksOverflowMenu({
const size = popoverRef.current
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
: { width: 220, height: 280 }
: { width: 220, height: 320 }
setPopoverPos(fitFixedPopover(rect, size, 'end'))
}
@@ -190,6 +193,18 @@ export function DomainLinksOverflowMenu({
</button>
))}
<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
type="button"
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 { DomainLinksOverflowMenu } from '../components/DomainLinksOverflowMenu'
import { DnsProviderSwitch } from '../components/DnsProviderSwitch'
import { EmailDnsModal } from '../components/EmailDnsModal'
import { ParkedDomainsModal } from '../components/ParkedDomainsModal'
import { Modal } from '../components/Modal'
import { ToggleSwitch } from '../components/ToggleSwitch'
@@ -113,6 +114,7 @@ export function WebsitesPage() {
const [removeTarget, setRemoveTarget] = 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 [togglingActiveId, setTogglingActiveId] = useState<number | null>(null)
const [deployingId, setDeployingId] = useState<DomainListItem['id'] | null>(null)
@@ -778,6 +780,7 @@ export function WebsitesPage() {
<DomainLinksOverflowMenu
host={domain.host}
onAiPrompts={() => setPromptsDomain(domain)}
onEmailDns={() => setMailDomain(domain)}
onRemove={() => setRemoveTarget(domain)}
/>
</div>
@@ -908,6 +911,8 @@ export function WebsitesPage() {
</div>
</Modal>
<EmailDnsModal domain={mailDomain} onClose={() => setMailDomain(null)} />
<ParkedDomainsModal
domain={parkedDomain}
onClose={() => setParkedDomain(null)}
@@ -6,6 +6,7 @@ import type {
IssueWebsiteSslResponse,
SyncSslResponse,
UpdateParkedHostsResponse,
ApplyMailDnsResponse,
} from '../types/domain'
export interface ListDomainsParams {
@@ -106,3 +107,18 @@ export async function issueDomainSsl(domainId: number | string) {
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
}>
}
export interface ApplyMailDnsResponse {
host: string
dnsProvider: ParkedDnsProvider
created: string[]
updated: string[]
skipped: string[]
removed: string[]
message: string
}