Add workshop generate-link type, Email DNS for domains, and editor polish.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
6be867ea7a
commit
3cbadb4001
@@ -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>
|
||||
)}
|
||||
{providerLabel ? (
|
||||
<span className={`${cardStyles.statusBadge} ${cardStyles.verifiedBadge}`}>
|
||||
{video.videoProvider === 'youtube' ? 'YouTube' : 'Aparat'}
|
||||
{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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 "v=DKIM1; k=ed25519; …""
|
||||
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 "v=DKIM1; k=rsa; …""
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ Add to `/etc/hosts` (one line per tenant):
|
||||
| `/businesses/:businessId/invoices` | Business invoices list |
|
||||
| `/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) |
|
||||
| `/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). |
|
||||
| `/settings` | Platform settings (invoice templates + item templates) |
|
||||
| `/settings/invoice-templates/new` | Create invoice template |
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
background-color: transparent;
|
||||
border-width: 2px;
|
||||
border-style: dashed;
|
||||
border-color: #0a1628;
|
||||
border-color: var(--primary);
|
||||
border-radius: 12px;
|
||||
color: var(--primary);
|
||||
font-family: var(--font-ui);
|
||||
@@ -89,7 +89,7 @@ button.addBox {
|
||||
background-color: transparent;
|
||||
border-width: 2px;
|
||||
border-style: dashed;
|
||||
border-color: #0a1628;
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ a.addBox:hover,
|
||||
button.addBox:hover {
|
||||
background-color: rgba(var(--primary-rgb) / 0.08);
|
||||
border-style: dashed;
|
||||
border-color: #0a1628;
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(var(--primary-rgb) / 0.18);
|
||||
|
||||
@@ -48,6 +48,11 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolbar button.toolbarBtnActive {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.dirBtn {
|
||||
width: auto;
|
||||
min-width: 34px;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useRef, useEffect, useState, useCallback, useContext } from 'react'
|
||||
import {
|
||||
AlignCenter,
|
||||
AlignJustify,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
Bold,
|
||||
IndentDecrease,
|
||||
IndentIncrease,
|
||||
@@ -17,6 +21,15 @@ import styles from './RichTextEditor.module.css'
|
||||
const INDENT_STEP_PX = 24
|
||||
const INDENT_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4'])
|
||||
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 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 [colorOpen, setColorOpen] = useState(false)
|
||||
const [hexInput, setHexInput] = useState(DEFAULT_TEXT_COLOR)
|
||||
const [currentAlign, setCurrentAlign] = useState<TextAlign | null>(null)
|
||||
|
||||
const syncChange = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
@@ -204,6 +218,48 @@ export function RichTextEditor({
|
||||
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) {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
@@ -245,6 +301,23 @@ export function RichTextEditor({
|
||||
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) {
|
||||
const normalized = normalizeHexColor(color) ?? DEFAULT_TEXT_COLOR
|
||||
setTextColor(normalized)
|
||||
@@ -435,6 +508,51 @@ export function RichTextEditor({
|
||||
LTR
|
||||
</button>
|
||||
<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">
|
||||
<List size={16} />
|
||||
</button>
|
||||
@@ -498,6 +616,8 @@ export function RichTextEditor({
|
||||
data-placeholder={placeholder}
|
||||
onInput={syncChange}
|
||||
onKeyDown={onEditorKeyDown}
|
||||
onKeyUp={refreshAlignFromSelection}
|
||||
onMouseUp={refreshAlignFromSelection}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
{allowImages && selectedImageEl && handlePos && (
|
||||
|
||||
Reference in New Issue
Block a user