| 1 |
import { v4 as uuidv4 } from 'uuid'; |
| 2 |
import { arrayMove } from '@dnd-kit/sortable'; |
| 3 |
|
| 4 |
/** |
| 5 |
* Campaign builder state — useReducer store. |
| 6 |
* |
| 7 |
* State shape: |
| 8 |
* { |
| 9 |
* meta: { title, bpc_goal_amount, ... }, |
| 10 |
* layout: { |
| 11 |
* layout: '1-column' | '2-column' | '3-column', |
| 12 |
* columns: [{ id, label, width, elements: [{ id, type, settings }] }] |
| 13 |
* }, |
| 14 |
* selectedElementId: null | string, |
| 15 |
* selectedColumnId: null | string, |
| 16 |
* templateModalOpen: boolean, // true on new campaign with no layout |
| 17 |
* isDirty: boolean, |
| 18 |
* isSaving: boolean, |
| 19 |
* saveError: null | string, |
| 20 |
* postStatus: 'publish' | 'draft', |
| 21 |
* viewUrl: string, // permalink when published, '' otherwise |
| 22 |
* } |
| 23 |
*/ |
| 24 |
|
| 25 |
export const initialState = { |
| 26 |
meta: { |
| 27 |
title: '', |
| 28 |
bpc_goal_amount: '', |
| 29 |
bpc_end_date: '', |
| 30 |
bpc_status: 'active', |
| 31 |
bpc_allow_custom_amount: 1, |
| 32 |
bpc_minimum_amount: '', |
| 33 |
bpc_form_page_id: 0, |
| 34 |
bpc_color_primary: '#6b63f6', |
| 35 |
bpc_color_button: '', |
| 36 |
bpc_css_class: '', |
| 37 |
bpc_template_key: '', |
| 38 |
bpc_suggested_amounts: [ |
| 39 |
{ id: 'sa_1', amount: '5', description: '', is_default: false }, |
| 40 |
{ id: 'sa_2', amount: '10', description: '', is_default: false }, |
| 41 |
{ id: 'sa_3', amount: '15', description: '', is_default: true }, |
| 42 |
{ id: 'sa_4', amount: '20', description: '', is_default: false }, |
| 43 |
], |
| 44 |
}, |
| 45 |
layout: { |
| 46 |
layout: '1-column', |
| 47 |
columns: [], |
| 48 |
}, |
| 49 |
selectedElementId: null, |
| 50 |
selectedColumnId: null, |
| 51 |
templateModalOpen: false, |
| 52 |
templateChosen: false, |
| 53 |
appliedTemplateKey: null, |
| 54 |
isDirty: false, |
| 55 |
isSaving: false, |
| 56 |
saveError: null, |
| 57 |
postStatus: 'draft', |
| 58 |
viewUrl: '', |
| 59 |
isInitializing: false, |
| 60 |
}; |
| 61 |
|
| 62 |
// ── Helpers ────────────────────────────────────────────────────────────────── |
| 63 |
|
| 64 |
/** Convert old flat-array layout (from Charitable-style builder) to column schema. */ |
| 65 |
function migrateLegacyLayout( raw ) { |
| 66 |
if ( raw && typeof raw === 'object' && raw.columns ) { |
| 67 |
return raw; // already new format |
| 68 |
} |
| 69 |
const elements = Array.isArray( raw ) ? raw : []; |
| 70 |
return { |
| 71 |
layout: '1-column', |
| 72 |
columns: [ |
| 73 |
{ id: 'main', label: 'Main Content', width: '100%', elements }, |
| 74 |
], |
| 75 |
}; |
| 76 |
} |
| 77 |
|
| 78 |
function makeElement( type, defaultSettings = {} ) { |
| 79 |
return { id: 'el_' + uuidv4().replace( /-/g, '' ).slice( 0, 12 ), type, settings: { ...defaultSettings } }; |
| 80 |
} |
| 81 |
|
| 82 |
function freshIds( columns ) { |
| 83 |
return columns.map( ( col ) => ( { |
| 84 |
...col, |
| 85 |
id: col.id || 'col_' + uuidv4().slice( 0, 8 ), |
| 86 |
elements: ( col.elements || [] ).map( ( el ) => ( { |
| 87 |
...el, |
| 88 |
id: 'el_' + uuidv4().replace( /-/g, '' ).slice( 0, 12 ), |
| 89 |
} ) ), |
| 90 |
} ) ); |
| 91 |
} |
| 92 |
|
| 93 |
function updateColumn( columns, columnId, updater ) { |
| 94 |
return columns.map( ( col ) => col.id === columnId ? updater( col ) : col ); |
| 95 |
} |
| 96 |
|
| 97 |
// ── Reducer ─────────────────────────────────────────────────────────────────── |
| 98 |
|
| 99 |
export function reducer( state, action ) { |
| 100 |
switch ( action.type ) { |
| 101 |
|
| 102 |
case 'SET_INITIALIZING': |
| 103 |
return { ...state, isInitializing: action.value }; |
| 104 |
|
| 105 |
case 'LOAD_CAMPAIGN': { |
| 106 |
const layout = migrateLegacyLayout( action.layout ); |
| 107 |
const loadedMeta = { ...action.meta }; |
| 108 |
|
| 109 |
// Migrate old comma-separated amounts string to array format. |
| 110 |
const sa = loadedMeta.bpc_suggested_amounts; |
| 111 |
if ( typeof sa === 'string' ) { |
| 112 |
const parts = sa.split( ',' ).map( ( s ) => s.trim() ).filter( Boolean ); |
| 113 |
loadedMeta.bpc_suggested_amounts = parts.map( ( amount, i ) => ( { |
| 114 |
id: 'sa_' + ( i + 1 ), |
| 115 |
amount, |
| 116 |
description: '', |
| 117 |
is_default: false, |
| 118 |
} ) ); |
| 119 |
} else if ( ! Array.isArray( sa ) || sa.length === 0 ) { |
| 120 |
loadedMeta.bpc_suggested_amounts = initialState.meta.bpc_suggested_amounts; |
| 121 |
} |
| 122 |
|
| 123 |
return { |
| 124 |
...state, |
| 125 |
meta: { ...state.meta, ...loadedMeta }, |
| 126 |
layout, |
| 127 |
templateModalOpen: false, |
| 128 |
templateChosen: !!loadedMeta.bpc_template_key, |
| 129 |
appliedTemplateKey: loadedMeta.bpc_template_key || null, |
| 130 |
isDirty: false, |
| 131 |
postStatus: action.postStatus || 'draft', |
| 132 |
viewUrl: action.viewUrl || '', |
| 133 |
isInitializing: false, |
| 134 |
}; |
| 135 |
} |
| 136 |
|
| 137 |
case 'UPDATE_META': |
| 138 |
return { ...state, meta: { ...state.meta, ...action.payload }, isDirty: true }; |
| 139 |
|
| 140 |
// ── Template ──────────────────────────────────────────────────────── |
| 141 |
case 'OPEN_TEMPLATE_MODAL': |
| 142 |
return { ...state, templateModalOpen: true }; |
| 143 |
|
| 144 |
case 'CLOSE_TEMPLATE_MODAL': |
| 145 |
return { ...state, templateModalOpen: false }; |
| 146 |
|
| 147 |
case 'APPLY_TEMPLATE': { |
| 148 |
const tpl = action.template; |
| 149 |
const defaultTitle = tpl.default_title || tpl.label; |
| 150 |
const metaUpdate = { bpc_template_key: tpl.key || '' }; |
| 151 |
// Only seed title from template when the user hasn't set one yet. |
| 152 |
if ( defaultTitle && ! state.meta.title ) metaUpdate.title = defaultTitle; |
| 153 |
|
| 154 |
// Effective title after this action — used to seed campaign_title elements. |
| 155 |
const effectiveTitle = metaUpdate.title || state.meta.title; |
| 156 |
let columns = freshIds( tpl.columns ); |
| 157 |
if ( effectiveTitle ) { |
| 158 |
columns = columns.map( ( col ) => ( { |
| 159 |
...col, |
| 160 |
elements: col.elements.map( ( el ) => |
| 161 |
el.type === 'campaign_title' && ! el.settings.title |
| 162 |
? { ...el, settings: { ...el.settings, title: effectiveTitle } } |
| 163 |
: el |
| 164 |
), |
| 165 |
} ) ); |
| 166 |
} |
| 167 |
const layout = { layout: tpl.layout, columns }; |
| 168 |
|
| 169 |
// Set default button color per template. |
| 170 |
const templateButtonColors = { |
| 171 |
'blank-1col': '#5AA152', |
| 172 |
'blank-2col': '#5AA152', |
| 173 |
'blank-3col': '#5AA152', |
| 174 |
'charity-basic': '#B49A5F', |
| 175 |
'medical-relief': '#7A8347', |
| 176 |
'education-fund': '#8FA040', |
| 177 |
'golf-destinations':'#B8A46A', |
| 178 |
'disaster-relief': '#c0392b', |
| 179 |
}; |
| 180 |
if ( templateButtonColors[ tpl.key ] ) { |
| 181 |
metaUpdate.bpc_color_button = templateButtonColors[ tpl.key ]; |
| 182 |
} |
| 183 |
|
| 184 |
return { |
| 185 |
...state, |
| 186 |
layout, |
| 187 |
meta: { ...state.meta, ...metaUpdate }, |
| 188 |
templateModalOpen: false, |
| 189 |
templateChosen: true, |
| 190 |
appliedTemplateKey: tpl.key || null, |
| 191 |
selectedElementId: null, |
| 192 |
selectedColumnId: null, |
| 193 |
isDirty: true, |
| 194 |
}; |
| 195 |
} |
| 196 |
|
| 197 |
// ── Elements ──────────────────────────────────────────────────────── |
| 198 |
case 'ADD_ELEMENT': { |
| 199 |
const elementDefaults = { ...( action.defaultSettings || {} ) }; |
| 200 |
// Seed campaign_title widget with the current top-nav title on first drop. |
| 201 |
if ( action.elementType === 'campaign_title' && state.meta.title && ! elementDefaults.title ) { |
| 202 |
elementDefaults.title = state.meta.title; |
| 203 |
} |
| 204 |
const newEl = makeElement( action.elementType, elementDefaults ); |
| 205 |
const columns = updateColumn( state.layout.columns, action.columnId, ( col ) => { |
| 206 |
const elements = [ ...col.elements ]; |
| 207 |
const insertAt = action.insertAt != null ? action.insertAt : elements.length; |
| 208 |
elements.splice( insertAt, 0, newEl ); |
| 209 |
return { ...col, elements }; |
| 210 |
} ); |
| 211 |
return { |
| 212 |
...state, |
| 213 |
layout: { ...state.layout, columns }, |
| 214 |
selectedElementId: newEl.id, |
| 215 |
selectedColumnId: action.columnId, |
| 216 |
isDirty: true, |
| 217 |
}; |
| 218 |
} |
| 219 |
|
| 220 |
case 'REMOVE_ELEMENT': { |
| 221 |
const columns = updateColumn( state.layout.columns, action.columnId, ( col ) => ( { |
| 222 |
...col, |
| 223 |
elements: col.elements.filter( ( el ) => el.id !== action.elementId ), |
| 224 |
} ) ); |
| 225 |
return { |
| 226 |
...state, |
| 227 |
layout: { ...state.layout, columns }, |
| 228 |
selectedElementId: state.selectedElementId === action.elementId ? null : state.selectedElementId, |
| 229 |
selectedColumnId: state.selectedElementId === action.elementId ? null : state.selectedColumnId, |
| 230 |
isDirty: true, |
| 231 |
}; |
| 232 |
} |
| 233 |
|
| 234 |
case 'DUPLICATE_ELEMENT': { |
| 235 |
const columns = updateColumn( state.layout.columns, action.columnId, ( col ) => { |
| 236 |
const idx = col.elements.findIndex( ( el ) => el.id === action.elementId ); |
| 237 |
if ( idx === -1 ) return col; |
| 238 |
const original = col.elements[ idx ]; |
| 239 |
const clone = { |
| 240 |
id: 'el_' + uuidv4().replace( /-/g, '' ).slice( 0, 12 ), |
| 241 |
type: original.type, |
| 242 |
settings: { ...original.settings }, |
| 243 |
}; |
| 244 |
const elements = [ ...col.elements ]; |
| 245 |
elements.splice( idx + 1, 0, clone ); |
| 246 |
return { ...col, elements }; |
| 247 |
} ); |
| 248 |
return { ...state, layout: { ...state.layout, columns }, isDirty: true }; |
| 249 |
} |
| 250 |
|
| 251 |
case 'REORDER_ELEMENTS': { |
| 252 |
const columns = updateColumn( state.layout.columns, action.columnId, ( col ) => ( { |
| 253 |
...col, |
| 254 |
elements: arrayMove( col.elements, action.oldIndex, action.newIndex ), |
| 255 |
} ) ); |
| 256 |
return { ...state, layout: { ...state.layout, columns }, isDirty: true }; |
| 257 |
} |
| 258 |
|
| 259 |
case 'MOVE_ELEMENT': { |
| 260 |
const { fromColumnId, toColumnId, elementId, insertAt } = action; |
| 261 |
let movedEl = null; |
| 262 |
|
| 263 |
let columns = state.layout.columns.map( ( col ) => { |
| 264 |
if ( col.id === fromColumnId ) { |
| 265 |
movedEl = col.elements.find( ( el ) => el.id === elementId ) || null; |
| 266 |
return { ...col, elements: col.elements.filter( ( el ) => el.id !== elementId ) }; |
| 267 |
} |
| 268 |
return col; |
| 269 |
} ); |
| 270 |
|
| 271 |
if ( ! movedEl ) return state; |
| 272 |
|
| 273 |
columns = columns.map( ( col ) => { |
| 274 |
if ( col.id === toColumnId ) { |
| 275 |
const elements = [ ...col.elements ]; |
| 276 |
elements.splice( insertAt != null ? insertAt : elements.length, 0, movedEl ); |
| 277 |
return { ...col, elements }; |
| 278 |
} |
| 279 |
return col; |
| 280 |
} ); |
| 281 |
|
| 282 |
return { |
| 283 |
...state, |
| 284 |
layout: { ...state.layout, columns }, |
| 285 |
selectedColumnId: toColumnId, |
| 286 |
isDirty: true, |
| 287 |
}; |
| 288 |
} |
| 289 |
|
| 290 |
case 'UPDATE_ELEMENT_SETTINGS': { |
| 291 |
const columns = state.layout.columns.map( ( col ) => ( { |
| 292 |
...col, |
| 293 |
elements: col.elements.map( ( el ) => |
| 294 |
el.id === action.elementId |
| 295 |
? { ...el, settings: { ...el.settings, ...action.settings } } |
| 296 |
: el |
| 297 |
), |
| 298 |
} ) ); |
| 299 |
return { ...state, layout: { ...state.layout, columns }, isDirty: true }; |
| 300 |
} |
| 301 |
|
| 302 |
case 'SELECT_ELEMENT': |
| 303 |
return { |
| 304 |
...state, |
| 305 |
selectedElementId: action.elementId, |
| 306 |
selectedColumnId: action.columnId, |
| 307 |
}; |
| 308 |
|
| 309 |
case 'DESELECT_ELEMENT': |
| 310 |
return { ...state, selectedElementId: null, selectedColumnId: null }; |
| 311 |
|
| 312 |
// ── Save ──────────────────────────────────────────────────────────── |
| 313 |
case 'SET_SAVING': |
| 314 |
return { ...state, isSaving: action.isSaving }; |
| 315 |
|
| 316 |
case 'SAVE_SUCCESS': |
| 317 |
return { |
| 318 |
...state, |
| 319 |
isDirty: false, |
| 320 |
isSaving: false, |
| 321 |
saveError: null, |
| 322 |
postStatus: action.postStatus || state.postStatus, |
| 323 |
viewUrl: action.viewUrl || state.viewUrl, |
| 324 |
}; |
| 325 |
|
| 326 |
case 'SAVE_ERROR': |
| 327 |
return { ...state, isSaving: false, saveError: action.error }; |
| 328 |
|
| 329 |
default: |
| 330 |
return state; |
| 331 |
} |
| 332 |
} |
| 333 |
|