Add customer SMS/Excel tools, selection bar, and sticky import toasts.
Bulk and single SMS from Users, row selection with overflow summary, persistent large import toasts, and business SMS sender in Super Admin. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
2a32c46b2e
commit
4c3bf8324e
@@ -9,6 +9,8 @@ import {
|
||||
} from '../services/blogService'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
|
||||
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import cardStyles from './BlogCard.module.css'
|
||||
import controlStyles from './ProductCard.module.css'
|
||||
@@ -47,6 +49,7 @@ export function BlogCard({
|
||||
// In FA UI, keep cards RTL even when title/abstract are English.
|
||||
const titleDir = isFa ? 'rtl' : titleLocale.dir
|
||||
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
|
||||
const websiteUrl = buildWebsiteContentUrl('blog', blog)
|
||||
|
||||
return (
|
||||
<article className={cardStyles.card}>
|
||||
@@ -120,6 +123,7 @@ export function BlogCard({
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<OpenOnWebsiteControl href={websiteUrl} />
|
||||
<Tooltip label={verifyLabel}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
.wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.trigger:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.triggerOpen {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.trigger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 1200;
|
||||
isolation: isolate;
|
||||
min-width: 200px;
|
||||
padding: 6px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--glass-border);
|
||||
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),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||||
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;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 9px 11px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
text-align: start;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.item:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.item:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@keyframes menuIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MoreHorizontal, Receipt, Shield, Ticket } from 'lucide-react'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { fitFixedPopover } from '../utils/fitFixedPopover'
|
||||
import styles from './CustomerActionsOverflowMenu.module.css'
|
||||
|
||||
type PopoverPosition = {
|
||||
top: number
|
||||
left: number
|
||||
}
|
||||
|
||||
export interface CustomerActionsOverflowMenuProps {
|
||||
customerName: string
|
||||
canManageAccess: boolean
|
||||
accessLocked: string | null
|
||||
financeEnabled: boolean
|
||||
disabled?: boolean
|
||||
onChangeAccess: () => void
|
||||
onViewInvoices: () => void
|
||||
onTickets: () => void
|
||||
}
|
||||
|
||||
export function CustomerActionsOverflowMenu({
|
||||
customerName,
|
||||
canManageAccess,
|
||||
accessLocked,
|
||||
financeEnabled,
|
||||
disabled = false,
|
||||
onChangeAccess,
|
||||
onViewInvoices,
|
||||
onTickets,
|
||||
}: CustomerActionsOverflowMenuProps) {
|
||||
const t = useT()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [popoverPos, setPopoverPos] = useState<PopoverPosition | null>(null)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const items = [
|
||||
canManageAccess
|
||||
? {
|
||||
key: 'access',
|
||||
label: accessLocked ?? t('customers.access.change'),
|
||||
icon: Shield,
|
||||
disabled: Boolean(accessLocked) || disabled,
|
||||
onClick: onChangeAccess,
|
||||
}
|
||||
: null,
|
||||
financeEnabled
|
||||
? {
|
||||
key: 'invoices',
|
||||
label: t('customers.invoices'),
|
||||
icon: Receipt,
|
||||
disabled,
|
||||
onClick: onViewInvoices,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: 'tickets',
|
||||
label: t('customers.tickets'),
|
||||
icon: Ticket,
|
||||
disabled,
|
||||
onClick: onTickets,
|
||||
},
|
||||
].filter(Boolean) as Array<{
|
||||
key: string
|
||||
label: string
|
||||
icon: typeof Shield
|
||||
disabled: boolean
|
||||
onClick: () => void
|
||||
}>
|
||||
|
||||
function updatePopoverPosition() {
|
||||
const rect = triggerRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
|
||||
const size = popoverRef.current
|
||||
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
|
||||
: { width: 200, height: 44 * Math.max(items.length, 1) + 12 }
|
||||
|
||||
setPopoverPos(fitFixedPopover(rect, size, 'end'))
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
if (disabled || items.length === 0) return
|
||||
if (open) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
updatePopoverPosition()
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return
|
||||
updatePopoverPosition()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const target = e.target as Node
|
||||
if (wrapRef.current?.contains(target)) return
|
||||
if (popoverRef.current?.contains(target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
|
||||
function handleReposition() {
|
||||
updatePopoverPosition()
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
window.addEventListener('resize', handleReposition)
|
||||
window.addEventListener('scroll', handleReposition, true)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
window.removeEventListener('resize', handleReposition)
|
||||
window.removeEventListener('scroll', handleReposition, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
const moreLabel = t('customers.moreActions')
|
||||
|
||||
return (
|
||||
<div className={styles.wrap} ref={wrapRef}>
|
||||
<Tooltip label={moreLabel}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={`${styles.trigger} ${open ? styles.triggerOpen : ''}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
toggleOpen()
|
||||
}}
|
||||
aria-label={`${moreLabel}: ${customerName}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{open &&
|
||||
popoverPos &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className={styles.menu}
|
||||
role="menu"
|
||||
aria-label={`${moreLabel}: ${customerName}`}
|
||||
style={{
|
||||
top: popoverPos.top,
|
||||
left: popoverPos.left,
|
||||
}}
|
||||
>
|
||||
{items.map(({ key, label, icon: Icon, disabled: itemDisabled, onClick }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={styles.item}
|
||||
role="menuitem"
|
||||
disabled={itemDisabled}
|
||||
onClick={() => {
|
||||
if (itemDisabled) return
|
||||
setOpen(false)
|
||||
onClick()
|
||||
}}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from '../services/instructionService'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
|
||||
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import cardStyles from './BlogCard.module.css'
|
||||
import controlStyles from './ProductCard.module.css'
|
||||
@@ -49,6 +51,7 @@ export function InstructionCard({
|
||||
: video.videoProvider === 'aparat'
|
||||
? 'Aparat'
|
||||
: null
|
||||
const websiteUrl = buildWebsiteContentUrl('instruction', video)
|
||||
|
||||
return (
|
||||
<article className={cardStyles.card}>
|
||||
@@ -118,6 +121,7 @@ export function InstructionCard({
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<OpenOnWebsiteControl href={websiteUrl} />
|
||||
<Tooltip label={t('instruction.card.comments')}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { Tooltip } from './Tooltip'
|
||||
|
||||
interface OpenOnWebsiteControlProps {
|
||||
href: string | null | undefined
|
||||
}
|
||||
|
||||
/** Icon control that opens the public storefront page for this content. */
|
||||
export function OpenOnWebsiteControl({ href }: OpenOnWebsiteControlProps) {
|
||||
const t = useT()
|
||||
const url = href?.trim()
|
||||
if (!url) return null
|
||||
|
||||
const label = t('common.viewOnWebsite')
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" aria-label={label}>
|
||||
<ExternalLink size={16} />
|
||||
</a>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from '../services/portfolioService'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
|
||||
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import cardStyles from './PortfolioCard.module.css'
|
||||
import controlStyles from './ProductCard.module.css'
|
||||
@@ -49,6 +51,7 @@ export function PortfolioCard({
|
||||
// In FA UI, keep cards RTL even when EN titles/abstracts are shown.
|
||||
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
|
||||
const externalUrl = resolvePortfolioExternalUrl(portfolio.projectUrl)
|
||||
const websiteUrl = buildWebsiteContentUrl('portfolio', portfolio)
|
||||
|
||||
return (
|
||||
<article className={cardStyles.card}>
|
||||
@@ -133,6 +136,7 @@ export function PortfolioCard({
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<OpenOnWebsiteControl href={websiteUrl} />
|
||||
{externalUrl ? (
|
||||
<Tooltip label={externalUrl}>
|
||||
<a
|
||||
|
||||
@@ -17,6 +17,8 @@ import { Tooltip } from './Tooltip'
|
||||
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||
import { hasBusinessModule } from '../utils/businessModules'
|
||||
import { formatProductCategoryChip } from '../services/productService'
|
||||
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
|
||||
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
|
||||
import styles from './ProductCard.module.css'
|
||||
|
||||
interface ProductCardProps {
|
||||
@@ -57,6 +59,7 @@ export function ProductCard({
|
||||
: ''
|
||||
: product.nameFa
|
||||
const categoryLabel = formatProductCategoryChip(product, isFa)
|
||||
const websiteUrl = buildWebsiteContentUrl('product', product)
|
||||
const imageSrc = product.image?.trim() || ''
|
||||
const [imageFailed, setImageFailed] = useState(false)
|
||||
const showImage = Boolean(imageSrc) && !imageFailed
|
||||
@@ -111,6 +114,7 @@ export function ProductCard({
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<OpenOnWebsiteControl href={websiteUrl} />
|
||||
{showTranslationControls ? (
|
||||
<Tooltip label={t('products.card.translate')}>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||
import {
|
||||
sendCustomerSms,
|
||||
sendCustomersBulkSms,
|
||||
type BusinessCustomerListItem,
|
||||
} from '../services/customerService'
|
||||
import modalStyles from './VariationsModal.module.css'
|
||||
|
||||
export type SmsComposeTarget =
|
||||
| { mode: 'single'; customer: BusinessCustomerListItem }
|
||||
| { mode: 'selected'; userIds: string[]; count: number }
|
||||
| { mode: 'all'; totalHint?: number }
|
||||
|
||||
interface SendCustomerSmsModalProps {
|
||||
open: boolean
|
||||
target: SmsComposeTarget | null
|
||||
onClose: () => void
|
||||
onSent: (info: { mode: SmsComposeTarget['mode']; count: number; phone?: string }) => void
|
||||
}
|
||||
|
||||
const ANIMATION_MS = 220
|
||||
const MAX_LEN = 700
|
||||
|
||||
export function SendCustomerSmsModal({
|
||||
open,
|
||||
target,
|
||||
onClose,
|
||||
onSent,
|
||||
}: SendCustomerSmsModalProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (open && target) {
|
||||
setMounted(true)
|
||||
setClosing(false)
|
||||
setMessage('')
|
||||
setError('')
|
||||
} else if (mounted) {
|
||||
setClosing(true)
|
||||
const timer = setTimeout(() => {
|
||||
setMounted(false)
|
||||
setClosing(false)
|
||||
}, ANIMATION_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, target, mounted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || closing) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !isSubmitting) onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [mounted, closing, onClose, isSubmitting])
|
||||
|
||||
if (!mounted || !target) return null
|
||||
|
||||
const trimmed = message.trim()
|
||||
const canSend = trimmed.length > 0 && trimmed.length <= MAX_LEN
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!target || !canSend) return
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
if (target.mode === 'single') {
|
||||
const result = await sendCustomerSms(target.customer.id, trimmed)
|
||||
if (!result.enabled) {
|
||||
setError(result.message || t('customers.smsModal.errorDisabled'))
|
||||
return
|
||||
}
|
||||
onSent({
|
||||
mode: 'single',
|
||||
count: 1,
|
||||
phone: formatCellForDisplay(target.customer.cellNumber),
|
||||
})
|
||||
} else {
|
||||
const userIds = target.mode === 'selected' ? target.userIds : undefined
|
||||
const result = await sendCustomersBulkSms(trimmed, userIds)
|
||||
if (!result.enabled) {
|
||||
setError(result.message || t('customers.smsModal.errorDisabled'))
|
||||
return
|
||||
}
|
||||
onSent({ mode: target.mode, count: result.recipientCount })
|
||||
}
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t('customers.smsModal.error'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
let subtitle: string
|
||||
if (target.mode === 'single') {
|
||||
const phone = formatCellForDisplay(target.customer.cellNumber)
|
||||
const name = [target.customer.firstName, target.customer.lastName]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim()
|
||||
subtitle = t('customers.smsModal.subtitle', { name: name || phone, phone })
|
||||
} else if (target.mode === 'selected') {
|
||||
subtitle = t('customers.smsModal.subtitleSelected', { count: target.count })
|
||||
} else {
|
||||
subtitle = t('customers.smsModal.subtitleAll')
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={() => !isSubmitting && onClose()}
|
||||
>
|
||||
<div
|
||||
className={`${modalStyles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="send-customer-sms-title"
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className={modalStyles.header}>
|
||||
<div>
|
||||
<h3 id="send-customer-sms-title" className={modalStyles.title}>
|
||||
{t('customers.smsModal.title')}
|
||||
</h3>
|
||||
<p className={modalStyles.subtitle}>{subtitle}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.closeBtn}
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={modalStyles.body}>
|
||||
{error ? <p className={modalStyles.errorText}>{error}</p> : null}
|
||||
|
||||
<div className={modalStyles.field}>
|
||||
<label htmlFor="customer-sms-message">{t('customers.smsModal.message')}</label>
|
||||
<textarea
|
||||
id="customer-sms-message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value.slice(0, MAX_LEN))}
|
||||
rows={5}
|
||||
disabled={isSubmitting}
|
||||
placeholder={t('customers.smsModal.placeholder')}
|
||||
dir={isFa ? 'rtl' : 'auto'}
|
||||
/>
|
||||
<p className={modalStyles.modalHint}>
|
||||
{message.length} / {MAX_LEN}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={modalStyles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.cancelBtn}
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('customers.smsModal.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.submitBtn}
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={isSubmitting || !canSend}
|
||||
>
|
||||
{isSubmitting ? t('customers.smsModal.sending') : t('customers.smsModal.send')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,48 @@
|
||||
box-shadow: var(--toast-shadow, 0 12px 40px rgba(15, 23, 42, 0.14));
|
||||
}
|
||||
|
||||
.toastLarge {
|
||||
min-width: 300px;
|
||||
max-width: min(520px, calc(100vw - 48px));
|
||||
padding: 16px 18px;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.toastPersistent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toastBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: -4px -6px -4px 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toastIn {
|
||||
animation: toastIn 0.22s ease forwards;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from '../services/videoService'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
|
||||
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import cardStyles from './BlogCard.module.css'
|
||||
import controlStyles from './ProductCard.module.css'
|
||||
@@ -41,6 +43,7 @@ export function VideoCard({
|
||||
const hasAbstract = Boolean(video.abstract?.trim())
|
||||
const titleDir = isFa ? 'rtl' : titleLocale.dir
|
||||
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
|
||||
const websiteUrl = buildWebsiteContentUrl('video', video)
|
||||
|
||||
return (
|
||||
<article className={cardStyles.card}>
|
||||
@@ -108,6 +111,7 @@ export function VideoCard({
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<OpenOnWebsiteControl href={websiteUrl} />
|
||||
<Tooltip label={t('video.card.comments')}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from '../services/workshopService'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
|
||||
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import cardStyles from './BlogCard.module.css'
|
||||
import controlStyles from './ProductCard.module.css'
|
||||
@@ -45,6 +47,7 @@ export function WorkshopCard({
|
||||
const statusLabel = published ? t('workshop.card.unpublish') : t('workshop.card.publish')
|
||||
const titleDir = isFa ? 'rtl' : titleLocale.dir
|
||||
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
|
||||
const websiteUrl = buildWebsiteContentUrl('workshop', workshop)
|
||||
|
||||
return (
|
||||
<article className={cardStyles.card}>
|
||||
@@ -124,6 +127,7 @@ export function WorkshopCard({
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<OpenOnWebsiteControl href={websiteUrl} />
|
||||
<Tooltip label={statusLabel}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user