diff --git a/__mocks__/expo-auth-session.ts b/__mocks__/expo-auth-session.ts new file mode 100644 index 0000000..e62bbed --- /dev/null +++ b/__mocks__/expo-auth-session.ts @@ -0,0 +1,26 @@ +// Mock for expo-auth-session +export const ResponseType = { + Code: 'code', + Token: 'token', + IdToken: 'id_token', +}; + +export const makeRedirectUri = jest.fn().mockReturnValue('test://auth/callback'); + +export const useAutoDiscovery = jest.fn().mockReturnValue({ + authorizationEndpoint: 'https://example.com/oauth/authorize', + tokenEndpoint: 'https://example.com/oauth/token', +}); + +export const useAuthRequest = jest.fn().mockReturnValue([ + null, // request + null, // response + jest.fn().mockResolvedValue({ type: 'cancel' }), // promptAsync +]); + +export const exchangeCodeAsync = jest.fn().mockResolvedValue({ + idToken: 'mock-id-token', + accessToken: 'mock-access-token', +}); + +export const AuthSessionRedirectUriOptions = {}; diff --git a/__mocks__/expo-crypto.ts b/__mocks__/expo-crypto.ts new file mode 100644 index 0000000..c361d59 --- /dev/null +++ b/__mocks__/expo-crypto.ts @@ -0,0 +1,20 @@ +// Mock for expo-crypto +export const digestStringAsync = jest.fn().mockResolvedValue('mock-hash'); + +export const CryptoDigestAlgorithm = { + SHA256: 'SHA-256', + SHA512: 'SHA-512', + SHA1: 'SHA-1', + MD2: 'MD2', + MD4: 'MD4', + MD5: 'MD5', +}; + +export const CryptoEncoding = { + HEX: 'hex', + BASE64: 'base64', +}; + +export const getRandomBytesAsync = jest.fn().mockResolvedValue(new Uint8Array(32)); + +export const randomUUID = jest.fn().mockReturnValue('mock-uuid-1234'); diff --git a/__mocks__/expo-web-browser.ts b/__mocks__/expo-web-browser.ts new file mode 100644 index 0000000..c46a2ee --- /dev/null +++ b/__mocks__/expo-web-browser.ts @@ -0,0 +1,8 @@ +// Mock for expo-web-browser +export const maybeCompleteAuthSession = jest.fn().mockReturnValue({ type: 'success' }); + +export const openBrowserAsync = jest.fn().mockResolvedValue({ type: 'cancel' }); + +export const openAuthSessionAsync = jest.fn().mockResolvedValue({ type: 'cancel' }); + +export const dismissBrowser = jest.fn(); diff --git a/__mocks__/react-native-mmkv.ts b/__mocks__/react-native-mmkv.ts index 54a7f57..dc41c80 100644 --- a/__mocks__/react-native-mmkv.ts +++ b/__mocks__/react-native-mmkv.ts @@ -5,13 +5,36 @@ import { useState } from 'react'; +const fallbackStores: Map> = new Map(); + +function getFallbackStorage(id: string): Storage { + let existing = fallbackStores.get(id); + if (existing === undefined) { + existing = {}; + fallbackStores.set(id, existing); + } + const _fallbackData: Record = existing; + const _fallbackStorage: Storage = { + getItem(key: string) { return _fallbackData[key] ?? null; }, + setItem(key: string, value: string) { _fallbackData[key] = value; }, + removeItem(key: string) { delete _fallbackData[key]; }, + key(index: number) { return Object.keys(_fallbackData)[index] ?? null; }, + get length() { return Object.keys(_fallbackData).length; }, + clear() { Object.keys(_fallbackData).forEach((k) => { delete _fallbackData[k]; }); }, + } as unknown as Storage; + return _fallbackStorage; +} + class MockMMKV { private storage: Storage; private prefix: string; constructor(config?: { id?: string; encryptionKey?: string }) { - this.storage = typeof window !== 'undefined' ? window.localStorage : ({} as Storage); - this.prefix = config?.id || 'mmkv'; + const id = config?.id || 'mmkv'; + this.storage = typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' + ? window.localStorage + : getFallbackStorage(id); + this.prefix = id; } private getKey(key: string): string { diff --git a/app.config.ts b/app.config.ts index 1a8d7af..555945f 100644 --- a/app.config.ts +++ b/app.config.ts @@ -51,6 +51,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ ITSAppUsesNonExemptEncryption: false, UIViewControllerBasedStatusBarAppearance: false, NSBluetoothAlwaysUsageDescription: 'Allow Resgrid Dispatch to connect to bluetooth devices for PTT.', + LSApplicationQueriesSchemes: [Env.SCHEME, 'https', 'http'], }, entitlements: { ...((Env.APP_ENV === 'production' || Env.APP_ENV === 'internal') && { @@ -72,6 +73,14 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ }, softwareKeyboardLayoutMode: 'pan', package: Env.PACKAGE, + intentFilters: [ + { + action: 'VIEW', + autoVerify: true, + data: [{ scheme: Env.SCHEME }], + category: ['BROWSABLE', 'DEFAULT'], + }, + ], ...(fs.existsSync(path.join(__dirname, 'google-services.json')) && { googleServicesFile: 'google-services.json', }), @@ -268,6 +277,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ '@livekit/react-native-expo-plugin', '@config-plugins/react-native-webrtc', '@config-plugins/react-native-callkeep', + 'expo-web-browser', './customGradle.plugin.js', './customManifest.plugin.js', ['app-icon-badge', appIconBadgeConfig], diff --git a/assets/js/form-render.min.js b/assets/js/form-render.min.js new file mode 100644 index 0000000..4cc6cf3 --- /dev/null +++ b/assets/js/form-render.min.js @@ -0,0 +1,2 @@ +/*! For license information please see form-render.min.js.LICENSE.txt */ +!function(e){"use strict";!function(){var t={962:function(e,t,r){r.r(t);var n=r(645),o=r.n(n),i=r(278),s=r.n(i)()(o());s.push([e.id,'.rendered-form *{box-sizing:border-box}.rendered-form.formbuilder-embedded-bootstrap button,.rendered-form.formbuilder-embedded-bootstrap input,.rendered-form.formbuilder-embedded-bootstrap select,.rendered-form.formbuilder-embedded-bootstrap textarea{font-family:inherit;font-size:inherit;line-height:inherit}.rendered-form.formbuilder-embedded-bootstrap input{line-height:normal}.rendered-form.formbuilder-embedded-bootstrap textarea{overflow:auto}.rendered-form.formbuilder-embedded-bootstrap button,.rendered-form.formbuilder-embedded-bootstrap input,.rendered-form.formbuilder-embedded-bootstrap select,.rendered-form.formbuilder-embedded-bootstrap textarea{font-family:inherit;font-size:inherit;line-height:inherit}.rendered-form.formbuilder-embedded-bootstrap .btn-group{position:relative;display:inline-block;vertical-align:middle}.rendered-form.formbuilder-embedded-bootstrap .btn-group>.btn{position:relative;float:left}.rendered-form.formbuilder-embedded-bootstrap .btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.rendered-form.formbuilder-embedded-bootstrap .btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.rendered-form.formbuilder-embedded-bootstrap .btn-group .btn+.btn,.rendered-form.formbuilder-embedded-bootstrap .btn-group .btn+.btn-group,.rendered-form.formbuilder-embedded-bootstrap .btn-group .btn-group+.btn,.rendered-form.formbuilder-embedded-bootstrap .btn-group .btn-group+.btn-group{margin-left:-1px}.rendered-form.formbuilder-embedded-bootstrap .btn-group>.btn:last-child:not(:first-child),.rendered-form.formbuilder-embedded-bootstrap .btn-group>.dropdown-toggle:not(:first-child),.rendered-form.formbuilder-embedded-bootstrap .btn-group .input-group .form-control:last-child,.rendered-form.formbuilder-embedded-bootstrap .btn-group .input-group-addon:last-child,.rendered-form.formbuilder-embedded-bootstrap .btn-group .input-group-btn:first-child>.btn-group:not(:first-child)>.btn,.rendered-form.formbuilder-embedded-bootstrap .btn-group .input-group-btn:first-child>.btn:not(:first-child),.rendered-form.formbuilder-embedded-bootstrap .btn-group .input-group-btn:last-child>.btn,.rendered-form.formbuilder-embedded-bootstrap .btn-group .input-group-btn:last-child>.btn-group>.btn,.rendered-form.formbuilder-embedded-bootstrap .btn-group .input-group-btn:last-child>.dropdown-toggle{border-top-left-radius:0;border-bottom-left-radius:0}.rendered-form.formbuilder-embedded-bootstrap .btn-group>.btn.active,.rendered-form.formbuilder-embedded-bootstrap .btn-group>.btn:active,.rendered-form.formbuilder-embedded-bootstrap .btn-group>.btn:focus,.rendered-form.formbuilder-embedded-bootstrap .btn-group>.btn:hover{z-index:2}.rendered-form.formbuilder-embedded-bootstrap .btn{display:inline-block;padding:6px 12px;margin-bottom:0;font-size:14px;font-weight:400;line-height:1.42857143;text-align:center;white-space:nowrap;vertical-align:middle;touch-action:manipulation;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;background-image:none;border-radius:4px}.rendered-form.formbuilder-embedded-bootstrap .btn.btn-lg{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.rendered-form.formbuilder-embedded-bootstrap .btn.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.rendered-form.formbuilder-embedded-bootstrap .btn.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.rendered-form.formbuilder-embedded-bootstrap .btn.active,.rendered-form.formbuilder-embedded-bootstrap .btn.btn-active,.rendered-form.formbuilder-embedded-bootstrap .btn:active{background-image:none}.rendered-form.formbuilder-embedded-bootstrap .input-group .form-control:last-child,.rendered-form.formbuilder-embedded-bootstrap .input-group-addon:last-child,.rendered-form.formbuilder-embedded-bootstrap .input-group-btn:first-child>.btn-group:not(:first-child)>.btn,.rendered-form.formbuilder-embedded-bootstrap .input-group-btn:first-child>.btn:not(:first-child),.rendered-form.formbuilder-embedded-bootstrap .input-group-btn:last-child>.btn,.rendered-form.formbuilder-embedded-bootstrap .input-group-btn:last-child>.btn-group>.btn,.rendered-form.formbuilder-embedded-bootstrap .input-group-btn:last-child>.dropdown-toggle{border-top-left-radius:0;border-bottom-left-radius:0}.rendered-form.formbuilder-embedded-bootstrap .input-group .form-control,.rendered-form.formbuilder-embedded-bootstrap .input-group-addon,.rendered-form.formbuilder-embedded-bootstrap .input-group-btn{display:table-cell}.rendered-form.formbuilder-embedded-bootstrap .input-group-lg>.form-control,.rendered-form.formbuilder-embedded-bootstrap .input-group-lg>.input-group-addon,.rendered-form.formbuilder-embedded-bootstrap .input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333}.rendered-form.formbuilder-embedded-bootstrap .input-group{position:relative;display:table;border-collapse:separate}.rendered-form.formbuilder-embedded-bootstrap .input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.rendered-form.formbuilder-embedded-bootstrap .form-control,.rendered-form.formbuilder-embedded-bootstrap output{font-size:14px;line-height:1.42857143;display:block}.rendered-form.formbuilder-embedded-bootstrap textarea.form-control{height:auto}.rendered-form.formbuilder-embedded-bootstrap .form-control{height:34px;display:block;width:100%;padding:6px 12px;font-size:14px;line-height:1.42857143;border-radius:4px}.rendered-form.formbuilder-embedded-bootstrap .form-control:focus{outline:0;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)}.rendered-form.formbuilder-embedded-bootstrap .form-group{margin-left:0px;margin-bottom:15px}.rendered-form.formbuilder-embedded-bootstrap .btn,.rendered-form.formbuilder-embedded-bootstrap .form-control{background-image:none}.rendered-form.formbuilder-embedded-bootstrap .pull-right{float:right}.rendered-form.formbuilder-embedded-bootstrap .pull-left{float:left}.rendered-form .formbuilder-required,.rendered-form .required-asterisk{color:#c10000}.rendered-form .formbuilder-checkbox-group input[type=checkbox],.rendered-form .formbuilder-checkbox-group input[type=radio],.rendered-form .formbuilder-radio-group input[type=checkbox],.rendered-form .formbuilder-radio-group input[type=radio]{margin:0 4px 0 0}.rendered-form .formbuilder-checkbox-inline,.rendered-form .formbuilder-radio-inline{margin-right:8px;display:inline-block;vertical-align:middle;padding-left:0}.rendered-form .formbuilder-checkbox-inline label input[type=text],.rendered-form .formbuilder-radio-inline label input[type=text]{margin-top:0}.rendered-form .formbuilder-checkbox-inline:first-child,.rendered-form .formbuilder-radio-inline:first-child{padding-left:0}.rendered-form .formbuilder-autocomplete-list{background-color:#fff;display:none;list-style:none;padding:0;border:1px solid #ccc;border-width:0 1px 1px;position:absolute;z-index:20;max-height:200px;overflow-y:auto}.rendered-form .formbuilder-autocomplete-list li{display:none;cursor:default;padding:5px;margin:0;transition:background-color 200ms ease-in-out}.rendered-form .formbuilder-autocomplete-list li:hover,.rendered-form .formbuilder-autocomplete-list li.active-option{background-color:rgba(0,0,0,.075)}.rendered-form *[tooltip]{position:relative}.rendered-form *[tooltip]:hover::after{background:rgba(0,0,0,.9);border-radius:5px 5px 5px 0;bottom:23px;color:#fff;content:attr(tooltip);padding:10px 5px;position:absolute;z-index:98;left:2px;width:230px;text-shadow:none;font-size:12px;line-height:1.5em;cursor:default}.rendered-form *[tooltip]:hover::before{border:solid;border-color:#222 rgba(0,0,0,0);border-width:6px 6px 0;bottom:17px;content:"";left:2px;position:absolute;z-index:99;cursor:default}.rendered-form .tooltip-element{visibility:visible;color:#fff;background:#000;width:16px;height:16px;border-radius:8px;display:inline-block;text-align:center;line-height:16px;margin:0 5px;font-size:12px;cursor:default}.rendered-form .kc-toggle{padding-left:0 !important}.rendered-form .kc-toggle span{position:relative;width:48px;height:24px;background:#e6e6e6;display:inline-block;border-radius:4px;border:1px solid #ccc;padding:2px;overflow:hidden;float:left;margin-right:5px;will-change:transform}.rendered-form .kc-toggle span::after,.rendered-form .kc-toggle span::before{position:absolute;display:inline-block;top:0}.rendered-form .kc-toggle span::after{position:relative;content:"";width:50%;height:100%;left:0;border-radius:3px;background:linear-gradient(to bottom, white 0%, #ccc 100%);border:1px solid #999;transition:transform 100ms;transform:translateX(0)}.rendered-form .kc-toggle span::before{border-radius:4px;top:2px;left:2px;content:"";width:calc(100% - 4px);height:18px;box-shadow:0 0 1px 1px #b3b3b3 inset;background-color:rgba(0,0,0,0)}.rendered-form .kc-toggle input{height:0;overflow:hidden;width:0;opacity:0;pointer-events:none;margin:0}.rendered-form .kc-toggle input:checked+span::after{transform:translateX(100%)}.rendered-form .kc-toggle input:checked+span::before{background-color:#6fc665}.rendered-form label{font-weight:normal}.rendered-form .form-group .formbuilder-required{color:#c10000}.rendered-form .other-option:checked+label input{display:inline-block}.rendered-form .other-val{margin-left:5px;display:none}.rendered-form .form-control.number{width:auto}.rendered-form .form-control[type=color]{width:60px;padding:2px;display:inline-block}.rendered-form .form-control[multiple]{height:auto}',""]),t.default=s},278:function(e){e.exports=function(e){var t=[];return t.toString=function(){return this.map((function(t){var r="",n=void 0!==t[5];return t[4]&&(r+="@supports (".concat(t[4],") {")),t[2]&&(r+="@media ".concat(t[2]," {")),n&&(r+="@layer".concat(t[5].length>0?" ".concat(t[5]):""," {")),r+=e(t),n&&(r+="}"),t[2]&&(r+="}"),t[4]&&(r+="}"),r})).join("")},t.i=function(e,r,n,o,i){"string"==typeof e&&(e=[[null,e,void 0]]);var s={};if(n)for(var a=0;a0?" ".concat(c[5]):""," {").concat(c[1],"}")),c[5]=i),r&&(c[2]?(c[1]="@media ".concat(c[2]," {").concat(c[1],"}"),c[2]=r):c[2]=r),o&&(c[4]?(c[1]="@supports (".concat(c[4],") {").concat(c[1],"}"),c[4]=o):c[4]="".concat(o)),t.push(c))}},t}},645:function(e){e.exports=function(e){return e[1]}},252:function(e){e.exports=function(e){var t={};function r(n){if(t[n])return t[n].exports;var o=t[n]={i:n,l:!1,exports:{}};return e[n].call(o.exports,o,o.exports,r),o.l=!0,o.exports}return r.m=e,r.c=t,r.d=function(e,t,n){r.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n},r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,"a",t),t},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.p="",r(r.s=7)}([function(e,t,r){var n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},o=r(2),i=r(10),s=Object.prototype.toString;function a(e){return"[object Array]"===s.call(e)}function l(e){return null!==e&&"object"===(void 0===e?"undefined":n(e))}function d(e){return"[object Function]"===s.call(e)}function c(e,t){if(null!=e)if("object"!==(void 0===e?"undefined":n(e))&&(e=[e]),a(e))for(var r=0,o=e.length;r=200&&e<300},headers:{common:{Accept:"application/json, text/plain, */*"}}};n.forEach(["delete","get","head"],(function(e){a.headers[e]={}})),n.forEach(["post","put","patch"],(function(e){a.headers[e]=n.merge(i)})),e.exports=a}).call(this,r(12))},function(e,t,r){e.exports=function(e,t){return function(){for(var r=new Array(arguments.length),n=0;n0&&void 0!==arguments[0]?arguments[0]:s;!function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}(this,e),this.langs=Object.create(null),this.loaded=[],this.processConfig(t)}return e.prototype.processConfig=function(e){var t=this,r=Object.assign({},s,e),n=r.location,o=function(e,t){var r={};for(var n in e)t.indexOf(n)>=0||Object.prototype.hasOwnProperty.call(e,n)&&(r[n]=e[n]);return r}(r,["location"]),i=n.replace(/\/?$/,"/");this.config=Object.assign({},{location:i},o);var a=this.config,l=a.override,d=a.preloaded,c=void 0===d?{}:d,u=Object.entries(this.langs).concat(Object.entries(l||c));this.langs=u.reduce((function(e,r){var n=r[0],o=r[1];return e[n]=t.applyLanguage.call(t,n,o),e}),{}),this.locale=this.config.locale||this.config.langs[0]},e.prototype.init=function(e){return this.processConfig.call(this,Object.assign({},this.config,e)),this.setCurrent(this.locale)},e.prototype.addLanguage=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};t="string"==typeof t?this.processFile.call(this,t):t,this.applyLanguage.call(this,e,t),this.config.langs.push("locale")},e.prototype.getValue=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this.locale;return this.langs[t]&&this.langs[t][e]||this.getFallbackValue(e)},e.prototype.getFallbackValue=function(e){var t=Object.values(this.langs).find((function(t){return t[e]}));return t&&t[e]},e.prototype.makeSafe=function(e){var t={"{":"\\{","}":"\\}","|":"\\|"};return e=e.replace(/\{|\}|\|/g,(function(e){return t[e]})),new RegExp(e,"g")},e.prototype.put=function(e,t){return this.current[e]=t},e.prototype.get=function(e,t){var r=this.getValue(e);if(r){var o=r.match(/\{[^}]+?\}/g),i=void 0;if(t&&o)if("object"===(void 0===t?"undefined":n(t)))for(var s=0;s1&&void 0!==arguments[1])||arguments[1],r=this;return new Promise((function(n,o){if(-1!==r.loaded.indexOf(e)&&t)return r.applyLanguage.call(r,r.langs[e]),n(r.langs[e]);var s=[r.config.location,e,r.config.extension].join("");return(0,i.get)(s).then((function(t){var o=t.data,i=r.processFile(o);return r.applyLanguage.call(r,e,i),r.loaded.push(e),n(r.langs[e])})).catch((function(){var t=r.applyLanguage.call(r,e);n(t)}))}))},e.prototype.applyLanguage=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},r=this.config.override[e]||{},n=this.langs[e]||{};return this.langs[e]=Object.assign({},n,t,r),this.langs[e]},e.prototype.setCurrent=function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"en-US";return this.loadLang(t).then((function(){return e.locale=t,e.current=e.langs[t],e.current}))},o(e,[{key:"getLangs",get:function(){return this.config.langs}}]),e}();t.default=new a},function(e,t,r){e.exports=r(9)},function(e,t,r){var n=r(0),o=r(2),i=r(11),s=r(1);function a(e){var t=new i(e),r=o(i.prototype.request,t);return n.extend(r,i.prototype,t),n.extend(r,t),r}var l=a(s);l.Axios=i,l.create=function(e){return a(n.merge(s,e))},l.Cancel=r(6),l.CancelToken=r(26),l.isCancel=r(5),l.all=function(e){return Promise.all(e)},l.spread=r(27),e.exports=l,e.exports.default=l},function(e,t,r){function n(e){return!!e.constructor&&"function"==typeof e.constructor.isBuffer&&e.constructor.isBuffer(e)}e.exports=function(e){return null!=e&&(n(e)||function(e){return"function"==typeof e.readFloatLE&&"function"==typeof e.slice&&n(e.slice(0,0))}(e)||!!e._isBuffer)}},function(e,t,r){var n=r(1),o=r(0),i=r(21),s=r(22);function a(e){this.defaults=e,this.interceptors={request:new i,response:new i}}a.prototype.request=function(e){"string"==typeof e&&(e=o.merge({url:arguments[0]},arguments[1])),(e=o.merge(n,{method:"get"},this.defaults,e)).method=e.method.toLowerCase();var t=[s,void 0],r=Promise.resolve(e);for(this.interceptors.request.forEach((function(e){t.unshift(e.fulfilled,e.rejected)})),this.interceptors.response.forEach((function(e){t.push(e.fulfilled,e.rejected)}));t.length;)r=r.then(t.shift(),t.shift());return r},o.forEach(["delete","get","head","options"],(function(e){a.prototype[e]=function(t,r){return this.request(o.merge(r||{},{method:e,url:t}))}})),o.forEach(["post","put","patch"],(function(e){a.prototype[e]=function(t,r,n){return this.request(o.merge(n||{},{method:e,url:t,data:r}))}})),e.exports=a},function(e,t,r){var n,o,i=e.exports={};function s(){throw new Error("setTimeout has not been defined")}function a(){throw new Error("clearTimeout has not been defined")}function l(e){if(n===setTimeout)return setTimeout(e,0);if((n===s||!n)&&setTimeout)return n=setTimeout,setTimeout(e,0);try{return n(e,0)}catch(t){try{return n.call(null,e,0)}catch(t){return n.call(this,e,0)}}}!function(){try{n="function"==typeof setTimeout?setTimeout:s}catch(e){n=s}try{o="function"==typeof clearTimeout?clearTimeout:a}catch(e){o=a}}();var d,c=[],u=!1,f=-1;function p(){u&&d&&(u=!1,d.length?c=d.concat(c):f=-1,c.length&&m())}function m(){if(!u){var e=l(p);u=!0;for(var t=c.length;t;){for(d=c,c=[];++f1)for(var r=1;r=0)return;s[t]="set-cookie"===t?(s[t]?s[t]:[]).concat([r]):s[t]?s[t]+", "+r:r}})),s):s}},function(e,t,r){var n=r(0);e.exports=n.isStandardBrowserEnv()?function(){var e,t=/(msie|trident)/i.test(navigator.userAgent),r=document.createElement("a");function o(e){var n=e;return t&&(r.setAttribute("href",n),n=r.href),r.setAttribute("href",n),{href:r.href,protocol:r.protocol?r.protocol.replace(/:$/,""):"",host:r.host,search:r.search?r.search.replace(/^\?/,""):"",hash:r.hash?r.hash.replace(/^#/,""):"",hostname:r.hostname,port:r.port,pathname:"/"===r.pathname.charAt(0)?r.pathname:"/"+r.pathname}}return e=o(window.location.href),function(t){var r=n.isString(t)?o(t):t;return r.protocol===e.protocol&&r.host===e.host}}():function(){return!0}},function(e,t,r){function n(){this.message="String contains an invalid character"}n.prototype=new Error,n.prototype.code=5,n.prototype.name="InvalidCharacterError",e.exports=function(e){for(var t,r,o=String(e),i="",s=0,a="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";o.charAt(0|s)||(a="=",s%1);i+=a.charAt(63&t>>8-s%1*8)){if((r=o.charCodeAt(s+=.75))>255)throw new n;t=t<<8|r}return i}},function(e,t,r){var n=r(0);e.exports=n.isStandardBrowserEnv()?{write:function(e,t,r,o,i,s){var a=[];a.push(e+"="+encodeURIComponent(t)),n.isNumber(r)&&a.push("expires="+new Date(r).toGMTString()),n.isString(o)&&a.push("path="+o),n.isString(i)&&a.push("domain="+i),!0===s&&a.push("secure"),document.cookie=a.join("; ")},read:function(e){var t=document.cookie.match(new RegExp("(^|;\\s*)("+e+")=([^;]*)"));return t?decodeURIComponent(t[3]):null},remove:function(e){this.write(e,"",Date.now()-864e5)}}:{write:function(){},read:function(){return null},remove:function(){}}},function(e,t,r){var n=r(0);function o(){this.handlers=[]}o.prototype.use=function(e,t){return this.handlers.push({fulfilled:e,rejected:t}),this.handlers.length-1},o.prototype.eject=function(e){this.handlers[e]&&(this.handlers[e]=null)},o.prototype.forEach=function(e){n.forEach(this.handlers,(function(t){null!==t&&e(t)}))},e.exports=o},function(e,t,r){var n=r(0),o=r(23),i=r(5),s=r(1),a=r(24),l=r(25);function d(e){e.cancelToken&&e.cancelToken.throwIfRequested()}e.exports=function(e){return d(e),e.baseURL&&!a(e.url)&&(e.url=l(e.baseURL,e.url)),e.headers=e.headers||{},e.data=o(e.data,e.headers,e.transformRequest),e.headers=n.merge(e.headers.common||{},e.headers[e.method]||{},e.headers||{}),n.forEach(["delete","get","head","post","put","patch","common"],(function(t){delete e.headers[t]})),(e.adapter||s.adapter)(e).then((function(t){return d(e),t.data=o(t.data,t.headers,e.transformResponse),t}),(function(t){return i(t)||(d(e),t&&t.response&&(t.response.data=o(t.response.data,t.response.headers,e.transformResponse))),Promise.reject(t)}))}},function(e,t,r){var n=r(0);e.exports=function(e,t,r){return n.forEach(r,(function(r){e=r(e,t)})),e}},function(e,t,r){e.exports=function(e){return/^([a-z][a-z\d\+\-\.]*:)?\/\//i.test(e)}},function(e,t,r){e.exports=function(e,t){return t?e.replace(/\/+$/,"")+"/"+t.replace(/^\/+/,""):e}},function(e,t,r){var n=r(6);function o(e){if("function"!=typeof e)throw new TypeError("executor must be a function.");var t;this.promise=new Promise((function(e){t=e}));var r=this;e((function(e){r.reason||(r.reason=new n(e),t(r.reason))}))}o.prototype.throwIfRequested=function(){if(this.reason)throw this.reason},o.source=function(){var e;return{token:new o((function(t){e=t})),cancel:e}},e.exports=o},function(e,t,r){e.exports=function(e){return function(t){return e.apply(null,t)}}}])},700:function(e,t,r){var n=r(292),o=r(962);"string"==typeof(o=o.__esModule?o.default:o)&&(o=[[e.id,o,""]]);n(o,{attributes:{class:"formBuilder-injected-style"},insert:"head",singleton:!1}),e.exports=o.locals||{}},292:function(e,t,r){var n,o=function(){var e={};return function(t){if(void 0===e[t]){var r=document.querySelector(t);if(window.HTMLIFrameElement&&r instanceof window.HTMLIFrameElement)try{r=r.contentDocument.head}catch(e){r=null}e[t]=r}return e[t]}}(),i=[];function s(e){for(var t=-1,r=0;re}};var i;const s=(e,t)=>{if(0===o.backendOrder.length)return!1;const r=e.toLowerCase();return t=t?t+"":"",r.startsWith("on")||["form","formaction"].includes(r)||t.trim().toLowerCase().startsWith("javascript:")};o.backends.fallback=function(t){const r=document.implementation.createHTMLDocument(""),n=r.createElement("base");n.href=document.location.href,r.head.appendChild(n);const o=["applet","comment","embed","iframe","link","listing","meta","noscript","object","plaintext","script","style","xmp"],i=e.parseHTML(t,r,!1);e(i).find("*").addBack().each(((t,r)=>{"#text"!==r.nodeName&&(r.tagName&&o.includes(r.tagName.toLowerCase())?r.parentElement?r.parentElement.removeChild(r):i.includes(r)&&i.splice(i.indexOf(r),1):r.attributes&&Array.from(r.attributes).forEach((t=>{s(t.name,t.value)&&e(r).removeAttr(t.name)})))}));const a=r.createElement("div");return e(a).html(i),a.innerHTML};const a=e=>{const t=!!o.clobberingProtection.document&&document,r=!!o.clobberingProtection.form&&document.createElement("form");return t&&e in t||r&&e in r?o.clobberingProtection.namespaceAttributes?"user-content-"+e:void 0:e},l={fallback:(e,t)=>{const r=o.backends.fallback,n="function"==typeof r;return n&&(t=r(t)),e.innerHTML=t,n},dompurify:(e,t)=>{const r=o.backends.dompurify;return!(!1===r||!r.isSupported||(e.innerHTML=r.sanitize(t),0))},sanitizer:(e,t)=>{const r=o.backends.sanitizer;return!!r&&(e.setHTML(t,{sanitizer:r}),!0)}},d=["events"],c=["tag","content"];function u(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,n)}return r}function f(e){for(var t=1;t=0)continue;r[n]=e[n]}return r}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(n=0;n=0||{}.propertyIsEnumerable.call(e,r)&&(o[r]=e[r])}return o}window.fbLoaded={js:[],css:[]},window.fbEditors={quill:{},tinymce:{}};const h=function(e,t=!1){if(null==e||"object"!=typeof e)return e;const r="function"==typeof window.structuredClone?window.structuredClone(e):Object.assign({},e),n=[null,void 0,""];t&&n.push(!1);for(const e in r)n.includes(r[e])?delete r[e]:Array.isArray(r[e])&&(r[e].length||delete r[e]);return r},b=function(e){return!["values","enableOther","other","label","subtype"].includes(e)},g=(e,t)=>{let r;return e=y(e),t&&(Array.isArray(t)?r=T(t.join(" ")):("boolean"==typeof t&&(t=t.toString()),r=T(t.trim()))),{name:e,value:t=t?`="${r}"`:""}},y=e=>({className:"class"}[e]||v(e)),v=e=>(e=(e=(e=e.replace(/[^\w\s\-\[\]]/gi,"")).replace(/\[([^\]]+)\]/g,"-$1")).replace(/([A-Z])/g,(function(e){return"-"+e.toLowerCase()}))).replace(/\s/g,"-").replace(/^-+/g,""),w=e=>e.replace(/-([a-z])/g,((e,t)=>t.toUpperCase())),x=function(){let e,t=0;return function(r){const n=Date.now();return n===e?++t:(t=0,e=n),(r.type||v(r.label))+"-"+n+"-"+t}}(),O=e=>void 0===e?e:[["array",e=>Array.isArray(e)],["node",e=>e instanceof window.Node||e instanceof window.HTMLElement],["component",()=>e?.dom],[typeof e,()=>!0]].find((t=>t[1](e)))[0],j=function(t,r="",n={}){let i=O(r);const{events:u}=n,f=m(n,d),p=document.createElement(t),h={string:t=>{((t,r,n=!1)=>{if(!n){const n=document.createElement(t.tagName);return void 0!==o.backendOrder.find((e=>l[e](n,r)))?((t=>{e(t).find("*").each(((e,t)=>{["embed","form","iframe","image","img","object"].includes(t.tagName.toLowerCase())&&t.removeAttribute("name"),["id","name"].forEach((e=>{if(t.hasAttribute(e)){const r=a(t.getAttribute(e));void 0===r?t.removeAttribute(e):t.setAttribute(e,r)}}))}))})(n),t.innerHTML=n.innerHTML,t):(t.innerHTML=r,t)}t.textContent=r})(p,p.innerHTML+t)},object:e=>{const{tag:t,content:r}=e,n=m(e,c);return p.appendChild(j(t,r,n))},node:e=>p.appendChild(e),array:e=>{for(let t=0;t{e=e(),i=O(e),h[i](e)},undefined:()=>{}};for(const e in f)if(f.hasOwnProperty(e)){const t=y(e);let r=Array.isArray(f[e])?P(f[e].join(" ").split(" ")).join(" "):f[e];if(s(t,r))continue;if("boolean"==typeof r){if(!0===r){const e="contenteditable"===t||t;p.setAttribute(t,e)}}else"id"!==t&&"name"!==t||(r=a(r)),void 0!==r&&p.setAttribute(t,r)}return r&&h[i](r),((e,t)=>{if(t)for(const r in t)t.hasOwnProperty(r)&&e.addEventListener(r,(e=>t[r](e)))})(p,u),p},k=e=>{const t=e.attributes,r={};return L(t,(e=>{let n=t[e].value||"";n.match(/false|true/g)?n="true"===n:n.match(/undefined/g)&&(n=void 0),n&&(r[w(t[e].name)]=n)})),r},C=e=>{const t=[];for(let r=0;r{const t=[];if(e.length){const r=e[0].getElementsByTagName("value");for(let e=0;e{const t=(new window.DOMParser).parseFromString(e,"text/xml"),r=[];if(t){const e=t.getElementsByTagName("field");for(let t=0;t{const t=document.createElement("textarea");return t.innerHTML=e,t.textContent},T=e=>{const t={'"':""","&":"&","<":"<",">":">"};return"string"==typeof e?e.replace(/["&<>]/g,(e=>t[e]||e)):e},L=function(e,t,r){for(let n=0;ne.filter(((e,t,r)=>r.indexOf(e)===t)),R=(e,t="")=>{const r=jQuery;let n=[];return Array.isArray(e)||(e=[e]),N(e)||(n=jQuery.map(e,(e=>{const r={dataType:"script",cache:!0,url:(t||"")+e};return jQuery.ajax(r).done((()=>window.fbLoaded.js.push(e)))}))),n.push(jQuery.Deferred((e=>r(e.resolve)))),jQuery.when(...n)},N=(e,t="js")=>{const r=window.fbLoaded[t];return Array.isArray(e)?e.every((e=>r.includes(e))):r.includes(e)},D=(t,r="")=>{Array.isArray(t)||(t=[t]),t.forEach((t=>{let n="href",o=t,i="";if("object"==typeof t&&(n=t.type||(t.style?"inline":"href"),i=t.id,o=i||t.href||t.style,t="inline"===n?t.style:t.href),!N(o,"css")){if("href"===n){const e=document.createElement("link");e.type="text/css",e.rel="stylesheet",e.href=(r||"")+t,document.head.appendChild(e)}else e(``).attr("id",i).appendTo(e(document.head));window.fbLoaded.css.push(o)}}))},q=(e,t)=>{const r=Object.assign({},e,t);for(const n in t)r.hasOwnProperty(n)&&(Array.isArray(t[n])?r[n]=Array.isArray(e[n])?P(e[n].concat(t[n])):t[n]:"object"==typeof t[n]?r[n]=q(e[n],t[n]):r[n]=t[n]);return r},M=/^col-(xs|sm|md|lg)-([^\s]+)/,F={addEventListeners:(e,t,r)=>t.split(" ").forEach((t=>e.addEventListener(t,r,!1))),attrString:e=>Object.entries(e).map((([e,t])=>b(e)&&Object.values(g(e,t)).join(""))).filter(Boolean).join(" "),camelCase:w,capitalize:e=>e.replace(/\b\w/g,(function(e){return e.toUpperCase()})),closest:(e,t)=>{const r=t.replace(".","");for(;(e=e.parentElement)&&!e.classList.contains(r););return e},getContentType:O,escapeAttr:T,escapeAttrs:e=>{for(const t in e)e.hasOwnProperty(t)&&(e[t]=T(e[t]));return e},escapeHtml:e=>{const t=document.createElement("textarea");return t.textContent=e,t.innerHTML},forceNumber:e=>e.replace(/[^0-9]/g,""),forEach:L,getScripts:R,getStyles:D,hyphenCase:v,isCached:N,markup:j,merge:q,mobileClass:()=>{let e="";var t;return t=navigator.userAgent||navigator.vendor||window.opera,/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(t)&&(e="formbuilder-mobile"),e},nameAttr:x,parsedHtml:A,parseXML:E,removeFromArray:(e,t)=>{const r=t.indexOf(e);r>-1&&t.splice(r,1)},safeAttr:g,safeAttrName:y,safename:e=>e.replace(/\s/g,"-").replace(/[^a-zA-Z0-9[\]_-]/g,""),subtract:(e,t)=>t.filter((function(e){return!~this.indexOf(e)}),e),trimObj:h,unique:P,validAttr:b,titleCase:function(e){const t=["a","an","and","as","at","but","by","for","for","from","in","into","near","nor","of","on","onto","or","the","to","with"].map((e=>`\\s${e}\\s`)),r=new RegExp(`(?!${t.join("|")})\\w\\S*`,"g");return`${e}`.replace(r,(e=>e.charAt(0).toUpperCase()+e.slice(1).replace(/[A-Z]/g,(e=>` ${e}`))))},firstNumberOrUndefined:function(...e){return e.find((e=>"number"==typeof e))},splitObject:(e,t)=>{const r=e=>(t,r)=>(t[r]=e[r],t);return[Object.keys(e).filter((e=>t.includes(e))).reduce(r(e),{}),Object.keys(e).filter((e=>!t.includes(e))).reduce(r(e),{})]}};e.fn.swapWith=function(t){const r=this,n=e(t),o=e("
");return r.before(o),n.before(r),o.before(n).remove(),r};var z=F;const B=(e,t,r=!0)=>{const n=[];let o=["none","block"];r&&(o=o.reverse());for(let r=e.length-1;r>=0;r--)-1!==e[r].textContent.toLowerCase().indexOf(t.toLowerCase())?(e[r].style.display=o[0],n.push(e[r])):e[r].style.display=o[1];return n};new RegExp(`(${["select","checkbox-group","checkbox","radio-group","autocomplete"].join("|")})`);const H=["label","type"];class ${constructor(e,t){this.rawConfig=jQuery.extend({},e),e=jQuery.extend({},e),this.preview=t,delete e.isPreview,this.preview&&delete e.required;const r=["label","description","subtype","required","disabled"];for(const t of r)this[t]=e[t],delete e[t];e.id||(e.name?e.id=e.name:e.id="control-"+Math.floor(1e7*Math.random()+1)),this.id=e.id,this.type=e.type,this.description&&(e.title=this.description),$.controlConfig||($.controlConfig={});const n=this.subtype?this.type+"."+this.subtype:this.type;this.classConfig=jQuery.extend({},$.controlConfig[this.type]||{},$.controlConfig[n]||{}),this.subtype&&(e.type=this.subtype),this.required&&(e.required="required",e["aria-required"]="true"),this.disabled&&(e.disabled="disabled"),this.config=e,this.configure()}static get definition(){return{}}static register(e,t,r){const n=r?r+".":"";$.classRegister||($.classRegister={}),Array.isArray(e)||(e=[e]);for(const r of e)-1===r.indexOf(".")?$.classRegister[n+r]=t:$.error(`Ignoring type ${r}. Cannot use the character '.' in a type name.`)}static getRegistered(e=!1){const t=Object.keys($.classRegister);return t.length?t.filter((t=>e?t.indexOf(e+".")>-1:-1===t.indexOf("."))):t}static getRegisteredSubtypes(){const e={};for(const t in $.classRegister)if($.classRegister.hasOwnProperty(t)){const[r,n]=t.split(".");if(!n)continue;e[r]||(e[r]=[]),e[r].push(n)}return e}static getClass(e,t){const r=t?e+"."+t:e;return $.classRegister[r]||$.classRegister[e]||$.error("Invalid control type. (Type: "+e+", Subtype: "+t+"). Please ensure you have registered it, and imported it correctly.")}static loadCustom(e){let t=[];if(e&&(t=t.concat(e)),window.fbControls&&(t=t.concat(window.fbControls)),!this.fbControlsLoaded){for(const e of t)e($,$.classRegister);this.fbControlsLoaded=!0}}static mi18n(e,t){const n=this.definition;let o=n.i18n||{};o=o[r().locale]||o.default||o;const i=this.camelCase(e),s="object"==typeof o?o[i]||o[e]:o;if(s)return s;let a=n.mi18n;return"object"==typeof a&&(a=a[i]||a[e]),a||(a=i),r().get(a,t)}static active(e){return!Array.isArray(this.definition.inactive)||-1===this.definition.inactive.indexOf(e)}static label(e){return this.mi18n(e)}static icon(e){const t=this.definition;return t&&"object"==typeof t.icon?t.icon[e]:t.icon}configure(){}build(){const e=this.config,{label:t,type:r}=e,n=function(e,t){if(null==e)return{};var r,n,o=function(e,t){if(null==e)return{};var r={};for(var n in e)if({}.hasOwnProperty.call(e,n)){if(t.indexOf(n)>=0)continue;r[n]=e[n]}return r}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(n=0;n=0||{}.propertyIsEnumerable.call(e,r)&&(o[r]=e[r])}return o}(e,H);return this.markup(r,A(t),n)}on(e){const t={prerender:e=>e,render:e=>{const t=()=>{this.onRender&&this.onRender(e)};this.css&&D(this.css),this.js&&!N(this.js)?R(this.js).done(t):t()}};return e?t[e]:t}static error(e){throw new Error(e)}markup(e,t="",r={}){return this.element=j(e,t,r),this.element}parsedHtml(e){return A(e)}static camelCase(e){return w(e)}}const U=(e,t)=>{let r=e.id?`formbuilder-${e.type} form-group field-${e.id}`:"";if(e.className){const n=(e=>"string"==typeof e?e.split(" ").filter((e=>M.test(e)||e.startsWith("row-"))):[])(e.className);n&&n.length>0&&(r+=` ${n.join(" ")}`,Array.isArray(t)||(t=[t]),t.forEach((e=>{e.classList&&e.classList.remove(...n),e.querySelectorAll("[class*=row-],[class*=col-]").forEach((e=>{e.classList&&e.classList.remove(...n)}))})))}return r};class I{constructor(e,t=!1,r=!1,n={}){this.preview=null!=t&&t,this.disableHTMLLabels=null!=r&&r,this.controlConfig=null!=n?n:{},this.templates={label:null,help:null,default:(e,t,r,n)=>(r&&t.appendChild(r),this.markup("div",[t,e],{className:U(n,e)})),noLabel:(e,t,r,n)=>this.markup("div",e,{className:U(n,e)}),hidden:e=>e},e&&(this.templates=jQuery.extend(this.templates,e)),this.configure()}configure(){}build(e,t,r){this.preview&&(t.name?t.name=t.name+"-preview":t.name=z.nameAttr(t)+"-preview"),t.id=t.name,this.data=jQuery.extend({},t),$.controlConfig=this.controlConfig;const n=new e(t,this.preview);let o=n.build();if("object"==typeof o&&o.field||(o={field:o}),"string"==typeof o.field){const e=this.markup("div",o.field,{});1===e.childElementCount?o.field=e.children.item(0):o.field=Array.from(e.children)}const i=this.label(),s=this.help();let a;a=r&&this.isTemplate(r)?r:this.isTemplate(o.layout)?o.layout:"default";const l=this.processTemplate(a,o.field,i,s);return n.on("prerender")(l),l.addEventListener("fieldRendered",n.on("render")),l}label(){const e=this.data.label||"",t=[this.disableHTMLLabels?document.createTextNode(e):z.parsedHtml(e)];return this.data.required&&t.push(this.markup("span","*",{className:"formbuilder-required"})),this.isTemplate("label")?this.processTemplate("label",t):this.markup("label",t,{for:this.data.id,className:`formbuilder-${this.data.type}-label`})}help(){return this.data.description?this.isTemplate("help")?this.processTemplate("help",this.data.description):this.markup("span","?",{className:"tooltip-element",tooltip:this.data.description}):null}isTemplate(e){return"function"==typeof this.templates[e]}processTemplate(e,...t){let r=this.templates[e](...t,this.data);return r.jquery&&(r=r[0]),r}markup(e,t="",r={}){return z.markup(e,t,r)}}const _=["values","type"];$.register("autocomplete",class extends ${static get definition(){return{mi18n:{requireValidOption:"requireValidOption"}}}build(){const e=this.config,{values:t,type:r}=e,n=function(e,t){if(null==e)return{};var r,n,o=function(e,t){if(null==e)return{};var r={};for(var n in e)if({}.hasOwnProperty.call(e,n)){if(t.indexOf(n)>=0)continue;r[n]=e[n]}return r}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(n=0;n=0||{}.propertyIsEnumerable.call(e,r)&&(o[r]=e[r])}return o}(e,_),o=e=>{const t=e.target.nextSibling.nextSibling,r=e.target.nextSibling,n=this.getActiveOption(t);let o=new Map([[38,()=>{const e=this.getPreviousOption(n);e&&this.selectOption(t,e)}],[40,()=>{const e=this.getNextOption(n);e&&this.selectOption(t,e)}],[13,()=>{n?(e.target.value=n.innerHTML,r.value=n.getAttribute("value"),"none"===t.style.display?this.showList(t,n):this.hideList(t)):this.config.requireValidOption&&(this.isOptionValid(t,e.target.value)||(e.target.value="",e.target.nextSibling.value="")),e.preventDefault()}],[27,()=>{this.hideList(t)}]]).get(e.keyCode);return o||(o=()=>!1),o()},i={focus:e=>{const t=e.target.nextSibling.nextSibling,r=B(t.querySelectorAll("li"),e.target.value);if(e.target.addEventListener("keydown",o),e.target.value.length>0){const e=r.length>0?r[r.length-1]:null;this.showList(t,e)}},blur:e=>{e.target.removeEventListener("keydown",o);const t=setTimeout((()=>{e.target.nextSibling.nextSibling.style.display="none",clearTimeout(t)}),200);if(this.config.requireValidOption){const t=e.target.nextSibling.nextSibling;this.isOptionValid(t,e.target.value)||(e.target.value="",e.target.nextSibling.value="")}},input:e=>{const t=e.target.nextSibling.nextSibling;e.target.nextSibling.value=e.target.value;const r=B(t.querySelectorAll("li"),e.target.value);if(0==r.length)this.hideList(t);else{let e=this.getActiveOption(t);e||(e=r[r.length-1]),this.showList(t,e)}}},s=Object.assign({},n,{id:`${n.id}-input`,autocomplete:"off",events:i}),a=Object.assign({},n,{type:"hidden"});delete s.name;const l=[this.markup("input",null,s),this.markup("input",null,a)],d=t.map((e=>{const t=e.label,r={events:{click:t=>{const r=t.target.parentElement,n=r.previousSibling.previousSibling;n.value=e.label,n.nextSibling.value=e.value,this.hideList(r)}},value:e.value};return this.markup("li",t,r)}));return l.push(this.markup("ul",d,{id:`${n.id}-list`,className:`formbuilder-${r}-list`})),l}hideList(e){this.selectOption(e,null),e.style.display="none"}showList(e,t){this.selectOption(e,t),e.style.display="block",e.style.width=e.parentElement.offsetWidth+"px"}getActiveOption(e){const t=e.getElementsByClassName("active-option")[0];return t&&"none"!==t.style.display?t:null}getPreviousOption(e){let t=e;do{t=t?t.previousSibling:null}while(null!=t&&"none"===t.style.display);return t}getNextOption(e){let t=e;do{t=t?t.nextSibling:null}while(null!=t&&"none"===t.style.display);return t}selectOption(e,t){const r=e.querySelectorAll("li");for(let e=0;e=0)continue;r[n]=e[n]}return r}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(n=0;n=0||{}.propertyIsEnumerable.call(e,r)&&(o[r]=e[r])}return o}(e,X);let n=t;const o={paragraph:"p",header:this.subtype};return o[t]&&(n=o[t]),{field:this.markup(n,z.parsedHtml(this.label),r),layout:"noLabel"}}}$.register(["paragraph","header"],W),$.register(["p","address","blockquote","canvas","output"],W,"paragraph"),$.register(["h1","h2","h3","h4","h5","h6"],W,"header");const J=["values","value","placeholder","type","inline","other","toggle"],G=["label"];function Z(e,t){if(null==e)return{};var r,n,o=function(e,t){if(null==e)return{};var r={};for(var n in e)if({}.hasOwnProperty.call(e,n)){if(t.indexOf(n)>=0)continue;r[n]=e[n]}return r}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(n=0;n=0||{}.propertyIsEnumerable.call(e,r)&&(o[r]=e[r])}return o}$.register(["select","checkbox-group","radio-group","checkbox"],class extends ${static get definition(){return{inactive:["checkbox"],mi18n:{minSelectionRequired:"minSelectionRequired"}}}build(){const e=[],t=this.config,{values:r,value:n,placeholder:o,type:i,inline:s,other:a,toggle:l}=t,d=Z(t,J),c=i.replace("-group",""),u="select"===i;if((d.multiple||"checkbox-group"===i)&&(d.name=d.name+"[]"),("checkbox-group"===i||"radio-group"===i)&&d.required){const e=this,t=this.onRender.bind(this);this.onRender=function(){t(),e.groupRequired()}}if(delete d.title,r){o&&u&&e.push(this.markup("option",o,{disabled:!0,selected:!0,value:""}));for(let t=0;t{const t=e.target;t.parentElement.previousElementSibling.value=t.value}},id:`${t.id}-value`,className:"other-val"},i=this.markup("input",null,n),a=[document.createTextNode($.mi18n("other")),this.markup("input",null,o)],l=this.markup("label",a,{for:n.id}),u=this.markup("div",[i,l],{className:r});e.push(u)}}return this.dom="select"==i?this.markup(c,e,h(d,!0)):this.markup("div",e,{className:i}),this.dom}groupRequired(){const e=this.element.getElementsByTagName("input"),t=this.element.querySelectorAll("input:not([type=text])"),r=this.element.querySelector(".other-option"),n=this.element.querySelector(".other-val"),o=()=>{const e=[].some.call(t,(e=>e.checked));((e,t,r,n)=>{[].forEach.call(e,(e=>{n?e.removeAttribute("required"):e.setAttribute("required","required"),((e,t)=>{const r=$.mi18n("minSelectionRequired",1);t?e.setCustomValidity(""):e.setCustomValidity(r)})(e,n)})),t&&(t.checked?r.setAttribute("required","required"):r.removeAttribute("required"))})(t,r,n,e)};for(let t=e.length-1;t>=0;t--)e[t].addEventListener("change",o);o()}onRender(){if(this.config.userData){const t=this.config.userData.slice();"select"===this.config.type?e(this.dom).val(t).prop("selected",!0):this.config.type.endsWith("-group")&&("checkbox-group"===this.config.type&&this.dom.querySelectorAll("input[type=checkbox]").forEach((e=>{e.removeAttribute("checked")})),this.dom.querySelectorAll("input").forEach((e=>{if(!e.classList.contains("other-val")){for(let r=0;r0){const r=this.dom.querySelector(`#${e.id}-value`);e.setAttribute("checked","checked"),r.value=e.value=t[0],r.style.display="inline-block"}}})))}}});class K extends ${static get definition(){return{mi18n:{date:"dateField",file:"fileUpload"}}}build(){let{name:e}=this.config;e=this.config.multiple?`${e}[]`:e;const t=Object.assign({},this.config,{name:e});return this.dom=this.markup("input",null,t),this.dom}onRender(){this.config.userData&&e(this.dom).val(this.config.userData[0])}}$.register(["text","file","date","number"],K),$.register(["text","password","email","color","tel"],K,"text"),$.register(["date","time","datetime-local"],K,"date"),$.register(["number","range"],K,"number");const Y=["value"];class ee extends ${static get definition(){return{mi18n:{textarea:"textArea"}}}build(){const e=this.config,{value:t=""}=e,r=function(e,t){if(null==e)return{};var r,n,o=function(e,t){if(null==e)return{};var r={};for(var n in e)if({}.hasOwnProperty.call(e,n)){if(t.indexOf(n)>=0)continue;r[n]=e[n]}return r}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(n=0;n=0||{}.propertyIsEnumerable.call(e,r)&&(o[r]=e[r])}return o}(e,Y);return delete r.type,this.field=this.markup("textarea",this.parsedHtml(t),r),this.field}onRender(){this.config.userData&&e(this.field).val(this.config.userData[0])}on(t){return"prerender"==t&&this.preview?t=>{this.field&&(t=this.field),e(t).on("mousedown",(e=>{e.stopPropagation()}))}:super.on(t)}}$.register("textarea",ee),$.register("textarea",ee,"textarea");const te=["value"];ee.register("tinymce",class extends ee{configure(){if(this.js=[],window.tinymce||this.js.push("https://cdnjs.cloudflare.com/ajax/libs/tinymce/4.9.11/tinymce.min.js"),this.classConfig.js){let e=this.classConfig.js;Array.isArray(e)||(e=new Array(e)),this.js=this.js.concat(e),delete this.classConfig.js}this.classConfig.css&&(this.css=this.classConfig.css),this.editorOptions={height:250,paste_data_images:!0,plugins:["advlist","autolink","lists","link","image","charmap","print","preview","anchor","searchreplace","visualblocks","code","fullscreen","insertdatetime","media","table","contextmenu","paste","code"],toolbar:"undo redo | styleselect | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image | table"}}build(){const e=this.config,{value:t=""}=e,r=function(e,t){if(null==e)return{};var r,n,o=function(e,t){if(null==e)return{};var r={};for(var n in e)if({}.hasOwnProperty.call(e,n)){if(t.indexOf(n)>=0)continue;r[n]=e[n]}return r}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(n=0;n=0||{}.propertyIsEnumerable.call(e,r)&&(o[r]=e[r])}return o}(e,te);return delete r.type,this.field=this.markup("textarea",this.parsedHtml(t),r),r.disabled&&(this.editorOptions.readonly=!0),this.field}onRender(){const e=window.tinymce.get(this.id);e&&window.tinymce.remove(e);const t=jQuery.extend(this.editorOptions,this.classConfig);t.target=this.field;const r=[];Number(window.tinymce.majorVersion)>=5&&r.push("contextmenu"),Number(window.tinymce.majorVersion)>=6&&r.push("paste","print"),t.plugins=t.plugins.filter((e=>-1===r.indexOf(e)));const n=this.config.userData?this.parsedHtml(this.config.userData[0]):void 0,o=window.lastFormBuilderCopiedTinyMCE?this.parsedHtml(window.lastFormBuilderCopiedTinyMCE):void 0;window.lastFormBuilderCopiedTinyMCE=null;const i=function(e){o?e[0].setContent(o):n&&e[0].setContent(n)};setTimeout((()=>{window.tinymce.init(t).then(i)}),0)}},"textarea");const re=["value"];function ne(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,n)}return r}function oe(e){for(var t=1;t=0)continue;r[n]=e[n]}return r}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(n=0;n=0||{}.propertyIsEnumerable.call(e,r)&&(o[r]=e[r])}return o}(e,re);return delete r.type,this.field=this.markup("div",null,r),this.field.classList.contains("form-control")&&this.field.classList.remove("form-control"),this.field}onRender(e){const t=this.config.value||"",r=window.Quill.import("delta");window.fbEditors.quill[this.id]={};const n=window.fbEditors.quill[this.id];return n.instance=new window.Quill(this.field,this.editorConfig),n.data=new r,t&&n.instance.setContents(window.JSON.parse(this.parsedHtml(t))),n.instance.on("text-change",(function(e){n.data=n.data.compose(e)})),e}},"textarea"),r().addLanguage("en-US",{NATIVE_NAME:"English (US)",ENGLISH_NAME:"English",addOption:"Add Option +",allFieldsRemoved:"All fields were removed.",allowMultipleFiles:"Allow users to upload multiple files",allowSelect:"Allow selection",autocomplete:"Autocomplete",button:"Button",cannotBeEmpty:"This field cannot be empty",checkboxGroup:"Checkbox Group",checkbox:"Checkbox",checkboxes:"Checkboxes",className:"Class",clearAllMessage:"Are you sure you want to clear all fields?",clear:"Clear",close:"Close",content:"Content",copy:"Copy To Clipboard",copyButton:"+",copyButtonTooltip:"Copy",dateField:"Date Field",description:"Help Text",descriptionField:"Description",devMode:"Developer Mode",editNames:"Edit Names",editorTitle:"Form Elements",editXML:"Edit XML",enableOther:"Enable "Other"",enableOtherMsg:"Let users enter an unlisted option",fieldDeleteWarning:"false",fieldVars:"Field Variables",fieldNonEditable:"This field cannot be edited.",fieldRemoveWarning:"Are you sure you want to remove this field?",fileUpload:"File Upload",formUpdated:"Form Updated",getStarted:"Drag a field from the right to this area",header:"Header",hide:"Edit",hidden:"Hidden Input",inline:"Inline",inlineDesc:"Display {type} inline",label:"Label",labelEmpty:"Field Label cannot be empty",limitRole:"Limit access to one or more of the following roles:",mandatory:"Mandatory",maxlength:"Max Length",minOptionMessage:"This field requires a minimum of 2 options",minSelectionRequired:"Minimum {min} selections required",multipleFiles:"Multiple Files",name:"Name",no:"No",noFieldsToClear:"There are no fields to clear",number:"Number",off:"Off",on:"On",option:"Option",optionCount:"Option {count}",options:"Options",optional:"optional",optionLabelPlaceholder:"Label",optionValuePlaceholder:"Value",optionEmpty:"Option value required",other:"Other",paragraph:"Paragraph",placeholder:"Placeholder","placeholders.value":"Value","placeholders.label":"Label","placeholders.email":"Enter your email","placeholders.className":"space separated classes","placeholders.password":"Enter your password",preview:"Preview",radioGroup:"Radio Group",radio:"Radio",removeMessage:"Remove Element",removeOption:"Remove Option",remove:"×",required:"Required",reset:"Reset",requireValidOption:"Only accept a pre-defined Option",richText:"Rich Text Editor",roles:"Access",rows:"Rows",save:"Save",selectOptions:"Options",select:"Select",selectColor:"Select Color",selectionsMessage:"Allow Multiple Selections",size:"Size",sizes:"Sizes","size.xs":"Extra Small","size.sm":"Small","size.m":"Default","size.lg":"Large",step:"Step",style:"Style",styles:"Styles","styles.btn":"Button Styles","styles.btn.default":"Default","styles.btn.danger":"Danger","styles.btn.info":"Info","styles.btn.primary":"Primary","styles.btn.success":"Success","styles.btn.warning":"Warning",submit:"Submit",subtype:"Type",text:"Text Field",textArea:"Text Area",toggle:"Toggle",warning:"Warning!",value:"Value",viewJSON:"[{…}]",viewXML:"</>",yes:"Yes"});const se={location:"assets/lang/"};n(700);class ae{constructor(e={},t=[]){this.customRegister={},this.templateControlRegister={},this.def={icon:{},i18n:{}},this.register(e,t)}register(e={},t=[]){t.forEach((t=>{if(t.template){const r=t.type||t.attrs?.type;e[r]=t.template}}));const n=r().locale;this.def.i18n[n]||(this.def.i18n[n]={});const o=this;Object.keys(e).forEach((t=>{const r=function(r,n){this.customControl=new Q(r,n,e[t]),this.build=function(){return this.customControl.build()},this.on=function(e){return this.customControl.on(e)}};r.definition={},r.label=e=>o.label(e),r.icon=e=>o.icon(e),this.templateControlRegister[t]=r}));for(const o of t){let t=o.type;if(o.attrs=o.attrs||{},!t){if(!o.attrs.type){$.error("Ignoring invalid custom field definition. Please specify a type property.");continue}t=o.attrs.type}let i=o.subtype||t;if(e[t]){const e=this.templateControlRegister[t];e.definition=o,this.customRegister[i]=jQuery.extend(o,{type:t,class:e})}else try{const e=$.getClass(t,o.subtype);i=o.datatype?o.datatype:`${t}-${Math.floor(9e3*Math.random()+1e3)}`,this.customRegister[i]=jQuery.extend(o,{type:t,class:e})}catch(e){$.error("Error while registering custom field: "+t+(o.subtype?":"+o.subtype:"")+". Unable to find any existing defined control or template for rendering.")}this.def.i18n[n][i]=Array.isArray(o.label)?r().get(...o.label)||o.label[0]:o.label,this.def.icon[i]=o.icon}}label(e){const t=this.def;let n=t.i18n||{};n=n[r().locale]||n.default||n;const o=$.camelCase(e),i="object"==typeof n?n[o]||n[e]:n;if(i)return i;{let n=t.mi18n;return"object"==typeof n&&(n=n[o]||n[e]),n||(n=o),r().get(n)}}get definition(){return{}}icon(e){const t=this.def;return t&&"object"==typeof t.icon?t.icon[e]:t.icon}getRegistered(e=!1){var t;return e?null!==(t=this.templateControlRegister[e])&&void 0!==t?t:void 0:Object.keys(this.customRegister)}getClass(e){var t;return null!==(t=this.templateControlRegister[e])&&void 0!==t?t:void 0}lookup(e){return this.customRegister[e]}}class le{constructor(e={}){const t={layout:I,layoutTemplates:{},controls:{},controlConfig:{},container:!1,dataType:"json",disableHTMLLabels:!1,formData:[],i18n:Object.assign({},se),messages:{formRendered:"Form Rendered",noFormData:"No form data.",other:"Other",selectColor:"Select Color",invalidControl:"Invalid control"},onRender:()=>{},render:!0,sanitizerOptions:{clobberingProtection:{document:!0,form:!1,namespaceAttributes:!0},backendOrder:["dompurify","sanitizer","fallback"]},templates:{},notify:{error:e=>{console.log(e)},success:e=>{console.log(e)},warning:e=>{console.warn(e)}}};this.options=jQuery.extend(!0,t,e),this.instanceContainers=[],(e=>{if("object"!=typeof e)throw"Invalid value given to setSanitizerConfig, expected config object";if(e.hasOwnProperty("clobberingProtection")&&["document","form","namespaceAttributes"].forEach((t=>{e.clobberingProtection.hasOwnProperty(t)&&"boolean"==typeof e.clobberingProtection[t]&&(o.clobberingProtection[t]=e.clobberingProtection[t])})),e.hasOwnProperty("backends")){if("object"!=typeof e.backends)throw"backends config expected to be an Object";Object.keys(e.backends).forEach((t=>o.backends[t]=e.backends[t]))}if(e.hasOwnProperty("backendOrder")){if(o.backendOrder=[],!Array.isArray(e.backendOrder))throw"backendOrder config expected to be an Array of backend keys as strings";e.backendOrder.forEach((e=>{if(!o.backends.hasOwnProperty(e))throw"unknown sanitizer backend "+e;o.backendOrder.push(e)}))}})(this.options.sanitizerOptions),r().current||r().init(this.options.i18n),this.options.formData?this.options.formData=this.parseFormData(this.options.formData):this.options.formData=[],$.controlConfig=e.controlConfig||{},$.loadCustom(e.controls),this.templatedControls=new ae(this.options.templates),"function"!=typeof Element.prototype.appendFormFields&&(Element.prototype.appendFormFields=function(e){Array.isArray(e)||(e=[e]);const t=z.markup("div",null,{className:"rendered-form formbuilder-embedded-bootstrap"});this.appendChild(t),e.forEach((e=>{const[r]=e.className.match(/row-([^\s]+)/)||[];if(r){const n=this.id?`${this.id}-row-${r}`:`row-${r}`;let o=document.getElementById(n);o||(o=z.markup("div",null,{id:n,className:"row"}),t.appendChild(o)),o.appendChild(e)}else t.appendChild(e);e.dispatchEvent(new Event("fieldRendered",{bubbles:!0,cancelable:!1}))}))}),"function"!=typeof Element.prototype.emptyContainer&&(Element.prototype.emptyContainer=function(){const e=this;for(;e.lastChild;)e.removeChild(e.lastChild)})}sanitizeField(e,t){let r=Object.assign({},e);return t&&(r.id=e.id&&`${e.id}-${t}`,r.name=e.name&&`${e.name}-${t}`),r.className=Array.isArray(e.className)?z.unique(e.className.join(" ").split(" ")).join(" "):e.className||e.class||null,delete r.class,e.values&&(r.values=e.values.map((e=>z.trimObj(e)))),r=z.trimObj(r),Array.isArray(e.userData)&&0===e.userData.length&&(r.userData=[]),r}getElement(e){return(e=this.options.container||e)instanceof jQuery?e=e[0]:"string"==typeof e&&(e=document.querySelector(e)),e}render(e=null,t=0){const r=this,n=this.options;e=this.getElement(e);const o=[],i=new n.layout(n.layoutTemplates,!1,n.disableHTMLLabels,n.controlConfig);if(n.formData.length)for(let e=0;ee.map((e=>e.innerHTML)).join("");r.markup=e(o)}if(!0===n.disableInjectedStyle){const e=document.getElementsByClassName("formBuilder-injected-style");L(e,(t=>(e=>{e.parentNode&&e.parentNode.removeChild(e)})(e[t])))}else"bootstrap"===n.disableInjectedStyle&&n.render&&e&&e.getElementsByClassName("formbuilder-embedded-bootstrap").item(0)?.classList.remove("formbuilder-embedded-bootstrap");return r}renderControl(e=null){const t=this.options,r=t.formData;if(!r||Array.isArray(r))throw new Error("To render a single element, please specify a single object of formData for the field in question");const n=this.sanitizeField(r),o=new t.layout,i=this.templatedControls.getClass(r.type)||$.getClass(r.type,r.subtype),s=t.forceTemplate||"hidden",a=o.build(i,n,s);return e.appendFormFields(a),t.notify.success(t.messages.formRendered),this}get userData(){const t=this.options.formData.slice();return t.filter((e=>"tinymce"===e.subtype)).forEach((e=>window.tinymce.get(e.name).save())),this.instanceContainers.forEach((r=>{const n=e("select, input, textarea",r).serializeArray().reduce(((e,{name:t,value:r})=>(t=t.replace(/\[\w*]/,""),e[t]??=[],e[t].push(r),e)),{}),o=t.length;for(let e=0;e{this.options.formData.slice().filter((e=>"tinymce"===e.subtype)).forEach((e=>window.tinymce.get(e.name).setContent(""))),e.querySelectorAll("input, select, textarea").forEach((e=>{["checkbox","radio"].includes(e.type)?e.checked=!1:e.value=""}))}))}parseFormData(e){const t={xml:e=>E(e),json:e=>window.JSON.parse(e)};return"object"!=typeof e&&(e=t[this.options.dataType](e)||!1),e}}!function(e){let t;const r={init:(e,n={})=>(t=e,r.instance=new le(n),e.each((t=>r.instance.render(e[t],t))),r.instance),userData:()=>r.instance&&r.instance.userData,clear:()=>r.instance&&r.instance.clear(),setData:e=>{if(r.instance){const t=r.instance;t.options.formData=t.parseFormData(e)}},render:(e,n={})=>{if(r.instance){const o=r.instance;e||(e=o.options.formData),o.options=Object.assign({},o.options,n,{formData:o.parseFormData(e)}),t.each((e=>r.instance.render(t[e],e)))}},html:()=>t.map((e=>t[e])).html()};e.fn.formRender=function(e={},...t){if(r[e])return r[e].apply(this,t);{const t=r.init(this,e);return Object.assign(r,t),t}},e.fn.controlRender=function(e,t={}){t.formData=e,t.dataType="string"==typeof e?"json":"xml";const r=new le(t),n=this;return n.each((e=>r.renderControl(n[e]))),n}}(jQuery)}()}()}(jQuery); \ No newline at end of file diff --git a/assets/js/jquery-3.6.0.min.js b/assets/js/jquery-3.6.0.min.js new file mode 100644 index 0000000..c4c6022 --- /dev/null +++ b/assets/js/jquery-3.6.0.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.6.0 | (c) OpenJS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.6.0",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},j=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||D,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,D=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var _t,zt=[],Ut=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=zt.pop()||S.expando+"_"+wt.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Ut.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Ut.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Ut,"$1"+r):!1!==e.jsonp&&(e.url+=(Tt.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,zt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((_t=E.implementation.createHTMLDocument("").body).innerHTML="
",2===_t.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=Fe(y.pixelPosition,function(e,t){if(t)return t=We(e,n),Pe.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0 { + // ── Content Security Policy ─────────────────────────────────────── + // Set a proper CSP to silence the Electron security warning about + // "unsafe-eval" / missing CSP. In development we allow the local + // dev-server origin; in production only the custom app:// scheme. + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + let csp; + if (isDev) { + // Dev mode: Metro/webpack needs 'unsafe-eval' for source maps + // and hot-reload, blob: for dynamic chunks, ws: for HMR. + csp = + "default-src 'self' http://localhost:8081;" + + " script-src 'self' http://localhost:8081 'unsafe-inline' 'unsafe-eval' blob:;" + + " style-src 'self' http://localhost:8081 'unsafe-inline';" + + " img-src 'self' http://localhost:8081 data: https: blob:;" + + " font-src 'self' http://localhost:8081 data:;" + + " connect-src 'self' http://localhost:8081 https: wss: ws:;" + + " media-src 'self' http://localhost:8081 data: blob:;" + + " worker-src 'self' blob:;"; + } else { + csp = + "default-src 'self' app:;" + + " script-src 'self' app: 'unsafe-inline';" + + " style-src 'self' app: 'unsafe-inline';" + + " img-src 'self' app: data: https:;" + + " font-src 'self' app: data:;" + + " connect-src 'self' app: https: wss:;" + + " media-src 'self' app: data:;" + + " worker-src 'self' blob:;"; + } + + callback({ + responseHeaders: { + ...details.responseHeaders, + 'Content-Security-Policy': [csp], + }, + }); + }); // Register the custom app:// protocol handler for production builds. // This serves all files from the dist/ directory so that absolute asset // paths in the bundled HTML/JS/CSS resolve correctly. @@ -77,6 +114,47 @@ app.whenReady().then(() => { createWindow(); + // ── Notification IPC handlers ────────────────────────────────────── + // Allow the renderer to request native Electron Notification objects + // which map to macOS Notification Center, Windows Toast & Linux + // libnotify/notify-send automatically. + + ipcMain.handle('notifications:isSupported', () => { + return Notification.isSupported(); + }); + + ipcMain.handle('notifications:show', (_event, payload) => { + if (!Notification.isSupported()) { + console.warn('Native notifications are not supported on this platform'); + return false; + } + + try { + const notification = new Notification({ + title: payload.title || 'Resgrid Dispatch', + body: payload.body || '', + icon: path.join(__dirname, '../assets/icon.png'), + silent: false, + }); + + notification.on('click', () => { + // Focus / restore the main window when the notification is clicked + const windows = BrowserWindow.getAllWindows(); + if (windows.length > 0) { + const win = windows[0]; + if (win.isMinimized()) win.restore(); + win.focus(); + } + }); + + notification.show(); + return true; + } catch (err) { + console.error('Failed to show native notification:', err); + return false; + } + }); + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); diff --git a/electron/preload.js b/electron/preload.js index e3d51b5..e35ca70 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -1,6 +1,55 @@ // preload.js // All of the Node.js APIs are available in the preload process. // It has the same sandbox as a Chrome extension. +const { contextBridge, ipcRenderer } = require('electron'); + +// ── Expose Electron platform flag ────────────────────────────────────── +// The renderer can use `window.__ELECTRON__` to detect if it is running +// inside Electron (as opposed to a regular browser). +contextBridge.exposeInMainWorld('__ELECTRON__', true); + +// ── Expose notification bridge ───────────────────────────────────────── +// Provides a safe bridge for the renderer to trigger native OS +// notifications via the main process (macOS, Windows, Linux). +const notificationCallbacks = []; + +contextBridge.exposeInMainWorld('electronNotifications', { + /** + * Show a native OS notification via the Electron main process. + * @param {{ title: string, body: string, eventCode?: string, data?: object }} payload + * @returns {Promise} true if the notification was shown + */ + show: (payload) => ipcRenderer.invoke('notifications:show', payload), + + /** + * Check whether native notifications are supported on this platform. + * @returns {Promise} + */ + isSupported: () => ipcRenderer.invoke('notifications:isSupported'), + + /** + * Register a callback that fires when the main process sends a + * notification payload to the renderer (e.g. from a backend push + * channel handled in the main process). + * @param {(payload: object) => void} callback + */ + onNotification: (callback) => { + notificationCallbacks.push(callback); + }, +}); + +// Forward notifications pushed from main → renderer +ipcRenderer.on('notification:push', (_event, payload) => { + for (const cb of notificationCallbacks) { + try { + cb(payload); + } catch (err) { + console.error('Error in notification callback:', err); + } + } +}); + +// ── Version information (original preload logic) ─────────────────────── window.addEventListener('DOMContentLoaded', () => { const replaceText = (selector, text) => { const element = document.getElementById(selector) diff --git a/package.json b/package.json index 1c24c57..c62ae30 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "install-maestro": "curl -Ls 'https://get.maestro.mobile.dev' | bash", "e2e-test": "maestro test .maestro/ -e APP_ID=com.obytes.development", "web:build": "cross-env APP_ENV=production expo export --platform web", - "electron:dev": "concurrently \"cross-env BROWSER=none yarn web\" \"wait-on http://localhost:8081 && electron .\"", + "electron:dev": "concurrently \"cross-env NODE_OPTIONS=--max-old-space-size=16384 BROWSER=none yarn web\" \"wait-on http://localhost:8081 && electron electron/main.js\"", "electron:pack": "yarn web:build && electron-builder -c.extraMetadata.main=electron/main.js", "docker:build": "docker build -t resgrid-dispatch-web .", "docker:run": "docker run -p 3000:80 --env-file .env.docker resgrid-dispatch-web", @@ -151,9 +151,11 @@ "expo-application": "~6.1.5", "expo-asset": "~11.1.7", "expo-audio": "~0.4.9", + "expo-auth-session": "~6.2.1", "expo-av": "~15.1.7", "expo-build-properties": "~0.14.8", "expo-constants": "~17.1.7", + "expo-crypto": "~14.1.5", "expo-dev-client": "~5.2.4", "expo-device": "~7.1.4", "expo-document-picker": "~13.1.6", @@ -175,6 +177,7 @@ "expo-status-bar": "~2.2.3", "expo-system-ui": "~5.0.11", "expo-task-manager": "~13.1.6", + "expo-web-browser": "~14.2.0", "geojson": "~0.5.0", "he": "^1.2.0", "i18next": "~23.14.0", @@ -298,4 +301,4 @@ "resolutions": { "form-data": "4.0.4" } -} \ No newline at end of file +} diff --git a/scripts/generate-vendor-sources.js b/scripts/generate-vendor-sources.js new file mode 100644 index 0000000..1b0f16c --- /dev/null +++ b/scripts/generate-vendor-sources.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node +/** + * Generates self-hosted TypeScript wrapper files for jQuery and form-render. + * Run: node scripts/generate-vendor-sources.js + * + * The output files embed the minified JS as a string literal so the + * call-form-renderer can inline them into the srcdoc iframe without relying + * on any external CDN. + */ + +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const outDir = path.join(root, 'src', 'lib', 'form-render'); + +fs.mkdirSync(outDir, { recursive: true }); + +function generate(srcRelative, outFile, exportName) { + const src = fs.readFileSync(path.join(root, srcRelative), 'utf8'); + const content = + '// Auto-generated – do not edit manually.\n' + + '// Regenerate via: node scripts/generate-vendor-sources.js\n' + + 'const ' + exportName + ': string = ' + JSON.stringify(src) + ';\n' + + 'export default ' + exportName + ';\n'; + fs.writeFileSync(path.join(outDir, outFile), content, 'utf8'); + const size = fs.statSync(path.join(outDir, outFile)).size; + console.log('Wrote', outFile, '(' + size + ' bytes)'); +} + +generate('assets/js/jquery-3.6.0.min.js', 'jquery-source.ts', 'jquerySource'); +generate('assets/js/form-render.min.js', 'form-render-source.ts', 'formRenderSource'); + +console.log('Done.'); diff --git a/scripts/generate-webview-scripts.js b/scripts/generate-webview-scripts.js new file mode 100644 index 0000000..86ac2ab --- /dev/null +++ b/scripts/generate-webview-scripts.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +/* eslint-env node */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); + +const jquery = fs.readFileSync(path.join(root, 'assets/js/jquery-3.6.0.min.js'), 'utf8'); +const formRender = fs.readFileSync(path.join(root, 'assets/js/form-render.min.js'), 'utf8'); + +// Escape content for safe embedding in a JS template literal +function escapeForTemplateLiteral(str) { + return str + .replace(/\\/g, '\\\\') + .replace(/`/g, '\\`') + .replace(/\$\{/g, '\\${'); +} + +const output = [ + '// AUTO-GENERATED — do not edit by hand.', + '// Regenerate with: node scripts/generate-webview-scripts.js', + '//', + '// jquery-3.6.0.min.js — jQuery v3.6.0, MIT License', + '// Source: https://code.jquery.com/jquery-3.6.0.min.js', + '// form-render.min.js — jQuery formBuilder formRender plugin, MIT License', + '// Source: https://formbuilder.online/assets/js/form-render.min.js', + '// Retrieved: 2026-03-12', + '', + '/* eslint-disable */', + '// prettier-ignore', + 'export const jquerySource = `' + escapeForTemplateLiteral(jquery) + '`;', + '', + '// prettier-ignore', + 'export const formRenderSource = `' + escapeForTemplateLiteral(formRender) + '`;', + '', +].join('\n'); + +const outPath = path.join(root, 'src/utils/webview-scripts.ts'); +fs.writeFileSync(outPath, output, 'utf8'); +console.log('Generated', outPath, '(' + Math.round(Buffer.byteLength(output) / 1024) + ' KB)'); diff --git a/src/app/(app)/__tests__/_layout.auth-guard.test.tsx b/src/__tests__/app/_layout.auth-guard.test.tsx similarity index 100% rename from src/app/(app)/__tests__/_layout.auth-guard.test.tsx rename to src/__tests__/app/_layout.auth-guard.test.tsx diff --git a/src/app/(app)/__tests__/_layout.test.tsx b/src/__tests__/app/_layout.test.tsx similarity index 100% rename from src/app/(app)/__tests__/_layout.test.tsx rename to src/__tests__/app/_layout.test.tsx diff --git a/src/app/call/__tests__/[id].security.test.tsx b/src/__tests__/app/call/[id].security.test.tsx similarity index 99% rename from src/app/call/__tests__/[id].security.test.tsx rename to src/__tests__/app/call/[id].security.test.tsx index fef3552..0d7d1a8 100644 --- a/src/app/call/__tests__/[id].security.test.tsx +++ b/src/__tests__/app/call/[id].security.test.tsx @@ -304,7 +304,7 @@ jest.mock('react-native-svg', () => ({ Mixin: {}, })); -import CallDetail from '../[id]'; +import CallDetail from '../../../app/call/[id]'; describe('CallDetail', () => { const { useCallDetailStore } = require('@/stores/calls/detail-store'); diff --git a/src/app/call/__tests__/[id].test.tsx b/src/__tests__/app/call/[id].test.tsx similarity index 99% rename from src/app/call/__tests__/[id].test.tsx rename to src/__tests__/app/call/[id].test.tsx index 02c8f97..fdfdc49 100644 --- a/src/app/call/__tests__/[id].test.tsx +++ b/src/__tests__/app/call/[id].test.tsx @@ -11,7 +11,7 @@ import { useLocationStore } from '@/stores/app/location-store'; import { useStatusBottomSheetStore } from '@/stores/status/store'; import { useToastStore } from '@/stores/toast/store'; -import CallDetail from '../[id]'; +import CallDetail from '../../../app/call/[id]'; diff --git a/src/app/call/new/__tests__/address-search.test.ts b/src/__tests__/app/call/new/address-search.test.ts similarity index 100% rename from src/app/call/new/__tests__/address-search.test.ts rename to src/__tests__/app/call/new/address-search.test.ts diff --git a/src/app/call/new/__tests__/coordinates-search.test.tsx b/src/__tests__/app/call/new/coordinates-search.test.tsx similarity index 100% rename from src/app/call/new/__tests__/coordinates-search.test.tsx rename to src/__tests__/app/call/new/coordinates-search.test.tsx diff --git a/src/app/call/new/__tests__/plus-code-search.test.ts b/src/__tests__/app/call/new/plus-code-search.test.ts similarity index 100% rename from src/app/call/new/__tests__/plus-code-search.test.ts rename to src/__tests__/app/call/new/plus-code-search.test.ts diff --git a/src/app/call/new/__tests__/what3words.test.tsx b/src/__tests__/app/call/new/what3words.test.tsx similarity index 100% rename from src/app/call/new/__tests__/what3words.test.tsx rename to src/__tests__/app/call/new/what3words.test.tsx diff --git a/src/app/(app)/__tests__/calls.test.tsx b/src/__tests__/app/calls.test.tsx similarity index 99% rename from src/app/(app)/__tests__/calls.test.tsx rename to src/__tests__/app/calls.test.tsx index 21656f3..0d94ff6 100644 --- a/src/app/(app)/__tests__/calls.test.tsx +++ b/src/__tests__/app/calls.test.tsx @@ -205,7 +205,7 @@ jest.mock('@react-navigation/native', () => ({ useIsFocused: jest.fn(() => true), })); -import CallsScreen from '../calls'; +import CallsScreen from '../../app/(app)/calls'; describe('CallsScreen', () => { const { useCallsStore } = require('@/stores/calls/store'); diff --git a/src/app/(app)/__tests__/contacts-pull-to-refresh.integration.test.tsx b/src/__tests__/app/contacts-pull-to-refresh.integration.test.tsx similarity index 99% rename from src/app/(app)/__tests__/contacts-pull-to-refresh.integration.test.tsx rename to src/__tests__/app/contacts-pull-to-refresh.integration.test.tsx index 4bfacec..40c01f1 100644 --- a/src/app/(app)/__tests__/contacts-pull-to-refresh.integration.test.tsx +++ b/src/__tests__/app/contacts-pull-to-refresh.integration.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { ContactType } from '@/models/v4/contacts/contactResultData'; -import Contacts from '../contacts'; +import Contacts from '../../app/(app)/contacts'; // Mock dependencies jest.mock('react-i18next', () => ({ diff --git a/src/app/(app)/__tests__/contacts.test.tsx b/src/__tests__/app/contacts.test.tsx similarity index 99% rename from src/app/(app)/__tests__/contacts.test.tsx rename to src/__tests__/app/contacts.test.tsx index c698c5c..99b0af3 100644 --- a/src/app/(app)/__tests__/contacts.test.tsx +++ b/src/__tests__/app/contacts.test.tsx @@ -5,7 +5,7 @@ import { RefreshControl } from 'react-native'; import { ContactType } from '@/models/v4/contacts/contactResultData'; -import Contacts from '../contacts'; +import Contacts from '../../app/(app)/contacts'; // Mock dependencies jest.mock('react-i18next', () => ({ diff --git a/src/app/(app)/__tests__/index.test.tsx b/src/__tests__/app/index.test.tsx similarity index 99% rename from src/app/(app)/__tests__/index.test.tsx rename to src/__tests__/app/index.test.tsx index 5d71c3c..eba6d11 100644 --- a/src/app/(app)/__tests__/index.test.tsx +++ b/src/__tests__/app/index.test.tsx @@ -3,7 +3,7 @@ import { useColorScheme } from 'nativewind'; import React from 'react'; import { SafeAreaProvider } from 'react-native-safe-area-context'; -import Map from '../map'; +import Map from '../../app/(app)/map'; import { useAppLifecycle } from '@/hooks/use-app-lifecycle'; import { useLocationStore } from '@/stores/app/location-store'; diff --git a/src/app/(app)/__tests__/initialization.test.tsx b/src/__tests__/app/initialization.test.tsx similarity index 100% rename from src/app/(app)/__tests__/initialization.test.tsx rename to src/__tests__/app/initialization.test.tsx diff --git a/src/app/login/__tests__/index.test.tsx b/src/__tests__/app/login/index.test.tsx similarity index 98% rename from src/app/login/__tests__/index.test.tsx rename to src/__tests__/app/login/index.test.tsx index d7e2143..5d7c01d 100644 --- a/src/app/login/__tests__/index.test.tsx +++ b/src/__tests__/app/login/index.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react-native'; import { View, Text, TouchableOpacity } from 'react-native'; -import Login from '../index'; +import Login from '../../../app/login/index'; const mockPush = jest.fn(); const mockReplace = jest.fn(); @@ -71,7 +71,7 @@ jest.mock('@/components/settings/server-url-bottom-sheet', () => { }; }); -jest.mock('../login-form', () => { +jest.mock('../../../app/login/login-form', () => { const React = require('react'); const { View, TouchableOpacity, Text } = require('react-native'); diff --git a/src/__tests__/app/login/index.web.test.tsx b/src/__tests__/app/login/index.web.test.tsx new file mode 100644 index 0000000..a19aaa8 --- /dev/null +++ b/src/__tests__/app/login/index.web.test.tsx @@ -0,0 +1,259 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react-native'; + +import LoginWeb from '../../../app/login/index.web'; +import { useAuth } from '@/lib/auth'; + +// Mock hooks and dependencies +const mockLogin = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockReplace = jest.fn(); +const mockPush = jest.fn(); + +// Map @/components/ui/text to the standard RN Text so getByText works +jest.mock('@/components/ui/text', () => { + const React = require('react'); + const { Text } = require('react-native'); + return { + Text: ({ children, ...props }: any) => React.createElement(Text, props, children), + }; +}); + +jest.mock('expo-router', () => ({ + useRouter: () => ({ + replace: mockReplace, + push: mockPush, + }), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => { + const translations: Record = { + 'login.page_subtitle': 'Enter the information below to Sign in to the Resgrid Dispatch application.', + 'login.username_placeholder': 'Enter your username', + 'login.password_placeholder': 'Enter your password', + 'login.login_button': 'Login', + 'login.login_button_loading': 'Logging in...', + 'login.no_account': "Don't have an account?", + 'login.register': 'Register', + 'login.footer_text': 'Created with ❤️ in Lake Tahoe', + 'settings.server_url': 'Server URL', + 'login.errorModal.title': 'Login Failed', + 'login.errorModal.message': 'Please check your username and password and try again.', + 'login.errorModal.confirmButton': 'OK', + 'common.cancel': 'Cancel', + 'common.save': 'Save', + 'sso.sso_button': 'SSO Login', + }; + return translations[key] || fallback || key; + }, + }), +})); + +jest.mock('@/hooks/use-analytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + }), +})); + +jest.mock('@/lib/auth', () => ({ + useAuth: jest.fn(), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('@/lib/env', () => ({ + Env: { + API_VERSION: 'v4', + }, +})); + +jest.mock('@/stores/app/server-url-store', () => ({ + useServerUrlStore: () => ({ + setUrl: jest.fn(), + getUrl: jest.fn().mockResolvedValue('https://api.example.com/api/v4'), + }), +})); + +jest.mock('nativewind', () => ({ + useColorScheme: () => ({ + colorScheme: 'light', + }), +})); + +jest.mock('react-native/Libraries/Utilities/useWindowDimensions', () => ({ + default: () => ({ + width: 1200, + height: 800, + }), +})); + +jest.mock('react-native-reanimated', () => { + const Reanimated = require('react-native-reanimated/mock'); + Reanimated.default.call = () => {}; + // Support any chain of .delay().duration() or .duration().delay() + const animChain: any = () => ({ duration: animChain, delay: animChain }); + return { + ...Reanimated, + FadeIn: animChain(), + FadeInDown: animChain(), + FadeInUp: animChain(), + FadeInRight: animChain(), + FadeOut: animChain(), + FadeOutLeft: animChain(), + }; +}); + +jest.mock('lucide-react-native', () => ({ + AlertCircle: () => null, + Eye: () => null, + EyeOff: () => null, + Loader2: () => null, + Lock: () => null, + Server: () => null, + User: () => null, +})); + +describe('LoginWeb', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useAuth as jest.Mock).mockReturnValue({ + login: mockLogin, + status: 'idle', + error: null, + isAuthenticated: false, + }); + }); + + it('renders without crashing', () => { + const { toJSON } = render(); + expect(toJSON()).toBeTruthy(); + }); + + it('renders the login button', () => { + render(); + expect(screen.getByText('Login')).toBeTruthy(); + }); + + it('renders the server URL button', () => { + render(); + expect(screen.getByText('Server URL')).toBeTruthy(); + }); + + it('renders the SSO login button', () => { + render(); + expect(screen.getByText('SSO Login')).toBeTruthy(); + }); + + it('renders the page subtitle', () => { + render(); + expect(screen.getByText('Enter the information below to Sign in to the Resgrid Dispatch application.')).toBeTruthy(); + }); + + it('renders the footer registration link', () => { + render(); + expect(screen.getByText(/Don't have an account/)).toBeTruthy(); + expect(screen.getByText('Register')).toBeTruthy(); + }); + + it('renders the copyright text', () => { + render(); + expect(screen.getByText(/Resgrid, LLC/)).toBeTruthy(); + }); + + it('opens server URL modal when server URL button is pressed', () => { + render(); + fireEvent.press(screen.getByText('Server URL')); + expect(screen.getByText('Cancel')).toBeTruthy(); + expect(screen.getByText('Save')).toBeTruthy(); + }); + + it('closes server URL modal when cancel is pressed', async () => { + render(); + fireEvent.press(screen.getByText('Server URL')); + expect(screen.getByText('Cancel')).toBeTruthy(); + fireEvent.press(screen.getByText('Cancel')); + await waitFor(() => { + expect(screen.queryByText('Cancel')).toBeNull(); + }); + }); + + it('navigates to SSO login when SSO button is pressed', () => { + render(); + fireEvent.press(screen.getByText('SSO Login')); + expect(mockPush).toHaveBeenCalledWith('/login/sso'); + }); + + it('shows loading text when auth status is loading', () => { + (useAuth as jest.Mock).mockReturnValue({ + login: mockLogin, + status: 'loading', + error: null, + isAuthenticated: false, + }); + render(); + expect(screen.getByText('Logging in...')).toBeTruthy(); + expect(screen.queryByText('Login')).toBeNull(); + }); + + it('shows error modal when auth status is error', async () => { + (useAuth as jest.Mock).mockReturnValue({ + login: mockLogin, + status: 'error', + error: 'Invalid credentials', + isAuthenticated: false, + }); + render(); + await waitFor(() => { + expect(screen.getByText('Login Failed')).toBeTruthy(); + expect(screen.getByText('Please check your username and password and try again.')).toBeTruthy(); + }); + }); + + it('closes error modal when OK is pressed', async () => { + (useAuth as jest.Mock).mockReturnValue({ + login: mockLogin, + status: 'error', + error: 'Invalid credentials', + isAuthenticated: false, + }); + render(); + await waitFor(() => { + expect(screen.getByText('OK')).toBeTruthy(); + }); + fireEvent.press(screen.getByText('OK')); + await waitFor(() => { + expect(screen.queryByText('Login Failed')).toBeNull(); + }); + }); + + it('redirects to home when authentication succeeds', async () => { + (useAuth as jest.Mock).mockReturnValue({ + login: mockLogin, + status: 'signedIn', + error: null, + isAuthenticated: true, + }); + render(); + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/(app)'); + }); + }); + + it('tracks page view analytics on mount', () => { + render(); + expect(mockTrackEvent).toHaveBeenCalledWith( + 'login_web_view_rendered', + expect.objectContaining({ + hasError: false, + status: 'idle', + }) + ); + }); +}); diff --git a/src/app/login/__tests__/login-form.test.tsx b/src/__tests__/app/login/login-form.test.tsx similarity index 99% rename from src/app/login/__tests__/login-form.test.tsx rename to src/__tests__/app/login/login-form.test.tsx index b7733d3..9b7cb18 100644 --- a/src/app/login/__tests__/login-form.test.tsx +++ b/src/__tests__/app/login/login-form.test.tsx @@ -3,7 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react-nativ import { View, Text, TouchableOpacity, TextInput } from 'react-native'; // Mock the entire login-form module to replace the schema creation -jest.mock('../login-form', () => { +jest.mock('../../../app/login/login-form', () => { const React = require('react'); const { View, Text, TouchableOpacity, TextInput } = require('react-native'); @@ -60,7 +60,7 @@ jest.mock('../login-form', () => { }; }); -import { LoginForm } from '../login-form'; +import { LoginForm } from '../../../app/login/login-form'; // Mock react-i18next jest.mock('react-i18next', () => ({ diff --git a/src/app/(app)/__tests__/protocols.test.tsx b/src/__tests__/app/protocols.test.tsx similarity index 99% rename from src/app/(app)/__tests__/protocols.test.tsx rename to src/__tests__/app/protocols.test.tsx index a74b76f..e3bd157 100644 --- a/src/app/(app)/__tests__/protocols.test.tsx +++ b/src/__tests__/app/protocols.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { CallProtocolsResultData } from '@/models/v4/callProtocols/callProtocolsResultData'; -import Protocols from '../protocols'; +import Protocols from '../../app/(app)/protocols'; // Mock dependencies jest.mock('react-i18next', () => ({ diff --git a/src/app/__tests__/lockscreen.test.tsx b/src/__tests__/app/root/lockscreen.test.tsx similarity index 99% rename from src/app/__tests__/lockscreen.test.tsx rename to src/__tests__/app/root/lockscreen.test.tsx index 70ab67c..903d397 100644 --- a/src/app/__tests__/lockscreen.test.tsx +++ b/src/__tests__/app/root/lockscreen.test.tsx @@ -3,7 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react-nativ import { NavigationContainer } from '@react-navigation/native'; import { useRouter } from 'expo-router'; -import Lockscreen from '../lockscreen'; +import Lockscreen from '../../../app/lockscreen'; import { useAuth } from '@/lib/auth'; import useLockscreenStore from '@/stores/lockscreen/store'; diff --git a/src/app/__tests__/maintenance.test.tsx b/src/__tests__/app/root/maintenance.test.tsx similarity index 98% rename from src/app/__tests__/maintenance.test.tsx rename to src/__tests__/app/root/maintenance.test.tsx index e6e98db..c101589 100644 --- a/src/app/__tests__/maintenance.test.tsx +++ b/src/__tests__/app/root/maintenance.test.tsx @@ -3,7 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react-nativ import { NavigationContainer } from '@react-navigation/native'; import { useRouter } from 'expo-router'; -import Maintenance from '../maintenance'; +import Maintenance from '../../../app/maintenance'; import { Env } from '@/lib/env'; // Mock dependencies diff --git a/src/app/(app)/__tests__/signalr-lifecycle.test.tsx b/src/__tests__/app/signalr-lifecycle.test.tsx similarity index 100% rename from src/app/(app)/__tests__/signalr-lifecycle.test.tsx rename to src/__tests__/app/signalr-lifecycle.test.tsx diff --git a/src/api/calls/callTemplates.ts b/src/api/calls/callTemplates.ts new file mode 100644 index 0000000..b8ae23d --- /dev/null +++ b/src/api/calls/callTemplates.ts @@ -0,0 +1,13 @@ +import { type CallQuickTemplatesResult } from '@/models/v4/templates/callQuickTemplatesResult'; + +import { createCachedApiEndpoint } from '../common/cached-client'; + +const getAllCallQuickTemplatesApi = createCachedApiEndpoint('/Templates/GetAllCallQuickTemplates', { + ttl: 60 * 1000 * 60, // Cache for 1 hour + enabled: true, +}); + +export const getAllCallQuickTemplates = async () => { + const response = await getAllCallQuickTemplatesApi.get(); + return response.data; +}; diff --git a/src/api/calls/calls.ts b/src/api/calls/calls.ts index 98210f2..fe66c7c 100644 --- a/src/api/calls/calls.ts +++ b/src/api/calls/calls.ts @@ -50,6 +50,11 @@ export interface CreateCallRequest { dispatchRoles?: string[]; dispatchUnits?: string[]; dispatchEveryone?: boolean; + callFormData?: string; + linkedCallId?: string; + externalId?: string; + referenceId?: string; + scheduledOn?: string; } export interface UpdateCallRequest { @@ -71,6 +76,10 @@ export interface UpdateCallRequest { dispatchRoles?: string[]; dispatchUnits?: string[]; dispatchEveryone?: boolean; + callFormData?: string; + linkedCallId?: string; + externalId?: string; + referenceId?: string; } export interface CloseCallRequest { @@ -120,6 +129,11 @@ export const createCall = async (callData: CreateCallRequest) => { What3Words: callData.what3words || '', PlusCode: callData.plusCode || '', DispatchList: dispatchList, + CallFormData: callData.callFormData || '', + IncidentId: callData.linkedCallId || '', + ExternalId: callData.externalId || '', + ReferenceId: callData.referenceId || '', + ScheduledOn: callData.scheduledOn || '', }; const response = await createCallApi.post(data); @@ -168,6 +182,10 @@ export const updateCall = async (callData: UpdateCallRequest) => { What3Words: callData.what3words || '', PlusCode: callData.plusCode || '', DispatchList: dispatchList, + CallFormData: callData.callFormData || '', + IncidentId: callData.linkedCallId || '', + ExternalId: callData.externalId || '', + ReferenceId: callData.referenceId || '', }; const response = await updateCallApi.post(data); diff --git a/src/api/forms/forms.ts b/src/api/forms/forms.ts new file mode 100644 index 0000000..9236a01 --- /dev/null +++ b/src/api/forms/forms.ts @@ -0,0 +1,32 @@ +import { type FormResult } from '@/models/v4/forms/formResult'; +import { type FormsResult } from '@/models/v4/forms/formsResult'; + +import { createCachedApiEndpoint } from '../common/cached-client'; +import { createApiEndpoint } from '../common/client'; + +const getNewCallFormApi = createCachedApiEndpoint('/Forms/GetNewCallForm', { + ttl: 60 * 1000 * 60, // Cache for 1 hour + enabled: true, +}); + +const getAllFormsApi = createCachedApiEndpoint('/Forms/GetAllForms', { + ttl: 60 * 1000 * 60, // Cache for 1 hour + enabled: true, +}); + +const getFormByIdApi = createApiEndpoint('/Forms/GetFormById'); + +export const getNewCallForm = async () => { + const response = await getNewCallFormApi.get(); + return response.data; +}; + +export const getAllForms = async () => { + const response = await getAllFormsApi.get(); + return response.data; +}; + +export const getFormById = async (formId: string) => { + const response = await getFormByIdApi.get({ formId }); + return response.data; +}; diff --git a/src/api/userDefinedFields/userDefinedFields.ts b/src/api/userDefinedFields/userDefinedFields.ts new file mode 100644 index 0000000..7c25baf --- /dev/null +++ b/src/api/userDefinedFields/userDefinedFields.ts @@ -0,0 +1,32 @@ +import { type UdfDefinitionResult } from '@/models/v4/userDefinedFields/udfDefinitionResult'; +import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput'; +import { type UdfFieldValuesResult } from '@/models/v4/userDefinedFields/udfFieldValuesResult'; + +import { api } from '../common/client'; + +// EntityType enum: Call=0, Personnel=1, Unit=2, Contact=3 +export const getUdfDefinition = async (entityType: number) => { + const response = await api.get(`/UserDefinedFields/${entityType}`); + return response.data; +}; + +export const getUdfValues = async (entityType: number, entityId: string) => { + const response = await api.get(`/UserDefinedFields/Values/${entityType}/${encodeURIComponent(entityId)}`); + return response.data; +}; + +export interface SaveUdfValuesRequest { + EntityType: number; + EntityId: string; + Values: UdfFieldValueInput[]; +} + +export const saveUdfValues = async (entityType: number, entityId: string, values: UdfFieldValueInput[]) => { + const data: SaveUdfValuesRequest = { + EntityType: entityType, + EntityId: entityId, + Values: values, + }; + const response = await api.post('/UserDefinedFields/Values', data); + return response.data; +}; diff --git a/src/app/call/[id]/edit.tsx b/src/app/call/[id]/edit.tsx index 2e165f2..ccd15d5 100644 --- a/src/app/call/[id]/edit.tsx +++ b/src/app/call/[id]/edit.tsx @@ -1,15 +1,17 @@ import { zodResolver } from '@hookform/resolvers/zod'; import axios from 'axios'; import { router, Stack, useLocalSearchParams } from 'expo-router'; -import { ChevronDownIcon, PlusIcon, SearchIcon } from 'lucide-react-native'; +import { ChevronDownIcon, ChevronUpIcon, PlusIcon, SearchIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useEffect, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { ScrollView, View } from 'react-native'; +import { ScrollView, TouchableOpacity, View } from 'react-native'; import * as z from 'zod'; +import { saveUdfValues } from '@/api/userDefinedFields/userDefinedFields'; import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal'; +import { UdfFieldsRenderer } from '@/components/calls/udf-fields-renderer'; import { Loading } from '@/components/common/loading'; import FullScreenLocationPicker from '@/components/maps/full-screen-location-picker'; import LocationPicker from '@/components/maps/location-picker'; @@ -24,6 +26,7 @@ import { Text } from '@/components/ui/text'; import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { useToast } from '@/components/ui/toast'; import { useAnalytics } from '@/hooks/use-analytics'; +import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallDetailStore } from '@/stores/calls/detail-store'; import { useCallsStore } from '@/stores/calls/store'; @@ -84,6 +87,8 @@ export default function EditCall() { const [showLocationPicker, setShowLocationPicker] = useState(false); const [showDispatchModal, setShowDispatchModal] = useState(false); const [showAddressSelection, setShowAddressSelection] = useState(false); + const [udfValues, setUdfValues] = useState([]); + const [isAdditionalFieldsExpanded, setIsAdditionalFieldsExpanded] = useState(false); const [isGeocodingAddress, setIsGeocodingAddress] = useState(false); const [isGeocodingPlusCode, setIsGeocodingPlusCode] = useState(false); const [isGeocodingCoordinates, setIsGeocodingCoordinates] = useState(false); @@ -231,6 +236,14 @@ export default function EditCall() { dispatchEveryone: data.dispatchSelection?.everyone, }); + if (udfValues.length > 0 && callId) { + try { + await saveUdfValues(0, callId, udfValues); + } catch (udfError) { + console.warn('Failed to save UDF values:', udfError); + } + } + // Show success toast toast.show({ placement: 'top', @@ -644,6 +657,19 @@ export default function EditCall() { + {/* Additional Fields (UDF) */} + + setIsAdditionalFieldsExpanded((prev) => !prev)}> + {t('calls.additional_fields', 'Additional Fields')} + {isAdditionalFieldsExpanded ? : } + + {isAdditionalFieldsExpanded ? ( + + + + ) : null} + + {t('calls.dispatch_to')} + + ) : null} - - - - {t('calls.nature')} - - ( - - )} - /> - {errors.nature && ( - - {errors.nature.message} - - )} - + + toggleSection('callName')} className="flex-row items-center justify-between p-4"> + {t('calls.name')} + {sectionsExpanded.callName ? : } + + {sectionsExpanded.callName ? ( + + + ( + + + + )} + /> + {errors.name && ( + + {errors.name.message} + + )} + + + ) : null} - - - - {t('calls.priority')} - - ( - - )} - /> - {errors.priority && ( - - {errors.priority.message} - - )} - + + toggleSection('nature')} className="flex-row items-center justify-between p-4"> + {t('calls.nature')} + {sectionsExpanded.nature ? : } + + {sectionsExpanded.nature ? ( + + + ( + + )} + /> + {errors.nature && ( + + {errors.nature.message} + + )} + + + ) : null} - - - - {t('calls.type')} - - ( - - )} - /> - {errors.type && ( - - {errors.type.message} - - )} - + + toggleSection('priorityType')} className="flex-row items-center justify-between p-4"> + {t('calls.priority_and_type', 'Priority & Type')} + {sectionsExpanded.priorityType ? : } + + {sectionsExpanded.priorityType ? ( + + + + {t('calls.priority')} + + ( + + )} + /> + {errors.priority && ( + + {errors.priority.message} + + )} + + + + {t('calls.type')} + + ( + + )} + /> + {errors.type && ( + + {errors.type.message} + + )} + + + ) : null} - - - - {t('calls.note')} - - ( - - )} - /> - + + toggleSection('note')} className="flex-row items-center justify-between p-4"> + {t('calls.note')} + {sectionsExpanded.note ? : } + + {sectionsExpanded.note ? ( + + + ( + + )} + /> + + + ) : null} - - {t('calls.call_location')} - - {/* Address Field */} - - - {t('calls.address')} - - ( - - - - - - - - - )} - /> - - - {/* GPS Coordinates Field */} - - - {t('calls.coordinates')} - - ( - - - - - - - + + )} + /> + + + {/* GPS Coordinates Field */} + + + {t('calls.coordinates')} + + ( + + + + + + + + + )} + /> + + + {/* what3words Field */} + + + {t('calls.what3words')} + + ( + + + + + + + + + )} + /> + + + {/* Plus Code Field */} + + + {t('calls.plus_code')} + + ( + + + + + + + + + )} + /> + + + {/* Map Preview */} + + {selectedLocation ? ( + + ) : ( + - - )} - /> - - - {/* what3words Field */} - - - {t('calls.what3words')} - - ( - - + )} + + + ) : null} + + + + toggleSection('contact')} className="flex-row items-center justify-between p-4"> + + + {t('calls.contact_information', 'Contact Information')} + + {sectionsExpanded.contact ? : } + + {sectionsExpanded.contact ? ( + + + + + {t('calls.contact_name')} + + ( - + - - - - )} - /> - - - {/* Plus Code Field */} - - - {t('calls.plus_code')} - - ( - - + )} + /> + + + + {t('calls.contact_info')} + + ( - + - - - - )} - /> - - - {/* Map Preview */} - - {selectedLocation ? ( - - ) : ( - - )} - + + ) : null} - - - - {t('calls.contact_name')} - - ( - - - - )} - /> - + {/* Linked Call */} + + toggleSection('linkedCall')} className="flex-row items-center justify-between p-4"> + + + {t('calls.linked_calls.title', 'Linked Call')} + {linkedCall ? ( + + #{linkedCall.number} + + ) : null} + + {sectionsExpanded.linkedCall ? : } + + {sectionsExpanded.linkedCall ? ( + + {linkedCall ? ( + + + #{linkedCall.number} — {linkedCall.name} + + + + ) : null} + + + ) : null} - - - - {t('calls.contact_info')} - - ( - - - - )} - /> - + {/* Additional Fields (UDF) */} + + toggleSection('additionalFields')} className="flex-row items-center justify-between p-4"> + {t('calls.additional_fields', 'Additional Fields')} + {sectionsExpanded.additionalFields ? ( + + ) : ( + + )} + + {sectionsExpanded.additionalFields ? ( + + + + ) : null} - - {t('calls.dispatch_to')} - + {/* Call Form */} + {callForm ? ( + + toggleSection('callForm')} className="flex-row items-center justify-between p-4"> + {callForm.Name || t('calls.form.title', 'Call Form')} + {sectionsExpanded.callForm ? : } + + {sectionsExpanded.callForm ? ( + + + + ) : null} + + ) : null} + + + toggleSection('dispatch')} className="flex-row items-center justify-between p-4"> + {t('calls.dispatch_to')} + {sectionsExpanded.dispatch ? : } + + {sectionsExpanded.dispatch ? ( + + + + ) : null} @@ -908,6 +1145,18 @@ export default function NewCall() { {/* Dispatch selection modal */} setShowDispatchModal(false)} onConfirm={handleDispatchSelection} initialSelection={dispatchSelection} /> + {/* Call Templates modal */} + setShowTemplatesModal(false)} onSelect={handleTemplateSelect} /> + + {/* Contact Picker modal */} + setShowContactPicker(false)} onSelect={handleContactSelect} /> + + {/* Protocol Selector modal */} + setShowProtocolSelector(false)} onConfirm={setSelectedProtocols} initialSelected={selectedProtocols} /> + + {/* Linked Calls modal */} + setShowLinkedCallsModal(false)} onSelect={handleLinkedCallSelect} selectedCallId={linkedCall?.callId} /> + {/* Address selection bottom sheet */} setShowAddressSelection(false)} isLoading={false}> diff --git a/src/app/call/new/index.web.tsx b/src/app/call/new/index.web.tsx index 180d72c..9b21655 100644 --- a/src/app/call/new/index.web.tsx +++ b/src/app/call/new/index.web.tsx @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import axios from 'axios'; import { type Href, router, Stack } from 'expo-router'; -import { ChevronDownIcon, MapPinIcon, PlusIcon, SearchIcon, XIcon } from 'lucide-react-native'; +import { BookOpenIcon, CalendarClockIcon, ChevronDownIcon, ChevronUpIcon, FileTextIcon, LinkIcon, MapPinIcon, PlusIcon, SearchIcon, UserIcon, XIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; @@ -10,7 +10,15 @@ import { Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 're import * as z from 'zod'; import { createCall } from '@/api/calls/calls'; +import { getNewCallForm } from '@/api/forms/forms'; +import { saveUdfValues } from '@/api/userDefinedFields/userDefinedFields'; +import { CallFormRenderer } from '@/components/calls/call-form-renderer'; +import { CallTemplatesModal, type TemplateSelection } from '@/components/calls/call-templates-modal'; +import { ContactPickerModal } from '@/components/calls/contact-picker-modal'; import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal'; +import { LinkedCallsModal } from '@/components/calls/linked-calls-modal'; +import { ProtocolSelectorModal, type SelectedProtocol } from '@/components/calls/protocol-selector-modal'; +import { UdfFieldsRenderer } from '@/components/calls/udf-fields-renderer'; import { Loading } from '@/components/common/loading'; import FullScreenLocationPicker from '@/components/maps/full-screen-location-picker'; import LocationPicker from '@/components/maps/location-picker'; @@ -23,6 +31,10 @@ import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; import { useToast } from '@/hooks/use-toast'; +import { type CallResultData } from '@/models/v4/calls/callResultData'; +import { type ContactResultData } from '@/models/v4/contacts/contactResultData'; +import { type FormResultData } from '@/models/v4/forms/formResultData'; +import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; import { type DispatchSelection } from '@/stores/dispatch/store'; @@ -42,6 +54,7 @@ const formSchema = z.object({ type: z.string().min(1, { message: 'Type is required' }), contactName: z.string().optional(), contactInfo: z.string().optional(), + scheduledOn: z.string().optional(), dispatchSelection: z .object({ everyone: z.boolean(), @@ -166,6 +179,38 @@ const WebInput: React.FC = ({ label, placeholder, value, onChange ); }; +// Web-optimized datetime input component +interface WebDateTimeInputProps { + label: string; + value: string; + onChange: (value: string) => void; + min?: string; + error?: string; + helperText?: string; + testID?: string; +} + +const WebDateTimeInput: React.FC = ({ label, value, onChange, min, error, helperText, testID }) => { + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === 'dark'; + + const inputStyles = StyleSheet.flatten([webStyles.webInput as any, isDark ? styles.webInputDark : styles.webInputLight, error ? styles.webInputError : {}]); + + const accessibleInputStyles = { + ...inputStyles, + outline: 'none', + } as React.CSSProperties; + + return ( + + {label} + onChange(e.target.value)} min={min} data-testid={testID} /> + {helperText ? {helperText} : null} + {error ? {error} : null} + + ); +}; + // Web-optimized select component interface WebSelectProps { label: string; @@ -220,6 +265,15 @@ export default function NewCallWeb() { const [showLocationPicker, setShowLocationPicker] = useState(false); const [showDispatchModal, setShowDispatchModal] = useState(false); const [showAddressSelection, setShowAddressSelection] = useState(false); + const [showTemplatesModal, setShowTemplatesModal] = useState(false); + const [showContactPicker, setShowContactPicker] = useState(false); + const [showProtocolSelector, setShowProtocolSelector] = useState(false); + const [showLinkedCallsModal, setShowLinkedCallsModal] = useState(false); + const [callFormData, setCallFormData] = useState(null); + const [callForm, setCallForm] = useState(null); + const [udfValues, setUdfValues] = useState([]); + const [selectedProtocols, setSelectedProtocols] = useState([]); + const [linkedCall, setLinkedCall] = useState<{ callId: string; number: string; name: string } | null>(null); const [isGeocodingAddress, setIsGeocodingAddress] = useState(false); const [isGeocodingPlusCode, setIsGeocodingPlusCode] = useState(false); const [isGeocodingCoordinates, setIsGeocodingCoordinates] = useState(false); @@ -238,6 +292,22 @@ export default function NewCallWeb() { address?: string; } | null>(null); const [isSubmitting, setIsSubmitting] = useState(false); + const [sectionsExpanded, setSectionsExpanded] = useState({ + templates: false, + callDetails: true, + scheduledDispatch: false, + contact: false, + protocols: false, + linkedCall: false, + callForm: false, + additionalFields: false, + location: true, + dispatch: true, + }); + + const toggleSection = (section: keyof typeof sectionsExpanded) => { + setSectionsExpanded((prev) => ({ ...prev, [section]: !prev[section] })); + }; const nameInputRef = useRef(null); @@ -266,6 +336,7 @@ export default function NewCallWeb() { type: '', contactName: '', contactInfo: '', + scheduledOn: '', dispatchSelection: { everyone: false, users: [], @@ -286,6 +357,14 @@ export default function NewCallWeb() { fetchCallTypes(); }, [fetchCallPriorities, fetchCallTypes]); + useEffect(() => { + getNewCallForm() + .then((result) => { + if (result?.Data?.Data) setCallForm(result.Data); + }) + .catch(() => {}); + }, []); + useEffect(() => { trackEvent('new_call_web_view_rendered', { prioritiesCount: callPriorities.length, @@ -303,6 +382,16 @@ export default function NewCallWeb() { data.longitude = selectedLocation.longitude; } + // Validate scheduled time is in the future if provided + if (data.scheduledOn?.trim()) { + const scheduledDate = new Date(data.scheduledOn); + if (scheduledDate <= new Date()) { + setIsSubmitting(false); + toast.error(t('calls.scheduled_on_past_error')); + return; + } + } + const priority = callPriorities.find((p) => p.Name === data.priority); const type = callTypes.find((t) => t.Name === data.type); @@ -318,7 +407,7 @@ export default function NewCallWeb() { return; } - await createCall({ + const response = await createCall({ name: data.name, nature: data.nature, priority: priority.Id, @@ -334,8 +423,19 @@ export default function NewCallWeb() { dispatchRoles: data.dispatchSelection?.roles, dispatchUnits: data.dispatchSelection?.units, dispatchEveryone: data.dispatchSelection?.everyone, + callFormData: callFormData ?? undefined, + linkedCallId: linkedCall?.callId, + scheduledOn: data.scheduledOn?.trim() ? new Date(data.scheduledOn).toISOString() : undefined, }); + if (udfValues.length > 0 && response?.Id) { + try { + await saveUdfValues(0, response.Id, udfValues); + } catch (udfError) { + console.warn('Failed to save UDF values:', udfError); + } + } + toast.success(t('calls.create_success')); router.push('/calls' as Href); } catch (err) { @@ -345,7 +445,7 @@ export default function NewCallWeb() { setIsSubmitting(false); } }, - [selectedLocation, callPriorities, callTypes, toast, t] + [selectedLocation, callPriorities, callTypes, toast, t, callFormData, linkedCall?.callId, udfValues] ); // Keyboard shortcuts @@ -408,6 +508,35 @@ export default function NewCallWeb() { return `${count} ${t('calls.selected')}`; }; + const handleTemplateSelect = useCallback( + (template: TemplateSelection) => { + if (template.name) setValue('name', template.name); + if (template.nature) setValue('nature', template.nature); + if (template.type) setValue('type', template.type); + if (template.priority) { + const matched = callPriorities.find((p) => p.Id === template.priority); + if (matched) setValue('priority', matched.Name); + } + toast.success(t('calls.templates.template_applied', 'Template applied')); + }, + [callPriorities, setValue, toast, t] + ); + + const handleContactSelect = useCallback( + (contact: ContactResultData) => { + const parts = [contact.FirstName, contact.MiddleName, contact.LastName].filter(Boolean); + const name = contact.CompanyName || parts.join(' ') || contact.Name || ''; + const info = contact.Email || String(contact.Phone || contact.Mobile || ''); + setValue('contactName', name); + setValue('contactInfo', info); + }, + [setValue] + ); + + const handleLinkedCallSelect = useCallback((call: CallResultData) => { + setLinkedCall({ callId: call.CallId, number: call.Number, name: call.Name }); + }, []); + const handleAddressSearch = async (address: string) => { if (!address.trim()) { toast.warning(t('calls.address_required')); @@ -606,270 +735,433 @@ export default function NewCallWeb() { {t('calls.new_call_web_hint', 'Fill in the call details below. Press Ctrl+Enter to create.')} + {/* Call Templates */} + + toggleSection('templates')}> + {t('calls.templates.title', 'Call Templates')} + {sectionsExpanded.templates ? : } + + {sectionsExpanded.templates ? ( + setShowTemplatesModal(true)}> + + + {t('calls.templates.select_template', 'Select Template')} + + + ) : null} + + {/* Main Content - Two Column Layout for Wide Screens */} {/* Left Column - Call Details */} - {t('calls.call_details')} - - ( - - )} - /> - - ( - toggleSection('callDetails')}> + {t('calls.call_details')} + {sectionsExpanded.callDetails ? : } + + {sectionsExpanded.callDetails ? ( + + ( + + )} /> - )} - /> - - ( - ( + ({ id: p.Id, name: p.Name, color: p.Color }))} - error={errors.priority?.message} + onBlur={onBlur} + error={errors.nature?.message} + multiline + rows={3} required + testID="nature-input" /> )} /> + + + + ( + ({ id: p.Id, name: p.Name, color: p.Color }))} + error={errors.priority?.message} + required + /> + )} + /> + + + ( + ({ id: t.Id, name: t.Name }))} + error={errors.type?.message} + required + /> + )} + /> + + + + ( + + )} + /> + + ) : null} + + + {/* Schedule Dispatch */} + + toggleSection('scheduledDispatch')}> + + + {t('calls.schedule_dispatch')} - + {sectionsExpanded.scheduledDispatch ? : } + + {sectionsExpanded.scheduledDispatch ? ( + + {t('calls.schedule_dispatch_description')} ( - ({ id: t.Id, name: t.Name }))} - error={errors.type?.message} - required + min={new Date(Date.now() + 60000).toISOString().slice(0, 16)} + helperText={t('calls.scheduled_on_helper')} + error={errors.scheduledOn?.message} + testID="scheduled-on-input" /> )} /> - - - ( - - )} - /> + ) : null} {/* Contact Information */} - {t('calls.contact_information')} + toggleSection('contact')}> + {t('calls.contact_information')} + {sectionsExpanded.contact ? : } + + {sectionsExpanded.contact ? ( + + setShowContactPicker(true)}> + + + {t('calls.contact_picker.search_placeholder', 'Search contacts...')} + + - - - ( - - )} - /> + + + ( + + )} + /> + + + ( + + )} + /> + + - - ( - - )} - /> + ) : null} + + + {/* Call Form */} + {callForm ? ( + + toggleSection('callForm')}> + {callForm.Name || t('calls.form.title', 'Call Form')} + {sectionsExpanded.callForm ? : } + + {sectionsExpanded.callForm ? ( + + + + ) : null} + + ) : null} + + {/* Additional Fields (UDF) */} + + toggleSection('additionalFields')}> + {t('calls.additional_fields', 'Additional Fields')} + {sectionsExpanded.additionalFields ? : } + + {sectionsExpanded.additionalFields ? ( + + - + ) : null} - {/* Right Column - Location & Dispatch */} + {/* Right Column - Location, Dispatch, Protocols, Linked Call */} {/* Location Card */} - {t('calls.call_location')} - - ( - { - if (e.key === 'Enter') { - e.preventDefault(); - handleAddressSearch(value || ''); - } - }} - rightElement={ - handleAddressSearch(value || '')} - style={StyleSheet.flatten([styles.searchButton, isGeocodingAddress ? styles.searchButtonDisabled : {}])} - disabled={isGeocodingAddress || !value?.trim()} - > - {isGeocodingAddress ? ... : } - - } - /> - )} - /> - - ( - { - if (e.key === 'Enter') { - e.preventDefault(); - handleCoordinatesSearch(value || ''); - } - }} - rightElement={ - handleCoordinatesSearch(value || '')} - style={StyleSheet.flatten([styles.searchButton, isGeocodingCoordinates ? styles.searchButtonDisabled : {}])} - disabled={isGeocodingCoordinates || !value?.trim()} - > - {isGeocodingCoordinates ? ... : } - - } - /> - )} - /> - - - + toggleSection('location')}> + {t('calls.call_location')} + {sectionsExpanded.location ? : } + + {sectionsExpanded.location ? ( + ( { if (e.key === 'Enter') { e.preventDefault(); - handleWhat3WordsSearch(value || ''); + handleAddressSearch(value || ''); } }} rightElement={ handleWhat3WordsSearch(value || '')} - style={StyleSheet.flatten([styles.searchButton, isGeocodingWhat3Words ? styles.searchButtonDisabled : {}])} - disabled={isGeocodingWhat3Words || !value?.trim()} + onPress={() => handleAddressSearch(value || '')} + style={StyleSheet.flatten([styles.searchButton, isGeocodingAddress ? styles.searchButtonDisabled : {}])} + disabled={isGeocodingAddress || !value?.trim()} > - {isGeocodingWhat3Words ? ... : } + {isGeocodingAddress ? ... : } } /> )} /> - - + ( { if (e.key === 'Enter') { e.preventDefault(); - handlePlusCodeSearch(value || ''); + handleCoordinatesSearch(value || ''); } }} rightElement={ handlePlusCodeSearch(value || '')} - style={StyleSheet.flatten([styles.searchButton, isGeocodingPlusCode ? styles.searchButtonDisabled : {}])} - disabled={isGeocodingPlusCode || !value?.trim()} + onPress={() => handleCoordinatesSearch(value || '')} + style={StyleSheet.flatten([styles.searchButton, isGeocodingCoordinates ? styles.searchButtonDisabled : {}])} + disabled={isGeocodingCoordinates || !value?.trim()} > - {isGeocodingPlusCode ? ... : } + {isGeocodingCoordinates ? ... : } } /> )} /> - - - - {/* Map Preview */} - - {selectedLocation ? ( - - - setShowLocationPicker(true)}> - - {t('calls.expand_map')} - + + + + ( + { + if (e.key === 'Enter') { + e.preventDefault(); + handleWhat3WordsSearch(value || ''); + } + }} + rightElement={ + handleWhat3WordsSearch(value || '')} + style={StyleSheet.flatten([styles.searchButton, isGeocodingWhat3Words ? styles.searchButtonDisabled : {}])} + disabled={isGeocodingWhat3Words || !value?.trim()} + > + {isGeocodingWhat3Words ? ... : } + + } + /> + )} + /> + + + ( + { + if (e.key === 'Enter') { + e.preventDefault(); + handlePlusCodeSearch(value || ''); + } + }} + rightElement={ + handlePlusCodeSearch(value || '')} + style={StyleSheet.flatten([styles.searchButton, isGeocodingPlusCode ? styles.searchButtonDisabled : {}])} + disabled={isGeocodingPlusCode || !value?.trim()} + > + {isGeocodingPlusCode ? ... : } + + } + /> + )} + /> + - ) : ( - setShowLocationPicker(true)}> - - {t('calls.select_location')} - - )} - + + {/* Map Preview */} + + {selectedLocation ? ( + + + setShowLocationPicker(true)}> + + {t('calls.expand_map')} + + + ) : ( + setShowLocationPicker(true)}> + + {t('calls.select_location')} + + )} + + + ) : null} {/* Dispatch Card */} - {t('calls.dispatch_to')} - setShowDispatchModal(true)}> - {getDispatchSummary()} - + toggleSection('dispatch')}> + {t('calls.dispatch_to')} + {sectionsExpanded.dispatch ? : } + + {sectionsExpanded.dispatch ? ( + setShowDispatchModal(true)}> + {getDispatchSummary()} + + + ) : null} + + + {/* Protocols */} + + toggleSection('protocols')}> + + {t('calls.protocols.title', 'Protocols')} + {selectedProtocols.length > 0 ? ( + + {selectedProtocols.length} + + ) : null} + + {sectionsExpanded.protocols ? : } + + {sectionsExpanded.protocols ? ( + setShowProtocolSelector(true)}> + + + {selectedProtocols.length > 0 ? `${selectedProtocols.length} ${t('calls.protocols.selected_count', 'selected')}` : t('calls.protocols.select', 'Select Protocols')} + + + ) : null} + + + {/* Linked Call */} + + toggleSection('linkedCall')}> + + {t('calls.linked_calls.title', 'Linked Call')} + {linkedCall ? ( + + #{linkedCall.number} + + ) : null} + + {sectionsExpanded.linkedCall ? : } + {sectionsExpanded.linkedCall ? ( + + {linkedCall ? ( + + + #{linkedCall.number} — {linkedCall.name} + + setLinkedCall(null)}> + + + + ) : null} + setShowLinkedCallsModal(true)}> + + + {linkedCall ? t('calls.linked_calls.change', 'Change linked call') : t('calls.linked_calls.select', 'Link to existing call')} + + + + ) : null} @@ -902,6 +1194,18 @@ export default function NewCallWeb() { {/* Dispatch selection modal */} setShowDispatchModal(false)} onConfirm={handleDispatchSelection} initialSelection={dispatchSelection} /> + {/* Call Templates modal */} + setShowTemplatesModal(false)} onSelect={handleTemplateSelect} /> + + {/* Contact Picker modal */} + setShowContactPicker(false)} onSelect={handleContactSelect} /> + + {/* Protocol Selector modal */} + setShowProtocolSelector(false)} onConfirm={setSelectedProtocols} initialSelected={selectedProtocols} /> + + {/* Linked Calls modal */} + setShowLinkedCallsModal(false)} onSelect={handleLinkedCallSelect} selectedCallId={linkedCall?.callId} /> + {/* Address selection modal */} {showAddressSelection ? ( @@ -1166,6 +1470,34 @@ const styles = StyleSheet.create({ dispatchButtonTextLight: { color: '#374151', }, + linkedCallBadge: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 10, + borderRadius: 8, + marginBottom: 10, + borderWidth: 1, + }, + linkedCallBadgeDark: { + backgroundColor: '#262626', + borderColor: '#404040', + }, + linkedCallBadgeLight: { + backgroundColor: '#f3f4f6', + borderColor: '#e5e7eb', + }, + linkedCallText: { + fontSize: 13, + flex: 1, + marginRight: 8, + }, + linkedCallTextDark: { + color: '#d1d5db', + }, + linkedCallTextLight: { + color: '#374151', + }, actionButtons: { flexDirection: 'row', justifyContent: 'flex-end', @@ -1308,6 +1640,28 @@ const styles = StyleSheet.create({ addressItemTextLight: { color: '#374151', }, + collapsibleHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingBottom: 0, + }, + collapsibleHeaderLeft: { + flexDirection: 'row', + alignItems: 'center', + }, + countBadge: { + marginLeft: 8, + backgroundColor: '#2563eb', + borderRadius: 10, + paddingHorizontal: 7, + paddingVertical: 2, + }, + countBadgeText: { + color: '#ffffff', + fontSize: 11, + fontWeight: '600', + }, }); // Web-specific styles that use CSS-only properties diff --git a/src/app/login/__tests__/index.web.test.tsx b/src/app/login/__tests__/index.web.test.tsx deleted file mode 100644 index f69292b..0000000 --- a/src/app/login/__tests__/index.web.test.tsx +++ /dev/null @@ -1,357 +0,0 @@ -import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react-native'; -import { View, Text, TextInput, Pressable } from 'react-native'; - -// Mock the entire web login module -jest.mock('../index.web', () => { - const React = require('react'); - const { View, Text, TextInput, Pressable } = require('react-native'); - - const MockLoginWeb = () => { - const [username, setUsername] = React.useState(''); - const [password, setPassword] = React.useState(''); - const [showPassword, setShowPassword] = React.useState(false); - const [isLoading, setIsLoading] = React.useState(false); - const [showServerUrlModal, setShowServerUrlModal] = React.useState(false); - const [showErrorModal, setShowErrorModal] = React.useState(false); - - const mockLogin = jest.fn(); - const mockOnSubmit = () => { - if (username && password) { - setIsLoading(true); - mockLogin({ username, password }); - setTimeout(() => setIsLoading(false), 100); - } - }; - - return ( - - - Resgrid Dispatch - Enter the information below to Sign in... - - - - - - - - setShowPassword(!showPassword)}> - {showPassword ? 'Hide' : 'Show'} - - - - - {isLoading ? 'Logging in...' : 'Login'} - - - setShowServerUrlModal(true)}> - Server URL - - - - Don't have an account? Register - © 2026 Resgrid, LLC. - - - - {showServerUrlModal && ( - - Server URL - - setShowServerUrlModal(false)}> - Cancel - - setShowServerUrlModal(false)}> - Save - - - )} - - {showErrorModal && ( - - Login Failed - Please check your username and password and try again. - setShowErrorModal(false)}> - OK - - - )} - - ); - }; - - return { - __esModule: true, - default: MockLoginWeb, - }; -}); - -// Import after mocking -import LoginWeb from '../index.web'; - -// Mock hooks and dependencies -const mockLogin = jest.fn(); -const mockTrackEvent = jest.fn(); -const mockReplace = jest.fn(); - -jest.mock('expo-router', () => ({ - useRouter: () => ({ - replace: mockReplace, - }), -})); - -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, fallback?: string) => { - const translations: Record = { - 'login.page_title': 'Resgrid Dispatch', - 'login.page_subtitle': 'Enter the information below to Sign in to the Resgrid Dispatch application.', - 'login.username_placeholder': 'Enter your username', - 'login.password_placeholder': 'Enter your password', - 'login.login_button': 'Login', - 'login.login_button_loading': 'Logging in...', - 'login.no_account': "Don't have an account?", - 'login.register': 'Register', - 'login.footer_text': 'Created with ❤️ in Lake Tahoe', - 'settings.server_url': 'Server URL', - 'login.errorModal.title': 'Login Failed', - 'login.errorModal.message': 'Please check your username and password and try again.', - 'login.errorModal.confirmButton': 'OK', - 'common.cancel': 'Cancel', - 'common.save': 'Save', - }; - return translations[key] || fallback || key; - }, - }), -})); - -jest.mock('@/hooks/use-analytics', () => ({ - useAnalytics: () => ({ - trackEvent: mockTrackEvent, - }), -})); - -jest.mock('@/lib/auth', () => ({ - useAuth: () => ({ - login: mockLogin, - status: 'idle', - error: null, - isAuthenticated: false, - }), -})); - -jest.mock('@/lib/logging', () => ({ - logger: { - info: jest.fn(), - error: jest.fn(), - }, -})); - -jest.mock('@/lib/env', () => ({ - Env: { - API_VERSION: 'v4', - }, -})); - -jest.mock('@/stores/app/server-url-store', () => ({ - useServerUrlStore: () => ({ - setUrl: jest.fn(), - getUrl: jest.fn().mockResolvedValue('https://api.example.com/api/v4'), - }), -})); - -jest.mock('nativewind', () => ({ - useColorScheme: () => ({ - colorScheme: 'light', - }), -})); - -// Mock useWindowDimensions properly without spreading all RN -jest.mock('react-native/Libraries/Utilities/useWindowDimensions', () => ({ - default: () => ({ - width: 1200, - height: 800, - }), -})); - -jest.mock('react-native-reanimated', () => { - const Reanimated = require('react-native-reanimated/mock'); - Reanimated.default.call = () => {}; - return { - ...Reanimated, - FadeIn: { duration: () => ({ delay: () => ({}) }) }, - FadeInDown: { duration: () => ({ delay: () => ({}) }), delay: () => ({ duration: () => ({}) }) }, - FadeInUp: { duration: () => ({ delay: () => ({}) }), delay: () => ({ duration: () => ({}) }) }, - FadeOut: { duration: () => ({}) }, - }; -}); - -jest.mock('lucide-react-native', () => ({ - AlertCircle: () => null, - Eye: () => null, - EyeOff: () => null, - Loader2: () => null, - Lock: () => null, - Server: () => null, - User: () => null, -})); - -describe('LoginWeb', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders login form elements', () => { - render(); - - expect(screen.getByTestId('login-web-container')).toBeTruthy(); - expect(screen.getByTestId('login-card')).toBeTruthy(); - expect(screen.getByTestId('page-title')).toBeTruthy(); - expect(screen.getByTestId('page-subtitle')).toBeTruthy(); - }); - - it('renders username and password inputs', () => { - render(); - - expect(screen.getByTestId('username-input')).toBeTruthy(); - expect(screen.getByTestId('password-input')).toBeTruthy(); - expect(screen.getByPlaceholderText('Enter your username')).toBeTruthy(); - expect(screen.getByPlaceholderText('Enter your password')).toBeTruthy(); - }); - - it('renders login button', () => { - render(); - - expect(screen.getByTestId('login-button')).toBeTruthy(); - expect(screen.getByText('Login')).toBeTruthy(); - }); - - it('renders server URL button', () => { - render(); - - expect(screen.getByTestId('server-url-button')).toBeTruthy(); - expect(screen.getByText('Server URL')).toBeTruthy(); - }); - - it('renders footer with registration link and copyright', () => { - render(); - - expect(screen.getByTestId('footer')).toBeTruthy(); - expect(screen.getByTestId('no-account-text')).toBeTruthy(); - expect(screen.getByTestId('copyright-text')).toBeTruthy(); - }); - - it('allows user to enter username', () => { - render(); - - const usernameInput = screen.getByTestId('username-input'); - fireEvent.changeText(usernameInput, 'testuser'); - - expect(usernameInput.props.value).toBe('testuser'); - }); - - it('allows user to enter password', () => { - render(); - - const passwordInput = screen.getByTestId('password-input'); - fireEvent.changeText(passwordInput, 'testpassword'); - - expect(passwordInput.props.value).toBe('testpassword'); - }); - - it('toggles password visibility', () => { - render(); - - const passwordInput = screen.getByTestId('password-input'); - const toggleButton = screen.getByTestId('toggle-password'); - - // Initially should be secured - expect(passwordInput.props.secureTextEntry).toBe(true); - - // Toggle visibility - fireEvent.press(toggleButton); - expect(passwordInput.props.secureTextEntry).toBe(false); - - // Toggle back - fireEvent.press(toggleButton); - expect(passwordInput.props.secureTextEntry).toBe(true); - }); - - it('opens server URL modal when button is pressed', () => { - render(); - - const serverUrlButton = screen.getByTestId('server-url-button'); - fireEvent.press(serverUrlButton); - - expect(screen.getByTestId('server-url-modal')).toBeTruthy(); - }); - - it('closes server URL modal when cancel is pressed', () => { - render(); - - // Open modal - fireEvent.press(screen.getByTestId('server-url-button')); - expect(screen.getByTestId('server-url-modal')).toBeTruthy(); - - // Close modal - fireEvent.press(screen.getByTestId('server-url-cancel')); - expect(screen.queryByTestId('server-url-modal')).toBeNull(); - }); - - it('closes server URL modal when save is pressed', () => { - render(); - - // Open modal - fireEvent.press(screen.getByTestId('server-url-button')); - - // Save and close - fireEvent.press(screen.getByTestId('server-url-save')); - expect(screen.queryByTestId('server-url-modal')).toBeNull(); - }); - - it('shows loading state when login button is pressed', async () => { - render(); - - const usernameInput = screen.getByTestId('username-input'); - const passwordInput = screen.getByTestId('password-input'); - const loginButton = screen.getByTestId('login-button'); - - // Fill in form - fireEvent.changeText(usernameInput, 'testuser'); - fireEvent.changeText(passwordInput, 'testpassword'); - - // Press login - fireEvent.press(loginButton); - - // Should show loading text - await waitFor(() => { - expect(screen.getByText('Logging in...')).toBeTruthy(); - }); - }); - - it('displays correct page title', () => { - render(); - - expect(screen.getByText('Resgrid Dispatch')).toBeTruthy(); - }); - - it('has proper accessibility for username input', () => { - render(); - - const usernameInput = screen.getByTestId('username-input'); - expect(usernameInput.props.autoCapitalize).toBe('off'); - }); -}); diff --git a/src/app/login/index.tsx b/src/app/login/index.tsx index 9249d4b..94c8de1 100644 --- a/src/app/login/index.tsx +++ b/src/app/login/index.tsx @@ -78,7 +78,7 @@ export default function Login() { return ( <> - setShowServerUrl(true)} /> + setShowServerUrl(true)} onSsoPress={() => router.push('/login/sso' as any)} /> - {/* Server URL Button */} - StyleSheet.flatten([styles.serverUrlButton, isDark ? styles.serverUrlButtonDark : styles.serverUrlButtonLight, pressed ? styles.serverUrlButtonPressed : {}])} - onPress={() => setShowServerUrlModal(true)} - > - - {t('settings.server_url')} - + {/* Server URL and SSO Buttons */} + + + StyleSheet.flatten([styles.serverUrlButton, styles.actionButtonFlex, isDark ? styles.serverUrlButtonDark : styles.serverUrlButtonLight, pressed ? styles.serverUrlButtonPressed : {}]) + } + onPress={() => setShowServerUrlModal(true)} + > + + {t('settings.server_url')} + + + StyleSheet.flatten([styles.serverUrlButton, styles.actionButtonFlex, isDark ? styles.serverUrlButtonDark : styles.serverUrlButtonLight, pressed ? styles.serverUrlButtonPressed : {}]) + } + onPress={() => router.push('/login/sso' as any)} + > + {t('sso.sso_button')} + + {/* Footer */} @@ -868,6 +880,16 @@ const styles = StyleSheet.create({ }, // Server URL Button + actionButtonRow: { + flexDirection: 'row', + marginTop: 8, + }, + actionButtonFlex: { + flex: 1, + marginTop: 0, + marginLeft: 4, + marginRight: 4, + }, serverUrlButton: { flexDirection: 'row', alignItems: 'center', diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index dd0b328..35c3f13 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -40,9 +40,10 @@ export type LoginFormProps = { isLoading?: boolean; error?: string; onServerUrlPress?: () => void; + onSsoPress?: () => void; }; -export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = undefined, onServerUrlPress }: LoginFormProps) => { +export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = undefined, onServerUrlPress, onSsoPress }: LoginFormProps) => { const { colorScheme } = useColorScheme(); const { t } = useTranslation(); const { @@ -171,11 +172,18 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde )} - {onServerUrlPress && ( - - )} + + {onServerUrlPress ? ( + + ) : null} + {onSsoPress ? ( + + ) : null} + {/* Footer */} diff --git a/src/app/login/sso.tsx b/src/app/login/sso.tsx new file mode 100644 index 0000000..0f784dd --- /dev/null +++ b/src/app/login/sso.tsx @@ -0,0 +1,433 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import * as Linking from 'expo-linking'; +import { useRouter } from 'expo-router'; +import { AlertTriangle, ChevronLeft, ShieldCheck } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import { useCallback, useEffect, useState } from 'react'; +import type { SubmitHandler } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { Image, ScrollView } from 'react-native'; +import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; +import * as z from 'zod'; + +import { View } from '@/components/ui'; +import { Button, ButtonSpinner, ButtonText } from '@/components/ui/button'; +import { FormControl, FormControlError, FormControlErrorIcon, FormControlErrorText, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control'; +import { Input, InputField } from '@/components/ui/input'; +import { Text } from '@/components/ui/text'; +import colors from '@/constants/colors'; +import { useOidcLogin } from '@/hooks/use-oidc-login'; +import { useSamlLogin } from '@/hooks/use-saml-login'; +import type { AuthResponse, SsoConfig } from '@/lib/auth/types'; +import { logger } from '@/lib/logging'; +import { fetchSsoConfigForUser } from '@/services/sso-discovery'; +import useAuthStore from '@/stores/auth/store'; + +// --------------------------------------------------------------------------- +// OidcSignInSection — only mounted when a valid OIDC authority is available +// --------------------------------------------------------------------------- + +interface OidcSignInSectionProps { + authority: string; + clientId: string; + username: string; + departmentId?: number; + isAuthenticating: boolean; + onAuthStart: () => void; + onAuthEnd: () => void; + onTokenReceived: (authResponse: AuthResponse) => void; + onError: (msg: string) => void; +} + +function OidcSignInSection({ authority, clientId, username, departmentId, isAuthenticating, onAuthStart, onAuthEnd, onTokenReceived, onError }: OidcSignInSectionProps) { + const { t } = useTranslation(); + const { request, response, promptAsync, exchangeCodeForResgridToken } = useOidcLogin(authority, clientId, username, departmentId); + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + if (response?.type === 'success') { + (async () => { + onAuthStart(); + try { + const authResponse = await exchangeCodeForResgridToken(); + if (!authResponse) { + onError(t('sso.error_token_exchange')); + } else { + onTokenReceived(authResponse); + } + } catch (err) { + logger.error({ message: 'SSO OidcSignInSection: exchange failed', context: { err } }); + onError(t('sso.error_generic')); + } finally { + onAuthEnd(); + } + })(); + } else if (response?.type === 'error') { + onError(t('sso.error_oidc_cancelled')); + } + }, [response]); // eslint-disable-line react-hooks/exhaustive-deps + + const handlePress = async () => { + onAuthStart(); + try { + await promptAsync(); + } finally { + onAuthEnd(); + } + }; + + if (isAuthenticating) { + return ( + + ); + } + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// SamlSignInSection — only mounted when a valid SAML idpSsoUrl is available +// --------------------------------------------------------------------------- + +interface SamlSignInSectionProps { + idpSsoUrl: string; + username: string; + departmentId?: number; + isAuthenticating: boolean; + onAuthStart: () => void; + onAuthEnd: () => void; + onTokenReceived: (authResponse: AuthResponse) => void; + onError: (msg: string) => void; +} + +function SamlSignInSection({ idpSsoUrl, username, departmentId, isAuthenticating, onAuthStart, onAuthEnd, onTokenReceived, onError }: SamlSignInSectionProps) { + const { t } = useTranslation(); + const { startSamlLogin, handleSamlDeepLink } = useSamlLogin(idpSsoUrl, username, departmentId); + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + const processDeepLink = async (url: string) => { + if (url.includes('auth/callback') && url.includes('saml_response')) { + onAuthStart(); + try { + const authResponse = await handleSamlDeepLink(url); + if (!authResponse) { + onError(t('sso.error_token_exchange')); + } else { + onTokenReceived(authResponse); + } + } catch (err) { + logger.error({ message: 'SSO SamlSignInSection: deep link failed', context: { err } }); + onError(t('sso.error_generic')); + } finally { + onAuthEnd(); + } + } + }; + + // Handle cold-start: app opened directly via SAML redirect URL + Linking.getInitialURL().then((url) => { + if (url) processDeepLink(url); + }); + + const subscription = Linking.addEventListener('url', ({ url }) => { + processDeepLink(url); + }); + + return () => subscription.remove(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const handlePress = async () => { + onAuthStart(); + try { + await startSamlLogin(); + } finally { + onAuthEnd(); + } + }; + + if (isAuthenticating) { + return ( + + ); + } + + return ( + + ); +} + +// --------------------------------------------------------------------------- + +const ssoLookupSchema = z.object({ + username: z.string({ required_error: 'Username is required' }).min(3, 'Username must be at least 3 characters'), + departmentId: z + .string() + .optional() + .refine((val) => !val || /^\d+$/.test(val), 'Department ID must be a number'), +}); + +type SsoLookupFormType = z.infer; + +export default function SsoLoginScreen() { + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + const router = useRouter(); + + const [phase, setPhase] = useState<'lookup' | 'sso-options'>('lookup'); + const [ssoConfig, setSsoConfig] = useState(null); + const [isLookingUp, setIsLookingUp] = useState(false); + const [isAuthenticating, setIsAuthenticating] = useState(false); + const [lookupError, setLookupError] = useState(null); + const [authError, setAuthError] = useState(null); + const [resolvedUsername, setResolvedUsername] = useState(''); + const [resolvedDepartmentId, setResolvedDepartmentId] = useState(); + + const loginWithSso = useAuthStore((s) => s.loginWithSso); + const status = useAuthStore((s) => s.status); + + const { + control, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(ssoLookupSchema), + }); + + // Navigate to app once signed in + useEffect(() => { + if (status === 'signedIn') { + router.replace('/(app)' as any); + } + }, [status, router]); + + const handleTokenReceived = useCallback( + async (authResponse: AuthResponse) => { + try { + await loginWithSso(authResponse); + } catch (err) { + logger.error({ message: 'SSO: loginWithSso failed', context: { err } }); + setAuthError(t('sso.error_generic')); + } + }, + [loginWithSso, t] + ); + + const onLookup: SubmitHandler = async (data) => { + setLookupError(null); + setIsLookingUp(true); + + const parsedDeptId = data.departmentId ? parseInt(data.departmentId, 10) : undefined; + + try { + const config = await fetchSsoConfigForUser(data.username.trim(), parsedDeptId); + + if (!config) { + setLookupError(t('sso.error_user_not_found')); + return; + } + + if (!config.ssoEnabled) { + setLookupError(t('sso.error_sso_not_enabled')); + return; + } + + setResolvedUsername(data.username.trim()); + setResolvedDepartmentId(parsedDeptId); + setSsoConfig(config); + setPhase('sso-options'); + } catch (err) { + const message = err instanceof Error ? err.message : t('sso.error_lookup_failed'); + setLookupError(message); + } finally { + setIsLookingUp(false); + } + }; + + if (phase === 'lookup') { + return ( + + + + + + + + {t('sso.page_title')} + + {t('sso.page_subtitle')} + + + + + {t('login.username')} + + ( + + + + )} + /> + + + {errors.username?.message} + + + + + + + {t('sso.department_id_label')} + ({t('sso.optional')}) + + + ( + + + + )} + /> + + + {errors.departmentId?.message} + + + + {lookupError ? ( + + + {lookupError} + + ) : null} + + {isLookingUp ? ( + + ) : ( + + )} + + + + + + ); + } + + // Phase: sso-options + return ( + + + + + + + + {t('sso.sign_in_title')} + + {resolvedUsername} + {ssoConfig?.providerType === 'oidc' ? t('sso.provider_oidc') : t('sso.provider_saml')} + + + {authError ? ( + + + {authError} + + ) : null} + + {ssoConfig?.providerType === 'oidc' && ssoConfig.authority && ssoConfig.clientId ? ( + { + setIsAuthenticating(true); + setAuthError(null); + }} + onAuthEnd={() => setIsAuthenticating(false)} + onTokenReceived={handleTokenReceived} + onError={(msg) => { + setAuthError(msg); + setIsAuthenticating(false); + }} + /> + ) : null} + + {ssoConfig?.providerType === 'saml2' && ssoConfig.idpSsoUrl ? ( + { + setIsAuthenticating(true); + setAuthError(null); + }} + onAuthEnd={() => setIsAuthenticating(false)} + onTokenReceived={handleTokenReceived} + onError={(msg) => { + setAuthError(msg); + setIsAuthenticating(false); + }} + /> + ) : null} + + + + + + ); +} diff --git a/src/components/calls/call-card.web.tsx b/src/components/calls/call-card.web.tsx index ac18ab6..dde101d 100644 --- a/src/components/calls/call-card.web.tsx +++ b/src/components/calls/call-card.web.tsx @@ -92,7 +92,7 @@ export const CallCard: React.FC = ({ call, priority }) => { {/* Nature of Call - Use iframe for web instead of WebView */} {call.Nature ? ( - +