1window.NeonMultiselect = { 2 name: 'neon-multiselect', 3 props: { 4 placeholder: { 5 type: String, 6 default: 'All' 7 }, 8 multiSelect: { 9 type: Boolean, 10 default: true 11 }, 12 items: { 13 type: Array, 14 default() { 15 return [] 16 } 17 }, 18 listClass: { 19 type: String, 20 default: '' 21 }, 22 initValue: { 23 type: String, 24 default: '' 25 }, 26 clearable: { 27 type: Boolean, 28 default: true 29 } 30 }, 31 data() { 32 return { 33 uuid: new Date().getTime() + '-' + ((Math.random() + '').replace('0.', '')), 34 show: false, 35 selectedItems: [] 36 } 37 }, 38 methods: { 39 toggleItem(item) { 40 let idx = this.selectedItems.findIndex(item_ => item.value === item_.value) 41 if (idx === -1) { 42 43 if (!this.multiSelect) { 44 this.clear(false) 45 } 46 47 this.selectedItems.push(item) 48 } else if (this.multiSelect) { 49 this.selectedItems.splice(idx, 1) 50 } 51 52 if (!this.multiSelect) { 53 this.show = false 54 } 55 }, 56 isSelectedItem(item) { 57 return this.selectedItems.findIndex(item_ => item.value === item_.value) !== -1 58 }, 59 clear(hide) { 60 this.selectedItems.splice(0, this.selectedItems.length) 61 if (hide) { 62 this.show = false 63 } 64 }, 65 handleDocumentClick(e) { 66 let container = e.target.closest('.neon-multi-select') 67 if (!container || container.getAttribute('uuid') !== this.uuid) { 68 this.show = false 69 } 70 }, 71 selectItemsByInitValue(value) { 72 let arr = value.split(',') 73 for (let i = 0; i < arr.length; i++) { 74 let res = this.items.filter(item => item.value === arr[i]) 75 if (res && res.length > 0) { 76 this.toggleItem(res[0]) 77 } 78 } 79 } 80 }, 81 computed: { 82 displayText() { 83 if (this.selectedItems.length === 0) { 84 return this.placeholder 85 } 86 return this.selectedItems[0].name 87 } 88 }, 89 watch: { 90 show() { 91 if (!this.show) { 92 this.$emit('hide', this.selectedItems) 93 } 94 }, 95 initValue(newVal) { 96 if (newVal) { 97 this.selectItemsByInitValue(newVal) 98 } 99 } 100 }, 101 mounted() { 102 if (this.initValue) { 103 this.selectItemsByInitValue(this.initValue) 104 } 105 document.addEventListener('mouseup', this.handleDocumentClick) 106 }, 107 unmounted() { 108 document.removeEventListener('mouseup', this.handleDocumentClick) 109 }, 110 template: ` 111 <div class="neon-multi-select" :uuid="uuid"> 112 <div class="placeholder" @click="show=!show"> 113 <span :class="{'has-val' : selectedItems.length >0}">{{displayText}}</span> 114 <span :class="{'has-val' : selectedItems.length >0}" style="margin-left: 3px;" v-if="multiSelect && selectedItems.length>1">+{{selectedItems.length - 1}}</span> 115 <span style="margin-left: 3px;"> 116 <i :class="{'fas fa-caret-down' : !show, 'fas fa-caret-up':show}"></i> 117 </span> 118 </div> 119 <div class="list" :class="listClass" v-show="show"> 120 <div class="up-arrow"></div> 121 <ul class="items"> 122 <li v-for="item in items" 123 class="item" 124 :class="{'is-selected': isSelectedItem(item)}" 125 @click="toggleItem(item)"> 126 <slot name="item" :item="item">{{item.name}}</slot> 127 </li> 128 </ul> 129 <div class="footer" v-if="selectedItems.length >0 && clearable">
130 <span @click="clear(true)">Clear</span> 131 </div> 132 </div> 133 </div>` 134} 135window.NeonFileupload= { 136 name: 'neon-fileupload', 137 props: { 138 uploadId: { 139 type: String, 140 default: '' 141 }, 142 multiple: { 143 type: Boolean, 144 default: true, 145 }, 146 accept: { 147 type: String, 148 default: '', 149 }, 150 /** 151 * Comma-separated extensions from a CO FILE field (allowFileType), e.g. "pdf,doc". 152 * When set, this list replaces the default extension allow-list for both picker and 153 * addFile validation. Not the same as accept (HTML MIME / dotted extensions). 154 */ 155 allowedTypes: { 156 type: String, 157 default: '', 158 }, 159 uploadurl: { 160 type: String, 161 default: '', 162 }, 163 uwidth: { 164 type: Number, 165 default: null, 166 }, 167 uheight: { 168 type: Number, 169 default: null, 170 }, 171 borderstyle: { 172 type: String, 173 default: 'dashed', 174 }, 175 filesize: { 176 type: Number, 177 default: 1000, 178 }, 179 unit: { 180 type: String, 181 default: 'MB' 182 }, 183 isupload: { 184 type: Boolean, 185 default: false, 186 }, 187 filename: { 188 type: Boolean, 189 default: 'file', 190 }, 191 paramsobj: { 192 type: Object, 193 default: {}, 194 }, 195 defaultFiles: { 196 type: Array, 197 default: [], 198 }, 199 async: { 200 type: Boolean, 201 default: true, 202 }, 203 clearWithInit: { 204 type: Boolean, 205 default: false, 206 }, 207 extension: { 208 type: Array, 209 default: ['mobi', 'km', 'me', 'ppk ', 'jtl', 'dat', 'one', 'kdc', 'stl', 'midi', 'png', 'thmx', 'conf', 'pnm', 'mht', 'dwg', 'mj2', 'xroles', 'onepkg', 'csv', 'jar', 'exr', 'exp', 'fh50', 'crw', 'srf', 'scala', 'aifc', 'slddrw', 'ecelp7470', 'aiff', 'jb2', 'iso19139', 'css', 'perl', 'f90', 'fh40', 'am', 'amr', 'f77', 'as', 'au', 'mef', 'sql', 'pkg', 'bpg', 'ad', 'ac', 'dtd', 'bash', 'dts', 'heif', 'junit', 'al', 'bpm', 'properties', 'kar', 'aj', 'bpk', 'heic', 'mli', 'sxi', 'sxw', 'webp', 'html', 'idl', 'data', 'mmpt', 'jfi', 'pro', 'mp4v', 'xbm', 'psd', 'mhtml', 'icb', 'mkd', 'asx', 'cs', 'rw2', 'mp4a', 'mkv', 'ics', 'webm', 'ico', 'cc', 'cxx', 'sxc', 'sxd', 'cl', 'ppj', 'arw', 'rtf', 'svg', 'lha', 'pps', 'txt', 'flac', 'ppm', 'ppz', 'ft12', 'ft11', 'asm', 'ft10', 'lhs', 'ppt', 'asf', 'r3d', 'xsamples', 'mka', 'asp', 'htaccess', 'groovy', 'mmmp', 'mid', 'abs-menulinks', 'g3', 'pod', 'dxf', 'rst', 'dxb', 'pom', 'mp2a', 'pot', 'jfif', 'el', 'ppa', 'ft', 'mjp2', 'pict', 'mpg', 'fv', 'nrw', 'mpt', 'mpp', 'xmind', 'fbs', 'fh', 'mpx', 'wdb', 'sldasm', 'mime', 'fn', 'gv', 'vsdm', 'markdown', 'jif', 'xdp', 'distz', 'ost', 'mos', 'diff', 'install', 'mov', 'mpe', 'wcm', 'zip', 'i3', 'vsdx', 'go', 'mng', 'sldprt', 'roles', 'ifb', 'hx', 'mp3', 'mp4', 'wax', 'xweb', 'mp2', 'wav', 'npx', 'fb2', 'ptx', 'awk', 'hh', 'mod', 'hs', 'xfdf', 'hp', 'ief', 'mmr', 'mmp', 'pst', 'xcf', 'ig', 'rest', 'avi', 'rwz', 'orf', 'in', 'xlog', 'jx', 'fff', 'list', 'lrm', 'fh4', 'fh5', 'dwfx', 'm2v', 'jp2', 'sldm', 'mdtext', 'jbig2', 'm3a', 'potm', 'fh8', 'jpgm', 'fh9', 'jl', 'fh7', 'sldx', 'coffee', 'xargs', 'jpgv', 'js', 'pptx', 'msp', 'owl', 'xht', 'm1v', 'mst', 'pyv', 'xif', 'jmx', 'm4', 'lisp', 'm3', 'jng', 'lrf', 'm2a', 'ppsx', 'pxn', 'vcs', 'keys', 'icns', 'mrw', 'md', 'vda', 'jpeg', 'xcat', 'mb', 'ma', 'wsdd', 'mg', 'mf', 'pya', 'pptm', 'ecelp9600', 'xltx', 'msi', 'xltm', 'n3', 'msg', 'rexx', 'epub', 'kml', 'opus', 'log', 'dotm', 'nb', 'iiq', 'readme', 'xlsb', 'ml', 'dotx', 'kmz', 'vcf', 'ppsm', 'xlsx', 'haml', 'xlsm', 'ntf', 'mxu', 'raw', 'lvp', 'tex', 'ras', 'config', 'cdr', 'bibtex', 'xquery', 'movie', 'project', 'makefile', 'aac', 'adoc', 'text', '3fr', 'cwiki', 'xlz', 'def', 'ocaml', 'smil', 'h263', 'h261', 'h264', 'ipa', 'bay', 'xmp', 'bau', 'xml', 'raf', 'bas', 'bat', 'rar', 'viv', 'ram', 'wks', 'scm', '3g2', 'jpm', 'ini', 'sas', 'vhd', 'xlf', 'json', 'inx', 'xld', 'xwelcome', 'k25', 'm4u', 'xlc', 'm4v', 'xla', 'xhtml', 'cbl', 'btif', 'xll', 'lua', 'pm', 'xlm', 'pl', 'pp', 'xlw', 'xlt', 'xlr', 'xls', 'py', 'ra', 'abs-linkmap', 'caf', 'h++', 'lsp', 'cab', 'gif', 'rb', 'ims', 'm3u', 'potx', 'dcl', 'm4a', 'mseq', 'm4b', 'dcx', 'cat', 'qt', 'dcs', 'jpf', 'jpg', 'fhc', 'jpe', 'tcl', 'chm', 'dist', 'wpl', 'sh', 'djv', 'ads', 'adp', 'wps', 'pbm', 'php3', 'oxps', 'shw', 'wbmp', 'emf', 'aspx', 'cgm', 'dtshd', 'cgi', 'tk', 'tif', 'mpeg', 'lzh', 'so', 'mpg4', 'st', 'pas', 'itk', 'less', 'ada', 'adb', 'elc', 'cfm', 'hpp', 'xgrm', 'cfg', 'flv', 'asnd', 'xps', 'rdf', 'nitf', 'xpm', 'restx', 'dif', 'dib', 'wma', 'cfml', 'jsp', 'vm', 'sed', 'classpath', 'wmf', 'tga', 'ihtml', 'j2c', 'wmx', 'fli', 'cfc', 'wmv', '3gp', 'flc', 'ft8', 'sfdu', 'ft9', 'ft7', 'java', 'wm', 'odf', 'xlam', 'cmd', 'doc', 'ppam', 'patch', 'docm', 'aif', 'odt', 'xegrm', 'dms', 'eot', 'x3f', 'bin', 'pef', 'vstx', 'xq', 'meta', 'ibooks', 'pen', 'clj', 'nar', 'dng', 'applescript', 'ent', 'xmap', 'pcx', 'pdf', 'fpx', 'vssx', 'tld', 'vstm', 'bib', 'manifest', 'tiff', 'eol', 'c++', 'dump', 'xsd', 'for', 'cil', 'mpga', 'eml', 'emz', 'php4', 'types', 'xsl', 'djvu', 'rgb', 'mmas', 'vssm', 'mmat', 'asciidoc', 'mmap', 'xsp', 'grm', 'pct', 'htm', 'xlex', 'mdb', 'rng', '4th', 'f4v', 'etx', 'asice', 'rnc', 'fvt', 'yaml', 'mdi', 'sr2', 'spx', 'deploy', 'vhdl', 'rnx', 'c', 'pic', 'd', 'e', 'f', 'oga', 'g', 'xliff', 'ecelp4800', 'h', 'bmp', 'l', 'm', 'rmi', 'p', 'ogg', 'nef', 'r', 's', 'asics', 'drf', 'v', 'svgz', 'y', 'rmp', 'drc', 'ogm', 'cr2', 'cpp', 'ogv', 'egrm', 'hxx', 'snd', 'rlc', 'fh12', 'dpr', 'fh11', 'fh10', 'aart', 'dpx', 'php', 'wvx', 'vtt', 'cob', 'xconf', 'dot', 'vsd', 'fst', 'docx', 'xwd', 'sml', 'cmx', 'smi', 'vsl', 'vsw', 'erf', 'vss', 'pgm', 'vst', 'erl'] 210 }, 211 showFileList: { 212 type: Boolean, 213 default: true, 214 }, 215 preview: { 216 type: Boolean, 217 default: false, 218 }, 219 autoPreview: { 220 type: Boolean, 221 default: true, 222 }, 223 previewUrl: { 224 type: String, 225 default: '', 226 }, 227 previewImageClass: { 228 type: String, 229 default: '', 230 } 231 }, 232 data() { 233 return { 234 isDragging: false, 235 dragCount: 0, 236 isBig: false, 237 showProcess: false, 238 files: this.defaultFiles, 239 uploadPrecent: 0, 240 isNull: false, 241 isType: false, 242 rejectedType: '', 243 errorMessage: '', 244 isError: false, 245 imageUrl: '', 246 } 247 }, 248 methods: { 249 uploadFile() { 250 this.$refs.fileinput.dispatchEvent(new MouseEvent('click')); 251 }, 252 onInputChange(e) { 253 const files = e.target.files;
254 Array.from(files).forEach(file => this.addFile(file)); 255 }, 256 addFile(file) { 257 this.isNull = false; 258 this.isType = false; 259 this.rejectedType = ''; 260 this.isBig = false; 261 this.isError = false; 262 if (file) { 263 const fileSize = file.size; 264 const fileName = file.name; 265 if (fileSize === 0){ 266 this.isNull = true; 267 return; 268 } 269 const supportedFileTypes = this.resolvedExtensions() 270 if (supportedFileTypes.length > 0) { 271 if (fileName.indexOf('.') > -1) { 272 let type = fileName.substring(fileName.lastIndexOf('.') + 1) 273 if(supportedFileTypes.indexOf(type.toLowerCase()) < 0) { 274 this.isType = true; 275 this.rejectedType = type.toLowerCase(); 276 return; 277 } 278 } else if (this.allowedTypes) { 279 this.isType = true; 280 this.rejectedType = ''; 281 return; 282 } 283 } 284 const isExceed = ("KB" === this.unit? fileSize / 1024 : f
284ileSize / 1024 / 1024) < this.filesize; 285 if (!isExceed) { 286 this.isBig = true; 287 file = ''; 288 return 289 } 290 } 291 if (this.files.length === 1 && !this.multiple){ 292 this.files[0] = file; 293 }else { 294 this.files.push(file); 295 } 296 if (this.preview && this.autoPreview) { 297 this.imageUrl = URL.createObjectURL(file); 298 } 299 if (this.async) { 300 this.upload(); 301 }else { 302 this.$emit('fileselected',this.getResponse(file)); 303 } 304 }, 305 getFileSize(size) { 306 const fSExt = ['Bytes', 'KB', 'MB', 'GB']; 307 let i = 0; 308 while(size > 900) { 309 size /= 1024; 310 i++; 311 } 312 return `${(Math.round(size * 100) / 100)} ${fSExt[i]}`; 313 }, 314 getFileName(str) { 315 return str.split('_copyCustomerFile_')[0] 316 }, 317 removeFile (v,i) { 318 this.files.splice(i,1); 319 this.$refs.fileinput.value = ''; 320 this.imageUrl = '' 321 this.$emit('remove',this.getResponse(v)); 322 }, 323 onDragEnter(e) { 324 e.preventDefault(); 325 this.dragCount++; 326 this.isDragging = true; 327 return false; 328 }, 329 onDragLeave(e) { 330 e.preventDefault(); 331 this.dragCount--; 332 if (this.dragCount <= 0) 333 this.isDragging = false; 334 }, 335 onDrop(e) { 336 e.preventDefault(); 337 e.stopPropagation(); 338 this.isDragging = false; 339 const files = e.dataTransfer.files; 340 Array.from(files).forEach(file => this.addFile(file)); 341 }, 342 clearFileList() { 343 this.files = []; 344 }, 345 async upload() { 346 const formData = new FormData(); 347 this.showProcess = true; 348 let config = { 349 headers: { 350 'Content-Type': 'multipart/form-data' 351 }, 352 onUploadProgress: (progressEvent) => { 353 this.uploadPrecent = (((progressEvent.loaded / progressEvent.total).toFixed(2) * 100) | 0); 354 } 355 }; 356 this.files.forEach(file => { 357 formData.append(this.filename, file); 358 }); 359 if (this.paramsobj){ 360 for (let i in this.paramsobj) { 361 formData.append(i, this.paramsobj[i]) 362 } 363 } 364 await axios.post(`${this.uploadurl}`, formData, config) 365 .then(response => { 366 this.uploadPrecent = 0; 367 this.showProcess = false; 368 let data = response.data; 369 if(!data.success){ 370 this.isError = true; 371 this.files = []; 372 } 373 this.$emit('uploaded', this.getResponse(response.data)); 374 }) 375 .catch(error => { 376 this.showProcess = false; 377 this.$emit('uploaderror',error); 378 }) 379 }, 380 getResponse(obj) { 381 return {key: this.uploadId, obj: obj} 382 }, 383 resolvedExtensions() { 384 if (this.allowedTypes) { 385 return String(this.allowedTypes).split(',').map(function (s) { 386 return s.trim().toLowerCase(); 387 }).filter(Boolean); 388 } 389 const ext = this.extension; 390 if (Array.isArray(ext)) { 391 return ext; 392 } 393 if (ext) { 394 return String(ext).split(',').map(function (s) { 395 return s.trim().toLowerCase(); 396 }).filter(Boolean); 397 } 398 return []; 399 }, 400 }, 401 computed: { 402 uploadPrecentStyle() { 403 return `width:${this.uploadPrecent}%;` 404 }, 405 fileSizeFormat(){ 406 if (!this.filesize) return 0 407 const value = this.filesize + '' 408 const regex = /[^0-9]/g; 409 const cleanValue = value.replace(regex, ''); 410 return cleanValue.replace(/\B(?=(\d{3})+(?!\d))/g, ','); 411 }, 412 acceptedFileTypes() { 413 if (this.accept) { 414 return this.accept; 415 } 416 const types = this.resolvedExtensions() 417 if (types.length === 0) { 418 return undefined; 419 } 420 // Explicit CO allow-list: always reflect it on the picker. The length < 10 cap is only 421 // to avoid stuffing the huge default extension array into the accept attribute. 422 if (this.allowedTypes || types.length < 10) { 423 return types.map(function (v) { return '.' + v; }).join(','); 424 } 425 return undefined; 426 }, 427 }, 428 watch: { 429 defaultFiles: { 430 handler: function(newVal, oldVal) { 431 if(newVal && newVal.length>0) { 432 this.files = newVal 433 } 434 }, 435 deep: true 436 }, 437 previewUrl(newVal) { 438 this.imageUrl = newVal; 439 } 440 }, 441 mounted() { 442 if(this.clearWithInit){ 443 this.clearFileList(); 444 } 445 this.imageUrl = this.previewUrl; 446 }, 447 unmounted() { 448 449 }, 450 template: ` 451 <div class="neon-file-upload" 452 tabindex="0" 453 :id="'neon-file-upload-box-' + uploadId" 454 @dragenter="onDragEnter" 455 @dragleave="onDragLeave" 456 @dragover.prevent 457 @drop="onDrop" 458 :style="{'width':uwidth+'px'}" 459 > 460 <div class="placeholder" 461 :class="{dragging: isDragging,error: isupload}" 462 :style="{'min-height':uheight+'px','border-style':borderstyle}" 463 > 464 <div class="upload-content" v-if="!(preview && imageUrl)"> 465 <i class="fas fa-cloud-upload-alt fa-fw"></i> 466 <p>Drop file here or <a style="color: #0074D9;">click to upload</a></p> 467 </div> 468 <label @click.self="uploadFile"></label> 469 <div class="upload-loading" v-if="showProcess">
470 <span> 471 <i class="fad fa-spinner"></i> 472 <p>Uploading...</p> 473 </span> 474 </div> 475 <input 476 type="file" 477 style="display: none;" 478 name="file" 479 :multiple="multiple" 480 ref="fileinput" 481 :accept="acceptedFileTypes" 482 class="upload-input" 483 @change="onInputChange"> 484 <img v-if="preview && imageUrl" :src="imageUrl" class="image-preview" :class="previewImageClass"/> 485 </div> 486 <div class="upload-tip error-text"> 487 <span v-if="isBig"><i class="fa fa-exclamation-triangle"></i>The file size must be less than {{fileSizeFormat}}{{unit}}</span> 488 <span v-if="isNull"><i class="fa fa-exclamation-triangle"></i>Can't upload empty file.</span> 489 <span v-if="isType"><i class="fa fa-exclamation-triangle"></i>{{ rejectedType ? (rejectedType + ' is not an allowed file type') : 'This file type is not allowed' }}</span> 490 <span v-if="isError"><i class="fa fa-exclamation-triangle"></i>This file is invalid,virus scan failed.</span> 491 <span v-if="isupload"> 492 <slot name="error"></slot> 493 </span> 494 </div> 495 <div class="upload-list" v-if="showFileList"> 496 <ul> 497 <li v-for="(v,i) in files" :key="i"> 498 <a class="upload-list-item-name"> 499 <span> 500 <i class="fa-solid fa-file"></i> 501 </span> 502 <span class="file-name">{{getFileName(v.name)}}</span> 503 </a> 504 <span class="file-size" v-if="v.size">{{getFileSize(v.size)}}</span> 505 <label class="upload-list-item-status-label"><i class="upload-success"></i></label> 506 <span class="upload-remove" @click="removeFile(v,i)"><i class="fal fa-times"></i></span> 507 </li> 508 </ul> 509 <div class="upload-precent" v-show="showProcess"> 510 <div class="upload-progress-bar" :style="uploadPrecentStyle"></div> 511 </div> 512 </div> 513 </div>` 514} 515window.NeonImgCutter= { 516 name: 'neon-img-cutter', 517 props: { 518 boxWidth: { 519 type: Number, 520 default: 372, 521 required: false, 522 }, 523 boxHeight: { 524 type: Number, 525 default: 279, 526 required: false, 527 }, 528 isModal: { 529 type: Boolean, 530 default: true, 531 required: false, 532 }, 533 isUplodedImg: { 534 type: String, 535 default: '' 536 }, 537 onlyCutter: { 538 type: Boolean, 539 default: false 540 } 541 }, 542 data() { 543 return { 544 cropper: null, 545 imgSrc: null, 546 originalImgSrc: '', 547 isPreviewImg: false, 548 customFile: null, 549 drawImg:{ 550 img:'' 551 }, 552 showToolBox: true, 553 } 554 }, 555 watch: { 556 isModal: { 557 handler(newVal){ 558 if(newVal){ 559 this.$nextTick(() => { 560 this.initializeCropper() 561 }) 562 } 563 } 564 }, 565 isUplodedImg: { 566 handler(newVal) { 567 if(newVal) { 568 this.imgSrc = this.isUplodedImg 569 } 570 } 571 } 572 }, 573 methods: { 574 initializeCropper() { 575 this.showToolBox = false 576 if (this.cropper) { 577 this.cropper.destroy() 578 } 579 if (this.originalImgSrc) { 580 this.$nextTick(()=>{ 581 const canvas = this.$refs.cropperImg 582 this.cropper = new Cropper(canvas, { 583 aspectRatio: 1, 584 viewMode: 1, 585 cropBoxResizable: false, 586 dragMode: 'move', 587 background: false, 588 guides: false, 589 scalable: false, 590 zoomOnWheel: false, 591 movable: false, 592 center: false, 593 minCropBoxWidth: 200, 594 minCropBoxHeight: 200, 595 autoCropArea: 0.4, 596 responsive: true, 597 }); 598 }) 599 } 600 }, 601 putImgToCanv(e) { 602 let file 603 if (e.target.files) { 604 file = e.target.files[0] || null 605 } else { 606 return false 607 } 608 if(file){ 609 if(file.type!=='image/png' && file.type!=='image/gif' && file.type!=='image/jpeg'){ 610 alert('Please choose jpg/gif/png format picture.') 611 app.resetProfilePhoto() 612 return 613 } 614 const fileSize = file.size 615 const isLt10M = fileSize/1024 < 500 616 if (!isLt10M) {
617 alert('The uploaded photo size cannot exceed 500KB.') 618 file = '' 619 app.resetProfilePhoto() 620 return 621 } 622 this.isPreviewImg = false 623 const reader = new FileReader(); 624 reader.onload = (e) => { 625 this.originalImgSrc = e.target.result 626 this.initializeCropper() 627 }; 628 reader.readAsDataURL(file) 629 } 630 }, 631 ediitProfilePicture() { 632 this.isPreviewImg = false 633 this.initializeCropper() 634 }, 635 cropPicture() { 636 if (this.cropper) { 637 const croppedCanvas = this.cropper.getCroppedCanvas({ 638 width: 372, 639 height: 279 640 }); 641 const croppedImage = croppedCanvas.toDataURL("image/png") 642 this.customFile = this.dataURLtoFile(croppedImage, "cropped.png"); 643 this.imgSrc = croppedImage 644 this.isPreviewImg = true 645 this.showToolBox = true 646 this.cropper.destroy() 647 } 648 }, 649 dataURLtoFile(dataurl, filename) { 650 let arr = dataurl.split(','), 651 mime = arr[0].match(/:(.*?);/)[1], 652 bstr = atob(arr[1]), 653 n = bstr.length, 654 u8arr = new Uint8Array(n) 655 656 while (n--) { 657 u8arr[n] = bstr.charCodeAt(n) 658 } 659 return new File([u8arr], filename, { type: mime }) 660 }, 661 handleClosed(){ 662 this.closedModal() 663 this.$emit('neonimgclearall') 664 }, 665 closedModal() { 666 if(this.isUplodedImg) { 667 this.imgSrc = this.isUplodedImg 668 }else { 669 this.imgSrc = null 670 } 671 this.originalImgSrc = '' 672 this.isPreviewImg = false 673 this.customFile = null 674 if(this.cropper){ 675 this.cropper.clear() 676 this.cropper.destroy() 677 } 678 }, 679 saveProfilePicture() { 680 app.uploadProfilePhoto(this.customFile) 681 this.handleClosed() 682 }, 683 backButton(){ 684 if(this.originalImgSrc && !this.isPreviewImg){ 685 this.showToolBox = true 686 this.$nextTick(function (){ 687 const dom = document.getElementById('account-profile-photo') 688 if (dom) { 689 dom.value = '' 690 } 691 }) 692 this.closedModal() 693 if (this.onlyCutter) { 694 this.$emit('neonimgclearall'); 695 } 696 }else { 697 this.ediitProfilePicture() 698 } 699 } 700 701 }, 702 mounted() { 703 if(this.isUplodedImg) { 704 this.imgSrc = this.isUplodedImg 705 } 706 if(this.onlyCutter){ 707 this.originalImgSrc = this.isUplodedImg 708 this.initializeCropper() 709 } 710 }, 711 computed: { 712 713 }, 714 template: `<div class="neon-img-cutter"> 715 <div class="nic-header"> 716 <span @click="handleClosed"><i class="fal fa-times"></i></span> 717 </div> 718 <div class="nic-body"> 719 <div class="nicb-title"> 720 <span>Add profile picture</span> 721 </div> 722 <div class="nicb-body" ref="copperbody"> 723 <div class="toolMain"> 724 <div ref="toolBox" class="toolBox" v-show="showToolBox"> 725 <div class="tips"> 726 <div class="imgCutDownBox"> 727 <div class="imgCutDownBoxContainer"> 728 <img ref="image" :src="imgSrc" v-if="!!imgSrc" alt="" /> 729 <i v-else class="fal fa-user"></i> 730 </div> 731 </div> 732 </div> 733 </div> 734 </div> 735 <img :src="originalImgSrc" class="img-canvas" ref="cropperImg"> 736 </div> 737 <div class="nicb-footer"> 738 <div v-if="!isPreviewImg"> 739 <label for="account-profile-photo" class="nicbf-default-button btn btn-outline-secondary btn-lg neonfront-btn-outline-primary w-auto preview-action-control">
740 <span>Upload</span> 741 </label> 742 <input class="form-control d-none" type="file" id="account-profile-photo" style="width: 1px; height: 1px; border: none; opacity: 0" accept="image/jpeg,image/png,image/gif" @change="putImgToCanv"> 743 </div> 744 <div v-else> 745 <label @click="ediitProfilePicture" class="nicbf-default-button btn btn-outline-secondary btn-lg neonfront-btn-outline-primary w-auto preview-action-control"> 746 <span>Edit profile picture</span> 747 </label> 748 </div> 749 </div> 750 </div> 751 <div class="nic-footer"> 752 <div class="nicf-cancel"> 753 <span @click="backButton" v-show="originalImgSrc">Back</span> 754 </div> 755 <div class="nicf-next"> 756 <button @click="cropPicture" :disabled="!originalImgSrc" :class="{'disabled-button':!originalImgSrc}" v-if="!isPreviewImg">Next</button> 757 <button v-else @click="saveProfilePicture">Save as profile picture</button> 758 </div> 759 </div> 760</div> 761 <div class="neon-img-cutter-mask"></div> 762` 763} 764window.NeonAdvancedsearch = { 765 name: 'neon-advancedsearch', 766 props: { 767 criteriaFields: { 768 type: Array, 769 default() { 770 return [] 771 } 772 }, 773 sortByItems: { 774 type: Array, 775 default() { 776 return [] 777 } 778 }, 779 defaultSortBy: { 780 type: String, 781 default: '' 782 }, 783 className: { 784 String: String, 785 default: '' 786 }, 787 }, 788 data() { 789 return { 790 showPopover: false, 791 uuid: new Date().getTime() + '-' + ((Math.random() + '').replace('0.', '')), 792 selectedItems: [], 793 currentActive: -1, 794 selectSortByText: '', 795 top: 0, 796 left: 0, 797 scrollTop: 0, 798 miniObject: { 799 showOverlay: false, 800 tempShow: '', 801 tempSelect: [], 802 selectedField: {} 803 }, 804 dateFieldInit: false 805 } 806 }, 807 methods: { 808 miniClickToShow($key) { 809 this.miniObject.tempSelect = [] 810 if (this.miniObject.selectedField[$key]) { 811 this.miniObject.tempSelect.push(...this.miniObject.selectedField[$key]) 812 } 813 this.miniObject.tempShow = $key 814 this.miniObject.showOverlay = true 815 }, 816 miniDoneSelect($key) { 817 this.miniObject.selectedField[$key] = [] 818 this.miniObject.selectedField[$key].push(...this.miniObject.tempSelect) 819 this.miniCloseShow() 820 }, 821 miniCloseShow() { 822 this.miniObject.tempSelect = [] 823 this.miniObject.tempShow = '' 824 this.miniObject.showOverlay = false 825 }, 826 miniSelectItem(item, $single) { 827 if ($single) { 828 this.miniObject.tempSelect = [] 829 this.miniObject.tempSelect.push(item.value) 830 } else { 831 let idx = this.miniObject.tempSelect.findIndex($v => $v === item.value) 832 if (idx === -1) { 833 this.miniObject.tempSelect.push(item.value) 834 } else { 835 this.miniObject.tempSelect.splice(idx, 1) 836 } 837 } 838 }, 839 isMiniSelectItem(item) { 840 return this.miniObject.tempSelect.findIndex(e => e === item.value) !== -1 841 }, 842 isSelectAll($items) { 843 return this.miniObject.tempSelect.length === $items.length 844 }, 845 miniSelectAll($items) { 846 if ($items && $items.length > 0 && this.miniObject.tempSelect.length !== $items.length) { 847 this.miniObject.tempSelect = [] 848 $items.forEach($e => { 849 this.miniObject.tempSelect.push($e.value) 850 }) 851 } else { 852 this.miniObject.tempSelect = [] 853 } 854 }, 855 miniClear() { 856 for (let k in this.miniObject.selectedField) { 857 if (k !== 'sort') {
858 delete this.miniObject.selectedField[k] 859 } 860 } 861 }, 862 miniCancelSearch() { 863 this.initSearchFields() 864 this.showPopover = false 865 }, 866 miniDoSearch() { 867 let sortBy = this.miniObject.selectedField['sort'] 868 this.criteriaFields.forEach($field => { 869 let $key = 'f_' + $field.fieldId 870 let vls = this.miniObject.selectedField[$key] || [] 871 $field.value = vls 872 if ('1,2,3'.indexOf($field.displayType) > -1) { 873 $field.value = [] 874 if ($field.options && $field.options.length > 0) { 875 $field.value.push(...vls) 876 $field.options.forEach(opt => { 877 opt.selected = vls.indexOf(opt) > -1 878 }) 879 } 880 } 881 }) 882 this.$emit('selected', this.criteriaFields, sortBy && sortBy.length > 0 ? sortBy[0] : null) 883 this.showPopover = false 884 this.$emit('popovercalbk', this.showPopover) 885 }, 886 miniUpdateCriteria($field) { 887 let $key = 'f_' + $field.fieldId 888 if ('1,2,3'.indexOf($field.displayType) > -1) { 889 this.miniObject.selectedField[$key] = [] 890 this.miniObject.selectedField[$key].push(...$field.value) 891 } else { 892 this.miniObject.selectedField[$key] = $field.value 893 } 894 }, 895 togglePopoverBox(e) { 896 let container = e.target.closest(`.${this.className}`) 897 if (this.showPopover && !this.$el.contains(e.target) && !container) { 898 this.showPopover = false 899 this.$emit('popovercalbk', this.showPopover) 900 } 901 }, 902 closePopoverBox() { 903 this.initSearchFields() 904 this.showPopover = false 905 this.$emit('popovercalbk', this.showPopover) 906 }, 907 handleDocumentClick(e) { 908 let container = e.target.closest('.npb-multi-select') 909 if (!container || container.getAttribute('uuid') !== this.uuid) { 910 this.criteriaFields.forEach($field => { 911 if ($field.keyword) { 912 $field.keyword = '' 913 } 914 }) 915 this.currentActive = -1 916 } 917 }, 918 changeCalbk($field) { 919 if ($field) { 920 let $p = [$field] 921 this.miniUpdateCriteria($field) 922 this.$emit('selected', $p) 923 } else { 924 this.criteriaFields.forEach(e => { 925 this.miniUpdateCriteria(e) 926 }) 927 this.$emit('selected') 928 } 929 }, 930 toggleItem($field, item) { 931 item.selected = !item.selected 932 933 if (item.selected) { 934 $field.value.push(item.value) 935 } else { 936 let idx = $field.value.findIndex(val => val === item.value) 937 $field.value.splice(idx, 1) 938 } 939 940 $field.selectAll = $field.value.length === $field.options.length 941 this.changeCalbk($field) 942 }, 943 toggleAllItem($field) { 944 $field.selectAll = !$field.selectAll 945 $field.value = [] 946 $field.options.forEach(opt => { 947 opt.selected = $field.selectAll 948 if (opt.selected) { 949 $field.value.push(opt.value) 950 } 951 }) 952 this.changeCalbk($field) 953 }, 954 clearData() { 955 this.criteriaFields.forEach($field => { 956 $field.value = '' 957 $field.selectAll = false 958 if ($field.options && $field.options.length > 0) { 959 $field.value = [] 960 $field.options.forEach(opt => { 961 opt.selected = false 962 }) 963 } 964 }) 965 this.miniClear() 966 this.changeCalbk() 967 }, 968 sortBy($selectedVal) {
969 this.miniObject.selectSortBy = $selectedVal 970 this.$emit("sortby", $selectedVal) 971 }, 972 togglePopOver() { 973 this.initDateField() 974 this.showPopover = !this.showPopover 975 this.buttonPosition() 976 this.$emit('popovercalbk', this.showPopover) 977 }, 978 buttonPosition() { 979 this.$nextTick(() => { 980 let dom = '' 981 let $dom = document.querySelectorAll(`.${this.className}`); 982 $dom.forEach(e => { 983 if (e.getBoundingClientRect().height > 0) return dom = e 984 }) 985 if (dom) { 986 if (this.scrollTop > 0) { 987 this.top = dom.getBoundingClientRect().y + dom.getBoundingClientRect().height + 10 + this.scrollTop 988 } else { 989 this.top = dom.getBoundingClientRect().y + dom.getBoundingClientRect().height + 10 990 } 991 } 992 }) 993 }, 994 handleScroll() { 995 this.scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop 996 }, 997 initSearchFields() { 998 if (this.criteriaFields && this.criteriaFields.length > 0) { 999 this.criteriaFields.forEach($f => { 1000 let $key = 'f_' + $f.fieldId 1001 if ('1,2,3'.indexOf($f.displayType) > -1) { 1002 this.miniObject.selectedField[$key] = $f.value || [] 1003 } else { 1004 this.miniObject.selectedField[$key] = $f.value || '' 1005 } 1006 }) 1007 } 1008 }, 1009 initDateField() { 1010 if (this.dateFieldInit) { 1011 return 1012 } 1013 this.$nextTick(function () { 1014 for (let f of this.criteriaFields) { 1015 this.registerLayDate(f) 1016 } 1017 this.dateFieldInit = true 1018 }) 1019 }, 1020 registerLayDate($field) { 1021 if ($field.dataType !== '4' && $field.dataType !== '5') { 1022 return 1023 } 1024 let _this = this 1025 let format = $field.dataType === '4' ? 'MM/dd/yyyy' : 'HH:mm:ss' 1026 let type = $field.dataType === '4' ? 'date' : 'time' 1027 let elem = document.getElementById('cf_' + $field.fieldId) 1028 let miniElem = document.getElementById('minicf_' + $field.fieldId) 1029 let rangeField = ['membership_stats.firstJoinDate','membership_listing.startDate','membership_listing.endDate'] 1030 let $range = rangeField.indexOf($field.fieldCode) > -1 ? '~' : false 1031 laydate.render({ 1032 elem: elem, 1033 lang: 'en', 1034 type: type, 1035 format: format, 1036 range: $range, 1037 ready: function (value) { 1038 }, 1039 done: function (value) { 1040 $field.value = value 1041 _this.changeCalbk($field) 1042 } 1043 }) 1044 let $key = 'f_' + $field.fieldId 1045 laydate.render({ 1046 elem: miniElem, 1047 lang: 'en', 1048 type: type, 1049 format: format, 1050 range: $range, 1051 ready: function (value) { 1052 }, 1053 done: function (value) { 1054 _this.miniObject.selectedField[$key] = value 1055 } 1056 }) 1057 } 1058 }, 1059 computed: { 1060 selectedOptNum() { 1061 return ($field) => { 1062 return this.miniObject.selectedField['f_' + $field.fieldId] && this.miniObject.selectedField['f_' + $field.fieldId].length > 0 ? this.miniObject.selectedField['f_' + $field.fieldId].length : 0 1063 } 1064 }, 1065 getValueLabel() { 1066 return ($key, $items) => { 1067 let selectedVals = this.miniObject.selectedField[$key] 1068 if (selectedVals && selectedVals.length > 0) { 1069 let $item = $items.filter($itm => $itm.value === selectedVals[0]) 1070 return $item && $item.length > 0 ? $item[0].name : '' 1071 } 1072 return '' 1073 } 1074 }, 1075 searchData() { 1076 return ($options, $searchValue) => { 1077 if (!$searchValue) return $options 1078 return $options.filter(v => v.name.toLowerCase().includes($searchValue.toLowerCase().trim())) 1079 } 1080 } 1081 }, 1082 watch: { 1083 defaultSortBy: { 1084 immediate: true, 1085 handler($newVal) {
1086 this.miniObject.selectedField['sort'] = [] 1087 this.miniObject.selectedField['sort'].push($newVal) 1088 } 1089 } 1090 }, 1091 mounted() { 1092 this.initSearchFields() 1093 window.onresize = () => { 1094 this.buttonPosition() 1095 } 1096 window.addEventListener('scroll', this.handleScroll) 1097 }, 1098 created() { 1099 this.$nextTick(() => { 1100 let $dom = document.querySelectorAll(`.${this.className}`); 1101 $dom.forEach(e => { 1102 e.addEventListener('click', this.togglePopOver); 1103 }) 1104 }) 1105 document.addEventListener('click', this.togglePopoverBox) 1106 document.addEventListener('mouseup', this.handleDocumentClick) 1107 }, 1108 beforeDestroy() { 1109 this.$nextTick(() => { 1110 let $dom = document.querySelectorAll(`.${this.className}`); 1111 $dom.forEach(e => { 1112 e.removeEventListener('click', this.togglePopOver); 1113 }) 1114 }) 1115 document.removeEventListener('click', this.togglePopoverBox) 1116 document.removeEventListener('mouseup', this.handleDocumentClick) 1117 window.removeEventListener('scroll', this.handleScroll) 1118 }, 1119 template: ` 1120 <div class="neon-advanced-search-box" :style="[{top:top + 'px'},{left:left + 'px'}]"> 1121 <div class="neon-advanced-search"> 1122 <div class="nas-max-show-body"> 1123 <transition name="slideLeft"> 1124 <div class="neon-advanced-search-popover" v-show="showPopover"> 1125 <div class="nas-popover-header"> 1126 <span class="title-text nas-max-show">Advanced Settings</span> 1127 <span class="title-clear" @click="clearData">Clear</span> 1128 </div> 1129 <div class="nas-popover-body"> 1130 <div class="nas-mini-show-sort-by npb-options" v-if="sortByItems && sortByItems.length > 0"> 1131 <span class="npb-options-lab">Sort-by</span> 1132 <neon-select class="select-sort-by" :items="sortByItems" :default-val="defaultSortBy" @selected="sortBy"></neon-select> 1133 </div> 1134 <div class="npb-options" v-for="($field, index) in criteriaFields" :key="$field.fieldId"> 1135 <span class="npb-options-lab">{{$field.isCustom === '1' ? $field.fieldPublicName : $field.fieldDisplayName}}</span> 1136 <div class="input-group" v-if="$field.type === 'text' && ($field.dataType === '4' || $field.dataType === '5')"> 1137 <input :id="'cf_' + $field.fieldId" class="npb-options-input calendar-input" type="text" :placeholder="$field.placeHolder" v-model="$field.value"/> 1138 <span class="input-group-text far" :class="[$field.dataType === '4' ? 'fa-calendar-alt':'fa-clock']" style=""></span> 1139 </div> 1140 <div class="input-group" v-else-if="$field.type === 'text'"> 1141 <input :id="'cf_' + $field.fieldId" class="npb-options-input calendar-input" type="text" :placeholder="$field.placeHolder" @blur="changeCalbk($field)" @keydown.enter.prevent="changeCalbk($field)" v-model="$field.value"/> 1142 </div> 1143 <div class="npb-multi-select" v-if="$field.type === 'dropdown'" :uuid="uuid"> 1144 <div class="placeholder" @click="currentActive = index"> 1145 <input :placeholder="$field.placeHolder" type="text" v-model="$field.keyword" class="npb-options-input"> 1146 <div class="input-right-text"> 1147 <span style="margin-left: 3px;" v-show="$field.value.length > 0">+{{$field.value.length}}</span>
1148 <span style="margin-left: 7px;"> 1149 <i class="fas fa-caret-down"></i> 1150 </span> 1151 </div> 1152 </div> 1153 <div class="list" v-show="currentActive === index"> 1154 <div class="list-scrollbar"> 1155 <div class="up-arrow"></div> 1156 <ul class="items"> 1157 <li class="item" 1158 @click.prevent="toggleAllItem($field)" 1159 :class="{'is-selected': $field.selectAll}" 1160 v-show="!$field.keyword" 1161 > 1162 <label> 1163 <span class="list-checkbox-input"> 1164 <span class="list-checkbox-inner"></span> 1165 <input type="checkbox" class="item-checked" v-model="$field.selectAll"> 1166 </span> 1167 <sapn name="item" :item="item">Select All</sapn> 1168 </label> 1169 </li> 1170 <li class="item" 1171 v-for="(option,i) in searchData($field.options,$field.keyword)" 1172 :key="i" 1173 @click.prevent="toggleItem($field,option)" 1174 :class="{'is-selected': option.selected}" 1175 > 1176 <label> 1177 <span class="list-checkbox-input"> 1178 <span class="list-checkbox-inner"></span> 1179 <input type="checkbox" class="item-checked" :value="option.value" v-model="option.selected"> 1180 </span> 1181 <sapn class="item-text" name="item">{{option.name}}</sapn> 1182 </label> 1183 </li> 1184 </ul> 1185 <div class="filter-seach-empty" v-if="!searchData($field.options,$field.keyword).length > 0">No results match "{{$field.keyword}}"</div> 1186 </div> 1187 </div> 1188 </div> 1189 </div> 1190 </div> 1191 <div class="nas-popper-arrow"></div> 1192 </div> 1193 </transition> 1194 </div> 1195 <div class="nas-mini-show-transition"> 1196 <transition name="slideLeft" appear> 1197 <div class="neon-advanced-search-popover" v-show="showPopover"> 1198 <div class="nas-mini-show nas-mini-show-header">
1199 <span @click="closePopoverBox" class="clickable"><i class="fas fa-arrow-left" style="color: #0074d9;"></i></span> 1200 <span class="header-title-text">Search</span> 1201 <span @click="closePopoverBox" class="clickable"><i class="fal fa-times" style="color: #002d41;"></i></span> 1202 </div> 1203 <div class="nas-popover-header"> 1204 <span class="title-text nas-mini-show">Advanced Search</span> 1205 <span class="title-clear" @click="miniClear">Clear</span> 1206 </div> 1207 <div class="nas-popover-body"> 1208 <div class="nas-mini-show npb-options"> 1209 <span class="npb-options-lab">Sort By</span> 1210 <div class="npb-multi-select"> 1211 <div class="placeholder" @click="miniClickToShow('sort')"> 1212 <input type="text" class="npb-options-input" readonly="readonly" :value="getValueLabel('sort', sortByItems)"> 1213 <div class="input-right-text"> 1214 <span style="margin-left: 10px;"> 1215 <i class="fas fa-caret-down"></i> 1216 </span> 1217 </div> 1218 </div> 1219 <div class="list" v-show="miniObject.tempShow === 'sort'"> 1220 <div class="nas-mini-show nas-dropdown-list-header"> 1221 <span class="mini-dropdown-cancel" @click="miniCloseShow()"><i class="fal fa-times" style="color: #002d41;font-size: 14px"></i></span> 1222 <span class="mini-dropdown-done" @click="miniDoneSelect('sort')">Done</span> 1223 </div> 1224 <div class="up-arrow"></div> 1225 <ul class="items"> 1226 <li v-for="item in sortByItems" class="item" @click.prevent="miniSelectItem(item, true)" :class="{'is-selected': isMiniSelectItem(item)}" > 1227 <label> 1228 <sapn name="item" class="item-text">{{item.name}}</sapn> 1229 </label> 1230 </li> 1231 </ul> 1232 </div> 1233 </div> 1234 </div> 1235 1236 <div class="npb-options" v-for="($field, index) in criteriaFields" :key="$field.fieldId"> 1237 <span class="npb-options-lab">{{$field.isCustom === '1' ? $field.fieldPublicName : $field.fieldDisplayName}}</span> 1238 <div class="input-group" v-if="$field.type === 'text' && ($field.dataType === '4' || $field.dataType === '5')"> 1239 <input :id="'minicf_' + $field.fieldId" class="npb-options-input calendar-input" type="text" :placeholder="$field.placeHolder" v-model="miniObject.selectedField['f_'+ $field.fieldId]"/> 1240 <span class="input-group-text far" :class="[$field.dataType === '4' ? 'fa-calendar-alt':'fa-clock']" style=""></span> 1241 </div> 1242 <div class="input-group" v-else-if="$field.type === 'text'"> 1243 <input :id="'minicf_' + $field.fieldId" class="npb-options-input" type="text" :placeholder="$field.placeHolder" v-model="miniObject.
1243selectedField['f_'+ $field.fieldId]"/> 1244 </div> 1245 <div class="npb-multi-select" v-if="$field.type === 'dropdown'" :uuid="uuid"> 1246 <div class="placeholder" @click="miniClickToShow('f_' + $field.fieldId)"> 1247 <input :placeholder="$field.placeHolder" type="text" v-model="$field.keyword" readonly="readonly" class="npb-options-input"> 1248 <div class="input-right-text"> 1249 <span style="margin-left: 3px;" v-show="selectedOptNum($field) > 0">+{{selectedOptNum($field)}}</span> 1250 <span style="margin-left: 7px;"> 1251 <i class="fas fa-caret-down"></i> 1252 </span> 1253 </div> 1254 </div> 1255 <div class="list" v-show="miniObject.tempShow === 'f_' + $field.fieldId"> 1256 <div class="list-scrollbar"> 1257 <div class="nas-mini-show nas-dropdown-list-header"> 1258 <span class="mini-dropdown-cancel" @click="miniCloseShow()"><i class="fal fa-times" style="color: #002d41;font-size: 14px"></i></span> 1259 <span class="mini-dropdown-done" @click="miniDoneSelect('f_' + $field.fieldId)">Done</span> 1260 </div> 1261 <div class="up-arrow"></div> 1262 <ul class="items"> 1263 <li class="item" 1264 @click.prevent="miniSelectAll($field.options)" 1265 :class="{'is-selected': isSelectAll($field.options)}" 1266 v-show="!$field.keyword"> 1267 <label> 1268 <span class="list-checkbox-input"> 1269 <span class="list-checkbox-inner"></span> 1270 <input type="checkbox" class="item-checked" :checked="isSelectAll($field.options)"> 1271 </span> 1272 <sapn name="item" :item="item">Select All</sapn> 1273 </label> 1274 </li> 1275 <li class="item" 1276 v-for="(option,i) in $field.options" 1277 :key="i" 1278 @click.prevent="miniSelectItem(option)" 1279 :class="{'is-selected': isMiniSelectItem(option)}" 1280 > 1281 <label> 1282 <span class="list-checkbox-input"> 1283 <span class="list-checkbox-inner"></span> 1284 <input type="checkbox" class="item-checked" :checked="isMiniSelectItem(option)"> 1285 </span> 1286 <sapn class="item-text" name="item">{{option.name}}</sapn> 1287 </label> 1288 </li> 1289 </ul> 1290 </div> 1291 </div> 1292 </div> 1293 </div> 1294 </div> 1295 <div class="nas-popper-arrow"></div> 1296 <div class="nas-mini-show nas-mini-showoverlay" v-show="miniObject.
1296showOverlay"></div> 1297 <div class="nas-mini-show nas-nini-show-footer"> 1298 <span class="footer-cancle clickable" @click="closePopoverBox()">Cancel</span> 1299 <span class="footer-button" @click="miniDoSearch()"><button>Apply</button></span> 1300 </div> 1301 </div> 1302 </transition> 1303 </div> 1304 </div> 1305 </div>` 1306} 1307window.NeonSelect = { 1308 name: 'neon-select', 1309 props: { 1310 defaultVal: { 1311 type: String, 1312 default: '' 1313 }, 1314 items: { 1315 type: Array, 1316 default() { 1317 return [] 1318 } 1319 }, 1320 listClass: { 1321 type: String, 1322 default: '' 1323 }, 1324 showLabel: { 1325 type: Boolean, 1326 default: true 1327 }, 1328 sortLabel: { 1329 type: String, 1330 default: 'Sort by' 1331 } 1332 }, 1333 data() { 1334 return { 1335 uuid: new Date().getTime() + '-' + ((Math.random() + '').replace('0.', '')), 1336 show: false, 1337 selectedItems: [] 1338 } 1339 }, 1340 methods: { 1341 toggleItem(item) { 1342 let idx = this.selectedItems.findIndex(item_ => item.value === item_.value) 1343 if (idx === -1) { 1344 this.selectedItems.splice(0, this.selectedItems.length) 1345 this.selectedItems.push(item) 1346 } 1347 this.show = false 1348 this.$emit('selected', item.value) 1349 }, 1350 isSelectedItem(item) { 1351 return this.selectedItems.findIndex(item_ => item.value === item_.value) !== -1 1352 }, 1353 handleDocumentClick(e) { 1354 let container = e.target.closest('.neon-multi-select') 1355 if (!container || container.getAttribute('uuid') !== this.uuid) { 1356 this.show = false 1357 } 1358 } 1359 }, 1360 computed: { 1361 displayText() { 1362 let selectedItem = this.items && this.items.length > 0 && this.selectedItems && this.selectedItems.length > 0 ? this.items.filter(e => e.value === this.selectedItems[0].value) : null 1363 return selectedItem && selectedItem.length > 0 ? selectedItem[0].name : '' 1364 } 1365 }, 1366 watch: { 1367 defaultVal: { 1368 immediate: true, 1369 handler($newVal) { 1370 if (this.selectedItems.length > 0) { 1371 this.selectedItems = [] 1372 } 1373 let def = this.items.filter(e => e.value === $newVal) 1374 if (def && def.length > 0) { 1375 this.selectedItems.push(def[0]) 1376 } 1377 } 1378 } 1379 }, 1380 mounted() { 1381 document.addEventListener('mouseup', this.handleDocumentClick) 1382 }, 1383 unmounted() { 1384 document.removeEventListener('mouseup', this.handleDocumentClick) 1385 }, 1386 template: ` 1387 <div class="neon-select" :class="{'isfous' : show}" :uuid="uuid"> 1388 <div class="placeholder" @click="show=!show"> 1389 <span class="span-text" :class="{'has-val' : selectedItems.length >0}"><span v-if="showLabel">{{sortLabel}}: </span>{{displayText}}</span> 1390 <span style="margin-left: 3px;"> 1391 <i :class="{'fas fa-caret-down' : !show, 'fas fa-caret-up':show}"></i> 1392 </span> 1393 </div> 1394 <div class="list" :class="listClass" v-show="show"> 1395 <div class="up-arrow"></div> 1396 <ul class="items"> 1397 <li v-for="item in items" 1398 class="item" 1399 :class="{'is-selected': isSelectedItem(item)}" 1400 @click="toggleItem(item)"> 1401 <slot name="item" :item="item">{{item.name}}</slot> 1402 </li> 1403 </ul> 1404 </div> 1405 </div>` 1406} 1407window.NeonSearchBox = { 1408 name: 'neon-search-box', 1409 props: { 1410 placeholder: { 1411 type: String, 1412 default: 'All' 1413 }, 1414 multiSelect: { 1415 type: Boolean, 1416 default: true 1417 }, 1418 items: { 1419 type: Array, 1420 default() { 1421 return [] 1422 } 1423 }, 1424 listClass: { 1425 type: String, 1426 default: '' 1427 }, 1428 initValue: { 1429 type: String, 1430 default: '' 1431 }, 1432 }, 1433 data() { 1434 return { 1435 uuid: new Date().getTime() + '-' + ((Math.random() + '').replace('0.', '')), 1436 show: false, 1437 selectedItems: [], 1438 searchKeyword: '', 1439 itemList: [] 1440 } 1441 }, 1442 methods: { 1443 toggleItem(item) { 1444 let idx = this.selectedItems.findIndex(item_ => item.value === item_.value) 1445 if (idx === -1) { 1446 1447 if (!this.multiSelect) { 1448 this.clear(false) 1449 } 1450 1451 this.selectedItems.push(item) 1452 } else { 1453 this.selectedItems.splice(idx, 1) 1454 } 1455 1456 if (!this.multiSelect) { 1457 this.show = false 1458 } 1459 }, 1460 isSelectedItem(item) { 1461 return this.selectedItems.findIndex(item_ => item.value === item_.value) !== -1 1462 }, 1463 clear(hide) { 1464 this.selectedItems.splice(0, this.selectedItems.length) 1465 if (hide) { 1466 this.show = false 1467 } 1468 }, 1469 handleDocumentClick(e) { 1470 let container = e.target.closest('.neon-search-box') 1471 if (!container || container.getAttribute('uuid') !== this.uuid) { 1472 this.show = false 1473 } 1474 }, 1475 searchItem() { 1476 if (this.searchKeyword.length > 0) { 1477 let list = [] 1478 for (const item of this.items) { 1479 let itemName = item.name.toLowerCase() 1480 if (itemName.includes(this.searchKeyword.toLowerCase())) { 1481 list.push(item) 1482 } 1483 } 1484 this.itemList = list 1485 } else { 1486 this.itemList = this.items 1487 } 1488 }, 1489 clearSearch() { 1490 this.searchKeyword = '' 1491 this.itemList = this.items 1492 } 1493 }, 1494 computed: { 1495 displayText() { 1496 if (this.selectedItems.length === 0) { 1497 return this.placeholder 1498 } 1499 return this.selectedItems[0].name 1500 } 1501 }, 1502 watch: { 1503 show() { 1504 if (!this.show) { 1505 this.clearSearch() 1506 this.$emit('hide', this.selectedItems) 1507 } 1508 }, 1509 initValue(newVal){ 1510 if (newVal) { 1511 let arr = newVal.split(',') 1512 for (let i = 0; i < arr.length; i++) { 1513 let res = this.items.filter(item => item.value === arr[i]) 1514 if (res && res.length > 0) { 1515 this.toggleItem(res[0]) 1516 } 1517 } 1518 } 1519 } 1520 }, 1521 mounted() { 1522 this.itemList.push(...this.items) 1523 document.addEventListener('mouseup', this.handleDocumentClick) 1524 }, 1525 unmounted() { 1526 document.removeEventListener('mouseup', this.handleDocumentClick) 1527 }, 1528 template: `
1529 <span class="neon-search-box" :uuid="uuid"> 1530 <div class="placeholder" @click="show=!show"> 1531 <span :class="{'has-val' : selectedItems.length >0}">{{displayText}}</span> 1532 <span :class="{'has-val' : selectedItems.length >0}" style="margin-left: 3px;" v-if="multiSelect && selectedItems.length>1">+{{selectedItems.length - 1}}</span> 1533 <span style="margin-left: 3px;"> 1534 <i :class="{'fas fa-caret-down' : !show, 'fas fa-caret-up':show}"></i> 1535 </span> 1536 </div> 1537 <div class="list" :class="listClass" v-show="show"> 1538 <div class="up-arrow"></div> 1539 <div class="search-box"> 1540 <input type="text" v-model="searchKeyword" :oninput="searchItem"/> 1541 <i class="fa-solid fa-magnifying-glass" v-if="searchKeyword.length === 0"></i> 1542 </div> 1543 <div class="list-items-container"> 1544 <ul class="items"> 1545 <div v-if="searchKeyword.length > 0 && itemList.length === 0" class="empty"> 1546 <i>No results match "{{searchKeyword}}"</i> 1547 </div> 1548 <li v-for="item in itemList" 1549 class="item" 1550 :class="{'is-selected': isSelectedItem(item)}" 1551 @click="toggleItem(item)"> 1552 <slot name="item" :item="item">{{item.name}}</slot> 1553 </li> 1554 </ul> 1555 </div> 1556 <div class="footer" v-if="selectedItems.length >0"> 1557 <span @click="clear(true)">Clear</span> 1558 </div> 1559 </div> 1560 </span>` 1561} 1562window.NeonPagination = { 1563 name: 'neon-pagination', 1564 props: { 1565 page: { 1566 type: Object, 1567 default () { 1568 return {} 1569 } 1570 } 1571 }, 1572 data() { 1573 return { 1574 } 1575 }, 1576 methods: { 1577 clkPre() { 1578 if (this.page.pageNo === 1) { 1579 return; 1580 } 1581 this.$emit('clkpage', this.page.pageNo - 1) 1582 }, 1583 clkNext() { 1584 if (this.page.pageNo === this.totalPage) { 1585 return; 1586 } 1587 this.$emit('clkpage', this.page.pageNo + 1) 1588 }, 1589 clkPageNo($num) { 1590 if($num === this.page.pageNo) { 1591 return; 1592 } 1593 this.$emit('clkpage', $num) 1594 } 1595 }, 1596 computed: { 1597 totalPage() { 1598 if (!this.page || !this.page.total) { 1599 return 0 1600 } 1601 let pages = Math.floor(this.page.total / this.page.pageSize) 1602 return this.page.total % this.page.pageSize === 0 ? pages : pages + 1 1603 }, 1604 displayNums() { 1605 let pageNums = [] 1606 let totalPage = this.totalPage 1607 let start = 1 1608 let end = 1 1609 if (totalPage <= 5 || this.page.pageNo <= 3) { 1610 end = totalPage > 5 ? 5 : totalPage 1611 } 1612 if (this.page.pageNo > 3) { 1613 start = this.page.pageNo - 2 1614 end = this.page.pageNo + 2 1615 if (end > totalPage) { 1616 end = totalPage 1617 start = end - 4 1618 } 1619 if (start < 1) { 1620 start = 1 1621 } 1622 } 1623 for (let idx = start; idx <= end; idx ++) { 1624 pageNums.push(idx) 1625 } 1626 return pageNums 1627 } 1628 }, 1629 watch: { 1630 }, 1631 mounted() { 1632 }, 1633 created() { 1634 }, 1635 beforeDestroy() { 1636 }, 1637 template: ` 1638 <div class="neon-portal-pagination" v-show="page && page.total > 0"> 1639 <span @click="clkPre()" class="pagination-pre" :class="page.pageNo === 1 ? 'disabled' : ''"> 1640 <i class="fas fa-arrow-left" style="margin-right: 8px"></i> Previous Page 1641 </span> 1642 <span @click="clkPageNo(pageNo)" v-for="pageNo in displayNums" class="pagination-number" :class="pageNo === page.pageNo ? 'selected' : ''"> 1643 {{pageNo}} 1644 </span>
1645 <span @click="clkNext()" class="pagination-next" :class="page.pageNo === totalPage ? 'disabled' : ''"> 1646 Next Page <i class="fas fa-arrow-right" style="margin-left: 8px"></i> 1647 </span> 1648 </div>` 1649} 1650window.NeonPaginationPlus = { 1651 name: 'neon-pagination-plus', 1652 props: { 1653 pages: { 1654 type: Object, 1655 default () { 1656 return { 1657 pageNo: 1, 1658 pageSize:6, 1659 total:60 1660 } 1661 } 1662 }, 1663 pageSizeList: { 1664 type: Array, 1665 default () { 1666 return [10, 20, 30, 40, 50, 100] 1667 } 1668 }, 1669 pageSize: { 1670 type: Number, 1671 default: 10 1672 }, 1673 showLabel: { 1674 type: Boolean, 1675 default: true 1676 } 1677 }, 1678 data() { 1679 return { 1680 currentPage: this.pages.pageNo, 1681 pageLen: this.pageSize, 1682 } 1683 }, 1684 watch: { 1685 pages: { 1686 handler(newVal) { 1687 this.currentPage = newVal.pageNo 1688 this.pageLen = newVal.pageSize 1689 }, 1690 deep: true 1691 } 1692 }, 1693 methods: { 1694 clkPres() { 1695 if (this.currentPage === 1) return; 1696 this.currentPage -= 1 1697 this.onPageChange(); 1698 }, 1699 clkNexts() { 1700 if (this.currentPage === this.totalPage) return; 1701 this.currentPage += 1 1702 this.onPageChange(); 1703 }, 1704 clkPageNos($num) { 1705 if ($num === this.currentPage) return; 1706 if ($num === 'ellipsis-next') { 1707 let next = this.currentPage + 5; 1708 this.currentPage = next > this.totalPage ? this.totalPage : next; 1709 this.onPageChange(); 1710 return; 1711 } 1712 if ($num === 'ellipsis-prev') { 1713 let prev = this.currentPage - 5; 1714 this.currentPage = prev < 1 ? 1 : prev; 1715 this.onPageChange(); 1716 return; 1717 } 1718 this.currentPage = $num; 1719 this.onPageChange(); 1720 }, 1721 onPageChange () { 1722 this.$emit('page-change', this.currentPage,this.pageLen); 1723 }, 1724 }, 1725 mounted() { 1726 this.currentPage = this.pages.pageNo 1727 this.pageLen = this.pages.pageSize 1728 }, 1729 computed: { 1730 totalPage() { 1731 if (!this.pages || !this.pages.total) return 0 1732 let pages = Math.floor(this.pages.total / this.pages.pageSize) 1733 return this.pages.total % this.pages.pageSize === 0 ? pages : pages + 1 1734 }, 1735 displayNum() { 1736 const total = this.totalPage 1737 const current = this.currentPage 1738 const nums = [] 1739 if (total <= 7) { 1740 for (let i = 1; i <= total; i++) nums.push(i) 1741 } else if (current <= 3) { 1742 for (let i = 1; i <= 5; i++) nums.push(i) 1743 nums.push('ellipsis-next') 1744 nums.push(total) 1745 } else if (current >= total - 2) { 1746 nums.push(1) 1747 nums.push('ellipsis-prev') 1748 for (let i = total - 4; i <= total; i++) nums.push(i) 1749 } else { 1750 nums.push(1) 1751 nums.push('ellipsis-prev') 1752 for (let i = current - 2; i <= current + 2; i++) nums.push(i) 1753 nums.push('ellipsis-next') 1754 nums.push(total) 1755 } 1756 return nums 1757 }, 1758 pageInfoItems () { 1759 if (this.pageLen <= 0) return '' 1760 if (this.pages.total === 0) return `| 0 Items` 1761 1762 let startIndex = (this.pages.pageNo - 1) * this.pageLen + 1 1763 let endIndex = startIndex + this.pageLen - 1 1764 if (endIndex > this.pages.total) endIndex = this.pages.total 1765 1766 return `| ${startIndex} - ${endIndex} of ${this.pages.total} items` 1767 }, 1768 }, 1769 template: ` 1770 <div class="neon-portal-pagination-plus" v-show="pages && pages.total > 0"> 1771 <div class="pagination-left">
1772 <span @click="clkPres()" class="pagination-pre" :class="currentPage === 1 ? 'disabled' : ''"> 1773 <i class="fa-solid fa-chevron-left" :style="showLabel ? '' : 'margin-right: 0;'"></i> {{showLabel ? 'Back' : ''}} 1774 </span> 1775 <ul class="pagination-pager"> 1776 <li v-for="item in displayNum" 1777 :key="item + '-'" 1778 @click="clkPageNos(item)" 1779 class="pagination-number" 1780 :class=" {selected:item === currentPage} "> 1781 <template v-if="item === 'ellipsis-prev' || item === 'ellipsis-next'"> 1782 <i class="fa-solid fa-ellipsis"></i> 1783 </template> 1784 <template v-else> 1785 {{item}} 1786 </template> 1787 </li> 1788 </ul> 1789 <span @click="clkNexts()" class="pagination-next" :class="currentPage === totalPage ? 'disabled' : ''" > 1790 {{showLabel ? 'Next' : ''}} <i class="fa-solid fa-chevron-right" :style="showLabel ? '' : 'margin-left: 0;'"></i> 1791 </span> 1792 </div> 1793 <div class="pagination-right"> 1794 <div class="page-item"> 1795 <span>Items per page: </span> 1796 <span> 1797 <select class="page-size" v-model="pageLen" @change="onPageChange"> 1798 <option v-for="p in pageSizeList" :value="p">{{p}}</option> 1799 </select> 1800 </span> 1801 <span style="margin-left: 12px;" v-html="pageInfoItems"></span></div> 1802 </div> 1803 </div>` 1804} 1805window.NeonShareModal = { 1806 name: 'neon-share-modal', 1807 props: { 1808 shareUrlMeta: { 1809 type: Object, 1810 required: true 1811 }, 1812 shareModalColor: { 1813 type: String, 1814 default: '' 1815 } 1816 }, 1817 data() { 1818 return { 1819 shareModalId: 'neon-share-' + new Date().getTime() 1820 } 1821 }, 1822 methods: { 1823 open() { 1824 this.$nextTick(() => { 1825 const modalElement = document.getElementById(this.shareModalId) 1826 document.body.appendChild(modalElement); 1827 const modal = new bootstrap.Modal(modalElement) 1828 modal.show() 1829 }) 1830 }, 1831 async copyLink() { 1832 let value = this.shareUrlMeta.url 1833 if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { 1834 await navigator.clipboard.writeText(value).then(() => { 1835 alert('Copied!') 1836 }).catch(e => { 1837 console.log(e) 1838 alert('Copy Error!') 1839 }); 1840 } 1841 }, 1842 sharePageToMedia(type) { 1843 let u = this.shareUrlMeta.url 1844 let t = this.shareUrlMeta.title && this.shareUrlMeta.title !== '' ? this.shareUrlMeta.title : this.shareUrlMeta.description 1845 if (type === 'F') { 1846 window.open("https://www.facebook.com/sharer.php?u=" + encodeURIComponent(u) + "&t="+ encodeURIComponent(t), "sharer","toolbar=0,status=0,width=626,height=436") 1847 } else if (type === 'L') { 1848 window.open('https://www.linkedin.com/shareArticle?mini=true&url=' + encodeURIComponent(u) + '&title=' + encodeURIComponent(t),"sharer","toolbar=0,status=0,width=626,height=436") 1849 } else if (type === 'T') { 1850 window.open("https://twitter.com/intent/tweet?text=" + encodeURIComponent(t) + "&url=" + encodeURIComponent(u), "sharer", "toolbar=0,status=0,width=626,height=436") 1851 } 1852 } 1853 }, 1854 computed: { 1855 retrieveShareEmailLink() { 1856 if (this.shareUrlMeta && this.shareUrlMeta.title) { 1857 return 'mailto:?body=' + this.shareUrlMeta.url + "&subject=" + (this.shareUrlMeta.title && this.shareUrlMeta.title !== '' ? this.shareUrlMeta.title : this.shareUrlMeta.description) 1858 } 1859 return '#' 1860 }, 1861 modalTitle() { 1862 return this.shareUrlMeta && this.shareUrlMeta.shareModalTitle ? this.shareUrlMeta.shareModalTitle : 'Share this page' 1863 }, 1864 urlLabel() { 1865 return this.shareUrlMeta && this.shareUrlMeta.urlLabel ? this.shareUrlMeta.urlLabel : 'Page URL' 1866 }, 1867 }, 1868 watch: { 1869 }, 1870 mounted() { 1871 if (this.shareModalColor) { 1872 this.$nextTick(function () { 1873 let r = document.querySelector(':root') 1874 r.style.setProperty('--share-modal-color', this.shareModalColor); 1875 }) 1876 } 1877 }, 1878 created() { 1879 }, 1880 beforeDestroy() { 1881 }, 1882 template: 1883 ` 1884 <div class="modal fade neonfront-confirm-modal neon-page-sharer-modal" aria-hidden="true" :id="shareModalId" data-bs-backdrop="static" tabindex="-1"> 1885 <div class="modal-dialog" style="max-width: 580px;"> 1886 <div class="modal-content px-sm-4 px-1"> 1887 <div class="modal-header"> 1888 <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> 1889 </div> 1890 <div class="modal-body"> 1891 <div class="row mt-1"> 1892 <div class="col shared-body-title"> 1893 <p>{{modalTitle}}</p> 1894 </div> 1895 </div> 1896 <div class="row mt-1"> 1897 <div class="share-body-col"> 1898 <div class="share-button-box" @click="sharePageToMedia('F')"> 1899 <i class="fa-brands fa-facebook"></i> 1900 <div class="share-body-text">Facebook</div> 1901 </div> 1902 <div class="share-button-box" @click="sharePageToMedia('L')"> 1903 <i class="fa-brands fa-linkedin"></i> 1904 <div class="share-body-text">LinkedIn</div> 1905 </div> 1906 <div class="share-button-box" @click="sharePageToMedia('T')"> 1907 <i class="fa-brands fa-x-twitter"></i> 1908 <div class="share-body-text">Twitter</div> 1909 </div> 1910 <div class="share-button-box"> 1911 <a class="share-button-box-link" :href="retrieveShareEmailLink"><i class="fa-light fa-envelope"></i><div>Email</div></a> 1912 </div> 1913 </div> 1914 <div class="share-body-col"> 1915 <div class="share-button-label" v-text="urlLabel"> 1916 </div> 1917 </div> 1918 <div class="share-body-col"> 1919 <div class="share-url-container" v-text="shareUrlMeta.url"></div> 1920 </div> 1921 <div class="share-body-col"> 1922 <div class="share-button-copy" @click="copyLink()"> 1923 <i class="fa-regular fa-copy"></i> 1924 <div class="share-body-text">Copy to Clipboard</div> 1925 </div> 1926 </div> 1927 </div> 1928 </div> 1929 <div class="modal-footer share-footer" style="justify-content: end"> 1930 </div> 1931 </div> 1932 </div> 1933 </div> 1934 ` 1935} 1936window.NeonSearchSelect = { 1937 name: 'neon-search-select', 1938 props: { 1939 placeholder: { 1940 type: String, 1941 default: '' 1942 }, 1943 options: { 1944 type: Array, 1945 default() { 1946 return [] 1947 } 1948 }, 1949 inputClass: { 1950 type: String, 1951 default: '' 1952 }, 1953 listClass: { 1954 type: String, 1955 default: '' 1956 }, 1957 disabled: { 1958 type: Boolean, 1959 default: false 1960 }, 1961 modelValue: { 1962 type: Object, 1963 default: null 1964 }, 1965 isMultiple: { 1966 type: Boolean, 1967 default: false 1968 } 1969 }, 1970 data() { 1971 return { 1972 showList: false, 1973 isSearch: false, 1974 keyword: '', 1975 isClickingOptions: false, 1976 selectedValues: [], 1977 uuid: new Date().getTime() + '-' + ((Math.random() + '').replace('0.', '')), 1978 } 1979 }, 1980 methods: { 1981 select(e, item) { 1982 this.keyword = item.name 1983 1984 document.querySelectorAll('.options li.selected').forEach(li => li.classList.remove('selected')) 1985 e.target.classList.add('selected') 1986 1987 this.modelValue = item.id 1988 this.$emit('update:modelValue', item.id) 1989 this.$emit('change', item) 1990 this.closeOptions() 1991 this.$refs.seachInput.blur(); 1992 1993 setTimeout(() => { 1994 this.isClickingOptions = false 1995 }, 100) 1996 }, 1997 closeOptions() { 1998 this.showList = false 1999 }, 2000 handleInputBlur(e) { 2001 if(this.isMultiple) return 2002 setTimeout(() => { 2003 if (!this.isClickingOptions) { 2004 if (this.displayList.length === 0) { 2005 this.keyword = '' 2006 } 2007 2008 this.isSearch = false;
2009 this.closeOptions(); 2010 2011 const value = e.target.value 2012 if (value) { 2013 const selectedOption = this.options.find(option => option.name === value) 2014 if (selectedOption) { 2015 this.modelValue = selectedOption.id 2016 this.$emit('update:modelValue', selectedOption.id) 2017 this.$emit('change', selectedOption) 2018 } else { 2019 this.keyword = '' 2020 this.modelValue = null 2021 this.$emit('update:modelValue', null) 2022 this.$emit('change', null) 2023 this.unselect(); 2024 } 2025 } else { 2026 this.modelValue = null 2027 this.$emit('update:modelValue', null) 2028 this.$emit('change', null) 2029 this.unselect(); 2030 } 2031 } 2032 }, 100); 2033 }, 2034 handleInput(e) { 2035 this.isSearch = true 2036 }, 2037 handelMouseUp() { 2038 this.isClickingOptions = false 2039 this.$refs.seachInput.focus(); 2040 }, 2041 clear() { 2042 this.keyword = '' 2043 this.modelValue = null 2044 this.options = [] 2045 this.unselect(); 2046 }, 2047 unselect() { 2048 document.querySelectorAll('.options li.selected').forEach(li => li.classList.remove('selected')) 2049 }, 2050 toggleAllItem($field) { 2051 $field.selectAll = !$field.selectAll; 2052 this.selectedValues = []; 2053 $field.forEach(opt => { 2054 opt.selected = $field.selectAll; 2055 if (opt.selected) { 2056 this.selectedValues.push(opt.value); 2057 } 2058 }); 2059 this.changeCalbk($field); 2060 }, 2061 toggleItem($field, item) { 2062 item.selected = !item.selected 2063 2064 if (item.selected) { 2065 this.selectedValues.push(item.value) 2066 } else { 2067 let idx = this.selectedValues.findIndex(val => val === item.value) 2068 this.selectedValues.splice(idx, 1) 2069 } 2070 2071 $field.selectAll = this.selectedValues.length === $field.length 2072 this.changeCalbk($field) 2073 }, 2074 changeCalbk() { 2075 this.$emit('update:modelValue', [...this.selectedValues]) 2076 }, 2077 handleDocumentClick(e) { 2078 if(!this.isMultiple) return 2079 let container = e.target.closest('.npb-multi-select') 2080 if (!container || container.getAttribute('uuid') !== this.uuid) { 2081 this.keyword = '' 2082 this.showList = false 2083 } 2084 }, 2085 }, 2086 mounted() { 2087 if (this.modelValue && this.options) { 2088 if(this.isMultiple) { 2089 this.selectedValues = Array.isArray(this.modelValue) ? [...this.modelValue] : [] 2090 this.options.forEach(item => { 2091 item.selected = this.selectedValues.includes(item.value) 2092 }) 2093 } else { 2094 const selectedOption = this.options.find(option => option.id === this.modelValue) 2095 if (selectedOption) { 2096 this.keyword = selectedOption.name 2097 document.querySelector('li[data="' + this.modelValue + '"]').classList.add('selected') 2098 } 2099 } 2100 } 2101 }, 2102 created() { 2103 document.addEventListener('mouseup', this.handleDocumentClick) 2104 }, 2105 beforeDestroy() { 2106 document.removeEventListener('mouseup', this.handleDocumentClick) 2107 }, 2108 computed: { 2109 displayList() { 2110 if (!this.isSearch) { 2111 return this.options 2112 } 2113 return this.options.filter(option => option.name.toLowerCase().includes(this.keyword.trim().toLowerCase())) 2114 } 2115 }, 2116 watch: { 2117 modelValue(newValue) { 2118 if (newValue && this.options) { 2119 const selectedOption = this.options.find(option => option.id === newValue) 2120 if (selectedOption) { 2121 this.keyword = selectedOption.name 2122 } 2123 } 2124 } 2125 }, 2126 template: ` 2127 <div :class="['neon-search-select', { 'npb-multi-select': isMultiple }]" :uuid="uuid"> 2128 <input 2129 v-model="keyword" 2130 type="text" 2131 ref="seachInput" 2132 :placeholder="placeholder" 2133 :class="inputClass" 2134 :disabled="disabled" 2135 @focus="showList = true" 2136 @blur="handleInputBlur"
2137 @input="handleInput" 2138 /> 2139 <div class="input-right-text" v-if="isMultiple"> 2140 <span style="margin-left: 3px;" v-show="selectedValues.length > 0">+{{selectedValues.length}}</span> 2141 <span style="margin-left: 12px;"> 2142 <i class="fas fa-caret-down"></i> 2143 </span> 2144 </div> 2145 <span class="select-arrow" v-else> 2146 <i class="fas fa-caret-down"></i> 2147 </span> 2148 <ul v-show="showList" class="options items" :class="listClass" @mousedown="isClickingOptions = true" @mouseup="handelMouseUp"> 2149 <template v-if="isMultiple"> 2150 <li class="item" 2151 @click.prevent="toggleAllItem(displayList)" 2152 :class="{'is-selected': displayList.selectAll}" 2153 v-show="!keyword" 2154 > 2155 <label> 2156 <span class="list-checkbox-input"> 2157 <span class="list-checkbox-inner"></span> 2158 <input type="checkbox" class="item-checked" v-model="displayList.selectAll"> 2159 </span> 2160 <sapn name="item" :item="item">Select All</sapn> 2161 </label> 2162 </li> 2163 <li class="item" 2164 v-for="item in displayList" 2165 :key="item.id" 2166 @click.prevent="toggleItem(displayList,item)" 2167 :class="{'is-selected': item.selected}" 2168 > 2169 <label> 2170 <span class="list-checkbox-input"> 2171 <span class="list-checkbox-inner"></span> 2172 <input type="checkbox" class="item-checked" :value="item.value" v-model="item.selected"> 2173 </span> 2174 <span class="item-text" name="item">{{ item.name }}</span> 2175 </label> 2176 </li> 2177 </template> 2178 <template v-else> 2179 <li 2180 v-for="item in displayList" 2181 :key="item.id" 2182 @click="select($event, item)" 2183 :data="item.id" 2184 > 2185 {{ item.name }} 2186 </li> 2187 </template> 2188 <li v-if="displayList.length === 0" class="empty">No results match</li> 2189 </ul> 2190 </div>` 2191} 2192window.NeonSearchLocation = { 2193 name: 'neon-search-location', 2194 props: { 2195 placeholder: { 2196 type: String, 2197 default: 'Search by Location' 2198 }, 2199 modelValue: '' 2200 }, 2201 emits: ['update:modelValue', 'selectAddress', 'clearAddress'], 2202 data() { 2203 return { 2204 show: false, 2205 address: '', 2206 uuid: new Date().getTime() + '-' + ((Math.random() + '').replace('0.', '')), 2207 addressList: [], 2208 loading: false, 2209 searchTimer: null, 2210 isSelecting: false, 2211 selectedAddress: null 2212 } 2213 }, 2214 methods: { 2215 handleDocumentClick(e) { 2216 let container = e.target.closest('.neon-search-location-box') 2217 if (!container || container.getAttribute('uuid') !== this.uuid) { 2218 this.show = false 2219 } 2220 }, 2221 querySearchLocation() { 2222 if(this.address){ 2223 this.show = true 2224 } 2225 clearTimeout(this.searchTimer) 2226 if (!this.address || !this.address.trim()) { 2227 this.addressList = [] 2228 this.loading = false 2229 return 2230 } 2231 this.loading = true 2232 this.searchTimer = setTimeout(() => { 2233 axios.get('/nx/publicaccess/geocoding/search', { 2234 params: { q: this.address } 2235 }).then(response => { 2236 this.addressList = response.data || [] 2237 this.loading = false 2238 }).catch(error => { 2239 console.error(error) 2240 this.addressList = [] 2241 this.loading = false 2242 }) 2243 }, 300) 2244 }, 2245 keywordOnblur() { 2246 this.querySearchLocation() 2247 }, 2248 clearKeyword() { 2249 this.address = '' 2250 this.selectedAddress = null; 2251 this.$emit('update:modelValue', '') 2252 this.$emit('clearAddress') 2253 }, 2254 choseAddress(item) { 2255 this.isSelecting = true; 2256 this.address = item.display_name 2257 this.selectedAddress = item 2258 this.$emit('update:modelValue', item.display_name) 2259 this.$emit('selectAddress', item) 2260 this.show = false;
2261 setTimeout(_ => { 2262 this.isSelecting = false 2263 }, 150) 2264 }, 2265 handleSearchInputBlur(e) { 2266 setTimeout(_ => { 2267 if (this.isSelecting) { 2268 return; 2269 } 2270 if (!this.selectedAddress) { 2271 this.address = '' 2272 } else if (this.address !== this.selectedAddress.display_name) { 2273 this.address = this.selectedAddress.display_name 2274 } 2275 }, 100) 2276 } 2277 }, 2278 mounted() { 2279 2280 }, 2281 created() { 2282 document.addEventListener('mouseup', this.handleDocumentClick) 2283 }, 2284 beforeDestroy() { 2285 document.removeEventListener('mouseup', this.handleDocumentClick) 2286 }, 2287 computed: { 2288 2289 }, 2290 watch: { 2291 modelValue(newValue) { 2292 if (newValue) { 2293 this.address = newValue 2294 } 2295 } 2296 }, 2297 template: ` 2298 <div class="neon-search-location-box" :uuid="uuid"> 2299 <div class="input-box"> 2300 <input class="search-name" 2301 type="text" 2302 v-model="address" 2303 @input="querySearchLocation" 2304 @focus="querySearchLocation" 2305 @blur="handleSearchInputBlur" 2306 @keydown.enter.prevent="keywordOnblur" 2307 :placeholder="placeholder"> 2308 <span class="msd-input-prefix"> 2309 <i class="fa-solid fa-location-dot" v-show="!address"></i> 2310 <i class="fal fa-times" v-show="address" @click="clearKeyword" style="cursor: pointer;"></i> 2311 </span> 2312 </div> 2313 <div class="location-list" v-show="show"> 2314 <div class="loading-item" v-if="loading"> 2315 <i class="fa fa-spinner fa-spin"></i> 2316 </div> 2317 <div class="pac-item empty" v-else-if="addressList.length === 0"> 2318 <span class="pac-icon pac-icon-marker"></span> 2319 <span class="pac-item-query"> 2320 <span class="pac-matched">No Results</span> 2321 </div> 2322 <div class="pac-item" v-else v-for="(item, index) in addressList" 2323 :title="item.display_name" 2324 @click="choseAddress(item)"> 2325 <span class="pac-icon pac-icon-marker"></span> 2326 <span class="pac-item-query"> 2327 <span class="pac-matched">{{item.display_name}}</span> 2328 </div> 2329 </div> 2330 </div> 2331 ` 2332} 2333window.NeonInput = { 2334 name: 'neon-input', 2335 props: { 2336 modelValue: { 2337 type: String, 2338 default: '' 2339 }, 2340 clearable: { 2341 type: Boolean, 2342 default: false 2343 }, 2344 type: { 2345 type: String, 2346 default: 'text' 2347 }, 2348 placeholder: { 2349 type: String, 2350 }, 2351 field: { 2352 type: Object, 2353 } 2354 }, 2355 data() { 2356 return { 2357 showClear: false 2358 } 2359 }, 2360 methods: { 2361 clear() { 2362 this.$emit('update:modelValue', ''); 2363 }, 2364 clearInput() { 2365 this.clear(); 2366 }, 2367 registerLayDate($field) { 2368 if ($field.dataType !== '4' && $field.dataType !== '5') { 2369 return 2370 } 2371 let _this = this 2372 let format = $field.dataType === '4' ? 'MM/dd/yyyy' : 'HH:mm:ss' 2373 let type = $field.dataType === '4' ? 'date' : 'time' 2374 let elem = document.getElementById('cf_' + $field.fieldId) 2375 let rangeField = ['membership_stats.firstJoinDate','membership_listing.startDate','membership_listing.endDate'] 2376 let $range = rangeField.indexOf($field.fieldCode) > -1 ? '~' : false 2377 laydate.render({ 2378 elem: elem, 2379 lang: 'en', 2380 type: type, 2381 format: format, 2382 range: $range, 2383 ready: function (value) { 2384 }, 2385 done: function (value) { 2386 $field.value = value 2387 _this.changeCalbk($field) 2388 } 2389 }) 2390 }, 2391 changeCalbk($field) { 2392 this.$emit('update:modelValue', $field.value) 2393 this.$emit('change', $field) 2394 } 2395 }, 2396 computed: {}, 2397 watch: {}, 2398 mounted() { 2399 if (this.field) { 2400 this.registerLayDate(this.field) 2401 } 2402 }, 2403 unmounted() { 2404 2405 }, 2406 template: ` 2407 <div class="neon-input input-group" 2408 @mouseenter="showClear = true" 2409 @mouseleave="showClear = false"> 2410 <template v-if="type === 'text'"> 2411 <input class="form-control input" v-bind="$attrs" :placeholder="placeholder" :type="type" :value="modelValue" @input="$emit('update:modelValue', $event.target.value)">
2412 <span 2413 v-if="clearable && modelValue && showClear" 2414 @click="clearInput" 2415 class="clear-icon" 2416 title="Clear"> 2417 <i class="fal fa-times"></i> 2418 </span> 2419 </template> 2420 <template v-if="type === 'calendar'"> 2421 <input :id="'cf_' + field.fieldId" v-bind="$attrs" class="npb-options-input calendar-input" type="text" :placeholder="placeholder" :value="modelValue" autocomplete="off"/> 2422 <span class="input-group-text far" :class="[field.dataType === '4' ? 'fa-calendar-alt':'fa-clock']" style=""></span> 2423 </template> 2424 </div>` 2425} 2426//===========AJAX settings============================== 2427 2428axios.defaults.headers.post['X-Requested-With'] = 'XMLHttpRequest' 2429axios.defaults.headers.patch['X-Requested-With'] = 'XMLHttpRequest' 2430axios.defaults.headers.get['X-Requested-With'] = 'XMLHttpRequest' 2431 2432// Add request interceptor 2433axios.interceptors.request.use(function (config) { 2434 if(appData.formEmbedEnv || appData.popDomainUrl) { 2435 config.headers['Authorization']=localStorage.getItem('embedToken') || '' 2436 } 2437 // Do something before sending the request 2438 return config 2439}, function (error) { 2440 // Do something with request errors 2441 return Promise.reject(error) 2442}); 2443 2444// Add response interceptor 2445axios.interceptors.response.use(function (response) { 2446 // Do something with response data 2447 return response 2448}, function (error) { 2449 const original = error.config 2450 if (error.response && error.response.status === 401 && original && !original._retried 2451 && original.url && original.url.indexOf('/nx/auth/') === -1) { 2452 original._retried = true 2453 return axios.post('/nx/auth/refresh' + (document.cookie.indexOf('neon-portal-session=') >= 0 ? '?portal=1' : '')).then(function (r) { 2454 if (r.data && r.data.success) { return axios(original) } 2455 window.location = '/login' 2456 return Promise.reject(error) 2457 }).catch(function () { 2458 window.location = '/login' 2459 return Promise.reject(error) 2460 }) 2461 } 2462 return Promise.reject(error) 2463}); 2464 2465/** 2466 * Modern social login entry. options: { targetUrl, orgId, custom, beforeNavigate(url) }. 2467 * If beforeNavigate returns false, navigation is skipped (e.g. iframe postMessage handled by caller). 2468 */ 2469window.neonSocialLogin = function (provider, options) { 2470 options = options || {} 2471 var loginUrl = new URL('/nx/auth/social/' + provider, window.location.origin) 2472 if (options.targetUrl) { 2473 loginUrl.searchParams.set('targetUrl', options.targetUrl) 2474 } 2475 if (options.orgId) { 2476 loginUrl.searchParams.set('ins', options.orgId) 2477 } 2478 if (options.custom) { 2479 loginUrl.searchParams.set('custom', options.custom) 2480 } 2481 var href = loginUrl.toString() 2482 if (typeof options.beforeNavigate === 'function' && options.beforeNavigate(href) === false) { 2483 return 2484 } 2485 window.location.href = href 2486} 2487 2488String.prototype.replaceAll = function (s1, s2) { 2489 return this.replace(new RegExp(s1, "gm"), s2) 2490} 2491 2492//=========JSON flatten============================== 2493 2494/** 2495 * un-flatten form include un-checked checkbox 2496 * @param formId 2497 * @returns un-flatten json 2498 */ 2499JSON.formAll = function (formId) { 2500 let form = document.getElementById(formId) 2501 let formData = new FormData(form) 2502 // checkbox 2503 for(let e of form.elements){ 2504 if(formData.has(e.getAttribute('name'))){ 2505 continue 2506 } 2507 formData.append(e.getAttribute('name'), '') 2508 } 2509 2510 return JSON.parseForm(formData) 2511} 2512 2513/** 2514 * un-flatten form 2515 * @param formId Form ID 2516 * @returns un-flatten json 2517 */ 2518JSON.form = function(formId){ 2519 let formData = new FormData(document.getElementById(formId)) 2520 return JSON.parseForm(formData) 2521} 2522 2523JSON.parseForm = function(formData){ 2524 let objData = {} 2525 for(let key of formData.keys()){
2526 if(formData.getAll(key).length>1){ 2527 objData[key]=formData.getAll(key) 2528 }else{ 2529 var ele = formData.get(key); 2530 if(ele instanceof File){ 2531 objData[key]=ele.fileInfo 2532 } 2533 objData[key]=ele 2534 } 2535 } 2536 return JSON.unflatten(objData) 2537} 2538 2539JSON.unflatten = function(data) { 2540 "use strict"; 2541 if (Object(data) !== data || Array.isArray(data)) 2542 return data; 2543 var regex = /\.?([^.\[\]]+)|\[(\d+)\]/g, 2544 resultholder = {}; 2545 for (var p in data) { 2546 var cur = resultholder, 2547 prop = "", 2548 m; 2549 while (m = regex.exec(p)) { 2550 cur = cur[prop] || (cur[prop] = (m[2] ? [] : {})); 2551 prop = m[2] || m[1]; 2552 } 2553 cur[prop] = data[p]; 2554 } 2555 return resultholder[""]; 2556}; 2557 2558//æå¹³å 2559JSON.flatten = function(data) { 2560 var result = {}; 2561 function recurse (cur, prop) { 2562 if (Object(cur) !== cur) { 2563 result[prop] = cur; 2564 } else if (Array.isArray(cur)) { 2565 for(var i=0, l=cur.length; i<l; i++) 2566 recurse(cur[i], prop + "[" + i + "]"); 2567 if (l == 0) 2568 result[prop] = []; 2569 } else { 2570 var isEmpty = true; 2571 for (var p in cur) { 2572 isEmpty = false; 2573 recurse(cur[p], prop ? prop+"."+p : p); 2574 } 2575 if (isEmpty) 2576 result[prop] = {}; 2577 } 2578 } 2579 recurse(data, ""); 2580 return result; 2581} 2582 2583 2584function setObjectValueByPath(obj, path, val) { 2585 let ths = [] 2586 for (let prop of path.split(".")){ 2587 if(prop.includes('[') && prop.includes(']')){ 2588 ths.push("['") 2589 ths.push(prop.substr(0,prop.indexOf('['))) 2590 ths.push("']") 2591 ths.push(prop.substr(prop.indexOf('['))) 2592 }else{ 2593 ths.push("['") 2594 ths.push(prop) 2595 ths.push("']") 2596 } 2597 } 2598 let th = ths.join('') 2599 2600 let isObj = new Function("return " + obj + th); 2601 let e = new Function(); 2602 if (typeof isObj() == "object") { 2603 e = new Function(obj + th + "=" + val); 2604 } else if (typeof isObj() == "string") { 2605 e = new Function(obj + th + '="' + val + '"'); 2606 } 2607 e(); 2608} 2609 2610function getObjectValueByPath(obj, path){ 2611 if (!obj || !path) { 2612 return undefined 2613 } 2614 const keys = path.split('.') 2615 let current = obj 2616 for (let i = 0; i < keys.length; i++) { 2617 if (current[keys[i]] === undefined || current[keys[i]] === null && i !== keys.length - 1){ 2618 return undefined 2619 } 2620 current = current[keys[i]] 2621 } 2622 return current 2623} 2624function uuid () { 2625 let s = [] 2626 var hexDigits = '0123456789abcdef' 2627 for (let i = 0; i < 36; i++) { 2628 s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1) 2629 } 2630 s[14] = '4' 2631 s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1) 2632 s[8] = s[13] = s[18] = s[23] = '-' 2633 2634 return s.join('') 2635} 2636 2637function formatDateToYYYYMMDD(date) { 2638 let y = date.getFullYear(); 2639 let m = date.getMonth() + 1; 2640 let d = date.getDate(); 2641 2642 m = m.toString().padStart(2, '0'); 2643 d = d.toString().padStart(2, '0'); 2644 2645 return `${y}-${m}-${d}`; 2646} 2647 2648const Formatter= { 2649 numberFormat: function (number, places, currencySign) { 2650 places = !isNaN(places = Math.abs(places)) ? places : 0 2651 currencySign = currencySign !== undefined ? currencySign : '$' 2652 let negative = number < 0 ? '-' : '' 2653 let i = parseInt(number = Math.abs(+number || 0).toFixed(places), 10) + '' 2654 let j = i.length 2655 j = j > 3 ? j % 3 : 0 2656 return currencySign + negative + (j ? i.substr(0, j) + ',' : '') + i.substr(j).replace(/(\d{3})(?=\d)/g, '$1' + ',') + (places ? '.' + Math.abs(number - i).toFixed(places).slice(2) : '') 2657 }, 2658 // Dec 31, 2024 2659 formatDateToEnglish: function (date) { 2660 let nowDate = new Date() 2661 let dd = new Date(date); 2662 if(nowDate.getUTCFullYear() === dd.getUTCFullYear()) { 2663 return dd.toLocaleDateString('en-US', {month: 'short',day: 'numeric'}) 2664 } 2665 return dd.toLocaleDateString('en-US', {year: 'numeric',month: 'short',day: 'numeric'}) 2666 }, 2667 // Dec 31, 2024 2668 formatDateStringToEnglish: function (dateStr) { 2669 let nowDate = new Date() 2670 let dd = new Date(dateStr); 2671 let mom = moment(dateStr) 2672 if(nowDate.getUTCFullYear() === dd.getUTCFullYear()) { 2673 return mom.format("MMM D") 2674 } 2675 return mom.format("MMM D, YYYY") 2676 }, 2677 // Monday,Tuesday... 2678 formatDateToWeek: function (date) { 2679 let dd = new Date(date); 2680 return dd.toLocaleDateString('en-US', {weekday: 'long'}) 2681 }, 2682 formatTime24To12: function (time24h) { 2683 if (!time24h) 2684 return '' 2685 let [hours, minutes] = time24h.split(':'); 2686 let period = 'AM'; 2687 let hour12; 2688 hours = parseInt(hours, 10); 2689 if (hours >= 12) { 2690 period = 'PM'; 2691 if (hours !== 12) { 2692 hours -= 12; 2693 } 2694 } else if (hours === 0) { 2695 hours = 12; 2696 } 2697 hour12 = hours.toString(); 2698 return `${hour12}:${minutes} ${period}`; 2699 }, 2700 formatMillisecondsToDateTime: function (millisecond) { 2701 let nowDate = new Date() 2702 let dd = new Date(millisecond); 2703 let mom = moment(millisecond).tz('America/Chicago'); 2704 if(nowDate.getUTCFullYear() === dd.getUTCFullYear()) { 2705 return mom.format("MMM D") + ' at ' + mom.format("hh:mm A") 2706 } 2707 return mom.format("MMM D, YYYY") + ' at ' + mom.format("hh:mm A") 2708 } 2709} 2710 2711const Validator ={ 2712 required:{ 2713 check:function(str){ 2714 return (str.length == 0) 2715 }, 2716 message:function(fieldLabel){ 2717 return fieldLabel +' is required.' 2718 } 2719 }, 2720 email:{ 2721 emailRegex: /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, 2722 check: function (str){ 2723 if(str.length == 0){ 2724 return false 2725 } 2726 return !this.emailRegex.test(str) 2727 }, 2728 message:function(fieldLabel){ 2729 return fieldLabel +' must be an email.' 2730 } 2731 }, 2732 number:{ 2733 numberRegex: /^([+\-])?[1-9]\d*(\.\d+)?$|^([+\-])?0(\.\d+)?$/, 2734 check:function (str){ 2735 if(str.length == 0){ 2736 return false 2737 } 2738 return !this.numberRegex.test(str) 2739 }, 2740 message:function(fieldLabel){ 2741 return fieldLabel +' must be a number.' 2742 } 2743 }, 2744 amount:{ 2745 amountRegexp: /^([+])?[1-9]\d*(\.\d{1,2})?$|^([+])?0(\.\d{1,2})?$/, 2746 check:function(str){ 2747 if (str.length == 0) { 2748 return false 2749 } 2750 return !this.amountRegexp.test(str) 2751 }, 2752 message: function (fieldLabel) { 2753 return fieldLabel + ' must be a positive number with two decimal places.' 2754 } 2755 }, 2756 date: { 2757 /** check mm/dd/yyyy format */ 2758 check: function (str) { 2759 if (str.length == 0) { 2760 return false 2761 } 2762 const regex = /^(0[1-9]|1[0-2])\/(0[1-9]|[12][0-9]|3[01])\/\d{4}$/; 2763 if (!regex.test(str)) return true; 2764 var bits = str.split('/'); 2765 var d = new Date(bits[2], bits[0] - 1, bits[1]) 2766 return !(d && (d.getMonth() + 1) === parseInt(bits[0])) 2767 }, 2768 message: function (fieldLabel) { 2769 return fieldLabel + ' must be a date in the format mm/dd/yyyy.' 2770 } 2771 }, 2772 future: { 2773 check: function (str){ 2774 let inputMoment = moment(str).tz("America/Chicago") 2775 let nowMoment = moment(moment().tz("America/Chicago").format('yyyy-MM-DD')).tz("America/Chicago") 2776 return inputMoment.isSameOrBefore(nowMoment) 2777 }, 2778 message: function(fieldLabel) { 2779 return fieldLabel + ' need a future date.' 2780 } 2781 }, 2782 integer: { 2783 integerRegex: /^([+\-])?(([1-9][0-9]*)|0)$/, 2784 check: function (str) { 2785 if (str.length == 0) { 2786 return false 2787 } 2788 return !this.integerRegex.test(str) 2789 },
2790 message: 'This field is invalid.', 2791 msg: function(fieldLabel){ 2792 return fieldLabel + 'must be a whole number.' 2793 } 2794 }, 2795 max: { 2796 check: function (str, args) { 2797 let r = isNaN(parseFloat(str)) 2798 if (!r) { 2799 if (parseFloat(str) > parseFloat(args[0].replaceAll(',',''))) { 2800 return true 2801 } 2802 } 2803 return false 2804 }, 2805 message: 'This field is invalid.', 2806 msg: function(fieldLabel){ 2807 return fieldLabel + 'is invalid.' 2808 } 2809 }, 2810 min: { 2811 check: function (str, args) { 2812 let r = isNaN(parseFloat(str)) 2813 if(!args[2]){ 2814 return false 2815 } 2816 if(!r && args[2] === '0'){ 2817 return parseFloat(str) <= parseFloat(args[2]) 2818 } 2819 if (!r) { 2820 if (parseFloat(str) < parseFloat(args[2].replaceAll(',',''))) { 2821 return true 2822 } 2823 } 2824 return false 2825 }, 2826 msg: function(fieldLabel){ 2827 return fieldLabel + 'is invalid.' 2828 } 2829 }, 2830 maxLength: { 2831 check: function (str, args) { 2832 if (str.length > parseInt(args[0])) { 2833 return true 2834 } 2835 return false 2836 }, 2837 message: 'This field is invalid.', 2838 msg: function(fieldLabel){ 2839 return fieldLabel + 'is invalid.' 2840 } 2841 }, 2842 creditCardNumber: { 2843 cardRegex: /^\d+$/, 2844 check:function(str){ 2845 if(str.length==0){ 2846 return false 2847 } 2848 let cardNumber = str.replace(/\s/g,""); 2849 return !this.cardRegex.test(cardNumber) || cardNumber.length < 12 || cardNumber.length > 19 2850 } 2851 }, 2852 password: { 2853 includeNumberRegex: /\d+/, 2854 check:function(str, arg){ 2855 let argVal = arg && arg.length > 0 ? document.getElementById(arg).value : '' 2856 if(str.length === 0 && argVal.length === 0){ 2857 return false 2858 } 2859 return !this.includeNumberRegex.test(str) || str.length < 8 || str.indexOf(' ')>-1 2860 } 2861 }, 2862 passwordLengthConfirmation:{ 2863 includeNumberRegex: /\d+/, 2864 check:function(str){ 2865 if(!this.includeNumberRegex.test(str) || str.length < 8 || str.indexOf(' ')>-1){ 2866 return false 2867 } 2868 if(str){ 2869 return str.length > 25 2870 }else { 2871 return false 2872 } 2873 } 2874 }, 2875 passwordConfirmation: { 2876 check: function(str,arg){ 2877 let argVal = document.getElementById(arg).value 2878 if(str.length === 0 && argVal.length === 0) { 2879 return false 2880 } 2881 return argVal!==str 2882 } 2883 }, 2884 // returning false means pass 2885 loginName: { 2886 check: function(str,arg) { 2887 const regexp = /^[\da-zA-Z_@.+-]*$/g 2888 let argVal = arg && arg.length > 0 ? document.getElementById(arg).value : '' 2889 if (str !== null && str !== '') { 2890 return !regexp.test(str) 2891 } else { 2892 return argVal && !str 2893 } 2894 }, 2895 message: "Login name may consist of a-z, A-Z, 0-9, '.', '@', '_', '-' and '+' only" 2896 }, 2897 loginNameLengthConfirmation: { 2898 check: function(str) { 2899 return str && (str.length < 4 || str.length > 80) 2900 }, 2901 message: 'Login name must be at least 4 characters and at most 80 characters' 2902 }, 2903 dateRangeComparator:{ 2904 check: function (str,extra){ 2905 const targetDate = moment(str).tz("America/Chicago") 2906 const extraLength = extra.length 2907 const [comparatorDateFieldErrorMessage,comparatorDateFieldName,operator] = [extra[extraLength-1],extra[extraLength-2],extra[extraLength-3]] 2908 const comparatorDate = moment(getObjectValueByPath(app.formData,comparatorDateFieldName)).tz("America/Chicago") 2909 let result = false 2910 // When the result is true, it indicates that the value is invali
2910d and an error message appears 2911 if(operator === 'GREATER_THAN'){ 2912 result = targetDate.isAfter(comparatorDate); 2913 }else if(operator === 'LESS_THAN'){ 2914 result = targetDate.isBefore(comparatorDate); 2915 } 2916 if(!result){ 2917 // clear error message 2918 const fieldErrorMessages = app.invalidField[comparatorDateFieldName] || [] 2919 const targetIndex = fieldErrorMessages.findIndex(message => message === comparatorDateFieldErrorMessage) 2920 if(targetIndex !== -1 && fieldErrorMessages.splice(targetIndex,1)){ 2921 if(fieldErrorMessages.length === 0){ 2922 app.invalidField[comparatorDateFieldName] = null 2923 } 2924 } 2925 } 2926 return result 2927 } 2928 }, 2929 presentFuture: { 2930 check: function (str){ 2931 let inputMoment = moment(str).tz("America/Chicago") 2932 let nowMoment = moment(moment().tz("America/Chicago").format('yyyy-MM-DD')).tz("America/Chicago") 2933 return inputMoment.isBefore(nowMoment) 2934 }, 2935 }, 2936} 2937 2938const Visibility = { 2939 MATCH_MODE:{ 2940 ALL:'ALL', 2941 ANY:'ANY' 2942 }, 2943 OPERATOR:{ 2944 IS: 'IS', 2945 IS_NOT: 'IS_NOT', 2946 ANY_OF: 'ANY_OF', 2947 ALL_OF: 'ALL_OF' 2948 }, 2949 /** 2950 * Judge fields visibility 2951 * @param contexts Array of visibility context 2952 * @param formData Current form data 2953 */ 2954 judgeFieldsVisibility: function(contexts = [], formData){ 2955 if(!contexts || contexts.length === 0){ 2956 return { 2957 visibleFields: [], 2958 hiddenFields: [] 2959 } 2960 } 2961 const visibleFields = [] 2962 const hiddenFields = [] 2963 contexts.filter(c => c.isActive).forEach(context => { 2964 const {matchMode,rules,fieldName,childFields} = context 2965 let isVisible = false 2966 if(matchMode === this.MATCH_MODE.ALL){ 2967 isVisible = rules.every(rule => this.matchConditionalRule(rule, formData.getAll(rule.fieldName))) 2968 }else if(matchMode === this.MATCH_MODE.ANY){ 2969 isVisible = rules.some(rule => this.matchConditionalRule(rule, formData.getAll(rule.fieldName))) 2970 } 2971 isVisible ? visibleFields.push(fieldName,...childFields) : hiddenFields.push(fieldName,...childFields) 2972 }) 2973 // return visible and hidden fields 2974 return { 2975 visibleFields, 2976 hiddenFields 2977 } 2978 }, 2979 matchConditionalRule: function(rule,inputValues){ 2980 const {operator:ruleOperator,value:ruleValue} = rule 2981 const inputVal = inputValues.toString() 2982 if(ruleOperator === this.OPERATOR.IS){ 2983 return inputVal === ruleValue 2984 } 2985 2986 if(ruleOperator === this.OPERATOR.IS_NOT){ 2987 return inputVal !== ruleValue 2988 } 2989 2990 const ruleValues = ruleValue ? ruleValue.split(',') : [] 2991 if(ruleOperator === this.OPERATOR.ANY_OF){ 2992 return ruleValues.some(val => inputValues.includes(val)) 2993 } 2994 2995 if(ruleOperator === this.OPERATOR.ALL_OF){ 2996 return ruleValues.every(val => inputValues.includes(val)) 2997 } 2998 2999 return false 3000 }, 3001 deleteObjectValueByPath: function (obj, path) { 3002 if (!obj || !path) { 3003 return 3004 } 3005 const keys = path.replace(/\[(\d+)\]/g, '.$1').split('.').filter(k => k !== '') 3006 if (keys.length === 0) { 3007 return 3008 } 3009 let current = obj 3010 for (let i = 0; i < keys.length - 1; i++) { 3011 if (current == null) { 3012 return 3013 } 3014 current = current[keys[i]] 3015 } 3016 if (current == null) { 3017 return 3018 } 3019 const lastKey = keys[keys.length - 1] 3020 if (!Object.prototype.hasOwnProperty.call(current, lastKey)) { 3021 return 3022 } 3023 if (Array.isArray(current) && /^\d+$/.test(lastKey)) { 3024 current.splice(Number(lastKey), 1) 3025 } else { 3026 delete current[lastKey] 3027 } 3028 }, 3029 3030 3031 setObjectValueByPath: function (obj, path, value) { 3032 if (!obj || !path) { 3033 return 3034 }
3035 const keys = path.replace(/\[(\d+)\]/g, '.$1').split('.') 3036 let current = obj 3037 for (let i = 0; i < keys.length - 1; i++) { 3038 const key = keys[i] 3039 if (!key) { 3040 continue 3041 } 3042 // Create the nested object/array if it doesn't exist 3043 if (current[key] == null) { 3044 current[key] = /^\d+$/.test(keys[i + 1]) ? [] : {} 3045 } 3046 current = current[key] 3047 } 3048 const lastKey = keys[keys.length - 1] 3049 current[lastKey] = value 3050 } 3051} 3052 3053const NeonFA = { 3054 3055 track(eventType, eventData) { 3056 try { window.neonone_fa?.push(eventType, eventData); } catch { /* swallow */ } 3057 }, 3058 3059 /** Track a successful form submission. */ 3060 trackSubmission(data) { 3061 this.track('submission', data); 3062 }, 3063 3064 /** 3065 * Track a successful purchase. 3066 */ 3067 trackPurchase(data) { 3068 this.track('purchase', data); 3069 }, 3070 3071 /** 3072 * Track a field-level validation error. 3073 */ 3074 trackFieldError(fieldName, fieldLabel) { 3075 if (!fieldName) return; 3076 this.track('field_error', { field_name: fieldName, field_label: fieldLabel }); 3077 }, 3078 3079 /** 3080 * Track a field-level field interaction. 3081 */ 3082 trackFieldInteraction(data) { 3083 if (!data.field_name) return; 3084 this.track('field_interaction', data); 3085 }, 3086 3087 /** 3088 * Track a payment failure from an axios error. 3089 */ 3090 trackPaymentFailed(error,formType) { 3091 if(formType !== 'DONATION' && formType !== 'MEMBERSHIP'){ 3092 return; 3093 } 3094 if(!error.response || !error.response.data){ 3095 return; 3096 } 3097 const {data: errors} = error.response 3098 const global = errors.find(e => e.type === 'GLOBAL') 3099 this.track('payment_failed', { 3100 error_code: global?.code ?? '', 3101 error_message: global?.message ?? '', 3102 }); 3103 }, 3104}; 3105try { 3106 window.onerror = function (errorMessage, scriptURI, lineNo, columnNo, error) { 3107 var errorObj = { 3108 sourceType: 'front-forms', 3109 errorMessage: errorMessage || null, 3110 scriptURI: scriptURI || null, 3111 lineNo: lineNo || null, 3112 columnNo: columnNo || null, 3113 stack: error && error.stack ? error.stack : null 3114 }; 3115 3116 sendErrorMessage(errorObj); 3117 }; 3118} catch (e) { 3119 console.log('Initialize error collector failed.'); 3120} 3121 3122function handleVueJSError(err, vm, info) { 3123 try { 3124 var errorObj = { 3125 sourceType: 'front-forms', 3126 errorMessage: err.message, 3127 stack: err.stack 3128 }; 3129 sendErrorMessage(errorObj); 3130 } catch (e) { 3131 console.log('Sending error stack failed.'); 3132 } 3133} 3134 3135// Throttle state: only applies to the opaque cross-origin "Script error." pattern 3136// (no stack/scriptURI/lineNo available) that caused a runaway report flood in 3137// WORK-31834 - caps how many such reports a single page load can send and 3138// enforces a minimum gap between them. Errors carrying a real stack trace are 3139// always sent immediately, since they are useful diagnostic signal rather than 3140// the anonymous cross-origin noise this throttle is meant to suppress. 3141const FRONT_ERROR_MAX_REPORTS = 5; 3142const FRONT_ERROR_MIN_INTERVAL_MS = 10000; 3143let frontErrorReportCount = 0; 3144let frontErrorLastReportTime = 0; 3145 3146function isOpaqueScriptError(errorObj) { 3147 return errorObj.errorMessage === 'Script error.' && !errorObj.stack; 3148} 3149 3150function sendErrorMessage(errorObj) { 3151 try { 3152 if (isOpaqueScriptError(errorObj)) { 3153 const now = Date.now(); 3154 const tooFrequent = now - frontErrorLastReportTime < FRONT_ERROR_MIN_INTERVAL_MS; 3155 if (frontErrorReportCount >= FRONT_ERROR_MAX_REPORTS || tooFrequent) { 3156 // Reporting to the backend is throttled, but keep the error visible 3157 // locally so it is not silently lost during local debugging. 3158 console.warn('[err-collect] report throttled, error still occurred:', errorObj); 3159 return; 3160 }
3161 frontErrorLastReportTime = now; 3162 frontErrorReportCount++; 3163 } 3164 axios.post('/nx/publicaccess/frontErrors', Qs.stringify({errMsg: JSON.stringify(errorObj)})); 3165 } catch (e) { 3166 console.log('Sending script error failed.'); 3167 } 3168} 3169/** 3170 * New Storefront cart: sessionStorage persistence + field-level account merge/hydrate 3171 * (design spec `docs/superpowers/specs/2026-07-23-new-storefront-form-flow-design.md` 3172 * §5 cart DTO, §6 account merge, §8.5 sessionStorage key, §12 integration contract). 3173 * 3174 * Runtime notes: 3175 * - This is a plain classic `<script>` (no build step, no ES module), matching the other 3176 * files in this directory (e.g. `common.js`, `err-collect.js`) that are merged into 3177 * `portal.all.js` in production and served individually under `/nx/portal-static/js/` 3178 * in dev (see `function/common/js.html`). 3179 * - It exposes a single global namespace, `window.NeonStoreCart`, to be consumed by the 3180 * per-item-type form JS (`js/product.html`, `js/membership.html`, 3181 * `js/event_registration.html`) and by `store-flow.js` (`window.NeonStore`, Task 5.2) 3182 * and the shared Summary/Payment page. 3183 * 3184 * @module store-cart 3185 */ 3186(function (global) { 3187 'use strict'; 3188 3189 /** 3190 * Resolve the current org id for namespacing the sessionStorage key. 3191 * 3192 * There is no standalone `window.orgId` global in this codebase. Every per-page inline 3193 * Vue bootstrap script (`function/form/js/base.html:25`, `js/exit_base.html:10`, 3194 * `function/p2p-portal/js/base.html:19`, `function/campaign/common/js/main.html:350`, ...) 3195 * declares its own top-level `const appData = { ..., orgId: [[${userContext.orgId}]], ... }` 3196 * followed by `const app = vueApp.mount('#app')`. Because these are classic (non-module) 3197 * `<script>` blocks, their top-level `const`/`let` bindings live in the single Global 3198 * Declaration Record shared by every classic script in the document â so later classic 3199 * scripts (this file, or `common.js`) can read the bare identifiers `appData`/`app` 3200 * directly. This is not a new trick: `portal-templates/static/js/common.js:9` already 3201 * reads bare `appData.formEmbedEnv`/`appData.popDomainUrl`, and `common.js:448-462` 3202 * already reads bare `app.formData`/`app.invalidField` the same way. 3203 * 3204 * `appData` is checked first (and preferred) because it is declared/initialized near the 3205 * top of the bootstrap script, before `app`, so it is safely available even if a cart 3206 * operation were ever triggered synchronously during the Vue `mount()` call itself (while 3207 * `const app = vueApp.mount(...)` is still being evaluated, i.e. before `app` there is 3208 * initialized). Both accesses are wrapped in try/catch because referencing an 3209 * uninitialized top-level `let`/`const` (temporal dead zone) throws `ReferenceError`, 3210 * unlike a plain undeclared identifier. 3211 * 3212 * @returns {string|number} the org id, or `'default'` if it cannot be resolved (e.g. a
3213 * page that has not defined `appData`/`app` yet, or a standalone test harness). 3214 */ 3215 function resolveOrgId() { 3216 try { 3217 // eslint-disable-next-line no-undef 3218 if (typeof appData !== 'undefined' && appData && appData.orgId != null) { 3219 // eslint-disable-next-line no-undef 3220 return appData.orgId; 3221 } 3222 } catch (e) { /* appData in TDZ or not defined on this page; fall through */ } 3223 try { 3224 // eslint-disable-next-line no-undef 3225 if (typeof app !== 'undefined' && app && app.orgId != null) { 3226 // eslint-disable-next-line no-undef 3227 return app.orgId; 3228 } 3229 } catch (e) { /* app in TDZ or not defined on this page; fall through */ } 3230 return 'default'; 3231 } 3232 3233 /** 3234 * Build the sessionStorage key for the current org's cart (spec §8.5 / §12.5: 3235 * `neon-store-cart:<orgId>`). 3236 * @returns {string} 3237 */ 3238 function storageKey() { 3239 return 'neon-store-cart:' + resolveOrgId(); 3240 } 3241 3242 /** 3243 * Resolve the currently logged-in constituent's account id from the shared portal bootstrap 3244 * (`function/common/constituent_portal_js.html`, which sets `appData.currentAccountId` from 3245 * `userContext.accountId`). Named `currentAccountId` (not `accountId`) on `appData` to avoid 3246 * colliding with any per-page appData field that may already carry that generic name. 3247 * 3248 * @returns {string|number|null} the logged-in constituent's account id, or `null` if the 3249 * shopper is a guest / bootstrap hasn't populated it yet. 3250 */ 3251 function resolveCurrentAccountId() { 3252 try { 3253 // eslint-disable-next-line no-undef 3254 if (typeof appData !== 'undefined' && appData && appData.currentAccountId != null) { 3255 // eslint-disable-next-line no-undef 3256 return appData.currentAccountId; 3257 } 3258 } catch (e) { /* appData in TDZ or not defined on this page; fall through */ } 3259 return null; 3260 } 3261 3262 /** 3263 * @returns {Object} a fresh, empty `StoreShoppingCartForm`-shaped cart (spec §5.2), 3264 * plus the front-end-only `pendingItem` (spec §12.4), `customFieldAnswers` 3265 * (front-end-only, see `upsertCustomFieldAnswers` below), and `editingItemIndex` 3266 * (front-end-only, Summary page "pencil" icon edit flow -- see `store-flow.js`'s `editItem`). 3267 */ 3268 function emptyCart() { 3269 return { 3270 account: {}, 3271 payment: null, 3272 shipping: null, 3273 shippingMethodId: null, 3274 shippingMethod: null, 3275 carrierCode: null, 3276 shipHandlingCost: null, 3277 // New Storefront (product checkout-flow redesign): whether the currently-selected shipping 3278 // method/rate is up to date with everything in cart.items right now. false/absent means "a 3279 // PRODUCT item requiring shipping is in the cart but its rate has not been (re)confirmed on 3280 // the Shipping page yet" -- see `needsShippingCalculation`/`mergeProductItem` below and 3281 // `storeCompleteItemForm`'s `if (json.shipping)` branch (`form/js/product.html`), which sets 3282 // this true once the shopper actually completes the Shipping page. 3283 shippingCalculated: false, 3284 items: [], 3285 pendingItem: null, 3286 editingItemIndex: null, 3287 extraDonation: null, 3288 source: null, 3289 discountCode: null, 3290 accountId: null, 3291 continueShoppingUrl: null, 3292 shoppingCartId: null, 3293 customFieldAnswers: {} 3294 }; 3295 } 3296 3297 /** 3298 * Load the cart from sessionStorage, or a fresh empty cart if absent/corrupt. 3299 * 3300 * Also lazily populates `cart.accountId` from the currently logged-in constituent 3301 * (see {@link resolveCurrentAccountId}) so that the backend 3302 * {@code StoreCartMerger.resolveBuyerAccountId} has the right buyer context for 3303 * discount/tax lookup. If the resolved current account id DIFFERS from a previously 3304 * persisted `cart.accountId` (e.g. logout+relogin as a different constituent, or a shared 3305 * device swap), the persisted cart is discarded and a fresh empty cart bound to the new 3306 * account is returned -- avoids cross-account contamination of items/account/shipping/etc. 3307 * @returns {Object} 3308 */ 3309 function loadCart() { 3310 var cart; 3311 try { 3312 var raw = global.sessionStorage.getItem(storageKey()); 3313 cart = raw ? JSON.parse(raw) : emptyCart(); 3314 } catch (e) { 3315 cart = emptyCart(); 3316 }
3317 var currentAid = resolveCurrentAccountId(); 3318 if (currentAid != null) { 3319 if (cart.accountId != null && String(cart.accountId) !== String(currentAid)) { 3320 // Account switch detected -- discard the prior shopper's cart entirely. 3321 cart = emptyCart(); 3322 } 3323 if (cart.accountId == null) { 3324 cart.accountId = currentAid; 3325 } 3326 saveCart(cart); 3327 } 3328 return cart; 3329 } 3330 3331 /** 3332 * Persist the cart to sessionStorage. 3333 * @param {Object} cart 3334 */ 3335 function saveCart(cart) { 3336 global.sessionStorage.setItem(storageKey(), JSON.stringify(cart)); 3337 } 3338 3339 /** Clear the cart from sessionStorage (e.g. after successful checkout, spec §8.5). */ 3340 function clearCart() { 3341 global.sessionStorage.removeItem(storageKey()); 3342 } 3343 3344 // --- nested path helpers (mirrors the dotted-path convention already used by 3345 // portal-static/js/common.js's setObjectValueByPath/getObjectValueByPath, but operating 3346 // on plain JS objects rather than DOM-bound `Function` construction, since cart state 3347 // is a detached JSON object, not a live Vue binding path expression) --- 3348 3349 /** 3350 * Set a value at a dotted path on a plain object, creating intermediate objects as needed. 3351 * @param {Object} obj 3352 * @param {string} path e.g. `"name.firstName"` 3353 * @param {*} val 3354 */ 3355 function setByPath(obj, path, val) { 3356 var parts = path.split('.'), cur = obj; 3357 for (var i = 0; i < parts.length - 1; i++) { 3358 if (cur[parts[i]] == null || typeof cur[parts[i]] !== 'object') cur[parts[i]] = {}; 3359 cur = cur[parts[i]]; 3360 } 3361 cur[parts[parts.length - 1]] = val; 3362 } 3363 3364 /** 3365 * Read a value at a dotted path on a plain object. 3366 * @param {Object} obj 3367 * @param {string} path 3368 * @returns {*} `undefined` if any intermediate segment is missing. 3369 */ 3370 function getByPath(obj, path) { 3371 var parts = path.split('.'), cur = obj; 3372 for (var i = 0; i < parts.length; i++) { 3373 if (cur == null) return undefined; 3374 cur = cur[parts[i]]; 3375 } 3376 return cur; 3377 } 3378 3379 /** 3380 * Enumerate leaf paths of a nested plain object (arrays are treated as leaves, not 3381 * recursed into, since account/address fields are all scalar or object-valued per 3382 * `AccountForm`/`AddressForm`). 3383 * 3384 * Exported (not just used internally by `upsertAccount`/`hydrateAccount`): the per-type item 3385 * form runtimes (`function/form/js/product.html` etc.) also call this directly to find which 3386 * hydrated `account.*`/`shipping.*` leaves correspond to `<select>` DROP_DOWN fields (Country/ 3387 * State-Province/Territory) so they can sync the actual DOM element too -- see those files' 3388 * `storeSyncDropdownFromCart` for why a plain `formData` write alone isn't enough for a 3389 * `<select>` with no `v-model` (`function/form/field.html`'s `DROP_DOWN` case). 3390 * @param {Object} obj 3391 * @param {string} [prefix] 3392 * @returns {string[]} 3393 */ 3394 function leafPaths(obj, prefix) { 3395 var out = []; 3396 Object.keys(obj || {}).forEach(function (k) { 3397 var p = prefix ? prefix + '.' + k : k, v = obj[k]; 3398 if (v != null && typeof v === 'object' && !Array.isArray(v)) { 3399 out = out.concat(leafPaths(v, p)); 3400 } else { 3401 out.push(p); 3402 } 3403 }); 3404 return out; 3405 } 3406 3407 /** 3408 * Field-level, non-destructive merge: write ONLY the account paths present in 3409 * `formAccountJson` into `cart.account`; leave every other existing `cart.account` field 3410 * untouched (spec §6.2 â "form 䏿²¡æçåæ®µä¸åå½±å"). 3411 * 3412 * Bug fix: EXCLUDES the `customFields` leaf (`AccountForm.customFields`, inherited from 3413 * `BaseForm` -- account-LEVEL custom fields configured on a form's own Account page, distinct 3414 * from that form's top-level item-specific `customFields`). `leafPaths` treats arrays as a 3415 * single opaque leaf, so blindly copying it here carried one form's ENTIRE account-custom-field 3416 * answer array into the shared cart -- and `hydrateAccount` below would then carry that SAME 3417 * array into a LATER, unrelated form's `formData.account.customFields`, submitting a custom 3418 * field id that form never configured and crashing `CustomFieldFormConverter`/`FormService 3419 * #retrieveCustomField` (`Optional.get()` on no match) wherever that account got submitted 3420 * (shipping-rates preview, calculate, checkout). Account-level custom field ANSWERS are instead 3421 * carried through the same id-keyed `cart.customFieldAnswers` map already used for item-level 3422 * customFields -- see `upsertCustomFieldAnswers`/`hydrateCustomFields` below and their call 3423 * sites (`storeCompleteItemForm`/the storeMode==='ITEM' init hooks in each per-type form file), 3424 * which only ever touch a field id a given form's OWN rendered array already has. 3425 * @param {Object} cart 3426 * @param {Object} formAccountJson the `account` sub-object of a form's `getNeonFormJson()` 3427 * output (i.e. shaped like `AccountForm`). 3428 */ 3429 function upsertAccount(cart, formAccountJson) { 3430 if (!formAccountJson) return; 3431 if (!cart.account) cart.account = {};
3432 leafPaths(formAccountJson).forEach(function (path) { 3433 if (path === 'customFields') return; 3434 var v = getByPath(formAccountJson, path); 3435 if (v !== undefined) setByPath(cart.account, path, v); 3436 }); 3437 } 3438 3439 /** 3440 * Fill a Vue `formData.account` from `cart.account` (spec §6.1 guest cross-purchase 3441 * hydrate). Only sets paths already present in `cart.account`; fields the current form 3442 * doesn't render simply aren't touched (harmless â hydrate is the mirror image of 3443 * `upsertAccount`'s non-destructive write). 3444 * 3445 * Skips null/undefined/empty-string values: a logged-in form is server-rendered from the 3446 * CRM account (often blank for phone / custom questions the shopper just filled on this 3447 * cart item). Copying those empty cart leaves would wipe the snapshot we are about to 3448 * overlay from the item itself. `0` and `false` still copy. 3449 * 3450 * Excludes the `customFields` leaf -- see `upsertAccount`'s doc comment for why (that array is 3451 * never written into `cart.account` in the first place, but this mirrors the same exclusion 3452 * defensively in case `cart.account.customFields` exists from an older/mixed cart state). 3453 * @param {Object} formData the form's reactive `formData` object (must already have the 3454 * shape produced by `JSON.formAll(...)`, per `js/base.html:16`). 3455 * @param {Object} cart 3456 */ 3457 function hydrateAccount(formData, cart) { 3458 if (!cart || !cart.account || !formData) return; 3459 if (!formData.account) formData.account = {}; 3460 leafPaths(cart.account).forEach(function (path) { 3461 if (path === 'customFields') return; 3462 var v = getByPath(cart.account, path); 3463 if (v === undefined || v === null || v === '') return; 3464 setByPath(formData.account, path, v); 3465 }); 3466 } 3467 3468 /** 3469 * Fill a Vue `formData.shipping` (+ top-level `shippingMethodId`/`shippingMethod`/ 3470 * `carrierCode`/`shipHandlingCost`) from `cart.shipping`, mirroring `hydrateAccount`'s 3471 * field-level fill: every subsequent product purchase must pre-fill previously-collected 3472 * shipping info (still editable), not just account (design revision: the Seam-2 "skip 3473 * straight to summary" fast path was removed, so the shopper always sees this form's own 3474 * Shipping page again and it must not start blank if shipping is already known). 3475 * @param {Object} formData the form's reactive `formData` object. 3476 * @param {Object} cart 3477 */ 3478 function hydrateShipping(formData, cart) { 3479 if (!cart || !cart.shipping || !formData) return; 3480 if (!formData.shipping) formData.shipping = {}; 3481 leafPaths(cart.shipping).forEach(function (path) { 3482 setByPath(formData.shipping, path, getByPath(cart.shipping, path)); 3483 }); 3484 if (cart.shippingMethodId != null) formData.shippingMethodId = cart.shippingMethodId; 3485 if (cart.shippingMethod != null) formData.shippingMethod = cart.shippingMethod; 3486 if (cart.carrierCode != null) formData.carrierCode = cart.carrierCode; 3487 if (cart.shipHandlingCost != null) formData.shipHandlingCost = cart.shipHandlingCost; 3488 } 3489 3490 /** 3491 * Append an item to the cart (spec §5.2 `items[]`: `{type, formId, customFields, 3492 * hiddenFields, payload}`). 3493 * @param {Object} cart 3494 * @param {Object} item 3495 */ 3496 function addItem(cart, item) { 3497 if (!cart.items) cart.items = []; 3498 cart.items.push(item); 3499 } 3500 3501 /** 3502 * Remove one item from the cart by index (Summary page "trash" icon, spec revision: 3503 * delete-item + recalculate). 3504 * @param {Object} cart 3505 * @param {number} index 3506 */ 3507 function removeItem(cart, index) { 3508 if (!cart.items || index == null || index < 0 || index >= cart.items.length) return; 3509 cart.items.splice(index, 1); 3510 } 3511 3512 /** 3513 * Total item quantity across the cart, used by the giftstore / product-detail
3514 * cart-icon badge (bottom-right corner, disabled when the total is 0). 3515 * 3516 * Per-item quantity rules: 3517 * - PRODUCT items store their qty on `payload.productLines[i].quantity` 3518 * (built by product.html's `onSubmit` via `window.NeonStore.addProduct`). 3519 * Sum every line so quantity-picker choices are honoured â a single cart 3520 * entry can carry qty>1 (or, defensively, multiple product lines). 3521 * - Every other item type (MEMBERSHIP / EVENT / DONATION / package parent, ...) 3522 * is atomic: one cart entry == one purchase, so it always contributes 1. 3523 * These types do not persist a shopper-editable quantity anywhere on the 3524 * cart item, so summing anything else would be inventing data. 3525 * 3526 * @param {Object} cart cart object returned by {@link loadCart} 3527 * @returns {number} total quantity (0 for an empty / missing cart) 3528 */ 3529 function totalQuantity(cart) { 3530 if (!cart || !Array.isArray(cart.items) || cart.items.length === 0) { 3531 return 0; 3532 } 3533 var total = 0; 3534 for (var i = 0; i < cart.items.length; i++) { 3535 var it = cart.items[i]; 3536 if (it && it.type === 'PRODUCT' && it.payload && Array.isArray(it.payload.productLines)) { 3537 for (var j = 0; j < it.payload.productLines.length; j++) { 3538 var q = Number(it.payload.productLines[j] && it.payload.productLines[j].quantity); 3539 if (!isNaN(q) && q > 0) { 3540 total += q; 3541 } 3542 } 3543 } else if (it) { 3544 total += 1; 3545 } 3546 } 3547 return total; 3548 } 3549 3550 /** 3551 * Whether the cart's account satisfies the minimum identity fields needed to skip 3552 * re-collecting account data on a repeat purchase (spec §12.3 Seam 2). 3553 * 3554 * Basis: `account/model/AccountForm.java` (and its nested `NameForm`) declare no 3555 * `@NotNull`/`@NotEmpty` bean-validation constraints on any field â required-ness for 3556 * account fields is entirely form-builder-config-driven (the same `fieldRules`/ 3557 * `fieldConditionalRules` mechanism validated client-side in `js/base.html`'s 3558 * `validateField`), not a fixed set baked into the model. 3559 * 3560 * Bug fix: email1 was previously part of this baseline too, but when a form's own config 3561 * leaves the Email field optional, a shopper who legitimately submits the Account page 3562 * without one would have this permanently return false -- Summary's "Check out" button 3563 * (`storeCheckoutClick`) sends them back to editAccount() on every click, an infinite loop 3564 * they can never escape by re-submitting the exact same (valid) data. First/last name are 3565 * the only fields this can safely assume are always present once an account has actually 3566 * been submitted here. 3567 * 3568 * Bug fix (user-reported): company accounts (`AccountForm.asCompany === true`, an admin-config 3569 * choice available to ANY form type via `AccountModeEnum.COMPANY`/`INDIVIDUAL_COMPANY` -- 3570 * `AsCompanyFieldProcessor`, not Product-specific) submit `account.company.name` instead of 3571 * `name.firstName`/`lastName` (`NameForm`'s fields stay blank for a company submission -- see 3572 * the Individual/Company toggle's own field-hiding rules, `js/base.html`'s `asCompany` watcher). 3573 * The name-only check above previously treated every company account as having "no account" 3574 * forever, sending them back to `editAccount()` on every Check Out click with no way to escape 3575 * (same class of infinite-loop bug the email1 fix above already closed for individual 3576 * accounts) -- now accepted as an equally valid "account known" baseline. 3577 * @param {Object} cart 3578 * @returns {boolean} 3579 */ 3580 function hasAccount(cart) { 3581 var a = cart && cart.account; 3582 if (!a) return false; 3583 if (a.asCompany) { 3584 return !!getByPath(a, 'company.name'); 3585 } 3586 var first = getByPath(a, 'name.firstName'), 3587 last = getByPath(a, 'name.lastName'); 3588 return !!(first && last); 3589 } 3590 3591 /** 3592 * Whether the cart already has shipping info (spec §12.3 Seam 2). 3593 * @param {Object} cart 3594 * @returns {boolean} 3595 */ 3596 function hasShipping(cart) { 3597 return !!(cart && cart.shipping); 3598 } 3599 3600 /** 3601 * Whether two ProductLine-shaped objects (`{productId, choice1, choice2, choice3, 3602 * accessCode}`) represent the exact same product configuration -- ported verbatim from 3603 * `form/js/product.html`'s `storeProductLineMatches` so `mergeProductItem` below (the 3604 * product-detail page's direct Add to Cart, which has no item-form Vue instance to call that 3605 * method on) can apply the identical "same product -> sum quantity" rule. `quantity` is 3606 * intentionally excluded (that's the field being summed, not matched on). 3607 * @param {Object} a 3608 * @param {Object} b 3609 * @returns {boolean} 3610 */ 3611 function productLineMatches(a, b) { 3612 if (!a || !b) return false;
3613 function norm(v) { return (v === undefined || v === null || v === '') ? null : String(v); } 3614 return norm(a.productId) === norm(b.productId) 3615 && norm(a.choice1) === norm(b.choice1) 3616 && norm(a.choice2) === norm(b.choice2) 3617 && norm(a.choice3) === norm(b.choice3) 3618 && norm(a.accessCode) === norm(b.accessCode); 3619 } 3620 3621 /** 3622 * Sums the quantity already reserved in `cart.items[]` for a PRODUCT configuration matching 3623 * `line` (by `productLineMatches` -- same productId+choice1/2/3+accessCode rule `mergeProductItem` 3624 * and `storeCompleteItemForm` (`form/js/product.html`) already use for "same product -> sum 3625 * quantity"). Bug fix (user-reported): the Giftstore product-detail page's own stock check 3626 * (`quantityError`/`recomputeCombo`, `giftstore/product/js/product.html`) only ever validated 3627 * the freshly-typed quantity against the product's total inventory -- it never accounted for 3628 * quantity of the SAME product+choice combo already sitting in the cart from an earlier visit 3629 * to this page, so a shopper could buy up to the stock limit, come back to the same product 3630 * page, and add more on top without ever being blocked (inventory only gets enforced once, at 3631 * checkout, server-side -- this closes the client-side gap that made the page's own stock 3632 * message misleading). 3633 * 3634 * `excludeItemIndex` (typically `cart.editingItemIndex`, when set) excludes one cart item's own 3635 * reserved quantity from the sum -- used when the shopper is editing that exact item (Summary 3636 * page pencil icon -> product-detail page -> `NeonStore.addProduct`), since `mergeProductItem` 3637 * REPLACES that item's line on save rather than adding to it; without the exclusion, editing an 3638 * item down to a lower quantity would still count its own pre-edit quantity as "already 3639 * reserved" and could wrongly block a value that is actually valid. 3640 * @param {Object} cart 3641 * @param {Object} line `{productId, choice1, choice2, choice3, accessCode}` (quantity ignored) 3642 * @param {number} [excludeItemIndex] index into `cart.items[]` to skip, or `null`/`undefined` 3643 * to sum across every item. 3644 * @returns {number} 3645 */ 3646 function reservedQuantityForProductLine(cart, line, excludeItemIndex) { 3647 if (!cart || !Array.isArray(cart.items) || !line) return 0; 3648 var total = 0; 3649 for (var i = 0; i < cart.items.length; i++) { 3650 if (excludeItemIndex != null && i === excludeItemIndex) continue; 3651 var it = cart.items[i]; 3652 if (!it || it.type !== 'PRODUCT' || !it.payload || !Array.isArray(it.payload.productLines)) continue; 3653 for (var j = 0; j < it.payload.productLines.length; j++) { 3654 var existingLine = it.payload.productLines[j]; 3655 if (productLineMatches(existingLine, line)) { 3656 total += Number(existingLine.quantity) || 0; 3657 } 3658 } 3659 } 3660 return total; 3661 } 3662 3663 /** 3664 * Add (or merge into an existing matching line) a PRODUCT selection straight into 3665 * `cart.items[]`, bypassing the item-form entirely (New Storefront product checkout-flow 3666 * redesign, confirmed with user: Add to Cart no longer visits the item-form's Account/Shipping 3667 * pages -- those are now collected lazily from the Summary page's Check Out button instead, see 3668 * `store-flow.js`'s `editAccount`/`editShipping`). Mirrors `storeCompleteItemForm`'s (`form/js/ 3669 * product.html`) non-editing "add" branch: same-configuration lines (by `productLineMatches`) 3670 * sum quantity in place; otherwise a new item is appended with an empty `customFields`/ 3671 * `hiddenFields` (this store's Product forms have no per-product custom fields configured today 3672 * -- if that ever changes, the shopper will simply never be asked, by explicit user decision, 3673 * once account info already exists). 3674 * 3675 * Marks `cart.shippingCalculated = false` whenever the merged/added line itself requires 3676 * shipping (user-confirmed rule): the cart's composition just changed, so any previously 3677 * confirmed shipping rate is potentially stale and must be reconfirmed on the Shipping page 3678 * before checkout can proceed to Payment (see `needsShippingCalculation`, and `storeCheckout` 3679 * in `form/js/product.html`). Does NOT reset when the item does not require shipping -- adding 3680 * a no-shipping product can't invalidate an already-confirmed rate. 3681 *
3682 * Bug fix: if `cart.editingItemIndex` is set (Summary page "pencil" icon -> product-detail page 3683 * -> `onSubmit` -> `NeonStore.addProduct` -> here), this call is completing an EDIT of that 3684 * existing item, not a fresh add -- it must REPLACE that item's line in place (same rule 3685 * `storeCompleteItemForm`'s `form/js/product.html` editingItemIndex branch already applies for 3686 * the item-form flow), never merge-by-match/sum quantity against it or any other line. Without 3687 * this branch, editing an item to a different quantity/selection routed through here (since the 3688 * checkout-flow redesign made this the only Add to Cart path for PRODUCT) would `productLineMatches` 3689 * the pre-edit entry it is supposed to be replacing and silently sum quantities instead (e.g. 3690 * qty 2 edited down to 1 becomes 3), and `editingItemIndex` would never be cleared. 3691 * @param {Object} cart 3692 * @param {Object} meta same shape as `enterItemForm`'s PRODUCT meta (`store-flow.js`): 3693 * `{formId, productId, quantity, choice1, choice2, choice3, accessCode, requiresShipping}`. 3694 */ 3695 function mergeProductItem(cart, meta) { 3696 if (!cart.items) cart.items = []; 3697 var newLine = { 3698 productId: meta.productId, 3699 quantity: meta.quantity, 3700 choice1: meta.choice1, 3701 choice2: meta.choice2, 3702 choice3: meta.choice3, 3703 accessCode: meta.accessCode 3704 }; 3705 var editingIndex = cart.editingItemIndex; 3706 if (editingIndex != null && editingIndex >= 0 && editingIndex < cart.items.length) { 3707 var editingItem = cart.items[editingIndex]; 3708 if (editingItem && editingItem.type === 'PRODUCT') { 3709 editingItem.formId = meta.formId; 3710 editingItem.payload = editingItem.payload || {}; 3711 editingItem.payload.productLines = [newLine]; 3712 editingItem.payload.requiresShipping = !!meta.requiresShipping; 3713 cart.editingItemIndex = null; 3714 if (meta.requiresShipping) { 3715 cart.shippingCalculated = false; 3716 } 3717 return; 3718 } 3719 // Defensive: stale/mismatched editingItemIndex (not a PRODUCT item) -- clear it rather than 3720 // silently falling through to the merge-by-match branch below with it still set. 3721 cart.editingItemIndex = null; 3722 } 3723 var merged = false; 3724 for (var i = 0; i < cart.items.length && !merged; i++) { 3725 var it = cart.items[i]; 3726 if (!it || it.type !== 'PRODUCT' || !it.payload || !Array.isArray(it.payload.productLines)) continue; 3727 for (var j = 0; j < it.payload.productLines.length; j++) { 3728 var existingLine = it.payload.productLines[j]; 3729 if (productLineMatches(existingLine, newLine)) { 3730 existingLine.quantity = (Number(existingLine.quantity) || 0) + (Number(newLine.quantity) || 0); 3731 merged = true; 3732 break; 3733 } 3734 } 3735 } 3736 if (!merged) { 3737 addItem(cart, { 3738 type: 'PRODUCT', 3739 formId: meta.formId, 3740 customFields: [], 3741 hiddenFields: [], 3742 payload: { 3743 productLines: [newLine], 3744 discountCode: '', 3745 requiresShipping: !!meta.requiresShipping 3746 } 3747 }); 3748 } 3749 if (meta.requiresShipping) { 3750 cart.shippingCalculated = false; 3751 } 3752 } 3753 3754 /** 3755 * Whether the Summary page's Check Out button (`storeCheckout`, `form/js/product.html`) must 3756 * route the shopper to the product item-form's Shipping page before checkout can proceed to 3757 * Payment: the cart has a PRODUCT item requiring shipping, and its rate has not been confirmed
3758 * (or was invalidated by a later add â see `mergeProductItem`) since. 3759 * 3760 * Deliberately re-checks "does the cart currently have a shipping-requiring PRODUCT item" fresh 3761 * every time rather than trusting `shippingCalculated` alone: if the shopper deletes the only 3762 * such item, this correctly returns false regardless of the flag's stale value (no explicit 3763 * reset needed on delete). 3764 * @param {Object} cart 3765 * @returns {boolean} 3766 */ 3767 function needsShippingCalculation(cart) { 3768 if (!cart || !Array.isArray(cart.items)) return false; 3769 var hasShippingProduct = cart.items.some(function (it) { 3770 return !!(it && it.type === 'PRODUCT' && it.payload && it.payload.requiresShipping); 3771 }); 3772 return hasShippingProduct && !cart.shippingCalculated; 3773 } 3774 3775 /** 3776 * New Storefront (user-requested): whether the cart's current MEMBERSHIP item (at most one, per 3777 * the existing single-membership-instance cart constraint -- see `js/membership.html`'s 3778 * `storeCompleteItemForm` doc comment) requires a specific account type. `true` = the shopper 3779 * must submit as a company (this term's membership is an Organization/company GROUP 3780 * membership), `false` = must submit as an individual (a Household/individual GROUP 3781 * membership), `null` = no constraint -- either no membership in the cart at all, or its term 3782 * is a regular (non-group) membership. 3783 * 3784 * The constraint is computed once, when the membership item-form completes 3785 * (`js/membership.html`'s `selectTerm()`/`storeCompleteItemForm`, from the backend's real 3786 * `Membership.isOrganizationGroupMembership()`/`isIndividualGroupMembership()` on the selected 3787 * term), and persisted onto THAT membership cart item's own `payload.forcedAsCompany` -- 3788 * deliberately not a separate cart-level field, so the constraint disappears automatically the 3789 * moment that item is deleted or replaced with a different term, with no separate cleanup path 3790 * to maintain. 3791 * 3792 * Consumed by `js/product.html`/`js/event_registration.html`'s own ITEM-mode init hooks (after 3793 * `hydrateAccount`) to force+hide THEIR OWN "Submit as a company" toggle the same way, so a 3794 * shopper who already locked in an account type via a household/company group membership sees 3795 * the same forced type (and no editable toggle) on every other item-form they subsequently 3796 * visit in the same cart -- mirroring the reverse direction (buy the item first, then a group 3797 * membership) that `js/membership.html`'s own `selectTerm()` already handles for a single form. 3798 * @param {Object} cart 3799 * @returns {(boolean|null)} 3800 */ 3801 function resolveMembershipAccountTypeConstraint(cart) { 3802 if (!cart || !Array.isArray(cart.items)) return null; 3803 var membershipItem = cart.items.find(function (it) { return it && it.type === 'MEMBERSHIP'; }); 3804 if (!membershipItem || !membershipItem.payload) return null; 3805 var forced = membershipItem.payload.forcedAsCompany; 3806 return (forced === true || forced === false) ? forced : null; 3807 } 3808 3809 /** 3810 * Remember custom-field answers, keyed BY CUSTOM FIELD ID (not per-form): a form's custom 3811 * questions are account-scoped data (`ProductFormConverter.applyAccountCustomFields` merges 3812 * them onto the buyer's Account/User by field id, "existing custom data ... preserved rather 3813 * than clobbered" -- mirroring `MembershipFormAssistant`/event equivalents), so the SAME 3814 * customFieldId answered on one form should hydrate onto ANY later form that asks the same 3815 * question, exactly like `hydrateAccount`'s leaf-path fields are recognized across forms by 3816 * identity rather than "only if the same form collected it." See `hydrateCustomFields` for the 3817 * read side (spec revision: pre-fill AND still show the question again on every purchase, the 3818 * same "åå¡«ï¼å¯ä¿®æ¹" treatment as account/shipping -- there is no longer a Seam-2-style skip). 3819 * @param {Object} cart 3820 * @param {Array} customFields the `CustomFieldForm[]`-shaped answers (`{id, value}`), i.e. the 3821 * completed item-form's own `getNeonFormJson().customFields`. 3822 */ 3823 function upsertCustomFieldAnswers(cart, customFields) { 3824 if (!customFields || customFields.length === 0) return; 3825 if (!cart.customFieldAnswers) cart.customFieldAnswers = {}; 3826 customFields.forEach(function (cf) { 3827 if (cf && cf.id != null) { 3828 cart.customFieldAnswers[String(cf.id)] = cf.value; 3829 } 3830 }); 3831 } 3832 3833 /** 3834 * Fill a Vue `formData.customFields` (`CustomFieldForm[]`-shaped, `{id, value}`) from 3835 * previously-recorded answers, by matching each entry's `id` -- mirrors `hydrateAccount`'s 3836 * "only touch what the current form already has" contract: `formData.customFields` already 3837 * has one entry per custom-field THIS form renders (server-rendered ids, default/blank 3838 * values); entries whose id has no recorded answer are left untouched (unanswered before, or 3839 * a question this cart has never seen). 3840 * @param {Object} formData 3841 * @param {Object} cart 3842 */ 3843 function hydrateCustomFields(formData, cart) { 3844 if (!cart || !cart.customFieldAnswers || !formData || !Array.isArray(formData.customFields)) return; 3845 formData.customFields.forEach(function (cf) { 3846 if (!cf || cf.id == null) return; 3847 var key = String(cf.id); 3848 if (Object.prototype.hasOwnProperty.call(cart.customFieldAnswers, key)) { 3849 var v = cart.customFieldAnswers[key]; 3850 if (v === undefined || v === null) return; 3851 cf.value = v; 3852 } 3853 }); 3854 } 3855 3856 global.NeonStoreCart = { 3857 loadCart: loadCart, 3858 saveCart: saveCart, 3859 clearCart: clearCart, 3860 emptyCart: emptyCart, 3861 upsertAccount: upsertAccount, 3862 hydrateAccount: hydrateAccount, 3863 hydrateShipping: hydrateShipping, 3864 addItem: addItem, 3865 removeItem: removeItem, 3866 hasAccount: hasAccount, 3867 hasShipping: hasShipping, 3868 mergeProductItem: mergeProductItem, 3869 reservedQuantityForProductLine: reservedQuantityForProductLine, 3870 needsShippingCalculation: needsShippingCalculation, 3871 resolveMembershipAccountTypeConstraint: resolveMembershipAccountTypeConstraint, 3872 upsertCustomFieldAnswers: upsertCustomFieldAnswers, 3873 hydrateCustomFields: hydrateCustomFields, 3874 getByPath: getByPath, 3875 setByPath: setByPath, 3876 leafPaths: leafPaths, 3877 totalQuantity: totalQuantity 3878 }; 3879})(window); 3880/** 3881 * New Storefront orchestration API for the store-list page (design spec 3882 * `docs/superpowers/specs/2026-07-23-new-storefront-form-flow-design.md` §12 3883 * â store-list integration contract). 3884 * 3885 * Runtime notes: 3886 * - Plain classic `<script>` (no build step, no ES module), matching 3887 * `store-cart.js` (Task 5.1) and the other files in this directory that are 3888 * merged into `portal.all.js` in production and served individually under 3889 * `/nx/portal-static/js/` in dev (see `function/common/js.html` and the 3890 * `merge-js` antrun execution in `neon-gui/backend/pom.xml`). 3891 * - Depends on `window.NeonStoreCart` (Task 5.1, `store-cart.js`) being 3892 * loaded first. This file must be included on the store-list page (which 3893 * calls into `window.NeonStore` below) as well as on the item-form pages 3894 * (`product-form`, `membership-form`, `event-registration-form`) and the 3895 * shared Summary/Payment page, since Task 5.3 reuses `_enterItemForm`. 3896 * See the report (`task-5.2-report.md`) for the exact recommended 3897 * `<script>` include points and `pom.xml` `merge-js` registration (both 3898 * left for a later task per this task's constraints). 3899 * 3900 * @module store-flow 3901 */ 3902(function (global) { 3903 'use strict'; 3904 3905 var Cart = global.NeonStoreCart; 3906 3907 /** 3908 * Module-level configuration, set by the store-list page via `configure()`. 3909 * 3910 * `contextPath` defaults to `/nx`, confirmed as the backend's 3911 * `server.servlet.context-path` (`neon-gui/backend/src/main/resources/ 3912 * application-dev.yml:3`). `summaryFormId` has no default â `goToSummary` 3913 * throws until the store-list page supplies one (spec §12.5 open 3914 * coordination item; see report). 3915 * 3916 * @type {{summaryFormId: (number|string|null), contextPath: string}} 3917 */ 3918 var config = { 3919 summaryFormId: null, 3920 contextPath: '/nx' 3921 }; 3922 3923 /** 3924 * Merge store-list-supplied options into the module config. 3925 * 3926 * `summaryFormId` is ALSO persisted onto the cart (sessionStorage, via 3927 * `NeonStoreCart`) so it survives full-page navigation. `config` is a 3928 * plain module-level variable re-initialized to its defaults every time 3929 * this script is loaded on a fresh page (the store-list page, each 3930 * trimmed item-form page, and the shared Summary page all load their own 3931 * independent copy of this file) â so a `configure()` call made once on 3932 * the store-list page would otherwise be invisible by the time an item 3933 * form's `storeCompleteItemForm` calls `goToSummary()` on an entirely 3934 * different page load. Persisting it on the cart (which already 3935 * round-trips through every page in the flow) closes that gap without 3936 * requiring every item-form template to call `configure()` itself. 3937 * 3938 * @param {Object} [opts] 3939 * @param {(number|string)} [opts.summaryFormId] default form id for the 3940 * shared Summary/Payment step (spec §12.5 â no backend default-lookup 3941 * endpoint exists; the store-list page must supply this explicitly). 3942 * @param {string} [opts.contextPath] override for the app's context path, 3943 * e.g. if it ever differs from `/nx` in a given environment. 3944 */ 3945 function configure(opts) { 3946 if (!opts) return;
3947 Object.assign(config, opts); 3948 if (opts.summaryFormId != null) { 3949 var cart = Cart.loadCart(); 3950 cart.summaryFormId = opts.summaryFormId; 3951 Cart.saveCart(cart); 3952 } 3953 } 3954 3955 function resolveFlowOrgId() { 3956 try { 3957 if (typeof appData !== 'undefined' && appData && appData.orgId != null) { 3958 return appData.orgId; 3959 } 3960 } catch (e) { /* TDZ */ } 3961 return 'default'; 3962 } 3963 3964 function flowStorageKey() { 3965 return 'neon-store-flow:' + resolveFlowOrgId(); 3966 } 3967 3968 /** 3969 * Persist return-copy that MUST survive NeonStoreCart.clearCart(). 3970 * @param {Object} [ctx] 3971 * @param {string|null} [ctx.continueShoppingLabel] 3972 * @param {string|null} [ctx.returnUrl] 3973 * @param {string|null} [ctx.returnLabel] 3974 */ 3975 function setFlowContext(ctx) { 3976 var next = { 3977 continueShoppingLabel: ctx && ctx.continueShoppingLabel != null ? ctx.continueShoppingLabel : null, 3978 returnUrl: ctx && ctx.returnUrl != null ? ctx.returnUrl : null, 3979 returnLabel: ctx && ctx.returnLabel != null ? ctx.returnLabel : null 3980 }; 3981 try { 3982 global.sessionStorage.setItem(flowStorageKey(), JSON.stringify(next)); 3983 } catch (e) { /* ignore quota / private mode */ } 3984 } 3985 3986 function getFlowContext() { 3987 try { 3988 var raw = global.sessionStorage.getItem(flowStorageKey()); 3989 if (!raw) return {}; 3990 var parsed = JSON.parse(raw); 3991 return parsed && typeof parsed === 'object' ? parsed : {}; 3992 } catch (e) { 3993 return {}; 3994 } 3995 } 3996 3997 /** 3998 * Resolve the shared Summary/Payment form id: prefers this page's own 3999 * in-memory `config.summaryFormId` (set by an explicit `configure()` call 4000 * on THIS page load), falling back to the value persisted on the cart by 4001 * an earlier `configure()` call on a different page (see `configure`'s 4002 * doc comment above for why the module-level config alone isn't enough). 4003 * @returns {(number|string|null)} 4004 */ 4005 function resolveSummaryFormId() { 4006 if (config.summaryFormId != null) return config.summaryFormId; 4007 var cart = Cart.loadCart(); 4008 return cart.summaryFormId != null ? cart.summaryFormId : null; 4009 } 4010 4011 /** 4012 * Store the "continue shopping" URL (store-list page) into the cart so the 4013 * Summary/Payment page's "Continue Shopping" link can send the shopper 4014 * back to it. 4015 * @param {string} url 4016 */ 4017 function setContinueShoppingUrl(url) { 4018 var cart = Cart.loadCart(); 4019 cart.continueShoppingUrl = url; 4020 Cart.saveCart(cart); 4021 } 4022 4023 /** @returns {Object} the current cart (see `NeonStoreCart.loadCart`). */ 4024 function getCart() { 4025 return Cart.loadCart(); 4026 } 4027 4028 /** Clear the current cart (see `NeonStoreCart.clearCart`). */ 4029 function clearCart() { 4030 Cart.clearCart(); 4031 } 4032 4033 /** 4034 * Build the item-form URL for a given store item type (spec §12.2). 4035 * 4036 * Uses the canonical short link, `host/forms/{formId}` (the SAME form 4037 * `FormListController#formUrlPrefix`/`ShareMetaDataProcessor` already build for admin 4038 * share-link display -- `HtmlUtils.getSchemeDomain(request) + "/forms/"`), per design 4039 * feedback that every form's browser-visible URL should be this short link regardless of 4040 * type, not the per-type path (`/portal/product-form/{formId}` etc.) or `config.contextPath`. 4041 * Deliberately built from `window.location.origin` rather than `config.contextPath` + 4042 * `/portal/forms/` (`CommonFormController#get`, `@GetMapping("/portal/forms/{formId}")`): 4043 * the short link is resolved a layer above that Spring mapping (reverse-proxy rewrite, already 4044 * configured, out of this codebase's scope) down to whatever backend path actually serves it, 4045 * so hardcoding `/portal` here would be redundant with (and could drift from) that rewrite. 4046 * `storeMode`/`termId` survive regardless of how many hops resolve the short link, since query 4047 * strings are preserved by both an HTTP redirect and a servlet forward. 4048 * 4049 * @param {('PRODUCT'|'MEMBERSHIP'|'EVENT_REGISTRATION')} type matches the 4050 * backend `StoreItemType` enum (`store/model/StoreItemType.java`); only used to decide 4051 * whether `termId` applies, not to select a path segment (the short link is type-agnostic). 4052 * @param {(number|string)} formId 4053 * @param {(number|string)} [termId] MEMBERSHIP only: the selected membership term id. 4054 * The chosen term only ever lives in this browser's cart (`pendingItem.membershipTermId`, 4055 * see `enterItemForm`), so it must be forwarded as a query param for the server to preload 4056 * it (`StoreItemFormProcessor`, `store/processor/StoreItemFormProcessor.java`) â otherwise 4057 * the sub-member page renders with no term/hasSubMembers info at all. 4058 * @returns {string} 4059 */ 4060 function itemFormUrl(type, formId, termId) { 4061 var url = global.location.origin + '/forms/' + formId + '?storeMode=ITEM'; 4062 if (type === 'MEMBERSHIP' && termId != null) { 4063 url += '&termId=' + encodeURIComponent(termId); 4064 } 4065 return url; 4066 } 4067 4068 /** 4069 * Resolve the item-form id for this add-to-cart action. 4070 * 4071 * Per spec §12.5 (open store-list/backend coordination item, still 4072 * unresolved as of this task): there is no backend "default form id for 4073 * this item type" lookup endpoint. The store-list page is the only place 4074 * that knows which form id to use (it is the page rendering the product/ 4075 * membership/event catalog), so it MUST pass `meta.formId` explicitly. No 4076 * silent fallback or invented endpoint is used here â an absent 4077 * `meta.formId` is a caller bug and throws immediately. 4078 * 4079 * @param {Object} meta 4080 * @param {(number|string)} [meta.formId] 4081 * @returns {(number|string)} 4082 * @throws {Error} if `meta.formId` is missing. 4083 */ 4084 function resolveFormId(meta) { 4085 if (meta && meta.formId != null) return meta.formId; 4086 throw new Error('NeonStore: meta.formId is required (default form id resolution â see spec §12.5)'); 4087 } 4088 4089 /** 4090 * Save the selected-item context into `cart.pendingItem` so the trimmed 4091 * item form (product/membership/event, `storeMode=ITEM`) can preload it 4092 * once loaded (spec §12.4 â the product/membership home pages were 4093 * removed, so this is the only channel carrying the shopper's selection 4094 * into the form). Task 5.3 reads `cart.pendingItem` to populate hidden 4095 * fields on the item form. 4096 * 4097 * @param {('PRODUCT'|'MEMBERSHIP'|'EVENT_REGISTRATION')} type 4098 * @param {Object}
4098 meta caller-supplied selection context (shape varies by 4099 * `type`; see field notes below). 4100 * @param {(number|string)} meta.formId required (see `resolveFormId`). 4101 * @param {number} [meta.productId] PRODUCT: matches `ProductLine.productId` 4102 * (`product/model/ProductLine.java:21`). 4103 * @param {number} [meta.quantity] PRODUCT: matches `ProductLine.quantity` 4104 * (`product/model/ProductLine.java:22`). 4105 * @param {string} [meta.choice1] PRODUCT: matches `ProductLine.choice1` 4106 * (`product/model/ProductLine.java:23`). 4107 * @param {string} [meta.choice2] PRODUCT: matches `ProductLine.choice2` 4108 * (`product/model/ProductLine.java:24`). 4109 * @param {string} [meta.choice3] PRODUCT: matches `ProductLine.choice3` 4110 * (`product/model/ProductLine.java:25`). 4111 * @param {string} [meta.accessCode] PRODUCT: matches `ProductLine.accessCode` 4112 * (`product/model/ProductLine.java:26`). Only required when the product's 4113 * own `requiresAccessCode` (see `GET /nx/portal/product-detail/{formId}/ 4114 * {productId}`) is true; the backend validates it case-insensitively at 4115 * checkout and rejects the whole cart if missing/wrong for such a product. 4116 * @param {boolean} [meta.requiresShipping] PRODUCT: REQUIRED, store-list- 4117 * supplied ground truth for whether this specific product needs shipping 4118 * (mirrors the product's own `shippingRequired`/`ProductItemDTO. 4119 * shippingRequired`, `GET /nx/portal/product-detail/{formId}/{productId}` 4120 * or the catalog listing). The trimmed item-form's own product catalog 4121 * lookup (`GET /nx/portal/product-form/{formId}/products`, scoped to 4122 * THIS form's own configured PRODUCT_SELECTION_MODE/PRODUCT_IDS) is not 4123 * guaranteed to include an arbitrary store-list productId â this default 4124 * store form exists only to collect Account/Shipping data, the store 4125 * list's own catalog is authoritative for which products exist â so the 4126 * item-form cannot reliably derive shipping need from its own catalog 4127 * match and needs it supplied explicitly. Omitting it defaults to 4128 * `false` (no Shipping page/fields shown or submitted for this item). 4129 * @param {(number|string)} [meta.membershipTermId] MEMBERSHIP: selected 4130 * membership term (no verified backend "MembershipLine" model exists yet 4131 * â Task 5.3/membership item-form task should confirm the real field 4132 * name before consuming this). 4133 * @param {(number|string)} [meta.eventId] EVENT_REGISTRATION: selected 4134 * event (same caveat as `membershipTermId` â unverified against a 4135 * backend model in this task's scope). 4136 */ 4137 function enterItemForm(type, meta) { 4138 var cart = Cart.loadCart(); 4139 var formId = resolveFormId(meta); 4140 var pendingItem = { type: type, formId: formId }; 4141 if (type === 'PRODUCT') { 4142 pendingItem.productId = meta.productId; 4143 pendingItem.quantity = meta.quantity; 4144 pendingItem.choice1 = meta.choice1; 4145 pendingItem.choice2 = meta.choice2; 4146 pendingItem.choice3 = meta.choice3; 4147 pendingItem.accessCode = meta.accessCode; 4148 pendingItem.requiresShipping = !!meta.requiresShipping; 4149 } else if (type === 'MEMBERSHIP') { 4150 pendingItem.membershipTermId = meta.membershipTermId; 4151 pendingItem.quantity = meta.quantity; 4152 } else { 4153 pendingItem.eventId = meta.eventId; 4154 pendingItem.quantity = meta.quantity; 4155 } 4156 cart.pendingItem = pendingItem; 4157 Cart.saveCart(cart); 4158 global.location.href = itemFormUrl(type, formId, pendingItem.membershipTermId); 4159 } 4160 4161 /** 4162 * Navigate to the shared Summary/Payment step (spec §12.2/§12.3). 4163 * 4164 * Resolves to `product-form` with `storeMode=SUMMARY` server-side (confirmed in Task 3.2: only 4165 * `ProductFormController.get()` wires the `"SUMMARY"` branch, `applyForSharedSummary` â 4166 * `MembershipFormController`/`EventRegistrationFormController` do not, so the product form 4167 * doubles as the shared default store form regardless of the item type(s) already in the 4168 * cart) -- but navigates via the canonical short link (`host/forms/{formId}`, see 4169 * `itemFormUrl`'s doc comment for why this is built from `window.location.origin` rather than 4170 * `config.contextPath`/`/portal`) rather than the per-type path directly, per design feedback 4171 * that every form's browser-visible URL should be the short link. 4172 * 4173 * @throws {Error} if `summaryFormId` has not been set via `configure()` 4174 * on any page in this cart's flow so far (spec §12.5 open coordination 4175 * item) â see `resolveSummaryFormId`. 4176 */ 4177 function goToSummary() { 4178 var summaryFormId = resolveSummaryFormId(); 4179 if (summaryFormId == null) { 4180 throw new Error('NeonStore: summaryFormId not configured (NeonStore.configure) â spec
4180§12.5'); 4181 } 4182 global.location.href = global.location.origin + '/forms/' 4183 + summaryFormId + '?storeMode=SUMMARY'; 4184 } 4185 4186 /** 4187 * Add a product to the cart (spec §12.1; product checkout-flow redesign, confirmed with user 4188 * -- supersedes the "always routes through the item-form" revision this doc comment previously 4189 * described). 4190 * 4191 * Add to Cart no longer enters the item-form at all. It merges the selection directly into 4192 * `cart.items[]` (`NeonStoreCart.mergeProductItem` â the SAME "same productId+choices 4193 * +accessCode sums quantity" rule `storeCompleteItemForm`'s add branch already used) and goes 4194 * straight to the shared Summary page. Account/shipping (and, per user's explicit call, any 4195 * per-product custom fields â none configured on this store's Product forms today, and if that 4196 * ever changes the shopper simply won't be asked again once account info exists, by design) are 4197 * no longer collected at add-to-cart time; they are collected lazily instead, from the Summary 4198 * page's Check Out button (`storeCheckoutClick` in `form/js/product.html`, which calls 4199 * `editAccount`/`editShipping` below on demand â that's also where the "recalculate shipping 4200 * against the full cart" requirement this old fast-path removal was protecting is now enforced, 4201 * via `mergeProductItem`/`needsShippingCalculation` marking shipping stale on every new 4202 * shipping-requiring add instead of silently reusing a stale rate). 4203 * 4204 * @param {Object} meta see `enterItemForm` for field shape. 4205 */ 4206 function addProduct(meta) { 4207 var cart = Cart.loadCart(); 4208 Cart.mergeProductItem(cart, meta); 4209 Cart.saveCart(cart); 4210 return goToSummary(); 4211 } 4212 4213 /** 4214 * Navigate to edit an existing cart item (Summary page "pencil" icon). Sets 4215 * `cart.editingItemIndex` so that, once the shopper eventually completes the item-form again, 4216 * `storeCompleteItemForm` replaces `cart.items[index]` in place instead of appending/merging a 4217 * new item â this survives regardless of which page the edit routes through below, since it is 4218 * only ever cleared when actually consumed (`storeCompleteItemForm`) or when the shopper 4219 * explicitly abandons the edit (`continueShopping`, product.html). 4220 * 4221 * MEMBERSHIP/EVENT_REGISTRATION: unchanged â routes straight to the item-form's own Account 4222 * page (`itemFormUrl`), which pre-fills from `cart.items[index]`'s payload itself (see 4223 * membership.html/event_registration.html's storeMode==='ITEM' init hooks); those forms have no 4224 * product-detail-style page where the shopper could change the item's own selection anyway 4225 * (MEMBERSHIP's level-selection page is removed by StorePageFilter in store mode; the term/ 4226 * ticket choice was fixed at add-to-cart time by the store list). 4227 * 4228 * PRODUCT (design revision): routes to the Giftstore product-detail page 4229 * (`giftstore/product/product.html`, `GiftstoreProductPageController` `/portal/product?id=`) 4230 * instead, using the item's own `payload.productLines[0].productId` â that page already lets 4231 * the shopper change quantity/options/access code and, on "Add to Cart", calls 4232 * `window.NeonStore.addProduct(meta)` itself (same as a fresh add), which re-enters the item-form 4233 * Account/Shipping pages exactly like any other add. `cart.editingItemIndex` (set below) rides 4234 * along through that whole chain untouched (`enterItemForm` never touches it) so the eventual 4235 * `storeCompleteItemForm` still replaces rather than merges/appends. Falls back to `itemFormUrl` 4236 * if the item has no recorded productId (defensive â should not happen for a real PRODUCT item). 4237 * 4238 * @param {number} index index into `cart.items[]` of the item to edit. 4239 */ 4240 function editItem(index) { 4241 var cart = Cart.loadCart(); 4242 var item = cart.items && cart.items[index]; 4243 if (!item) { 4244 return; 4245 } 4246 cart.editingItemIndex = index; 4247 Cart.saveCart(cart); 4248 if (item.type === 'PRODUCT') { 4249 var line = item.payload && Array.isArray(item.payload.productLines) ? item.payload.productLines[0] : null; 4250 var productId = line ? line.productId : null; 4251 if (productId != null) { 4252 global.location.href = '/giftstore/product?id=' + encodeURIComponent(productId); 4253 return; 4254 } 4255 } 4256 // Bug fix: MEMBERSHIP editing must forward the item's OWN termId the same way a fresh 4257 // enterItemForm() does -- itemFormUrl only appends `&termId=` when given one, and 4258 // MembershipFormController#shouldAppendStoreDiscountCodePage reads termId straight off the 4259 // request query param (there is no cart.pendingItem on an edit, see that method's own doc 4260 // comment). Without this, re-editing a membership item always requested the page with no 4261 // termId at all, so the backend could never determine the term has a coupon-code discount 4262 // configured -- the Discount Code page silently never got appended, and continuing without 4263 // re-typing the code then overwrote the item's existing (now-blank) discountCode on save. 4264 var termId = item.type === 'MEMBERSHIP' && item.payload ? item.payload.termId : null; 4265 global.location.href = itemFormUrl(item.type, item.formId, termId); 4266 } 4267 4268 /** 4269 * Pick which PRODUCT-type `cart.items[]` entry should serve as the "vehicle" for a full-page 4270 * round-trip into the product item-form to collect/re-collect cart-wide account or shipping 4271 * data (used by both `editShipping` and `editAccount` below â account/shipping are cart-level 4272 * fields, not item-level, so which PRODUCT item rides along is otherwise arbitrary). 4273 * 4274 * Prefers the first PRODUCT-type item whose own payload is flagged `requiresShipping` (mirrors 4275 * `StoreCartMerger#merge`'s server-side "first PRODUCT item with non-null shipping wins" rule, 4276 * `store/service/StoreCartMerger.java` â keeps the Shipping-page entry point consistent with 4277 * whichever item actually drives the shown Delivery Cost), falling back to the first PRODUCT 4278 * item at all if none are flagged. 4279 * @param {Array} items `cart.items` 4280 * @returns {number} index, or -1 if no PRODUCT item exists. 4281 */ 4282 function pickProductItemIndex(items) { 4283 items = items || []; 4284 for (var i = 0; i < items.length; i++) { 4285 var it = items[i]; 4286 if (it && it.type === 'PRODUCT' && it.payload && it.payload.requiresShipping) { 4287 return i; 4288 } 4289 } 4290 for (var j = 0; j < items.length; j++) { 4291 if (items[j] && items[j].type === 'PRODUCT') { 4292 return j; 4293 } 4294 } 4295 return -1; 4296 } 4297 4298 /** 4299 * Set `cart.editingItemIndex` + rebuild `cart.pendingItem` from the picked item's own 4300 * `payload.productLines[0]` (mirroring what `enterItemForm` would have set), so 4301 * `storeApplyPendingItemSelection` (`form/js/product.html`) restores this item's EXISTING 4302 * product selection on load -- without this, the item-form would see no `pendingItem` and no 4303 * in-progress Products-page selection (removed by `StorePageFilter` in ITEM mode), and 4304 * `storeCompleteItemForm` would replace the cart item with an empty product line on the next 4305 * Continue. 4306 * @param {Object} cart 4307 * @param {number} index 4308 * @returns {Object|null}
4308 the picked item, or null if index is invalid. 4309 */ 4310 function primeProductItemForRecollection(cart, index) { 4311 var item = cart.items && cart.items[index]; 4312 if (!item) { 4313 return null; 4314 } 4315 var line = item.payload && Array.isArray(item.payload.productLines) ? item.payload.productLines[0] : null; 4316 cart.editingItemIndex = index; 4317 if (line) { 4318 cart.pendingItem = { 4319 type: 'PRODUCT', 4320 formId: item.formId, 4321 productId: line.productId, 4322 quantity: line.quantity, 4323 choice1: line.choice1, 4324 choice2: line.choice2, 4325 choice3: line.choice3, 4326 accessCode: line.accessCode, 4327 requiresShipping: !!(item.payload && item.payload.requiresShipping) 4328 }; 4329 } 4330 return item; 4331 } 4332 4333 /** 4334 * Navigate straight to the product item-form's own Shipping page (Summary page "Delivery Cost" 4335 * pencil icon, and the Check Out button's shipping gate â `storeCheckoutClick`, `form/js/ 4336 * product.html`), so the shopper can reselect the shipping method/rate without walking back 4337 * through the Account page or the Giftstore product-detail page `editItem` uses. 4338 * 4339 * The `startPage=SHIPPING` query param is read directly off `window.location.search` by 4340 * `product.html`'s own `storeMode==='ITEM'` init hook (no backend/controller change needed -- 4341 * the query string already survives the `/forms/{id}` short-link redirect+forward chain 4342 * untouched, see `itemFormUrl`'s doc comment) to jump `currentPage` straight to the Shipping 4343 * page once the form has mounted and hydrated. 4344 */ 4345 function editShipping() { 4346 var cart = Cart.loadCart(); 4347 var index = pickProductItemIndex(cart.items); 4348 if (index === -1) { 4349 return; 4350 } 4351 var item = primeProductItemForRecollection(cart, index); 4352 Cart.saveCart(cart); 4353 global.location.href = itemFormUrl('PRODUCT', item.formId) + '&startPage=SHIPPING'; 4354 } 4355 4356 /** 4357 * Navigate to the product item-form's own Account page (Check Out button's account gate â 4358 * `storeCheckoutClick`, `form/js/product.html`), for the case where the cart has no account info 4359 * yet at all (e.g. the shopper's first-ever Add to Cart, now that Add to Cart itself skips the 4360 * item-form entirely â see `addProduct`'s doc comment). Plain entry (no `startPage` query 4361 * param): lands on page 1 (Account) like any normal item-form visit, and the item-form's own 4362 * nextPage() naturally carries on to its Shipping page afterwards if THIS item also needs 4363 * shipping, exactly like any other item-form visit -- see primeProductItemForRecollection's 4364 * doc comment for why the picked item's existing selection is preserved rather than lost. 4365 */ 4366 function editAccount() { 4367 var cart = Cart.loadCart(); 4368 var index = pickProductItemIndex(cart.items); 4369 if (index === -1) { 4370 return; 4371 } 4372 var item = primeProductItemForRecollection(cart, index); 4373 Cart.saveCart(cart); 4374 global.location.href = itemFormUrl('PRODUCT', item.formId); 4375 } 4376 4377 /** 4378 * Add a membership to the cart â always routes through the trimmed 4379 * membership item-form (no Seam 2 fast path defined for memberships in 4380 * spec §12.3). 4381 * @param {Object} meta see `enterItemForm` for field shape. 4382 */ 4383 function addMembership(meta) { 4384 return enterItemForm('MEMBERSHIP', meta); 4385 } 4386 4387 /** 4388 * Cart index of an existing EVENT_REGISTRATION for the same event, or -1. 4389 * Same event = same eventId (payload.eventId / item.eventId). Items added 4390 * before eventId was persisted fall back to matching formId only when they 4391 * have no eventId of their own. 4392 * @param {Object} cart 4393 * @param {Object} meta 4394 * @returns {number} 4395 */ 4396 function findExistingEventItemIndex(cart, meta) { 4397 var items = cart && cart.items ? cart.items : []; 4398 var eventId = meta && meta.eventId != null ? String(meta.eventId) : null; 4399 var formId = meta && meta.formId != null ? String(meta.formId) : null; 4400 var i; 4401 var it; 4402 var existingEventId; 4403 if (eventId) { 4404 for (i = 0; i < items.length; i++) { 4405 it = items[i]; 4406 if (!it || it.type !== 'EVENT_REGISTRATION') continue; 4407 existingEventId = it.payload && it.payload.eventId != null ? it.payload.eventId : it.eventId; 4408 if (existingEventId != null && String(existingEventId) === eventId) { 4409 return i; 4410 } 4411 } 4412 } 4413 if (formId) { 4414 for (i = 0; i < items.length; i++) { 4415 it = items[i]; 4416 if (!it || it.type !== 'EVENT_REGISTRATION') continue; 4417 existingEventId = it.payload && it.payload.eventId != null ? it.payload.eventId : it.eventId; 4418 if (existingEventId != null) continue; 4419 if (it.formId != null && String(it.formId) === formId) { 4420 return i; 4421 } 4422 } 4423 } 4424 return -1; 4425 } 4426 4427 /** 4428 * Add an event registration to the cart â always routes through the 4429 * trimmed event-registration item-form (no Seam 2 fast path defined for 4430 * event registrations in spec §12.3). 4431 * 4432 * One-event-once: if this event is already in the cart, set 4433 * `editingItemIndex` so the item-form hydrates the previous payload 4434 * (same path as the Summary pencil) and `storeCompleteItemForm` replaces 4435 * that row instead of appending. Always assign editingItemIndex (index 4436 * or null) so a stale edit from an abandoned re-register cannot replace 4437 * the wrong row. 4438 * @param {Object} meta see `enterItemForm` for field shape. 4439 */ 4440 function addEvent(meta) { 4441 var cart = Cart.loadCart(); 4442 var index = findExistingEventItemIndex(cart, meta); 4443 cart.editingItemIndex = index >= 0 ? index : null; 4444 Cart.saveCart(cart); 4445 return enterItemForm('EVENT_REGISTRATION', meta); 4446 } 4447 4448 global.NeonStore = { 4449 configure: configure, 4450 setFlowContext: setFlowContext, 4451 getFlowContext: getFlowContext, 4452 setContinueShoppingUrl: setContinueShoppingUrl, 4453 getCart: getCart, 4454 clearCart: clearCart, 4455 addProduct: addProduct, 4456 addMembership: addMembership, 4457 addEvent: addEvent, 4458 editItem: editItem, 4459 editShipping: editShipping, 4460 editAccount: editAccount, 4461 goToSummary: goToSummary, 4462 // Exposed for Task 5.3 (item-form completion) reuse â not part of the 4463 // store-list-facing §12.1 surface, but kept on the same public object 4464 // since this is a single global namespace, not a module with private 4465 // scope shared across files. 4466 _enterItemForm: enterItemForm 4467 }; 4468})(window);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.