diff --git a/apps/business/src/pages/BusinessProfilePage.tsx b/apps/business/src/pages/BusinessProfilePage.tsx index 4dc905a..c822f51 100644 --- a/apps/business/src/pages/BusinessProfilePage.tsx +++ b/apps/business/src/pages/BusinessProfilePage.tsx @@ -201,7 +201,7 @@ export function BusinessProfilePage() { : item.province, city: item.city, address: item.address, - postalCode: item.postalCode, + postalCode: item.postalCode ?? '', landline: item.landline ?? '', } }) @@ -332,7 +332,7 @@ export function BusinessProfilePage() { province: item.province.trim(), city: item.city.trim(), address: item.address.trim(), - postalCode: item.postalCode.trim(), + postalCode: item.postalCode.trim() || undefined, landline: item.landline?.trim() || null, })) diff --git a/apps/business/src/services/businessProfileService.ts b/apps/business/src/services/businessProfileService.ts index a76899c..98e7793 100644 --- a/apps/business/src/services/businessProfileService.ts +++ b/apps/business/src/services/businessProfileService.ts @@ -39,7 +39,7 @@ export interface BusinessAddress { province: string city: string address: string - postalCode: string + postalCode: string | null landline: string | null } @@ -66,7 +66,7 @@ export interface UpdateBusinessProfilePayload { province: string city: string address: string - postalCode: string + postalCode?: string landline?: string | null }> } diff --git a/packages/dashboard-ui/src/components/RichTextEditor.module.css b/packages/dashboard-ui/src/components/RichTextEditor.module.css index b3d84e3..12c9516 100644 --- a/packages/dashboard-ui/src/components/RichTextEditor.module.css +++ b/packages/dashboard-ui/src/components/RichTextEditor.module.css @@ -62,6 +62,21 @@ letter-spacing: 0.02em; } +.toolbar button.codeModeBtn { + width: auto; + min-width: 28px; + padding: 0 8px; + gap: 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.fontSizeSelect:disabled { + opacity: 0.45; + cursor: not-allowed; +} + .fontSizeWrap { display: inline-flex; align-items: center; @@ -238,6 +253,35 @@ pointer-events: none; } +.codeEditor { + display: block; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 140px; + padding: var(--field-padding-y) var(--field-padding-x); + border: 0; + resize: vertical; + outline: none; + box-sizing: border-box; + background: var(--elevated-surface); + color: var(--text-primary); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: break-word; + word-break: break-word; + unicode-bidi: isolate; + direction: ltr; +} + +.codeEditor::placeholder { + color: var(--text-muted); + font-family: var(--font-ui); +} + .editor > * { max-width: 100%; box-sizing: border-box; diff --git a/packages/dashboard-ui/src/components/RichTextEditor.tsx b/packages/dashboard-ui/src/components/RichTextEditor.tsx index 848afed..891ae82 100644 --- a/packages/dashboard-ui/src/components/RichTextEditor.tsx +++ b/packages/dashboard-ui/src/components/RichTextEditor.tsx @@ -5,6 +5,7 @@ import { AlignLeft, AlignRight, Bold, + Code2, IndentDecrease, IndentIncrease, Italic, @@ -60,6 +61,183 @@ function nearestFontSize(rawPx: number): FontSizeValue { const SELECTED_IMAGE_CLASS = 'richTextImageSelected' const HEX_COLOR_PATTERN = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/ +/** Tags kept on paste (structure + bold). Everything else is unwrapped. */ +const PASTE_KEEP_TAGS = new Set([ + 'B', + 'STRONG', + 'UL', + 'OL', + 'LI', + 'P', + 'DIV', + 'BR', + 'SPAN', +]) + +const PASTE_DROP_TAGS = new Set([ + 'SCRIPT', + 'STYLE', + 'META', + 'LINK', + 'TITLE', + 'XML', + 'HEAD', + 'NOSCRIPT', +]) + +function parseCssFontSizePx(value: string): number | null { + const trimmed = value.trim().toLowerCase() + if (!trimmed) return null + const px = trimmed.match(/^([\d.]+)\s*px$/) + if (px) { + const n = Number.parseFloat(px[1]) + return Number.isFinite(n) ? n : null + } + const pt = trimmed.match(/^([\d.]+)\s*pt$/) + if (pt) { + const n = Number.parseFloat(pt[1]) + // CSS: 1pt ≈ 1.333px at 96dpi + return Number.isFinite(n) ? n * (96 / 72) : null + } + const em = trimmed.match(/^([\d.]+)\s*em$/) + if (em) { + const n = Number.parseFloat(em[1]) + return Number.isFinite(n) ? n * 14 : null + } + return null +} + +function isCssBold(weight: string): boolean { + const w = weight.trim().toLowerCase() + if (w === 'bold' || w === 'bolder') return true + const n = Number.parseInt(w, 10) + return Number.isFinite(n) && n >= 600 +} + +/** + * Keep bold, lists, and font-size on paste; strip bg, font-family, colors, etc. + */ +function sanitizePastedHtml(html: string): string { + const template = document.createElement('template') + template.innerHTML = html + const source = template.content.querySelector('body') ?? template.content + const out = document.createDocumentFragment() + + function appendChildren(from: ParentNode, to: ParentNode) { + Array.from(from.childNodes).forEach((child) => { + const cleaned = cleanNode(child) + if (!cleaned) return + if (cleaned instanceof DocumentFragment) { + to.appendChild(cleaned) + } else { + to.appendChild(cleaned) + } + }) + } + + function wrapBold(node: Node): HTMLElement { + const strong = document.createElement('strong') + strong.appendChild(node) + return strong + } + + function cleanNode(node: Node): Node | DocumentFragment | null { + if (node.nodeType === Node.TEXT_NODE) { + return document.createTextNode(node.textContent ?? '') + } + if (node.nodeType !== Node.ELEMENT_NODE) return null + + const el = node as HTMLElement + const tag = el.tagName.toUpperCase() + + if (PASTE_DROP_TAGS.has(tag)) return null + + // Comments / Word junk like + if (tag.includes(':')) { + const frag = document.createDocumentFragment() + appendChildren(el, frag) + return frag.childNodes.length ? frag : null + } + + const styleBold = isCssBold(el.style.fontWeight) + const fontSizePx = parseCssFontSizePx(el.style.fontSize) + const snappedSize = fontSizePx != null ? nearestFontSize(fontSizePx) : null + + // → keep as sized span + let fontTagSize: FontSizeValue | null = null + if (tag === 'FONT') { + const sizeAttr = el.getAttribute('size') + // HTML font size 1–7 → approximate px + const map: Record = { + '1': '12px', + '2': '12px', + '3': '14px', + '4': '16px', + '5': '20px', + '6': '28px', + '7': '28px', + } + if (sizeAttr && map[sizeAttr]) fontTagSize = map[sizeAttr] + const faceSize = parseCssFontSizePx(el.style.fontSize) + if (faceSize != null) fontTagSize = nearestFontSize(faceSize) + } + + const sizeToApply = snappedSize ?? fontTagSize + let keepTag: string | null = PASTE_KEEP_TAGS.has(tag) ? tag : null + if (tag === 'B') keepTag = 'STRONG' + if (tag === 'FONT' && sizeToApply) keepTag = 'SPAN' + + if (keepTag) { + const clean = document.createElement(keepTag.toLowerCase()) + if (sizeToApply) clean.style.fontSize = sizeToApply + appendChildren(el, clean) + + // Drop empty style-less spans (e.g. leftover highlight wrappers). + if (keepTag === 'SPAN' && !clean.style.cssText && !styleBold) { + const frag = document.createDocumentFragment() + while (clean.firstChild) frag.appendChild(clean.firstChild) + return frag.childNodes.length ? frag : null + } + + if (styleBold && keepTag !== 'STRONG') { + const strong = document.createElement('strong') + while (clean.firstChild) strong.appendChild(clean.firstChild) + clean.appendChild(strong) + } + return clean + } + + // Unwrap unknown tags, but preserve bold / font-size via wrappers + const frag = document.createDocumentFragment() + appendChildren(el, frag) + + let result: Node | DocumentFragment = frag + if (sizeToApply) { + const span = document.createElement('span') + span.style.fontSize = sizeToApply + span.appendChild(frag) + result = span + } + if (styleBold) { + result = wrapBold(result) + } + return result + } + + appendChildren(source, out) + + const holder = document.createElement('div') + holder.appendChild(out) + // Drop empty Word paragraphs that are only   + holder.querySelectorAll('p, div').forEach((block) => { + const text = (block.textContent ?? '').replace(/\u00a0/g, ' ').trim() + if (!text && !block.querySelector('ul, ol, li, br, img')) { + block.remove() + } + }) + return holder.innerHTML +} + function normalizeHexColor(value: string): string | null { const trimmed = value.trim() const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}` @@ -119,6 +297,7 @@ export function RichTextEditor({ const [hexInput, setHexInput] = useState(DEFAULT_TEXT_COLOR) const [currentAlign, setCurrentAlign] = useState(null) const [currentFontSize, setCurrentFontSize] = useState(DEFAULT_FONT_SIZE) + const [codeMode, setCodeMode] = useState(false) const syncChange = useCallback(() => { if (editorRef.current) { @@ -148,6 +327,21 @@ export function RichTextEditor({ setHandlePos(null) }, []) + const toggleCodeMode = useCallback(() => { + setCodeMode((open) => { + const next = !open + if (next) { + // Flush latest visual edits before showing the HTML source. + if (editorRef.current) { + onChange(editorRef.current.innerHTML) + } + clearImageSelection() + setColorOpen(false) + } + return next + }) + }, [onChange, clearImageSelection]) + const selectImage = useCallback( (img: HTMLImageElement) => { editorRef.current?.querySelectorAll(`img.${SELECTED_IMAGE_CLASS}`).forEach((node) => { @@ -161,11 +355,12 @@ export function RichTextEditor({ ) useEffect(() => { + if (codeMode) return if (editorRef.current && editorRef.current.innerHTML !== value) { editorRef.current.innerHTML = value clearImageSelection() } - }, [value, clearImageSelection]) + }, [value, codeMode, clearImageSelection]) useEffect(() => { if (!allowImages) return @@ -460,6 +655,43 @@ export function RichTextEditor({ changeIndent(e.shiftKey ? -1 : 1) } + /** + * Paste cleaned HTML: keep bold, lists, and font-size; + * strip background, font-family, colors, and other text properties. + */ + function onEditorPaste(e: React.ClipboardEvent) { + e.preventDefault() + const editor = editorRef.current + if (!editor) return + + const html = e.clipboardData.getData('text/html') + const plain = e.clipboardData.getData('text/plain') + const cleaned = html ? sanitizePastedHtml(html) : '' + + editor.focus() + + if (cleaned.trim()) { + document.execCommand('insertHTML', false, cleaned) + syncChange() + return + } + + if (!plain) return + const inserted = document.execCommand('insertText', false, plain) + if (!inserted) { + const selection = window.getSelection() + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0) + range.deleteContents() + range.insertNode(document.createTextNode(plain)) + range.collapse(false) + selection.removeAllRanges() + selection.addRange(range) + } + } + syncChange() + } + function styleInsertedImage(img: HTMLImageElement) { img.style.width = '100%' img.style.maxWidth = '100%' @@ -538,6 +770,7 @@ export function RichTextEditor({ type="button" onMouseDown={onToolbarMouseDown} onClick={() => exec('bold')} + disabled={codeMode} title="Bold" aria-label="Bold" > @@ -547,6 +780,7 @@ export function RichTextEditor({ type="button" onMouseDown={onToolbarMouseDown} onClick={() => exec('italic')} + disabled={codeMode} title="Italic" aria-label="Italic" > @@ -556,6 +790,7 @@ export function RichTextEditor({ type="button" onMouseDown={onToolbarMouseDown} onClick={() => exec('underline')} + disabled={codeMode} title="Underline" aria-label="Underline" > @@ -569,6 +804,7 @@ export function RichTextEditor({ value={currentFontSize} onMouseDown={(e) => e.stopPropagation()} onChange={(e) => applyFontSize(e.target.value as FontSizeValue)} + disabled={codeMode} title="Font size" aria-label="Font size" > @@ -586,6 +822,7 @@ export function RichTextEditor({ className={[styles.colorBtn, colorOpen ? styles.colorBtnActive : ''].filter(Boolean).join(' ')} onMouseDown={onToolbarMouseDown} onClick={toggleColorPicker} + disabled={codeMode} title="Text color" aria-label="Text color" aria-expanded={colorOpen} @@ -594,7 +831,7 @@ export function RichTextEditor({