Add Arvan/Cloudflare DNS choice on domain edit and restyle empty add boxes.

Domain save can upsert tenant records on either provider, overflow menus stay opaque, and empty-state add controls use a dashed outline.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-31 18:46:48 +03:30
co-authored by Cursor
parent 0d62a4ca79
commit c962fe524b
11 changed files with 177 additions and 89 deletions
@@ -27,18 +27,14 @@
.menu {
position: fixed;
z-index: 1200;
isolation: isolate;
min-width: 220px;
padding: 6px;
border-radius: 14px;
border: 1px solid var(--glass-border);
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.72) 0%,
var(--glass-bg-strong) 100%
);
backdrop-filter: blur(var(--blur-glass)) saturate(1.85);
-webkit-backdrop-filter: blur(var(--blur-glass)) saturate(1.85);
/* Card glass (0.62) is too thin over row icons; page orbs already use
backdrop-filter, so a second blur on this panel often does nothing. */
background: var(--toast-bg, rgba(255, 255, 255, 0.94));
box-shadow:
0 18px 48px rgba(15, 23, 42, 0.2),
0 0 0 1px rgba(255, 255, 255, 0.45),
@@ -46,6 +42,17 @@
animation: menuIn 0.16s ease;
}
.menu::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
pointer-events: none;
backdrop-filter: blur(var(--blur-glass)) saturate(1.85);
-webkit-backdrop-filter: blur(var(--blur-glass)) saturate(1.85);
}
.item {
display: flex;
align-items: center;
@@ -0,0 +1,33 @@
import type { ParkedDnsProvider } from '../types/domain'
import styles from './ParkedDomainsModal.module.css'
interface DnsProviderSwitchProps {
value: ParkedDnsProvider
onChange: (dns: ParkedDnsProvider) => void
disabled?: boolean
}
export function DnsProviderSwitch({ value, onChange, disabled }: DnsProviderSwitchProps) {
return (
<div className={styles.dnsSwitch} role="group" aria-label="DNS provider">
<button
type="button"
className={`${styles.dnsOption} ${value === 'arvan' ? styles.dnsOptionOn : ''}`}
onClick={() => onChange('arvan')}
disabled={disabled}
aria-pressed={value === 'arvan'}
>
Arvan
</button>
<button
type="button"
className={`${styles.dnsOption} ${value === 'cloudflare' ? styles.dnsOptionOn : ''}`}
onClick={() => onChange('cloudflare')}
disabled={disabled}
aria-pressed={value === 'cloudflare'}
>
Cloudflare
</button>
</div>
)
}
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { Plus, X } from 'lucide-react'
import { Modal } from './Modal'
import { DnsProviderSwitch } from './DnsProviderSwitch'
import { ApiError } from '../lib/api'
import { updateParkedHosts } from '../services/domainService'
import type { DomainListItem, ParkedAlias, ParkedDnsProvider } from '../types/domain'
@@ -135,26 +136,11 @@ export function ParkedDomainsModal({ domain, onClose, onSaved }: ParkedDomainsMo
dir="ltr"
aria-label={`Parked domain ${index + 1}`}
/>
<div className={styles.dnsSwitch} role="group" aria-label="DNS provider">
<button
type="button"
className={`${styles.dnsOption} ${row.dns === 'arvan' ? styles.dnsOptionOn : ''}`}
onClick={() => updateDns(index, 'arvan')}
<DnsProviderSwitch
value={row.dns}
onChange={(dns) => updateDns(index, dns)}
disabled={submitting}
aria-pressed={row.dns === 'arvan'}
>
Arvan
</button>
<button
type="button"
className={`${styles.dnsOption} ${row.dns === 'cloudflare' ? styles.dnsOptionOn : ''}`}
onClick={() => updateDns(index, 'cloudflare')}
disabled={submitting}
aria-pressed={row.dns === 'cloudflare'}
>
Cloudflare
</button>
</div>
/>
<button
type="button"
className={styles.removeRowBtn}
@@ -144,6 +144,14 @@
line-height: 1.3;
}
.dnsUpdateRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.alertError {
margin: 0 0 12px;
padding: 12px 14px;
+19 -3
View File
@@ -31,6 +31,7 @@ import type {
PurgeBusinessDataResponse,
PurgeEntityResult,
} from '../types/business'
import type { ParkedDnsProvider } from '../types/domain'
import type { BusinessCategory } from '../types/category'
import type { ListBusinessesParams } from '../services/businessService'
import { ApiError, isAbortError } from '../lib/api'
@@ -58,6 +59,7 @@ import {
} from '../services/aiPromptService'
import type { AiPromptTemplate, DomainAiPrompt } from '../types/aiPrompt'
import { Modal } from '../components/Modal'
import { DnsProviderSwitch } from '../components/DnsProviderSwitch'
import { PrimaryColorSwatchControl } from '../components/PrimaryColorSwatchControl'
import { useToast } from '../context/ToastContext'
import {
@@ -346,6 +348,7 @@ export function BusinessesPage() {
const [domainHost, setDomainHost] = useState('')
const [domainGitRepoUrl, setDomainGitRepoUrl] = useState('')
const [domainUpdateDns, setDomainUpdateDns] = useState(false)
const [domainDnsProvider, setDomainDnsProvider] = useState<ParkedDnsProvider>('arvan')
const [domainSubmitting, setDomainSubmitting] = useState(false)
const [domainError, setDomainError] = useState('')
@@ -933,6 +936,7 @@ export function BusinessesPage() {
setDomainHost(b.domain ?? '')
setDomainGitRepoUrl(b.gitRepoUrl ?? '')
setDomainUpdateDns(!b.domainId)
setDomainDnsProvider('arvan')
setDomainError('')
setDomainOpen(true)
}
@@ -1126,6 +1130,7 @@ export function BusinessesPage() {
setDomainHost('')
setDomainGitRepoUrl('')
setDomainUpdateDns(false)
setDomainDnsProvider('arvan')
setDomainError('')
}
@@ -1144,6 +1149,7 @@ export function BusinessesPage() {
const updated = (await updateBusinessDomain(businessId, domainId, {
host,
updateDns: domainUpdateDns,
...(domainUpdateDns ? { dnsProvider: domainDnsProvider } : {}),
...(gitRepoUrl ? { gitRepoUrl } : {}),
})) as {
id: number
@@ -1193,6 +1199,7 @@ export function BusinessesPage() {
host,
isPrimary: true,
updateDns: domainUpdateDns,
...(domainUpdateDns ? { dnsProvider: domainDnsProvider } : {}),
...(gitRepoUrl ? { gitRepoUrl } : {}),
})) as {
id: number
@@ -1248,6 +1255,7 @@ export function BusinessesPage() {
setDomainHost('')
setDomainGitRepoUrl('')
setDomainUpdateDns(false)
setDomainDnsProvider('arvan')
setDomainError('')
} catch (err) {
if (submitId !== domainSubmitGenRef.current) return
@@ -1998,7 +2006,7 @@ export function BusinessesPage() {
<p className={styles.helperText} role="status">
{domainGitRepoUrl.trim()
? 'Saving domain and setting up storefront on the websites server…'
: 'Saving domain and updating Arvan DNS…'}
: `Saving domain and updating ${domainDnsProvider === 'cloudflare' ? 'Cloudflare' : 'Arvan'} DNS…`}
</p>
) : null}
<div className={styles.field}>
@@ -2029,6 +2037,7 @@ export function BusinessesPage() {
</p>
</div>
<div style={{ height: 12 }} />
<div className={styles.dnsUpdateRow}>
<label className={styles.entityCheck} htmlFor="domain-update-dns">
<input
id="domain-update-dns"
@@ -2039,9 +2048,16 @@ export function BusinessesPage() {
/>
<span>Update DNS records</span>
</label>
<DnsProviderSwitch
value={domainDnsProvider}
onChange={setDomainDnsProvider}
disabled={domainSubmitting || !domainUpdateDns}
/>
</div>
<p className={styles.subText} style={{ marginTop: 6 }}>
When checked, upserts Arvan records: @ ANAME → ns.meshkee.com, plus www,
business, customer, and api CNAMEs.
{domainDnsProvider === 'cloudflare'
? 'When checked, upserts Cloudflare records (DNS-only): @ CNAME → ns.meshkee.com, plus www, business, customer, and api CNAMEs.'
: 'When checked, upserts Arvan records: @ ANAME → ns.meshkee.com, plus www, business, customer, and api CNAMEs.'}
</p>
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
+18 -4
View File
@@ -16,10 +16,11 @@ import {
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { DomainAiPromptsModal } from '../components/DomainAiPromptsModal'
import { DomainLinksOverflowMenu } from '../components/DomainLinksOverflowMenu'
import { DnsProviderSwitch } from '../components/DnsProviderSwitch'
import { ParkedDomainsModal } from '../components/ParkedDomainsModal'
import { Modal } from '../components/Modal'
import { ToggleSwitch } from '../components/ToggleSwitch'
import type { DomainListItem, DomainsListResponse } from '../types/domain'
import type { DomainListItem, DomainsListResponse, ParkedDnsProvider } from '../types/domain'
import type { ListDomainsParams } from '../services/domainService'
import { ApiError, isAbortError } from '../lib/api'
import {
@@ -106,6 +107,7 @@ export function WebsitesPage() {
const [editExpiresAt, setEditExpiresAt] = useState('')
const [editGitRepoUrl, setEditGitRepoUrl] = useState('')
const [editUpdateDns, setEditUpdateDns] = useState(false)
const [editDnsProvider, setEditDnsProvider] = useState<ParkedDnsProvider>('arvan')
const [editSubmitting, setEditSubmitting] = useState(false)
const [editError, setEditError] = useState('')
@@ -179,6 +181,7 @@ export function WebsitesPage() {
setEditExpiresAt(domain.expiresAt ? domain.expiresAt.slice(0, 10) : '')
setEditGitRepoUrl(domain.gitRepoUrl ?? '')
setEditUpdateDns(false)
setEditDnsProvider('arvan')
setEditError('')
setEditOpen(true)
}
@@ -192,6 +195,7 @@ export function WebsitesPage() {
const updated = (await updateDomain(editDomain.id, {
host: editHost.trim(),
updateDns: editUpdateDns,
...(editUpdateDns ? { dnsProvider: editDnsProvider } : {}),
...(editExpiresAt ? { expiresAt: new Date(editExpiresAt).toISOString() } : {}),
...(gitRepoUrl ? { gitRepoUrl } : {}),
})) as {
@@ -204,6 +208,7 @@ export function WebsitesPage() {
setEditDomain(null)
setEditGitRepoUrl('')
setEditUpdateDns(false)
setEditDnsProvider('arvan')
if (updated.provisionError) {
showToast(`"${editHost.trim()}" was updated, but storefront setup failed.`, 'error')
showToast(updated.provisionError, 'error')
@@ -806,6 +811,7 @@ export function WebsitesPage() {
setEditDomain(null)
setEditGitRepoUrl('')
setEditUpdateDns(false)
setEditDnsProvider('arvan')
setEditError('')
}}
>
@@ -818,7 +824,7 @@ export function WebsitesPage() {
<p className={tableStyles.helperText} role="status">
{editGitRepoUrl.trim()
? 'Saving domain and setting up storefront on the websites server…'
: 'Saving domain and updating Arvan DNS…'}
: `Saving domain and updating ${editDnsProvider === 'cloudflare' ? 'Cloudflare' : 'Arvan'} DNS…`}
</p>
) : null}
<div className={tableStyles.field}>
@@ -859,6 +865,7 @@ export function WebsitesPage() {
</p>
</div>
<div style={{ height: 12 }} />
<div className={tableStyles.dnsUpdateRow}>
<label className={tableStyles.entityCheck} htmlFor="edit-domain-update-dns">
<input
id="edit-domain-update-dns"
@@ -869,9 +876,16 @@ export function WebsitesPage() {
/>
<span>Update DNS records</span>
</label>
<DnsProviderSwitch
value={editDnsProvider}
onChange={setEditDnsProvider}
disabled={editSubmitting || !editUpdateDns}
/>
</div>
<p className={tableStyles.subText} style={{ marginTop: 6 }}>
When checked, upserts Arvan records: @ ANAME → ns.meshkee.com, plus www,
business, customer, and api CNAMEs.
{editDnsProvider === 'cloudflare'
? 'When checked, upserts Cloudflare records (DNS-only): @ CNAME → ns.meshkee.com, plus www, business, customer, and api CNAMEs.'
: 'When checked, upserts Arvan records: @ ANAME → ns.meshkee.com, plus www, business, customer, and api CNAMEs.'}
</p>
<div style={{ height: 12 }} />
<div className={tableStyles.actionsRow}>
@@ -29,8 +29,10 @@ export interface UpdateBusinessPayload {
export interface AddDomainPayload {
host: string
isPrimary?: boolean
/** When true, upsert Meshkee tenant records in Arvan DNS. */
/** When true, upsert Meshkee tenant records (@, www, business, customer, api). */
updateDns?: boolean
/** DNS host when updateDns is true. Default arvan. */
dnsProvider?: 'arvan' | 'cloudflare'
/** When set, provisions storefront deploy on the websites VM. */
gitRepoUrl?: string
}
@@ -41,8 +43,10 @@ export interface DisableBusinessPayload {
export interface UpdateDomainPayload {
host: string
/** When true, upsert Meshkee tenant records in Arvan DNS. */
/** When true, upsert Meshkee tenant records (@, www, business, customer, api). */
updateDns?: boolean
/** DNS host when updateDns is true. Default arvan. */
dnsProvider?: 'arvan' | 'cloudflare'
/** When set, provisions storefront deploy on the websites VM. */
gitRepoUrl?: string
}
@@ -17,8 +17,10 @@ export interface ListDomainsParams {
export interface UpdateDomainPayload {
host?: string
expiresAt?: string
/** When true, upsert Meshkee tenant records in Arvan DNS. */
/** When true, upsert Meshkee tenant records (@, www, business, customer, api). */
updateDns?: boolean
/** DNS host when updateDns is true. Default arvan. */
dnsProvider?: 'arvan' | 'cloudflare'
/** When set, provisions storefront deploy on the websites VM. */
gitRepoUrl?: string
}
+2 -2
View File
@@ -138,11 +138,11 @@ Add to `/etc/hosts` (one line per tenant):
|------|------|
| `/login` | Login |
| `/` | Home |
| `/businesses` | Businesses list (migrate-from-old + delete-data for portfolios/blogs(+news)/customers + categories; single-select). **Add/Edit domain** accepts optional Git repo URL → provisions storefront on websites VM and sets `deploy_slug`. Optional **Update DNS records** checkbox upserts Arvan DNS (`@` ANAME → `ns.meshkee.com`, `www` CNAME → apex, `business`/`customer`/`api` CNAMEs). |
| `/businesses` | Businesses list (migrate-from-old + delete-data for portfolios/blogs(+news)/customers + categories; single-select). **Add/Edit domain** accepts optional Git repo URL → provisions storefront on websites VM and sets `deploy_slug`. Optional **Update DNS records** checkbox upserts tenant DNS (`@` → `ns.meshkee.com`, `www` CNAME → apex, `business`/`customer`/`api` CNAMEs) via **Arvan** or **Cloudflare** (switch; Cloudflare is DNS-only / not proxied). |
| `/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 Arvan DNS. 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, **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 |
@@ -2,7 +2,7 @@
display: flex;
justify-content: center;
width: 100%;
padding: 32px 0 48px;
padding: 20px 0 28px;
}
.rootDashed {
@@ -10,7 +10,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
gap: 12px;
width: 100%;
max-width: 880px;
margin-inline: auto;
@@ -20,7 +20,7 @@
.rootDashedInline {
max-width: 100%;
padding: 24px 12px;
padding: 12px 8px 16px;
}
.messageDashed {
@@ -55,36 +55,52 @@
.addBox {
appearance: none;
-webkit-appearance: none;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
gap: 6px;
width: 100%;
max-width: 880px;
min-height: 180px;
padding: 32px 28px;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 2px dashed rgba(var(--primary-rgb) / 0.38);
border-radius: var(--radius);
color: var(--primary);
max-width: 480px;
min-height: 76px;
padding: 12px 14px;
background: none;
background-color: transparent;
border-width: 2px;
border-style: dashed;
border-color: #64748b;
border-radius: 12px;
color: var(--primary, #0a1628);
font-family: var(--font-ui);
text-decoration: none;
cursor: pointer;
transition:
background 0.2s ease,
border-color 0.2s ease,
transform 0.2s ease,
box-shadow 0.2s ease;
background-color 0.15s ease,
border-color 0.15s ease,
color 0.15s ease,
box-shadow 0.15s ease,
transform 0.15s ease;
}
.addBox:hover {
background: rgba(var(--primary-rgb) / 0.08);
border-color: rgba(var(--primary-rgb) / 0.58);
transform: translateY(-3px);
box-shadow: var(--card-hover-shadow);
a.addBox,
button.addBox {
background: none;
background-color: transparent;
border-width: 2px;
border-style: dashed;
border-color: #64748b;
}
.addBox:hover,
a.addBox:hover,
button.addBox:hover {
background-color: rgba(10, 22, 40, 0.07);
border-style: dashed;
border-color: #0a1628;
color: #0a1628;
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.1);
}
.addBox:focus-visible {
@@ -96,16 +112,14 @@
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 50%;
background: rgba(var(--primary-rgb) / 0.12);
width: 28px;
height: 28px;
}
.addLabel {
font-size: 14px;
font-size: 13px;
font-weight: 600;
line-height: 1.4;
line-height: 1.3;
}
.cell {
@@ -127,4 +141,8 @@
.addBox {
max-width: min(100%, 640px);
}
.addBox {
max-width: 480px;
}
}
@@ -79,7 +79,7 @@ function EmptyStateContent({
const addContent = (
<>
<span className={styles.addIcon} aria-hidden="true">
<Plus size={28} strokeWidth={2} />
<Plus size={20} strokeWidth={2} />
</span>
{actionLabel ? <span className={styles.addLabel}>{actionLabel}</span> : null}
</>
@@ -92,11 +92,11 @@ function EmptyStateContent({
>
<p className={styles.messageDashed}>{messageContent}</p>
{actionTo ? (
<Link to={actionTo} className={styles.addBox} onClick={onAction}>
<Link to={actionTo} className={styles.addBox} data-empty-add="" onClick={onAction}>
{addContent}
</Link>
) : (
<button type="button" className={styles.addBox} onClick={onAction}>
<button type="button" className={styles.addBox} data-empty-add="" onClick={onAction}>
{addContent}
</button>
)}