diff --git a/app/ycode/components/CanvasTextEditor.tsx b/app/ycode/components/CanvasTextEditor.tsx index 03fd59fe..f1ed33da 100644 --- a/app/ycode/components/CanvasTextEditor.tsx +++ b/app/ycode/components/CanvasTextEditor.tsx @@ -11,7 +11,6 @@ */ import React, { useEffect, useMemo, useCallback, forwardRef, useImperativeHandle, useRef } from 'react'; -import { createRoot } from 'react-dom/client'; import { useEditor, EditorContent } from '@tiptap/react'; import { Mark, mergeAttributes } from '@tiptap/core'; import Document from '@tiptap/extension-document'; @@ -32,14 +31,11 @@ import { RichTextImage } from '@/lib/tiptap-extensions/rich-text-image'; import { getTextStyleClasses } from '@/lib/text-format-utils'; import type { Layer, TextStyle, CollectionField, Collection } from '@/types'; import type { FieldVariable } from '@/types'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import Icon from '@/components/ui/icon'; import { parseValueToContent, getVariableLabel, } from '@/lib/cms-variables-utils'; -import { DynamicVariable, getDynamicVariableLabel } from '@/lib/tiptap-extensions/dynamic-variable'; +import { createDynamicVariableNodeView } from '@/lib/dynamic-variable-view'; import { RichTextComponent } from '@/lib/tiptap-extensions/rich-text-component'; import { useCanvasTextEditorStore } from '@/stores/useCanvasTextEditorStore'; import { RichTextLink } from '@/lib/tiptap-extensions/rich-text-link'; @@ -76,61 +72,7 @@ export interface CanvasTextEditorHandle { * DynamicVariable with React node view for the canvas text editor. * Extends the shared extension with canvas-specific Badge styling. */ -const DynamicVariableWithNodeView = DynamicVariable.extend({ - addNodeView() { - return ({ node, getPos, editor }) => { - const container = document.createElement('span'); - container.className = 'inline-block'; - container.contentEditable = 'false'; - - const variable = node.attrs.variable; - if (variable) { - container.setAttribute('data-variable', JSON.stringify(variable)); - } - - const label = getDynamicVariableLabel(node); - - const handleDelete = () => { - const pos = getPos(); - if (typeof pos === 'number') { - editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run(); - } - }; - - const root = createRoot(container); - - const renderBadge = () => { - root.render( - - {label} - {editor.isEditable && ( - - )} - - ); - }; - - queueMicrotask(renderBadge); - - const updateListener = () => renderBadge(); - editor.on('update', updateListener); - - return { - dom: container, - destroy: () => { - editor.off('update', updateListener); - setTimeout(() => root.unmount(), 0); - }, - }; - }; - }, -}); +const DynamicVariableWithNodeView = createDynamicVariableNodeView('canvas'); /** * All block/mark extensions use a ref so renderHTML always reads the latest textStyles. diff --git a/app/ycode/components/CenterCanvas.tsx b/app/ycode/components/CenterCanvas.tsx index 0760b25c..31dbc12c 100644 --- a/app/ycode/components/CenterCanvas.tsx +++ b/app/ycode/components/CenterCanvas.tsx @@ -62,6 +62,7 @@ import { cn } from '@/lib/utils'; import { getCollectionVariable, canDeleteLayer, findLayerById, findParentCollectionLayer, canLayerHaveLink, updateLayerProps, removeRichTextSublayer } from '@/lib/layer-utils'; import { CANVAS_BORDER, CANVAS_PADDING } from '@/lib/canvas-utils'; import { buildFieldGroupsForLayer, flattenFieldGroups, filterFieldGroupsByType, SIMPLE_TEXT_FIELD_TYPES } from '@/lib/collection-field-utils'; +import { buildFieldVariableData } from '@/lib/variable-format-utils'; import { getRichTextValue } from '@/lib/tiptap-utils'; import { DropContainerIndicator, DropLineIndicator } from '@/components/DropIndicators'; import { DragCaptureOverlay } from '@/components/DragCaptureOverlay'; @@ -2147,15 +2148,7 @@ const CenterCanvas = React.memo(function CenterCanvas({ const flatFields = flattenFieldGroups(fieldGroups); const field = flatFields.find(f => f.id === fieldId); addFieldVariable( - { - type: 'field', - data: { - field_id: fieldId, - relationships: relationshipPath, - source, - field_type: field?.type || null, - }, - }, + buildFieldVariableData(fieldId, relationshipPath, field?.type ?? null, source), flatFields, collectionFieldsFromStore ); diff --git a/app/ycode/components/ExpandableRichTextEditor.tsx b/app/ycode/components/ExpandableRichTextEditor.tsx index 4737e046..3d2de719 100644 --- a/app/ycode/components/ExpandableRichTextEditor.tsx +++ b/app/ycode/components/ExpandableRichTextEditor.tsx @@ -5,7 +5,7 @@ * a RichTextEditorSheet for full-featured editing. */ -import { useMemo, useState } from 'react'; +import { useMemo, useState, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import Icon from '@/components/ui/icon'; import { @@ -15,9 +15,11 @@ import { } from '@/components/ui/dropdown-menu'; import RichTextEditor from './RichTextEditor'; import RichTextEditorSheet from './RichTextEditorSheet'; +import VariableFormatSelector from './VariableFormatSelector'; import { CollectionFieldSelector, type FieldSourceType } from './CollectionFieldSelector'; import { hasLinkOrComponent, getSoleCmsFieldBinding } from '@/lib/tiptap-utils'; import { getVariableLabel } from '@/lib/cms-variables-utils'; +import { isFormattableFieldType, buildFieldVariableData } from '@/lib/variable-format-utils'; import { flattenFieldGroups, filterFieldGroupsByType, RICH_TEXT_ONLY_FIELD_TYPES } from '@/lib/collection-field-utils'; import type { CollectionField, Collection, CollectionFieldType } from '@/types'; import type { FieldGroup } from '@/lib/collection-field-utils'; @@ -63,12 +65,41 @@ export default function ExpandableRichTextEditor({ const [cmsDropdownOpen, setCmsDropdownOpen] = useState(false); const isComplex = useMemo(() => hasLinkOrComponent(value), [value]); - const richTextBinding = useMemo(() => { + const soleBinding = useMemo(() => { if (!buttonOnly) return null; - const binding = getSoleCmsFieldBinding(value); - return binding?.field_type === 'rich_text' ? binding : null; + return getSoleCmsFieldBinding(value); }, [buttonOnly, value]); + const richTextBinding = useMemo(() => { + return soleBinding?.field_type === 'rich_text' ? soleBinding : null; + }, [soleBinding]); + + const formattableBinding = useMemo(() => { + return soleBinding && isFormattableFieldType(soleBinding.field_type) ? soleBinding : null; + }, [soleBinding]); + + const handleFormatChange = useCallback((formatId: string) => { + if (!value?.content?.[0]?.content?.[0]?.attrs?.variable) return; + const variable = value.content[0].content[0].attrs.variable; + const updatedContent = { + type: 'doc', + content: [{ + type: 'paragraph', + content: [{ + type: 'dynamicVariable', + attrs: { + ...value.content[0].content[0].attrs, + variable: { + ...variable, + data: { ...variable.data, format: formatId }, + }, + }, + }], + }], + }; + onChange(updatedContent); + }, [value, onChange]); + const textFieldGroups = useMemo( () => filterFieldGroupsByType(fieldGroups, allowedFieldTypes), [fieldGroups, allowedFieldTypes], @@ -82,16 +113,7 @@ export default function ExpandableRichTextEditor({ const handleFieldSelect = (fieldId: string, relationshipPath: string[], source?: FieldSourceType, layerId?: string) => { const field = fields.find(f => f.id === fieldId); - const variableData = { - type: 'field' as const, - data: { - field_id: fieldId, - relationships: relationshipPath, - source, - field_type: field?.type || null, - collection_layer_id: layerId, - }, - }; + const variableData = buildFieldVariableData(fieldId, relationshipPath, field?.type ?? null, source, layerId); const label = getVariableLabel(variableData, fields, allFields); const newContent = { @@ -108,36 +130,47 @@ export default function ExpandableRichTextEditor({ setCmsDropdownOpen(false); }; - if (richTextBinding && !sheetOpen) { + if ((richTextBinding || formattableBinding) && !sheetOpen) { + const activeBinding = richTextBinding || formattableBinding; return ( <> - - - +
+ +
+ + {formattableBinding && ( + + )} + { diff --git a/app/ycode/components/RichTextEditor.tsx b/app/ycode/components/RichTextEditor.tsx index 1f9c3142..6270b0a8 100644 --- a/app/ycode/components/RichTextEditor.tsx +++ b/app/ycode/components/RichTextEditor.tsx @@ -57,7 +57,8 @@ import { } from '@/components/ui/dropdown-menu'; import { CollectionFieldSelector, type FieldSourceType } from './CollectionFieldSelector'; import { flattenFieldGroups, filterFieldGroupsByType, RICH_TEXT_ONLY_FIELD_TYPES, type FieldGroup } from '@/lib/collection-field-utils'; -import { DynamicVariable, getDynamicVariableLabel } from '@/lib/tiptap-extensions/dynamic-variable'; +import { buildFieldVariableData } from '@/lib/variable-format-utils'; +import { createDynamicVariableNodeView } from '@/lib/dynamic-variable-view'; import { RichTextComponent } from '@/lib/tiptap-extensions/rich-text-component'; import { RichTextLink, getLinkSettingsFromMark } from '@/lib/tiptap-extensions/rich-text-link'; import { RichTextImage } from '@/lib/tiptap-extensions/rich-text-image'; @@ -120,61 +121,7 @@ export type { FieldVariable } from '@/types'; * DynamicVariable with React node view for the sidebar rich-text editor. * Extends the shared extension with a Badge-based node view. */ -const DynamicVariableWithNodeView = DynamicVariable.extend({ - addNodeView() { - return ({ node, getPos, editor }) => { - const container = document.createElement('span'); - container.className = 'inline-block'; - container.contentEditable = 'false'; - - const variable = node.attrs.variable; - if (variable) { - container.setAttribute('data-variable', JSON.stringify(variable)); - } - - const label = getDynamicVariableLabel(node); - - const handleDelete = () => { - const pos = getPos(); - if (typeof pos === 'number') { - editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run(); - } - }; - - const root = createRoot(container); - - const renderBadge = () => { - root.render( - - {label} - {editor.isEditable && ( - - )} - - ); - }; - - queueMicrotask(renderBadge); - - const updateListener = () => renderBadge(); - editor.on('update', updateListener); - - return { - dom: container, - destroy: () => { - editor.off('update', updateListener); - setTimeout(() => root.unmount(), 0); - }, - }; - }; - }, -}); +const DynamicVariableWithNodeView = createDynamicVariableNodeView('sidebar'); /** * RichTextComponent with React node view for embedding components. @@ -828,18 +775,8 @@ const RichTextEditor = forwardRef(({ const handleFieldSelect = (fieldId: string, relationshipPath: string[], source?: FieldSourceType, layerId?: string) => { const field = fields.find(f => f.id === fieldId); - addFieldVariableInternal({ - type: 'field', - data: { - field_id: fieldId, - relationships: relationshipPath, - source, - field_type: field?.type || null, - collection_layer_id: layerId, - }, - }); + addFieldVariableInternal(buildFieldVariableData(fieldId, relationshipPath, field?.type ?? null, source, layerId)); - // Close the dropdown after selection setIsDropdownOpen(false); }; diff --git a/app/ycode/components/VariableFormatSelector.tsx b/app/ycode/components/VariableFormatSelector.tsx new file mode 100644 index 00000000..893c2cff --- /dev/null +++ b/app/ycode/components/VariableFormatSelector.tsx @@ -0,0 +1,137 @@ +'use client'; + +/** + * Format selector for date and number inline variables. + * + * Two modes: + * - Default (no children): renders a chevron button that opens the popover + * - Wrapper (with children): wraps children as the popover trigger + */ + +import React, { useState, useCallback } from 'react'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { Button } from '@/components/ui/button'; +import Icon from '@/components/ui/icon'; +import { cn } from '@/lib/utils'; +import { + getFormatSectionsForFieldType, + getDateFormatPreview, + getNumberFormatPreview, + type DateFormatPreset, + type NumberFormatPreset, +} from '@/lib/variable-format-utils'; + +interface VariableFormatSelectorProps { + fieldType: string | null | undefined; + currentFormat?: string; + onFormatChange: (formatId: string) => void; + /** Visual variant for different editor contexts */ + variant?: 'sidebar' | 'canvas'; + /** When provided, children become the popover trigger instead of the chevron button */ + children?: React.ReactNode; +} + +function getPreview(preset: DateFormatPreset | NumberFormatPreset): string { + if ('sample' in preset) { + return getNumberFormatPreview(preset); + } + return getDateFormatPreview(preset); +} + +export default function VariableFormatSelector({ + fieldType, + currentFormat, + onFormatChange, + variant = 'sidebar', + children, +}: VariableFormatSelectorProps) { + const [open, setOpen] = useState(false); + const sections = getFormatSectionsForFieldType(fieldType); + + const handleSelect = useCallback((formatId: string) => { + onFormatChange(formatId); + setOpen(false); + }, [onFormatChange]); + + if (sections.length === 0) { + return children ? <>{children} : null; + } + + const trigger = children ? ( + + e.stopPropagation()} + > + {children} + + + ) : ( + + + + ); + + return ( + + {trigger} + e.stopPropagation()} + onPointerDownOutside={(e) => e.stopPropagation()} + > +
+ {sections.map((section) => ( +
+

+ {section.title} +

+ {section.presets.map((preset) => ( + + ))} +
+ ))} +
+
+
+ ); +} diff --git a/lib/cms-variables-utils.ts b/lib/cms-variables-utils.ts index 31e05b6a..13d6680e 100644 --- a/lib/cms-variables-utils.ts +++ b/lib/cms-variables-utils.ts @@ -8,17 +8,21 @@ import type { CollectionField, InlineVariable } from '@/types'; import { formatDateInTimezone } from '@/lib/date-format-utils'; import { extractPlainTextFromTiptap } from '@/lib/tiptap-utils'; +import { formatDateWithPreset, formatNumberWithPreset } from '@/lib/variable-format-utils'; /** * Format a field value for display based on field type - * - date: formats in user's timezone + * - date: formats in user's timezone (with optional format preset) + * - number: formats with optional number preset * - rich_text: extracts plain text from Tiptap JSON * Returns the original value for other fields + * @param format - Optional format preset ID (e.g. 'date-long', 'number-decimal') */ export function formatFieldValue( value: unknown, fieldType: string | null | undefined, - timezone: string = 'UTC' + timezone: string = 'UTC', + format?: string ): string { if (value === null || value === undefined) return ''; @@ -27,7 +31,6 @@ export function formatFieldValue( if (typeof value === 'object') { return extractPlainTextFromTiptap(value); } - // If it's a string (legacy or unparsed), try to parse and extract if (typeof value === 'string') { try { const parsed = JSON.parse(value); @@ -39,14 +42,24 @@ export function formatFieldValue( return ''; } - // Handle date fields + // Handle date fields with optional format preset if (fieldType === 'date' && typeof value === 'string') { + if (format) { + return formatDateWithPreset(value, format, timezone); + } return formatDateInTimezone(value, timezone, 'display'); } + // Handle number fields with optional format preset + if (fieldType === 'number' && format) { + const numValue = typeof value === 'number' ? value : parseFloat(String(value)); + if (!isNaN(numValue)) { + return formatNumberWithPreset(numValue, format); + } + } + // For other fields, ensure we return a string if (typeof value === 'object') { - // Safety fallback - if an object slips through, stringify it return JSON.stringify(value); } diff --git a/lib/dynamic-variable-view.tsx b/lib/dynamic-variable-view.tsx new file mode 100644 index 00000000..4b50f638 --- /dev/null +++ b/lib/dynamic-variable-view.tsx @@ -0,0 +1,152 @@ +'use client'; + +/** + * Shared TipTap NodeView factory for dynamic variable badges. + * + * Creates a DynamicVariable extension with a React-based NodeView that renders + * an inline badge with optional format selector (for date/number fields). + * + * Used by both RichTextEditor (sidebar variant) and CanvasTextEditor (canvas variant). + */ + +import React from 'react'; +import { createRoot } from 'react-dom/client'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import Icon from '@/components/ui/icon'; + +import { DynamicVariable, getDynamicVariableLabel } from '@/lib/tiptap-extensions/dynamic-variable'; +import { isFormattableFieldType } from '@/lib/variable-format-utils'; +import VariableFormatSelector from '@/app/ycode/components/VariableFormatSelector'; + +type VariableViewVariant = 'sidebar' | 'canvas'; + +const VARIANT_CONFIG = { + sidebar: { + badgeVariant: 'secondary' as const, + deleteButtonVariant: 'outline' as const, + formatSelectorVariant: 'sidebar' as const, + }, + canvas: { + badgeVariant: 'inline_variable_canvas' as const, + deleteButtonVariant: 'inline_variable_canvas' as const, + formatSelectorVariant: 'canvas' as const, + }, +}; + +/** + * Create a DynamicVariable TipTap extension with a React NodeView. + * The variant controls visual styling (Badge/Button variants). + */ +export function createDynamicVariableNodeView(variant: VariableViewVariant) { + const config = VARIANT_CONFIG[variant]; + + return DynamicVariable.extend({ + addNodeView() { + return ({ node: initialNode, getPos, editor }) => { + const container = document.createElement('span'); + container.className = 'inline-block'; + container.contentEditable = 'false'; + + let currentNode = initialNode; + const variable = currentNode.attrs.variable; + if (variable) { + container.setAttribute('data-variable', JSON.stringify(variable)); + } + + const label = getDynamicVariableLabel(currentNode); + const fieldType = variable?.data?.field_type; + const isFormattable = isFormattableFieldType(fieldType); + + const handleDelete = () => { + const pos = getPos(); + if (typeof pos === 'number') { + editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run(); + } + }; + + const handleFormatChange = (formatId: string) => { + const pos = getPos(); + if (typeof pos === 'number') { + const currentVariable = currentNode.attrs.variable; + const updatedVariable = { + ...currentVariable, + data: { ...currentVariable.data, format: formatId }, + }; + editor.chain().focus() + .command(({ tr }) => { + tr.setNodeMarkup(pos, undefined, { + ...currentNode.attrs, + variable: updatedVariable, + }); + return true; + }) + .run(); + } + }; + + const root = createRoot(container); + + const renderBadge = () => { + const currentFormat = currentNode.attrs.variable?.data?.format; + const badgeContent = ( + + {label} + {editor.isEditable && isFormattable && ( + + )} + {editor.isEditable && ( + + )} + + ); + + root.render( + editor.isEditable && isFormattable ? ( + + {badgeContent} + + ) : badgeContent + ); + }; + + queueMicrotask(renderBadge); + + const updateListener = () => renderBadge(); + editor.on('update', updateListener); + + return { + dom: container, + update: (updatedNode) => { + if (updatedNode.type.name !== 'dynamicVariable') return false; + currentNode = updatedNode; + renderBadge(); + return true; + }, + destroy: () => { + editor.off('update', updateListener); + setTimeout(() => root.unmount(), 0); + }, + }; + }; + }, + }); +} diff --git a/lib/inline-variables.ts b/lib/inline-variables.ts index 339cbc0f..c649a3d1 100644 --- a/lib/inline-variables.ts +++ b/lib/inline-variables.ts @@ -34,7 +34,7 @@ export function resolveInlineVariables( if (parsed.type === 'field' && parsed.data?.field_id) { const fieldValue = collectionItem.values[parsed.data.field_id]; - return formatFieldValue(fieldValue, parsed.data.field_type, timezone); + return formatFieldValue(fieldValue, parsed.data.field_type, timezone, parsed.data.format); } } catch { // Invalid JSON or not a field variable, leave as is @@ -84,7 +84,7 @@ export function resolveInlineVariablesFromData( parsed.data.collection_layer_id, layerDataMap ); - return formatFieldValue(fieldValue, parsed.data.field_type, timezone); + return formatFieldValue(fieldValue, parsed.data.field_type, timezone, parsed.data.format); } } catch { // Invalid JSON diff --git a/lib/page-fetcher.ts b/lib/page-fetcher.ts index e9d96723..e6475c55 100644 --- a/lib/page-fetcher.ts +++ b/lib/page-fetcher.ts @@ -24,6 +24,7 @@ import type { LinkResolutionContext } from '@/lib/link-utils'; import { getLinkSettingsFromMark } from '@/lib/tiptap-extensions/rich-text-link'; import { SWIPER_CLASS_MAP, SWIPER_DATA_ATTR_MAP } from '@/lib/templates/utilities'; import { resolveInlineVariables, resolveInlineVariablesFromData } from '@/lib/inline-variables'; +import { formatFieldValue } from '@/lib/cms-variables-utils'; import { buildLayerTranslationKey, getTranslationByKey, hasValidTranslationValue, getTranslationValue } from '@/lib/localisation-utils'; import { formatDateFieldsInItemValues } from '@/lib/date-format-utils'; import { getSettingByKey } from '@/lib/repositories/settingsRepository'; @@ -429,6 +430,7 @@ export const fetchPageByPath = cache(async function fetchPageByPath( // Format date fields in user's timezone const timezone = (await getSettingByKey('timezone') as string | null) || 'UTC'; + const rawItemValues = { ...enhancedItemValues }; enhancedItemValues = formatDateFieldsInItemValues(enhancedItemValues, collectionFields, timezone); // Create enhanced collection item with resolved reference values and translations @@ -444,7 +446,7 @@ export const fetchPageByPath = cache(async function fetchPageByPath( // This resolves inline variables like "Name → Location" on the page const layersWithInjectedData = await Promise.all( layersWithComponents.map((layer: Layer) => - injectCollectionData(layer, enhancedItemValues, collectionFields, isPublished) + injectCollectionData(layer, enhancedItemValues, collectionFields, isPublished, undefined, rawItemValues, timezone) ) ); @@ -979,6 +981,7 @@ async function resolveReferenceFields( * @param fields - Optional collection fields (for reference field resolution) * @param isPublished - Whether fetching published data * @param layerDataMap - Map of layer ID → item data for layer-specific resolution + * @param rawItemValues - Unformatted values (ISO dates) for applying custom format presets * @returns Layer with resolved field values */ async function injectCollectionData( @@ -986,7 +989,9 @@ async function injectCollectionData( itemValues: Record, fields?: CollectionField[], isPublished: boolean = true, - layerDataMap?: Record> + layerDataMap?: Record>, + rawItemValues?: Record, + timezone: string = 'UTC' ): Promise { // Resolve reference fields if we have field definitions let enhancedValues = itemValues; @@ -995,6 +1000,8 @@ async function injectCollectionData( } const updates: Partial = {}; + // Start with all original variables; each section overwrites only its own key + const resolvedVars: Record = { ...layer.variables }; // Resolve inline variables in text content const textVariable = layer.variables?.text; @@ -1003,8 +1010,6 @@ async function injectCollectionData( if (textVariable && textVariable.type === 'dynamic_rich_text') { const content = textVariable.data.content; if (content && typeof content === 'object') { - // Check if content contains block elements (lists) from inline variables - // If so, change restrictive tags (p, h1-h6, etc.) to div const restrictiveBlockTags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span', 'a', 'button']; const currentTag = layer.settings?.tag || layer.name || 'div'; if (restrictiveBlockTags.includes(currentTag) && @@ -1015,13 +1020,10 @@ async function injectCollectionData( }; } - const resolvedContent = resolveRichTextVariables(content, enhancedValues, layerDataMap); - updates.variables = { - ...layer.variables, - text: { - type: 'dynamic_rich_text', - data: { content: resolvedContent } - } + const resolvedContent = resolveRichTextVariables(content, enhancedValues, layerDataMap, rawItemValues, timezone); + resolvedVars.text = { + type: 'dynamic_rich_text', + data: { content: resolvedContent }, }; } } @@ -1041,14 +1043,11 @@ async function injectCollectionData( content_hash: null, values: enhancedValues, }; - const resolved = resolveInlineVariablesWithRelationships(textContent, mockItem); + const resolved = resolveInlineVariablesWithRelationships(textContent, mockItem, timezone); - updates.variables = { - ...layer.variables, - text: { - type: 'dynamic_text', - data: { content: resolved } - } + resolvedVars.text = { + type: 'dynamic_text', + data: { content: resolved }, }; } } @@ -1057,13 +1056,9 @@ async function injectCollectionData( const imageSrc = layer.variables?.image?.src; if (imageSrc && isFieldVariable(imageSrc) && imageSrc.data.field_id) { const resolvedValue = resolveFieldValueWithRelationships(imageSrc, enhancedValues, layerDataMap); - updates.variables = { - ...updates.variables, - ...layer.variables, - image: { - src: createResolvedAssetVariable(imageSrc.data.field_id, resolvedValue, imageSrc), - alt: layer.variables?.image?.alt || createDynamicTextVariable(''), - }, + resolvedVars.image = { + src: createResolvedAssetVariable(imageSrc.data.field_id, resolvedValue, imageSrc), + alt: layer.variables?.image?.alt || createDynamicTextVariable(''), }; } @@ -1071,13 +1066,9 @@ async function injectCollectionData( const videoSrc = layer.variables?.video?.src; if (videoSrc && isFieldVariable(videoSrc) && videoSrc.data.field_id) { const resolvedValue = resolveFieldValueWithRelationships(videoSrc, enhancedValues, layerDataMap); - updates.variables = { - ...updates.variables, - ...layer.variables, - video: { - ...layer.variables?.video, - src: createResolvedAssetVariable(videoSrc.data.field_id, resolvedValue, videoSrc), - }, + resolvedVars.video = { + ...layer.variables?.video, + src: createResolvedAssetVariable(videoSrc.data.field_id, resolvedValue, videoSrc), }; } @@ -1085,13 +1076,9 @@ async function injectCollectionData( const audioSrc = layer.variables?.audio?.src; if (audioSrc && isFieldVariable(audioSrc) && audioSrc.data.field_id) { const resolvedValue = resolveFieldValueWithRelationships(audioSrc, enhancedValues, layerDataMap); - updates.variables = { - ...updates.variables, - ...layer.variables, - audio: { - ...layer.variables?.audio, - src: createResolvedAssetVariable(audioSrc.data.field_id, resolvedValue, audioSrc), - }, + resolvedVars.audio = { + ...layer.variables?.audio, + src: createResolvedAssetVariable(audioSrc.data.field_id, resolvedValue, audioSrc), }; } @@ -1099,12 +1086,8 @@ async function injectCollectionData( const bgImageSrc = layer.variables?.backgroundImage?.src; if (bgImageSrc && isFieldVariable(bgImageSrc) && bgImageSrc.data.field_id) { const resolvedValue = resolveFieldValueWithRelationships(bgImageSrc, enhancedValues, layerDataMap); - updates.variables = { - ...updates.variables, - ...layer.variables, - backgroundImage: { - src: createResolvedAssetVariable(bgImageSrc.data.field_id, resolvedValue, bgImageSrc), - }, + resolvedVars.backgroundImage = { + src: createResolvedAssetVariable(bgImageSrc.data.field_id, resolvedValue, bgImageSrc), }; } @@ -1145,6 +1128,9 @@ async function injectCollectionData( } } + // Assign all resolved variables + updates.variables = resolvedVars as Layer['variables']; + // Recursively process children, but SKIP collection layers // Collection layers will be processed by resolveCollectionLayers with their own item data if (layer.children) { @@ -1154,7 +1140,7 @@ async function injectCollectionData( if (child.variables?.collection?.id) { return Promise.resolve(child); } - return injectCollectionData(child, enhancedValues, fields, isPublished, layerDataMap); + return injectCollectionData(child, enhancedValues, fields, isPublished, layerDataMap, rawItemValues, timezone); }) ); updates.children = resolvedChildren; @@ -1172,7 +1158,8 @@ async function injectCollectionData( */ function resolveInlineVariablesWithRelationships( text: string, - collectionItem: CollectionItemWithValues + collectionItem: CollectionItemWithValues, + timezone: string = 'UTC' ): string { if (!collectionItem || !collectionItem.values) { return text; @@ -1188,14 +1175,14 @@ function resolveInlineVariablesWithRelationships( const relationships = parsed.data.relationships || []; // Build the full path for relationship resolution - if (relationships.length > 0) { - const fullPath = [fieldId, ...relationships].join('.'); - const fieldValue = collectionItem.values[fullPath]; - return fieldValue || ''; - } + const fullPath = relationships.length > 0 + ? [fieldId, ...relationships].join('.') + : fieldId; - // Simple field lookup - const fieldValue = collectionItem.values[fieldId]; + const fieldValue = collectionItem.values[fullPath]; + if (parsed.data.format && fieldValue) { + return formatFieldValue(fieldValue, parsed.data.field_type, timezone, parsed.data.format); + } return fieldValue || ''; } } catch { @@ -1258,11 +1245,14 @@ function hasBlockElementsInInlineVariables( * Traverses the content tree and replaces variable nodes with resolved text * For rich_text fields, inline the nested Tiptap content * @param layerDataMap - Optional map of layer ID → item data for layer-specific resolution + * @param rawItemValues - Unformatted values (ISO dates) for applying custom format presets */ function resolveRichTextVariables( content: any, itemValues: Record, - layerDataMap?: Record> + layerDataMap?: Record>, + rawItemValues?: Record, + timezone: string = 'UTC' ): any { if (!content || typeof content !== 'object') { return content; @@ -1293,7 +1283,7 @@ function resolveRichTextVariables( // Handle rich_text fields - preserve block structure for proper rendering if (fieldType === 'rich_text' && isTiptapDoc(value)) { const resolvedBlocks = value.content.map((block: any) => - resolveRichTextVariables(block, itemValues, layerDataMap) + resolveRichTextVariables(block, itemValues, layerDataMap, rawItemValues, timezone) ); return resolvedBlocks.flat(); } @@ -1307,10 +1297,20 @@ function resolveRichTextVariables( }; } - // For other field types, convert to string - const textValue = value != null ? String(value) : ''; + // Apply custom format using raw (unformatted) values when available + // Date values in itemValues are pre-formatted by formatDateFieldsInItemValues, + // so custom format presets need the original ISO string from rawItemValues + const format = variable.data.format; + let textValue: string; + if (format && rawItemValues) { + const rawValue = rawItemValues[fullPath]; + textValue = rawValue != null + ? formatFieldValue(rawValue, fieldType, timezone, format) + : (value != null ? String(value) : ''); + } else { + textValue = value != null ? String(value) : ''; + } - // Replace variable node with text node, preserving marks (bold, italic, etc.) return { type: 'text', text: textValue, @@ -1324,7 +1324,7 @@ function resolveRichTextVariables( if (Array.isArray(content)) { // Flatten arrays that may contain nested arrays from rich_text expansion return content.flatMap(node => { - const resolved = resolveRichTextVariables(node, itemValues, layerDataMap); + const resolved = resolveRichTextVariables(node, itemValues, layerDataMap, rawItemValues, timezone); return Array.isArray(resolved) ? resolved : [resolved]; }); } @@ -1335,11 +1335,11 @@ function resolveRichTextVariables( if (key === 'content' && Array.isArray(content[key])) { // Flatten the content array in case of expanded rich_text nodes result[key] = content[key].flatMap((node: any) => { - const resolved = resolveRichTextVariables(node, itemValues, layerDataMap); + const resolved = resolveRichTextVariables(node, itemValues, layerDataMap, rawItemValues, timezone); return Array.isArray(resolved) ? resolved : [resolved]; }); } else if (typeof content[key] === 'object' && content[key] !== null) { - result[key] = resolveRichTextVariables(content[key], itemValues, layerDataMap); + result[key] = resolveRichTextVariables(content[key], itemValues, layerDataMap, rawItemValues, timezone); } else { result[key] = content[key]; } @@ -1630,7 +1630,7 @@ export async function resolveCollectionLayers( // Inject virtual field data into the resolved children const injectedChildren = await Promise.all( resolvedChildren.map(child => - injectCollectionData(child, virtualValues, undefined, isPublished, updatedLayerDataMap) + injectCollectionData(child, virtualValues, undefined, isPublished, updatedLayerDataMap, undefined, timezone) ) ); @@ -1805,12 +1805,16 @@ export async function resolveCollectionLayers( sortedItems.map(async (item) => { // Apply CMS translations to item values before using them let translatedValues = applyCmsTranslations(item.id, item.values, collectionFields, translations); + // Preserve raw values before date formatting for custom format presets + const rawTranslatedValues = { ...translatedValues }; // Format date fields in user's timezone translatedValues = formatDateFieldsInItemValues(translatedValues, collectionFields, timezone); // Resolve reference fields BEFORE building layerDataMap // This ensures relationship paths (e.g., "refFieldId.targetFieldId") are available const enhancedValues = await resolveReferenceFields(translatedValues, collectionFields, isPublished); + // Overlay raw values on enhanced to preserve relationship paths while keeping unformatted dates + const rawEnhancedValues = { ...enhancedValues, ...rawTranslatedValues }; // Extract slug for URL building const itemSlug = slugField ? (enhancedValues[slugField.id] || item.values[slugField.id]) : undefined; @@ -1832,7 +1836,7 @@ export async function resolveCollectionLayers( // Then inject field data into the resolved children const injectedChildren = await Promise.all( resolvedChildren.map(child => - injectCollectionData(child, enhancedValues, collectionFields, isPublished, updatedLayerDataMap) + injectCollectionData(child, enhancedValues, collectionFields, isPublished, updatedLayerDataMap, rawEnhancedValues, timezone) ) ); @@ -2538,6 +2542,7 @@ export async function renderCollectionItemsToHtml( const renderedItems = await Promise.all( items.map(async (item, index) => { // Format date fields in user's timezone + const rawValues = { ...item.values }; const formattedValues = formatDateFieldsInItemValues(item.values, collectionFields, htmlTimezone); // Deep clone the template for each item @@ -2546,7 +2551,7 @@ export async function renderCollectionItemsToHtml( // Inject collection data into each layer of the template (text, images, etc.) const injectedLayers = await Promise.all( clonedTemplate.map((layer: Layer) => - injectCollectionDataForHtml(layer, formattedValues, collectionFields, isPublished) + injectCollectionDataForHtml(layer, formattedValues, collectionFields, isPublished, rawValues, htmlTimezone) ) ); @@ -2627,7 +2632,9 @@ async function injectCollectionDataForHtml( layer: Layer, itemValues: Record, fields: CollectionField[], - isPublished: boolean + isPublished: boolean, + rawItemValues?: Record, + timezone: string = 'UTC' ): Promise { // Resolve reference fields if we have field definitions let enhancedValues = itemValues; @@ -2636,6 +2643,7 @@ async function injectCollectionDataForHtml( } const updates: Partial = {}; + const resolvedVars: Record = { ...layer.variables }; // Resolve inline variables in text content const textVariable = layer.variables?.text; @@ -2654,13 +2662,10 @@ async function injectCollectionDataForHtml( }; } - const resolvedContent = resolveRichTextVariables(content, enhancedValues); - updates.variables = { - ...layer.variables, - text: { - type: 'dynamic_rich_text', - data: { content: resolvedContent } - } + const resolvedContent = resolveRichTextVariables(content, enhancedValues, undefined, rawItemValues, timezone); + resolvedVars.text = { + type: 'dynamic_rich_text', + data: { content: resolvedContent }, }; } } @@ -2680,13 +2685,10 @@ async function injectCollectionDataForHtml( content_hash: null, values: enhancedValues, }; - const resolved = resolveInlineVariables(textContent, mockItem); - updates.variables = { - ...layer.variables, - text: { - type: 'dynamic_text', - data: { content: resolved } - } + const resolved = resolveInlineVariables(textContent, mockItem, timezone); + resolvedVars.text = { + type: 'dynamic_text', + data: { content: resolved }, }; } } @@ -2705,13 +2707,9 @@ async function injectCollectionDataForHtml( const imageSrc = layer.variables?.image?.src; if (imageSrc && isFieldVariable(imageSrc) && imageSrc.data.field_id) { const resolvedValue = resolveFieldPath(imageSrc); - updates.variables = { - ...updates.variables, - ...layer.variables, - image: { - src: createResolvedAssetVariable(imageSrc.data.field_id, resolvedValue, imageSrc), - alt: layer.variables?.image?.alt || createDynamicTextVariable(''), - }, + resolvedVars.image = { + src: createResolvedAssetVariable(imageSrc.data.field_id, resolvedValue, imageSrc), + alt: layer.variables?.image?.alt || createDynamicTextVariable(''), }; } @@ -2719,13 +2717,9 @@ async function injectCollectionDataForHtml( const videoSrc = layer.variables?.video?.src; if (videoSrc && isFieldVariable(videoSrc) && videoSrc.data.field_id) { const resolvedValue = resolveFieldPath(videoSrc); - updates.variables = { - ...updates.variables, - ...layer.variables, - video: { - ...layer.variables?.video, - src: createResolvedAssetVariable(videoSrc.data.field_id, resolvedValue, videoSrc), - }, + resolvedVars.video = { + ...layer.variables?.video, + src: createResolvedAssetVariable(videoSrc.data.field_id, resolvedValue, videoSrc), }; } @@ -2733,13 +2727,9 @@ async function injectCollectionDataForHtml( const audioSrc = layer.variables?.audio?.src; if (audioSrc && isFieldVariable(audioSrc) && audioSrc.data.field_id) { const resolvedValue = resolveFieldPath(audioSrc); - updates.variables = { - ...updates.variables, - ...layer.variables, - audio: { - ...layer.variables?.audio, - src: createResolvedAssetVariable(audioSrc.data.field_id, resolvedValue, audioSrc), - }, + resolvedVars.audio = { + ...layer.variables?.audio, + src: createResolvedAssetVariable(audioSrc.data.field_id, resolvedValue, audioSrc), }; } @@ -2747,12 +2737,8 @@ async function injectCollectionDataForHtml( const bgImageSrc = layer.variables?.backgroundImage?.src; if (bgImageSrc && isFieldVariable(bgImageSrc) && bgImageSrc.data.field_id) { const resolvedValue = resolveFieldPath(bgImageSrc); - updates.variables = { - ...updates.variables, - ...layer.variables, - backgroundImage: { - src: createResolvedAssetVariable(bgImageSrc.data.field_id, resolvedValue, bgImageSrc), - }, + resolvedVars.backgroundImage = { + src: createResolvedAssetVariable(bgImageSrc.data.field_id, resolvedValue, bgImageSrc), }; } @@ -2767,11 +2753,14 @@ async function injectCollectionDataForHtml( } } + // Assign all resolved variables + updates.variables = resolvedVars as Layer['variables']; + // Recursively process children if (layer.children) { const resolvedChildren = await Promise.all( layer.children.map(child => - injectCollectionDataForHtml(child, enhancedValues, fields, isPublished) + injectCollectionDataForHtml(child, enhancedValues, fields, isPublished, rawItemValues, timezone) ) ); updates.children = resolvedChildren; diff --git a/lib/text-format-utils.ts b/lib/text-format-utils.ts index 4c5213ab..333643d7 100644 --- a/lib/text-format-utils.ts +++ b/lib/text-format-utils.ts @@ -278,17 +278,16 @@ function getVariableNodeData( collectionItemData?: Record, pageCollectionItemData?: Record, layerDataMap?: Record> -): { fieldType: string | null; rawValue: unknown } { +): { fieldType: string | null; rawValue: unknown; format?: string } { if (node.attrs?.variable?.type === 'field' && node.attrs.variable.data?.field_id) { - const { field_id, field_type, relationships = [], source, collection_layer_id } = node.attrs.variable.data; + const { field_id, field_type, relationships = [], source, collection_layer_id, format } = node.attrs.variable.data; - // Build the full path for relationship resolution const fieldPath = relationships.length > 0 ? [field_id, ...relationships].join('.') : field_id; const rawValue = resolveFieldFromSources(fieldPath, source, collectionItemData, pageCollectionItemData, collection_layer_id, layerDataMap); - return { fieldType: field_type || null, rawValue }; + return { fieldType: field_type || null, rawValue, format }; } return { fieldType: null, rawValue: undefined }; @@ -307,8 +306,8 @@ function resolveVariableNode( pageCollectionItemData?: Record, timezone: string = 'UTC' ): string { - const { fieldType, rawValue } = getVariableNodeData(node, collectionItemData, pageCollectionItemData); - return formatFieldValue(rawValue, fieldType, timezone); + const { fieldType, rawValue, format } = getVariableNodeData(node, collectionItemData, pageCollectionItemData); + return formatFieldValue(rawValue, fieldType, timezone, format); } /** @@ -511,17 +510,15 @@ function renderInlineContent( } if (node.type === 'dynamicVariable') { - const { fieldType, rawValue } = getVariableNodeData(node, collectionItemData, pageCollectionItemData, layerDataMap); + const { fieldType, rawValue, format } = getVariableNodeData(node, collectionItemData, pageCollectionItemData, layerDataMap); // Handle rich_text fields - render nested Tiptap content if (fieldType === 'rich_text' && rawValue) { - // Parse JSON string if needed (published pages store as string) let richTextValue: unknown = rawValue; if (typeof rawValue === 'string') { try { richTextValue = JSON.parse(rawValue); } catch { - // If parsing fails, fall through to text rendering richTextValue = null; } } @@ -544,8 +541,8 @@ function renderInlineContent( } } - // For other field types, render as text - const value = formatFieldValue(rawValue, fieldType, timezone); + // For other field types, render as text with optional format + const value = formatFieldValue(rawValue, fieldType, timezone, format); const textNode = { type: 'text', text: value, diff --git a/lib/tiptap-utils.ts b/lib/tiptap-utils.ts index 3d4d8224..52b5b267 100644 --- a/lib/tiptap-utils.ts +++ b/lib/tiptap-utils.ts @@ -188,7 +188,7 @@ export function hasLinkOrComponent(node: any): boolean { } /** Extract the first CMS field binding from Tiptap JSON content (dynamicVariable node with type 'field'). */ -export function getCmsFieldBinding(node: any): { field_id: string; label?: string; source?: 'page' | 'collection'; collection_layer_id?: string; field_type?: string | null } | null { +export function getCmsFieldBinding(node: any): { field_id: string; label?: string; source?: 'page' | 'collection'; collection_layer_id?: string; field_type?: string | null; format?: string } | null { if (!node || typeof node !== 'object') return null; if (node.type === 'dynamicVariable') { const variable = node.attrs?.variable; @@ -199,6 +199,7 @@ export function getCmsFieldBinding(node: any): { field_id: string; label?: strin source: variable.data.source, collection_layer_id: variable.data.collection_layer_id, field_type: variable.data.field_type ?? null, + format: variable.data.format, }; } } diff --git a/lib/variable-format-utils.ts b/lib/variable-format-utils.ts new file mode 100644 index 00000000..c58a16df --- /dev/null +++ b/lib/variable-format-utils.ts @@ -0,0 +1,330 @@ +/** + * Variable Format Utilities + * + * Format presets and formatting functions for date and number inline variables. + * Uses Intl.DateTimeFormat and Intl.NumberFormat for locale-aware formatting. + */ + +import type { CollectionFieldType } from '@/types'; + +// ─── Date Format Presets ──────────────────────────────────────────── + +export interface DateFormatPreset { + id: string; + label: string; + options: Intl.DateTimeFormatOptions; + locale?: string; +} + +export interface FormatPresetSection { + title: string; + presets: T[]; +} + +export const DATE_FORMAT_SECTIONS: FormatPresetSection[] = [ + { + title: 'Date and time', + presets: [ + { + id: 'datetime-long', + label: 'March 26, 2026, 9:38 AM', + options: { month: 'long', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }, + }, + { + id: 'datetime-short', + label: 'Mar 26, 2026, 9:38 AM', + options: { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }, + }, + { + id: 'datetime-24h', + label: 'Mar 26, 2026, 09:38', + options: { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }, + }, + ], + }, + { + title: 'Date', + presets: [ + { + id: 'date-long', + label: 'March 26, 2026', + options: { month: 'long', day: 'numeric', year: 'numeric' }, + }, + { + id: 'date-short', + label: 'Mar 26, 2026', + options: { month: 'short', day: 'numeric', year: 'numeric' }, + }, + { + id: 'date-full', + label: 'Thursday, March 26, 2026', + options: { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }, + }, + { + id: 'date-short-weekday', + label: 'Thu, Mar 26, 2026', + options: { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }, + }, + { + id: 'date-us', + label: '3/26/2026', + options: { month: 'numeric', day: 'numeric', year: 'numeric' }, + locale: 'en-US', + }, + { + id: 'date-eu', + label: '26/03/2026', + options: { day: '2-digit', month: '2-digit', year: 'numeric' }, + locale: 'en-GB', + }, + { + id: 'date-eu-dot', + label: '26.03.2026', + options: { day: '2-digit', month: '2-digit', year: 'numeric' }, + locale: 'de-DE', + }, + { + id: 'date-iso', + label: '2026-03-26', + options: { year: 'numeric', month: '2-digit', day: '2-digit' }, + locale: 'sv-SE', + }, + { + id: 'date-month-year', + label: 'March 2026', + options: { month: 'long', year: 'numeric' }, + }, + { + id: 'date-short-month-year', + label: 'Mar 2026', + options: { month: 'short', year: 'numeric' }, + }, + { + id: 'date-day-month', + label: '26 Mar', + options: { day: 'numeric', month: 'short' }, + }, + ], + }, + { + title: 'Time', + presets: [ + { + id: 'time-12h', + label: '9:38 AM', + options: { hour: 'numeric', minute: '2-digit', hour12: true }, + }, + { + id: 'time-24h', + label: '09:38', + options: { hour: '2-digit', minute: '2-digit', hour12: false }, + }, + ], + }, +]; + +/** Flat list of all date presets (used for lookup by ID) */ +export const DATE_FORMAT_PRESETS: DateFormatPreset[] = + DATE_FORMAT_SECTIONS.flatMap(s => s.presets); + +// ─── Number Format Presets ────────────────────────────────────────── + +export interface NumberFormatPreset { + id: string; + label: string; + options: Intl.NumberFormatOptions; + locale?: string; + sample: number; +} + +export interface NumberFormatSection { + title: string; + presets: NumberFormatPreset[]; +} + +export const NUMBER_FORMAT_SECTIONS: NumberFormatSection[] = [ + { + title: 'Standard', + presets: [ + { + id: 'number-integer', + label: '12,345', + options: { maximumFractionDigits: 0, useGrouping: true }, + sample: 12345, + }, + { + id: 'number-decimal', + label: '12,345.00', + options: { minimumFractionDigits: 2, maximumFractionDigits: 2, useGrouping: true }, + sample: 12345, + }, + { + id: 'number-single-decimal', + label: '12,345.0', + options: { minimumFractionDigits: 1, maximumFractionDigits: 1, useGrouping: true }, + sample: 12345, + }, + { + id: 'number-plain', + label: '12345', + options: { maximumFractionDigits: 0, useGrouping: false }, + sample: 12345, + }, + { + id: 'number-plain-decimal', + label: '12345.00', + options: { minimumFractionDigits: 2, maximumFractionDigits: 2, useGrouping: false }, + sample: 12345, + }, + { + id: 'number-compact', + label: '12K', + options: { notation: 'compact', maximumFractionDigits: 1 }, + sample: 12345, + }, + ], + }, + { + title: 'Percent', + presets: [ + { + id: 'number-percent', + label: '12%', + options: { style: 'percent', maximumFractionDigits: 0 }, + sample: 0.12, + }, + { + id: 'number-percent-decimal', + label: '12.35%', + options: { style: 'percent', minimumFractionDigits: 2, maximumFractionDigits: 2 }, + sample: 0.1235, + }, + ], + }, +]; + +/** Flat list of all number presets (used for lookup by ID) */ +export const NUMBER_FORMAT_PRESETS: NumberFormatPreset[] = + NUMBER_FORMAT_SECTIONS.flatMap(s => s.presets); + +// ─── Helpers ──────────────────────────────────────────────────────── + +const datePresetMap = new Map(DATE_FORMAT_PRESETS.map(p => [p.id, p])); +const numberPresetMap = new Map(NUMBER_FORMAT_PRESETS.map(p => [p.id, p])); + +/** Check whether a field type supports format selection */ +export function isFormattableFieldType(fieldType: string | null | undefined): boolean { + return fieldType === 'date' || fieldType === 'number'; +} + +/** Get format preset sections for a field type (grouped with titles) */ +export function getFormatSectionsForFieldType( + fieldType: string | null | undefined +): FormatPresetSection[] { + if (fieldType === 'date') return DATE_FORMAT_SECTIONS; + if (fieldType === 'number') return NUMBER_FORMAT_SECTIONS; + return []; +} + +/** Get the default format ID for a field type */ +export function getDefaultFormatId(fieldType: string | null | undefined): string | undefined { + if (fieldType === 'date') return 'date-long'; + if (fieldType === 'number') return 'number-integer'; + return undefined; +} + +/** Build a field variable data object with the appropriate default format preset */ +export function buildFieldVariableData( + fieldId: string, + relationshipPath: string[], + fieldType: CollectionFieldType | null, + source?: string, + layerId?: string, +) { + const defaultFormat = getDefaultFormatId(fieldType); + return { + type: 'field' as const, + data: { + field_id: fieldId, + field_type: fieldType, + relationships: relationshipPath, + ...(defaultFormat && { format: defaultFormat }), + ...(source && { source: source as 'page' | 'collection' }), + ...(layerId && { collection_layer_id: layerId }), + }, + }; +} + +/** + * Format a date value using a preset ID + * Falls back to the default display format if preset is not found + */ +export function formatDateWithPreset( + value: string | Date, + presetId: string | undefined, + timezone: string = 'UTC' +): string { + const dateObj = typeof value === 'string' ? new Date(value) : value; + if (isNaN(dateObj.getTime())) return ''; + + const preset = presetId ? datePresetMap.get(presetId) : datePresetMap.get('date-long'); + if (!preset) { + return new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + month: 'long', + day: 'numeric', + year: 'numeric', + }).format(dateObj); + } + + try { + return new Intl.DateTimeFormat(preset.locale || 'en-US', { + timeZone: timezone, + ...preset.options, + }).format(dateObj); + } catch { + return ''; + } +} + +/** + * Format a number value using a preset ID + * Falls back to plain string conversion if preset is not found + */ +export function formatNumberWithPreset( + value: number, + presetId: string | undefined +): string { + const preset = presetId ? numberPresetMap.get(presetId) : undefined; + if (!preset) return String(value); + + try { + return new Intl.NumberFormat(preset.locale || 'en-US', preset.options).format(value); + } catch { + return String(value); + } +} + +/** + * Generate a live preview label for a date format preset using the current date + */ +export function getDateFormatPreview(preset: DateFormatPreset): string { + try { + return new Intl.DateTimeFormat(preset.locale || 'en-US', { + ...preset.options, + }).format(new Date()); + } catch { + return preset.label; + } +} + +/** + * Generate a live preview label for a number format preset + */ +export function getNumberFormatPreview(preset: NumberFormatPreset): string { + try { + return new Intl.NumberFormat(preset.locale || 'en-US', preset.options).format(preset.sample); + } catch { + return preset.label; + } +}