PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.2.0
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.2.0
2.4.1 2.4.0 2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 All 68 releases
better-payment / src / campaign-builder / store / campaignStore.js

campaignStore.js in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.2.0, at src/campaign-builder/store/campaignStore.js

333 lines 12.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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