@controleonline/ui-common 1.2.97 → 1.2.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
1
  {
2
- "version": "1.2.93",
3
- "rc": 1,
4
- "branch": "rc/1.2.93-rc.1",
5
- "baseMaster": "b4db94eb1ccd3bf878dc96e3d32c0249aad07402",
6
- "tasks": [77],
7
- "repositories": { "ControleOnline/ui-common": "4dfdb2d7ccb160f265115bdad3125d6ecd26bdfd" },
8
- "compositionNotes": ["Verified on Android emulator: Text width was still 0 with flexBasis auto because Yoga prioritises flex over flexGrow/flexBasis. flex:0 fixes it."],
2
+ "version": "1.2.98",
3
+ "rc": 2,
4
+ "branch": "rc/1.2.98-rc.2",
5
+ "baseMaster": "aa5ad406d201d23bb88023bc4b7a7d4f9b11fd0a",
6
+ "tasks": [88],
7
+ "repositories": { "ControleOnline/ui-common": "0c96fc6ebac3af856cfba103fa0694171f590ce1" },
8
+ "compositionNotes": ["Task 88: rodapé mostra …últimos 2 blocos quando o identificador não é IPv4. rc.2 inclui bump de versão 1.2.98 para publicação npm; supersedes rc.1."],
9
9
  "frozen": true
10
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@controleonline/ui-common",
3
- "version": "1.2.97",
3
+ "version": "1.2.99",
4
4
  "author": "ControleOnline",
5
5
  "description": "ControleOnline Common Quasar UI Component",
6
6
  "license": "MIT",
package/src/api/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import {readStoredSessionToken} from '@controleonline/ui-common/src/utils/storedSessionToken';
1
2
  import myFetch from '@controleonline/ui-common/src/api/fetch';
2
3
  import axios from 'axios';
3
4
  import {env as APP_ENV} from '@env';
@@ -215,8 +216,8 @@ async function requestSmokeUrl(url, options) {
215
216
  export const api = {
216
217
  device: JSON.parse(localStorage.getItem('device') || '{}'),
217
218
  masterDevice: JSON.parse(localStorage.getItem('master-device') || '{}'),
218
-
219
-
219
+
220
+
220
221
  upload: async function (uri, formData) {
221
222
  const token = await this.getToken();
222
223
  const appDomain = resolveAppDomain(APP_ENV.DOMAIN);
@@ -242,8 +243,8 @@ export const api = {
242
243
  fetch: async function (uri, options = {}) {
243
244
  if (typeof options.headers === 'undefined')
244
245
  Object.assign(options, { headers: new Headers() });
245
-
246
- // Always refresh device from localStorage in case it was set after module load
246
+
247
+ // Always refresh device from localStorage in case it was set after module load
247
248
  try {
248
249
  this.device = JSON.parse(localStorage.getItem('device') || '{}');
249
250
  } catch (e) {
@@ -270,16 +271,16 @@ export const api = {
270
271
  if (options.body) {
271
272
  if (typeof options.body === 'object') {
272
273
  bodyDeviceId = options.body.device || null;
273
- } else if (typeof options.body === 'string') {
274
- try {
275
- const parsedBody = JSON.parse(options.body);
276
- bodyDeviceId = parsedBody?.device || null;
277
- } catch (e) {
278
- bodyDeviceId = null;
279
- }
280
- }
281
- }
282
-
274
+ } else if (typeof options.body === 'string') {
275
+ try {
276
+ const parsedBody = JSON.parse(options.body);
277
+ bodyDeviceId = parsedBody?.device || null;
278
+ } catch (e) {
279
+ bodyDeviceId = null;
280
+ }
281
+ }
282
+ }
283
+
283
284
  const requestDevice = resolveRequestDevice(
284
285
  this.masterDevice,
285
286
  this.device,
@@ -296,10 +297,10 @@ export const api = {
296
297
  options.headers.set('DEVICE-TYPE', requestDeviceType);
297
298
  }
298
299
  }
299
- if (options.responseType != 'text') {
300
- options.headers.set('Content-Type', MIME_TYPE);
301
- options.headers.set('Accept', MIME_TYPE);
302
- }
300
+ if (options.responseType != 'text') {
301
+ options.headers.set('Content-Type', MIME_TYPE);
302
+ options.headers.set('Accept', MIME_TYPE);
303
+ }
303
304
  options.headers.set('App-Domain', resolveAppDomain(APP_ENV.DOMAIN));
304
305
 
305
306
  if (options.body && typeof options.body != 'string') {
@@ -310,28 +311,13 @@ export const api = {
310
311
  if (options.params) {
311
312
  uri = this.buildQueryString(uri, options);
312
313
  }
313
- return myFetch(uri, options).catch(e => {
314
- throw e;
315
- });
316
- },
317
- async getToken() {
318
- const sessionString = localStorage.getItem('session');
319
- let session = null;
320
- if (sessionString) {
321
- try {
322
- const cleanString =
323
- typeof sessionString == 'string' &&
324
- sessionString.startsWith('__q_objt|')
325
- ? sessionString.substring('__q_objt|'.length)
326
- : sessionString;
327
- session = JSON.parse(cleanString); // Transforma a string em objeto
328
- } catch (e) {
329
- console.error('Failed to parse session from localStorage', e);
330
- }
331
- }
332
-
333
- return session?.token || session?.api_key;
334
- },
314
+ return myFetch(uri, options).catch(e => {
315
+ throw e;
316
+ });
317
+ },
318
+ async getToken() {
319
+ return readStoredSessionToken();
320
+ },
335
321
  serialize(obj, prefix = '') {
336
322
  const pairs = [];
337
323
 
@@ -344,12 +330,12 @@ export const api = {
344
330
 
345
331
  const value = obj[key];
346
332
  let fullKey = prefix ? `${prefix}.${key}` : key; // Usa ponto para objetos
347
-
348
- if (value === null || value === undefined) {
349
- pairs.push(`${fullKey}=`);
350
- } else if (typeof value === 'object' && !Array.isArray(value)) {
351
- // Objeto aninhado: chama serialize recursivamente com ponto
352
- pairs.push(...this.serialize(value, fullKey));
333
+
334
+ if (value === null || value === undefined) {
335
+ pairs.push(`${fullKey}=`);
336
+ } else if (typeof value === 'object' && !Array.isArray(value)) {
337
+ // Objeto aninhado: chama serialize recursivamente com ponto
338
+ pairs.push(...this.serialize(value, fullKey));
353
339
  } else if (Array.isArray(value)) {
354
340
  // Array: adiciona [] e itera sobre os valores
355
341
  if (value.length === 0) {
@@ -364,14 +350,14 @@ export const api = {
364
350
  });
365
351
  }
366
352
  } else {
367
- // Valor simples
368
- pairs.push(`${fullKey}=${encodeURIComponent(value)}`);
369
- }
370
- }
371
- }
372
- return pairs;
373
- },
374
-
353
+ // Valor simples
354
+ pairs.push(`${fullKey}=${encodeURIComponent(value)}`);
355
+ }
356
+ }
357
+ }
358
+ return pairs;
359
+ },
360
+
375
361
  buildQueryString(uri, options) {
376
362
  const safeUri = stripDomainQueryParams(uri);
377
363
 
@@ -38,6 +38,8 @@ export default function AnimatedModal({
38
38
  onRequestClose,
39
39
  children,
40
40
  style,
41
+ overlayColor,
42
+ contentStyle,
41
43
  }) {
42
44
  const [showing, setShowing] = useState(visible);
43
45
  const [keyboardHeight, setKeyboardHeight] = useState(0);
@@ -125,6 +127,8 @@ export default function AnimatedModal({
125
127
  const overlayStyle = [
126
128
  styles.overlay,
127
129
  style,
130
+ overlayColor,
131
+ contentStyle,
128
132
  Platform.OS === 'android' ? {paddingBottom: keyboardHeight} : null,
129
133
  ];
130
134
 
@@ -152,11 +156,12 @@ export default function AnimatedModal({
152
156
  style={[
153
157
  StyleSheet.absoluteFillObject,
154
158
  styles.overlayBg,
159
+ overlayColor ? {backgroundColor: overlayColor} : null,
155
160
  {opacity: overlayOpacity},
156
161
  ]}
157
162
  />
158
163
  <Animated.View
159
- style={[styles.content, {transform: [{translateY: slideY}]}]}>
164
+ style={[styles.content, contentStyle, {transform: [{translateY: slideY}]}]}>
160
165
  {normalizedChildren}
161
166
  </Animated.View>
162
167
  <Toasts
@@ -17,7 +17,8 @@ const BottomNavigationBar = ({
17
17
  colors = {},
18
18
  testID = 'bottom-navigation',
19
19
  useModernWebChromeProps = false,
20
- appearance = 'legacy',
20
+ appearance = 'soft',
21
+ placement = 'overlay',
21
22
  }) => {
22
23
  const Icon = appearance === 'soft' ? HomeIcon : LegacyIcon;
23
24
  colors = appearance === 'soft' ? resolveHomepagePalette(colors) : colors;
@@ -104,9 +105,7 @@ const BottomNavigationBar = ({
104
105
 
105
106
  const routeItems = Array.isArray(items) ? items : [];
106
107
  const knownRoute = routeItems.some(item => item?.route === activeRouteName);
107
- const effectiveActiveRoute = knownRoute
108
- ? activeRouteName
109
- : routeItems[0]?.route || '';
108
+ const effectiveActiveRoute = knownRoute ? activeRouteName : '';
110
109
 
111
110
  const navigateTo = item => {
112
111
  try {
@@ -134,7 +133,7 @@ const BottomNavigationBar = ({
134
133
  };
135
134
 
136
135
  return (
137
- <View {...hostProps} style={hostStyle}>
136
+ <View {...hostProps} style={[hostStyle, placement === 'inline' ? {position: 'relative', flexShrink: 0, zIndex: 0} : null]}>
138
137
  <View style={styles.stack}>
139
138
  <View style={styles.dock} testID={testID}>
140
139
  <View style={styles.itemsRow}>
@@ -151,6 +150,8 @@ const BottomNavigationBar = ({
151
150
  <Pressable
152
151
  key={item.route}
153
152
  accessibilityRole="button"
153
+ accessibilityLabel={item.label}
154
+ accessibilityState={{selected: isActive, disabled: Boolean(isDisabled)}}
154
155
  disabled={isDisabled}
155
156
  onPress={() => navigateTo(item)}
156
157
  style={({pressed}) => [
@@ -1,6 +1,6 @@
1
1
  import {Platform} from 'react-native';
2
2
 
3
- // Only the homepage opts into this chrome; preserve all default navigation styles.
3
+ // Shared compact chrome on every route; explicit legacy consumers can opt out.
4
4
  export default (styles, colors, appearance) => appearance !== 'soft' ? styles : {
5
5
  ...styles,
6
6
  dock: [styles.dock, {
@@ -16,9 +16,10 @@ export default (styles, colors, appearance) => appearance !== 'soft' ? styles :
16
16
  shadowOpacity: 0,
17
17
  ...(Platform.OS === 'web' ? {boxShadow: 'none'} : {}),
18
18
  }],
19
- itemsRow: [styles.itemsRow, {minHeight: 64, paddingHorizontal: 12, paddingVertical: 5}],
20
- item: [styles.item, {minHeight: 54, borderRadius: 12}],
21
- itemLabel: [styles.itemLabel, {fontSize: 11, fontWeight: '400'}],
19
+ itemsRow: [styles.itemsRow, {minHeight: 56, paddingHorizontal: 8, paddingVertical: 4}],
20
+ item: [styles.item, {minHeight: 46, paddingVertical: 3, borderRadius: 10}],
21
+ iconWrap: [styles.iconWrap, {width: 22, height: 22, marginBottom: 2}],
22
+ itemLabel: [styles.itemLabel, {fontSize: 11, lineHeight: 14, fontWeight: '400'}],
22
23
  footerSlot: [styles.footerSlot, {
23
24
  borderTopWidth: 1,
24
25
  borderColor: colors.navigationBorder,
@@ -1,45 +1,53 @@
1
- import React, {useEffect} from 'react';
2
- import {useStore} from '@store';
3
-
4
- const ProductCatalogCacheService = () => {
5
- const categoriesStore = useStore('categories');
6
- const productsStore = useStore('products');
7
-
8
- const categoryActions = categoriesStore.actions;
9
- const productActions = productsStore.actions;
10
-
11
- const {messages, message} = categoriesStore.getters;
12
-
13
- useEffect(() => {
14
- if (
15
- messages &&
16
- messages.length > 0 &&
17
- (!message || Object.keys(message).length === 0)
18
- ) {
19
- const nextMessages = [...messages];
20
- categoryActions.setMessage(nextMessages.pop());
21
- categoryActions.setMessages(nextMessages);
22
- }
23
- }, [categoryActions, message, messages]);
24
-
25
- useEffect(() => {
26
- if (!message || Object.keys(message).length === 0) {
27
- return;
28
- }
29
-
30
- if (message?.command !== 'clear-product-cache') {
31
- categoryActions.setMessage(null);
32
- return;
33
- }
34
-
35
- localStorage.setItem('categories', JSON.stringify([]));
36
- localStorage.removeItem('categories-company');
37
- categoryActions.setItems([]);
38
- productActions.setItems([]);
39
- categoryActions.setMessage(null);
40
- }, [categoryActions, message, productActions]);
41
-
42
- return null;
43
- };
44
-
45
- export default ProductCatalogCacheService;
1
+ import React, {useEffect} from 'react';
2
+ import {useStore} from '@store';
3
+ import {invalidateProductCatalogCache} from '../utils/productCatalogCacheRevision';
4
+
5
+ const ProductCatalogCacheService = () => {
6
+ const categoriesStore = useStore('categories');
7
+ const productsStore = useStore('products');
8
+
9
+ const categoryActions = categoriesStore.actions;
10
+ const productActions = productsStore.actions;
11
+
12
+ const {messages, message} = categoriesStore.getters;
13
+
14
+ useEffect(() => {
15
+ if (
16
+ messages &&
17
+ messages.length > 0 &&
18
+ (!message || Object.keys(message).length === 0)
19
+ ) {
20
+ const nextMessages = [...messages];
21
+ categoryActions.setMessage(nextMessages.pop());
22
+ categoryActions.setMessages(nextMessages);
23
+ }
24
+ }, [categoryActions, message, messages]);
25
+
26
+ useEffect(() => {
27
+ if (!message || Object.keys(message).length === 0) {
28
+ return;
29
+ }
30
+
31
+ if (message?.command !== 'clear-product-cache') {
32
+ categoryActions.setMessage(null);
33
+ return;
34
+ }
35
+
36
+ invalidateProductCatalogCache();
37
+ for (const context of ['products', 'supplies']) {
38
+ localStorage.removeItem(`categories:${context}`);
39
+ localStorage.removeItem(`categories-company:${context}`);
40
+ }
41
+ localStorage.setItem('categories', JSON.stringify([]));
42
+ localStorage.removeItem('categories-company');
43
+ categoryActions.setItems([]);
44
+ productActions.setItems([]);
45
+ categoryActions.setReload?.(true);
46
+ productActions.setReload?.(true);
47
+ categoryActions.setMessage(null);
48
+ }, [categoryActions, message, productActions]);
49
+
50
+ return null;
51
+ };
52
+
53
+ export default ProductCatalogCacheService;
@@ -19,7 +19,8 @@ const {
19
19
  } = require('@controleonline/ui-common/src/react/utils/bottomNavigationToolbar');
20
20
 
21
21
  const RuntimeBottomNavigationBar = ({
22
- appearance = 'legacy',
22
+ appearance = 'soft',
23
+ placement = 'overlay',
23
24
  activeRouteName: activeRouteNameProp,
24
25
  colors: colorsOverride,
25
26
  disabled: disabledOverride,
@@ -42,13 +43,16 @@ const RuntimeBottomNavigationBar = ({
42
43
  const peopleGetters = peopleStore?.getters || {};
43
44
  const currentCompany = peopleGetters.currentCompany || {};
44
45
  const themeColors = themeGetters.colors || {};
45
- const [runtimeMenus, setRuntimeMenus] = useState([]);
46
+ const [menuState, setMenuState] = useState({scope: '', menus: []});
46
47
  const preset = presetKey ? getBottomNavigationPreset(presetKey) : null;
47
48
  const routeAliases = routeAliasesProp || preset?.routeAliases || {};
48
49
  const appType = normalizeAppType(app_type);
50
+ const menuScope = JSON.stringify([appType, currentCompany?.id, menuType, presetKey]);
51
+ const runtimeMenus = menuState.scope === menuScope ? menuState.menus : [];
49
52
 
50
53
  // Always consult menus-people for role-aware toolbar; preset is fallback only.
51
54
  useEffect(() => {
55
+ const setRuntimeMenus = menus => setMenuState({scope: menuScope, menus});
52
56
  if (!currentCompany?.id || !menuType) {
53
57
  setRuntimeMenus(
54
58
  resolveToolbarRuntimeMenus({
@@ -105,7 +109,7 @@ const RuntimeBottomNavigationBar = ({
105
109
  return () => {
106
110
  cancelled = true;
107
111
  };
108
- }, [appType, currentCompany?.id, menuType, preset, presetKey]);
112
+ }, [appType, currentCompany?.id, menuType, preset, presetKey, menuScope]);
109
113
 
110
114
  const colors = useMemo(() => {
111
115
  if (colorsOverride) {
@@ -151,16 +155,19 @@ const RuntimeBottomNavigationBar = ({
151
155
  const knownRoute = navItems.some(item => item.route === resolvedActiveRoute);
152
156
  const effectiveActiveRoute = knownRoute
153
157
  ? resolvedActiveRoute
154
- : navItems[0]?.route || activeRouteName;
158
+ : activeRouteName;
155
159
 
156
160
  const isDisabled =
157
161
  typeof disabledOverride === 'boolean'
158
162
  ? disabledOverride
159
163
  : !currentCompany || Object.keys(currentCompany).length === 0;
160
164
 
165
+ if (!navItems.length) return null;
166
+
161
167
  return (
162
168
  <BottomNavigationBar
163
169
  appearance={appearance}
170
+ placement={placement}
164
171
  activeRouteName={effectiveActiveRoute}
165
172
  colors={colors}
166
173
  disabled={isDisabled}
@@ -1,3 +1,4 @@
1
+ import {readStoredSessionToken} from '../../utils/storedSessionToken';
1
2
  import React, {useEffect, useMemo, useState} from 'react';
2
3
  import {Image, Platform, StyleSheet, Text, View} from 'react-native';
3
4
  import {env as APP_ENV} from '@env';
@@ -14,18 +15,6 @@ const isBackendDownloadUrl = uri =>
14
15
  /** Session cache: gravatar URL → true (ok) | false (missing). Avoids repeat probes. */
15
16
  const gravatarProbeCache = new Map();
16
17
 
17
- const readSessionToken = () => {
18
- if (typeof localStorage === 'undefined' || !localStorage?.getItem) {
19
- return '';
20
- }
21
-
22
- try {
23
- const session = JSON.parse(localStorage.getItem('session') || '{}');
24
- return normalizeUrl(session?.api_key || session?.token);
25
- } catch {
26
- return '';
27
- }
28
- };
29
18
 
30
19
  /**
31
20
  * Probe Gravatar (d=404) via fetch BEFORE exposing URL to <Image>.
@@ -86,7 +75,7 @@ const useDisplayUri = uri => {
86
75
  return;
87
76
  }
88
77
 
89
- const token = readSessionToken();
78
+ const token = readStoredSessionToken();
90
79
 
91
80
  // Authenticated backend download on web
92
81
  if (isBackendDownloadUrl(next) && Platform.OS === 'web' && token) {
@@ -0,0 +1,49 @@
1
+ import {useEffect, useRef, useState} from 'react';
2
+ import {Platform} from 'react-native';
3
+
4
+ const nativeStorage = () => {
5
+ const module = require('@react-native-async-storage/async-storage');
6
+ return module.default || module;
7
+ };
8
+ const deviceStorage = {
9
+ getItem: key => Platform.OS === 'web' ? globalThis.localStorage?.getItem(key) : nativeStorage().getItem(key),
10
+ setItem: (key, value) => Platform.OS === 'web' ? globalThis.localStorage.setItem(key, value) : nativeStorage().setItem(key, value),
11
+ };
12
+
13
+ // Visibility is personal device state. It never changes server menu permissions.
14
+ export default function useBottomNavigationPreference({enabled, principal, appType, storage = deviceStorage}) {
15
+ const key = enabled && principal && appType
16
+ ? `co.bottom-navigation.v1:${encodeURIComponent(appType)}:${encodeURIComponent(principal)}` : '';
17
+ const current = useRef(key);
18
+ current.current = key;
19
+ const [state, setState] = useState({key: '', ready: false, remembered: true, session: null, error: ''});
20
+ useEffect(() => {
21
+ current.current = key;
22
+ let cancelled = false;
23
+ setState({key, ready: false, remembered: true, session: null, error: ''});
24
+ if (key) Promise.resolve().then(() => storage.getItem(key)).then(value => {
25
+ if (!cancelled) setState({key, ready: true, remembered: value !== 'hidden', session: null, error: ''});
26
+ }).catch(() => {
27
+ if (!cancelled) setState({key, ready: true, remembered: true, session: null, error: 'Não foi possível recuperar a preferência da barra.'});
28
+ });
29
+ return () => {cancelled = true; if (current.current === key) current.current = null;};
30
+ }, [key, storage]);
31
+ const ready = Boolean(key && state.key === key && state.ready);
32
+ const setSessionVisible = visible => {
33
+ if (ready) setState(previous => ({...previous, session: Boolean(visible), error: ''}));
34
+ };
35
+ const setRememberedVisible = async visible => {
36
+ if (!ready) return false;
37
+ try {
38
+ await storage.setItem(key, visible ? 'visible' : 'hidden');
39
+ if (current.current !== key) return false;
40
+ setState(previous => ({...previous, remembered: Boolean(visible), session: null, error: ''}));
41
+ return true;
42
+ } catch {
43
+ if (current.current === key) setState(previous => ({...previous, error: 'Não foi possível lembrar a preferência neste aparelho.'}));
44
+ return false;
45
+ }
46
+ };
47
+ return {ready, visible: ready && (state.session ?? state.remembered), rememberedVisible: ready ? state.remembered : true,
48
+ error: state.key === key ? state.error : '', setSessionVisible, setRememberedVisible};
49
+ }
@@ -24,7 +24,7 @@ import {
24
24
  parseConfigsObject,
25
25
  tt,
26
26
  } from './deviceListHelpers';
27
- import styles from '../../Devices.styles';
27
+ import legacyStyles from '../../Devices.styles';
28
28
 
29
29
  /**
30
30
  * Normalize DefaultTable card `item` into a device group shape.
@@ -65,6 +65,7 @@ const normalizeDeviceGroup = item => {
65
65
  export default function DeviceGroupCard({
66
66
  item,
67
67
  brandColors,
68
+ themedStyles = null,
68
69
  creatingPdv,
69
70
  goToDetail,
70
71
  handleCreateCurrentPdv,
@@ -74,6 +75,7 @@ export default function DeviceGroupCard({
74
75
  runtimeDeviceType,
75
76
  showCurrentPdvSetup,
76
77
  }) {
78
+ const styles = themedStyles || legacyStyles;
77
79
  const deviceGroup = normalizeDeviceGroup(item);
78
80
  if (!deviceGroup) {
79
81
  return null;
@@ -116,7 +118,7 @@ export default function DeviceGroupCard({
116
118
  deviceGroup.device?.id ||
117
119
  deviceGroup.key,
118
120
  );
119
- const accent = getDeviceTypeAccent(normalizedType);
121
+ const accent = brandColors.deviceAccent || getDeviceTypeAccent(normalizedType);
120
122
  const metaChips = [];
121
123
 
122
124
  if (isPdv && primaryConfig) {
@@ -196,7 +198,7 @@ export default function DeviceGroupCard({
196
198
  <View style={styles.deviceConfigRow}>
197
199
  {deviceConfigs.map(deviceConfig => {
198
200
  const configType = getDeviceConfigType(deviceConfig);
199
- const configAccent = getDeviceTypeAccent(configType);
201
+ const configAccent = brandColors.deviceAccent || getDeviceTypeAccent(configType);
200
202
  const isSessionConfig =
201
203
  isCurrentDevice &&
202
204
  runtimeDeviceType &&
@@ -297,5 +299,5 @@ export default function DeviceGroupCard({
297
299
  </View>
298
300
  </View>
299
301
  );
300
-
302
+
301
303
  }
@@ -36,7 +36,7 @@ import {
36
36
  prioritizeCurrentDeviceGroups,
37
37
  readStoredRuntimeDevice,
38
38
  } from '../currentDevice';
39
- import styles from '../../Devices.styles';
39
+ import legacyStyles, {createStyles} from '../../Devices.styles';
40
40
  import DeviceGroupCard from './DeviceGroupCard';
41
41
  import {useDeviceNetworkConnectivity} from './useDeviceNetworkConnectivity';
42
42
  import {
@@ -89,7 +89,7 @@ export const createDeviceTypeTab = ({
89
89
  clientFilter = null,
90
90
  offerCurrentPdvSetup = false,
91
91
  }) => {
92
- const DeviceTypeTab = () => {
92
+ const DeviceTypeTab = ({themePalette = null} = {}) => {
93
93
  const navigation = useNavigation();
94
94
  const peopleStore = useStore('people');
95
95
  const deviceStore = useStore('device');
@@ -101,13 +101,15 @@ export const createDeviceTypeTab = ({
101
101
 
102
102
  const brandColors = useMemo(
103
103
  () =>
104
- resolveThemePalette(
104
+ themePalette || resolveThemePalette(
105
105
  {...themeColors, ...(currentCompany?.theme?.colors || {})},
106
106
  colors,
107
107
  ),
108
- [themeColors, currentCompany?.id],
108
+ [themePalette, themeColors, currentCompany?.id],
109
109
  );
110
110
 
111
+ const styles = useMemo(() => themePalette ? createStyles(brandColors) : legacyStyles, [themePalette, brandColors]);
112
+
111
113
  const [deviceConfigs, setDeviceConfigs] = useState([]);
112
114
  const [loading, setLoading] = useState(false);
113
115
  const [refreshing, setRefreshing] = useState(false);
@@ -372,6 +374,7 @@ export const createDeviceTypeTab = ({
372
374
  <DeviceGroupCard
373
375
  {...cardProps}
374
376
  brandColors={brandColors}
377
+ themedStyles={themePalette ? styles : null}
375
378
  creatingPdv={creatingPdv}
376
379
  goToDetail={goToDetail}
377
380
  handleCreateCurrentPdv={handleCreateCurrentPdv}
@@ -384,6 +387,8 @@ export const createDeviceTypeTab = ({
384
387
  ),
385
388
  [
386
389
  brandColors,
390
+ styles,
391
+ themePalette,
387
392
  creatingPdv,
388
393
  goToDetail,
389
394
  handleCreateCurrentPdv,
@@ -1,183 +1,184 @@
1
- import React, {useEffect, useMemo, useState} from 'react';
2
- import {Text, TouchableOpacity, View} from 'react-native';
3
- import {SafeAreaView} from 'react-native-safe-area-context';
4
- import {useNavigation} from '@react-navigation/native';
5
- import {useStore} from '@store';
6
- import {resolveThemePalette} from '@controleonline/../../src/styles/branding';
7
- import {colors} from '@controleonline/../../src/styles/colors';
8
- import CompactFilterSelector from '@controleonline/ui-default/src/react/components/filters/CompactFilterSelector';
9
- import {PDV_DEVICE_TYPE} from '@controleonline/ui-common/src/react/utils/printerDevices';
10
- import Icon from 'react-native-vector-icons/Feather';
11
- import {
12
- ALL_DEVICE_FILTER,
13
- getDeviceCreationAction,
14
- getDeviceFilterAccent,
15
- getDeviceFilterDefinition,
16
- getDeviceFilterIcon,
17
- getDeviceFilterOptions,
18
- getPdvSubfilterOptions,
19
- hasRegisteredDeviceFilter,
20
- isPdvSubfilter,
21
- } from './Devices/deviceTypes';
22
- import { createStyles } from './Devices.styles';
23
-
24
- const tt = (type, key) => global.t?.t('configs', type, key);
25
-
26
- const Devices = () => {
27
- const navigation = useNavigation();
28
- const peopleStore = useStore('people');
29
- const themeStore = useStore('theme');
30
-
31
- const {currentCompany} = peopleStore.getters;
32
- const {colors: themeColors} = themeStore.getters;
33
-
34
- const [activeFilter, setActiveFilter] = useState(ALL_DEVICE_FILTER);
35
-
36
- const brandColors = useMemo(
37
- () =>
38
- resolveThemePalette(
39
- {...themeColors, ...(currentCompany?.theme?.colors || {})},
40
- colors,
41
- ),
42
- [themeColors, currentCompany?.id],
43
- );
44
- const styles = useMemo(() => createStyles(brandColors), [brandColors]);
45
- const accentPalette = useMemo(
46
- () => ({
47
- primary: brandColors.info,
48
- success: brandColors.success,
49
- danger: brandColors.error,
50
- warning: brandColors.warning,
51
- }),
52
- [brandColors.error, brandColors.info, brandColors.success, brandColors.warning],
53
- );
54
-
55
- const filterOptions = useMemo(() => getDeviceFilterOptions(), []);
56
- const pdvSubfilterOptions = useMemo(() => getPdvSubfilterOptions(), []);
57
- const activeFilterDefinition = useMemo(
58
- () => getDeviceFilterDefinition(activeFilter),
59
- [activeFilter],
60
- );
61
- const topLevelActiveFilter = useMemo(
62
- () => (isPdvSubfilter(activeFilter) ? PDV_DEVICE_TYPE : activeFilter),
63
- [activeFilter],
64
- );
65
- const activeFilterAction = useMemo(
66
- () => getDeviceCreationAction(topLevelActiveFilter),
67
- [topLevelActiveFilter],
68
- );
69
- const deviceTypeSelectorOptions = useMemo(
70
- () => filterOptions.map(option => ({
71
- key: option.key,
72
- label: option.label,
73
- })),
74
- [filterOptions],
75
- );
76
- const pdvGatewaySelectorOptions = useMemo(
77
- () => [
78
- {key: PDV_DEVICE_TYPE, label: tt('device_filter', 'all') || 'Todos'},
79
- ...pdvSubfilterOptions.map(option => ({
80
- key: option.key,
81
- label: option.label,
82
- })),
83
- ],
84
- [pdvSubfilterOptions],
85
- );
86
- const selectedDeviceTypeLabel = useMemo(
87
- () =>
88
- deviceTypeSelectorOptions.find(option => option.key === topLevelActiveFilter)?.label ||
89
- tt('device_filter', 'allTypes') ||
90
- 'Todos os tipos',
91
- [deviceTypeSelectorOptions, topLevelActiveFilter],
92
- );
93
- const selectedPdvGatewayLabel = useMemo(
94
- () =>
95
- pdvGatewaySelectorOptions.find(option => option.key === activeFilter)?.label ||
96
- tt('device_filter', 'all') ||
97
- 'Todos',
98
- [activeFilter, pdvGatewaySelectorOptions],
99
- );
100
- const ActiveTabComponent = activeFilterDefinition.TabComponent;
101
-
102
- useEffect(() => {
103
- if (!hasRegisteredDeviceFilter(activeFilter)) {
104
- setActiveFilter(ALL_DEVICE_FILTER);
105
- }
106
- }, [activeFilter]);
107
-
108
- return (
109
- <SafeAreaView
110
- style={[styles.container, {backgroundColor: brandColors.background}]}>
111
- <View style={styles.filtersBlock}>
112
- <Text style={styles.filtersLabel}>{tt('device_filter', 'filterByType') || 'Filtrar por tipo'}</Text>
113
- <CompactFilterSelector
114
- icon={getDeviceFilterIcon(topLevelActiveFilter)}
115
- label={selectedDeviceTypeLabel}
116
- title={tt('device_filter', 'filterByType') || 'Filtrar por tipo'}
117
- accentColor={getDeviceFilterAccent(topLevelActiveFilter, {
118
- brandColors,
119
- hex: accentPalette,
120
- })}
121
- active={topLevelActiveFilter !== ALL_DEVICE_FILTER}
122
- options={deviceTypeSelectorOptions}
123
- selectedKey={topLevelActiveFilter}
124
- onSelect={optionKey => {
125
- setActiveFilter(optionKey);
126
- return true;
127
- }}
128
- />
129
- </View>
130
-
131
- {topLevelActiveFilter === PDV_DEVICE_TYPE && pdvSubfilterOptions.length > 0 ? (
132
- <View style={styles.filtersBlock}>
133
- <Text style={styles.filtersLabel}>{tt('device_filter', 'pdvGateway') || 'Gateway do PDV'}</Text>
134
- <CompactFilterSelector
135
- icon={getDeviceFilterIcon(activeFilter)}
136
- label={selectedPdvGatewayLabel}
137
- title={tt('device_filter', 'pdvGateway') || 'Gateway do PDV'}
138
- accentColor={getDeviceFilterAccent(activeFilter, {
139
- brandColors,
140
- hex: accentPalette,
141
- })}
142
- active={activeFilter !== PDV_DEVICE_TYPE}
143
- options={pdvGatewaySelectorOptions}
144
- selectedKey={activeFilter}
145
- onSelect={optionKey => {
146
- setActiveFilter(optionKey);
147
- return true;
148
- }}
149
- />
150
- </View>
151
- ) : null}
152
-
153
- {activeFilterAction ? (
154
- <View style={styles.printerActionBlock}>
155
- <TouchableOpacity
156
- style={[
157
- styles.createPrinterBtn,
158
- {backgroundColor: brandColors.primary},
159
- ]}
160
- activeOpacity={0.86}
161
- onPress={() => navigation.navigate(activeFilterAction.routeName)}>
162
- <Icon name="plus-circle" size={16} color={brandColors.white} />
163
- <Text style={styles.createPrinterBtnText}>
164
- {activeFilterAction.label}
165
- </Text>
166
- </TouchableOpacity>
167
-
168
- <Text style={styles.helperText}>
169
- {activeFilterAction.helperText}
170
- </Text>
171
- </View>
172
- ) : null}
173
-
174
- {ActiveTabComponent ? (
175
- <ActiveTabComponent
176
- key={`${activeFilter}-${currentCompany?.id || 'company'}`}
177
- />
178
- ) : null}
179
- </SafeAreaView>
180
- );
181
- };
182
-
183
- export default Devices;
1
+ import React, {useEffect, useMemo, useState} from 'react';
2
+ import {Text, TouchableOpacity, View} from 'react-native';
3
+ import {SafeAreaView} from 'react-native-safe-area-context';
4
+ import {useNavigation} from '@react-navigation/native';
5
+ import {useStore} from '@store';
6
+ import {resolveThemePalette} from '@controleonline/../../src/styles/branding';
7
+ import {colors} from '@controleonline/../../src/styles/colors';
8
+ import CompactFilterSelector from '@controleonline/ui-default/src/react/components/filters/CompactFilterSelector';
9
+ import {PDV_DEVICE_TYPE} from '@controleonline/ui-common/src/react/utils/printerDevices';
10
+ import Icon from 'react-native-vector-icons/Feather';
11
+ import {
12
+ ALL_DEVICE_FILTER,
13
+ getDeviceCreationAction,
14
+ getDeviceFilterAccent,
15
+ getDeviceFilterDefinition,
16
+ getDeviceFilterIcon,
17
+ getDeviceFilterOptions,
18
+ getPdvSubfilterOptions,
19
+ hasRegisteredDeviceFilter,
20
+ isPdvSubfilter,
21
+ } from './Devices/deviceTypes';
22
+ import { createStyles } from './Devices.styles';
23
+
24
+ const tt = (type, key) => global.t?.t('configs', type, key);
25
+
26
+ const Devices = ({themePalette = null} = {}) => {
27
+ const navigation = useNavigation();
28
+ const peopleStore = useStore('people');
29
+ const themeStore = useStore('theme');
30
+
31
+ const {currentCompany} = peopleStore.getters;
32
+ const {colors: themeColors} = themeStore.getters;
33
+
34
+ const [activeFilter, setActiveFilter] = useState(ALL_DEVICE_FILTER);
35
+
36
+ const brandColors = useMemo(
37
+ () =>
38
+ themePalette || resolveThemePalette(
39
+ {...themeColors, ...(currentCompany?.theme?.colors || {})},
40
+ colors,
41
+ ),
42
+ [themePalette, themeColors, currentCompany?.id],
43
+ );
44
+ const styles = useMemo(() => createStyles(brandColors), [brandColors]);
45
+ const accentPalette = useMemo(
46
+ () => ({
47
+ primary: brandColors.info,
48
+ success: brandColors.success,
49
+ danger: brandColors.error,
50
+ warning: brandColors.warning,
51
+ }),
52
+ [brandColors.error, brandColors.info, brandColors.success, brandColors.warning],
53
+ );
54
+
55
+ const filterOptions = useMemo(() => getDeviceFilterOptions(), []);
56
+ const pdvSubfilterOptions = useMemo(() => getPdvSubfilterOptions(), []);
57
+ const activeFilterDefinition = useMemo(
58
+ () => getDeviceFilterDefinition(activeFilter),
59
+ [activeFilter],
60
+ );
61
+ const topLevelActiveFilter = useMemo(
62
+ () => (isPdvSubfilter(activeFilter) ? PDV_DEVICE_TYPE : activeFilter),
63
+ [activeFilter],
64
+ );
65
+ const activeFilterAction = useMemo(
66
+ () => getDeviceCreationAction(topLevelActiveFilter),
67
+ [topLevelActiveFilter],
68
+ );
69
+ const deviceTypeSelectorOptions = useMemo(
70
+ () => filterOptions.map(option => ({
71
+ key: option.key,
72
+ label: option.label,
73
+ })),
74
+ [filterOptions],
75
+ );
76
+ const pdvGatewaySelectorOptions = useMemo(
77
+ () => [
78
+ {key: PDV_DEVICE_TYPE, label: tt('device_filter', 'all') || 'Todos'},
79
+ ...pdvSubfilterOptions.map(option => ({
80
+ key: option.key,
81
+ label: option.label,
82
+ })),
83
+ ],
84
+ [pdvSubfilterOptions],
85
+ );
86
+ const selectedDeviceTypeLabel = useMemo(
87
+ () =>
88
+ deviceTypeSelectorOptions.find(option => option.key === topLevelActiveFilter)?.label ||
89
+ tt('device_filter', 'allTypes') ||
90
+ 'Todos os tipos',
91
+ [deviceTypeSelectorOptions, topLevelActiveFilter],
92
+ );
93
+ const selectedPdvGatewayLabel = useMemo(
94
+ () =>
95
+ pdvGatewaySelectorOptions.find(option => option.key === activeFilter)?.label ||
96
+ tt('device_filter', 'all') ||
97
+ 'Todos',
98
+ [activeFilter, pdvGatewaySelectorOptions],
99
+ );
100
+ const ActiveTabComponent = activeFilterDefinition.TabComponent;
101
+
102
+ useEffect(() => {
103
+ if (!hasRegisteredDeviceFilter(activeFilter)) {
104
+ setActiveFilter(ALL_DEVICE_FILTER);
105
+ }
106
+ }, [activeFilter]);
107
+
108
+ return (
109
+ <SafeAreaView
110
+ style={[styles.container, {backgroundColor: brandColors.background}]}>
111
+ <View style={styles.filtersBlock}>
112
+ <Text style={styles.filtersLabel}>{tt('device_filter', 'filterByType') || 'Filtrar por tipo'}</Text>
113
+ <CompactFilterSelector
114
+ icon={getDeviceFilterIcon(topLevelActiveFilter)}
115
+ label={selectedDeviceTypeLabel}
116
+ title={tt('device_filter', 'filterByType') || 'Filtrar por tipo'}
117
+ accentColor={getDeviceFilterAccent(topLevelActiveFilter, {
118
+ brandColors,
119
+ hex: accentPalette,
120
+ })}
121
+ active={topLevelActiveFilter !== ALL_DEVICE_FILTER}
122
+ options={deviceTypeSelectorOptions}
123
+ selectedKey={topLevelActiveFilter}
124
+ onSelect={optionKey => {
125
+ setActiveFilter(optionKey);
126
+ return true;
127
+ }}
128
+ />
129
+ </View>
130
+
131
+ {topLevelActiveFilter === PDV_DEVICE_TYPE && pdvSubfilterOptions.length > 0 ? (
132
+ <View style={styles.filtersBlock}>
133
+ <Text style={styles.filtersLabel}>{tt('device_filter', 'pdvGateway') || 'Gateway do PDV'}</Text>
134
+ <CompactFilterSelector
135
+ icon={getDeviceFilterIcon(activeFilter)}
136
+ label={selectedPdvGatewayLabel}
137
+ title={tt('device_filter', 'pdvGateway') || 'Gateway do PDV'}
138
+ accentColor={getDeviceFilterAccent(activeFilter, {
139
+ brandColors,
140
+ hex: accentPalette,
141
+ })}
142
+ active={activeFilter !== PDV_DEVICE_TYPE}
143
+ options={pdvGatewaySelectorOptions}
144
+ selectedKey={activeFilter}
145
+ onSelect={optionKey => {
146
+ setActiveFilter(optionKey);
147
+ return true;
148
+ }}
149
+ />
150
+ </View>
151
+ ) : null}
152
+
153
+ {activeFilterAction ? (
154
+ <View style={styles.printerActionBlock}>
155
+ <TouchableOpacity
156
+ style={[
157
+ styles.createPrinterBtn,
158
+ {backgroundColor: brandColors.primary},
159
+ ]}
160
+ activeOpacity={0.86}
161
+ onPress={() => navigation.navigate(activeFilterAction.routeName)}>
162
+ <Icon name="plus-circle" size={16} color={brandColors.white} />
163
+ <Text style={styles.createPrinterBtnText}>
164
+ {activeFilterAction.label}
165
+ </Text>
166
+ </TouchableOpacity>
167
+
168
+ <Text style={styles.helperText}>
169
+ {activeFilterAction.helperText}
170
+ </Text>
171
+ </View>
172
+ ) : null}
173
+
174
+ {ActiveTabComponent ? (
175
+ <ActiveTabComponent
176
+ themePalette={themePalette}
177
+ key={`${activeFilter}-${currentCompany?.id || 'company'}`}
178
+ />
179
+ ) : null}
180
+ </SafeAreaView>
181
+ );
182
+ };
183
+
184
+ export default Devices;
@@ -0,0 +1,13 @@
1
+ import eventBus from '../components/EventBus';
2
+
3
+ const key = 'product-catalog-cache-revision';
4
+ let revision = '0';
5
+ let sequence = 0;
6
+ export const getProductCatalogCacheRevision = () => {
7
+ try { return localStorage.getItem(key) || revision; } catch { return revision; }
8
+ };
9
+ export const invalidateProductCatalogCache = () => {
10
+ revision = `${Date.now()}:${++sequence}`;
11
+ try { localStorage.setItem(key, revision); } catch { /* In-memory invalidation still applies. */ }
12
+ eventBus.emit('product-catalog-cache:cleared');
13
+ };
@@ -128,6 +128,26 @@ const formatRuntimeFooterVersion = version => {
128
128
  return normalizedVersion ? `v${normalizedVersion}` : '';
129
129
  };
130
130
 
131
+ const IPV4_PATTERN = /^(\d{1,3}\.){3}\d{1,3}$/;
132
+
133
+ // IPv4 is shown in full; other ':'-separated ids (IPv6/MAC-like) are shortened
134
+ // to an ellipsis plus the last two blocks, e.g. "…46f4:eb0b".
135
+ const formatRuntimeFooterIdentifier = value => {
136
+ const identifier = safeTrim(value);
137
+
138
+ if (!identifier || IPV4_PATTERN.test(identifier) || !identifier.includes(':')) {
139
+ return identifier;
140
+ }
141
+
142
+ const blocks = identifier.split(':').filter(Boolean);
143
+
144
+ if (blocks.length <= 2) {
145
+ return identifier;
146
+ }
147
+
148
+ return `…${blocks.slice(-2).join(':')}`;
149
+ };
150
+
131
151
  const getRuntimeFooterWebHost = () => {
132
152
  const hostname = safeTrim(globalThis?.location?.hostname);
133
153
 
@@ -364,9 +384,9 @@ const getRuntimeFooterDebugInfo = ({device, appVersion, deviceConfig}) => {
364
384
  const nativeIdentifier = nativeIdentifierCandidates[0]?.value || '';
365
385
  const runtimeDetail = operationModeLabel
366
386
  ? operationModeLabel
367
- : isWebRuntime
368
- ? webIdentifier
369
- : nativeIdentifier;
387
+ : formatRuntimeFooterIdentifier(
388
+ isWebRuntime ? webIdentifier : nativeIdentifier,
389
+ );
370
390
  const runtimeDetailSource = operationModeLabel
371
391
  ? 'device_config.configs.pos-operation-mode'
372
392
  : isWebRuntime
@@ -444,6 +464,7 @@ module.exports = {
444
464
  getRuntimeFooterStoredVersion,
445
465
  getRuntimeFooterTextLines,
446
466
  getRuntimeFooterVersionCandidates,
467
+ formatRuntimeFooterIdentifier,
447
468
  getRuntimeFooterWebIdentifierCandidates,
448
469
  getRuntimeFooterText,
449
470
  getRuntimeFooterWebHost,
@@ -2,6 +2,7 @@ const assert = require('node:assert/strict')
2
2
  const {after, before, test} = require('node:test')
3
3
 
4
4
  const {
5
+ formatRuntimeFooterIdentifier,
5
6
  getRuntimeFooterDebugInfo,
6
7
  getRuntimeFooterNativeIdentifierCandidates,
7
8
  getRuntimeFooterPrimaryText,
@@ -487,3 +488,28 @@ test('hides runtime device information on the customer paylist only', () => {
487
488
  assert.equal(shouldShowRuntimeFooter('LoginPage'), true)
488
489
  assert.equal(shouldShowRuntimeFooter('WalletsPage'), true)
489
490
  })
491
+
492
+ test('formats runtime identifier: IPv4 full, non-IPv4 shortened to last two blocks', () => {
493
+ assert.equal(formatRuntimeFooterIdentifier('179.110.95.138'), '179.110.95.138')
494
+ assert.equal(
495
+ formatRuntimeFooterIdentifier('2804:18:1119:f433:54b4:a587:46f4:eb0b'),
496
+ '…46f4:eb0b',
497
+ )
498
+ assert.equal(formatRuntimeFooterIdentifier('aa:bb:cc:dd:ee:ff'), '…ee:ff')
499
+ assert.equal(formatRuntimeFooterIdentifier('::1'), '::1')
500
+ assert.equal(formatRuntimeFooterIdentifier('abc'), 'abc')
501
+ assert.equal(formatRuntimeFooterIdentifier(''), '')
502
+ assert.equal(formatRuntimeFooterIdentifier(null), '')
503
+ })
504
+
505
+ test('primary text shortens IPv6 public ip', () => {
506
+ const text = getRuntimeFooterPrimaryText({
507
+ device: {
508
+ type: 'PDV',
509
+ systemName: 'web',
510
+ metadata: {network: {publicIp: '2804:18:1119:f433:54b4:a587:46f4:eb0b'}},
511
+ },
512
+ appVersion: '1.10.68',
513
+ })
514
+ assert.ok(text.includes('(…46f4:eb0b)'), text)
515
+ })
@@ -0,0 +1,22 @@
1
+ const assert = require('node:assert/strict');
2
+ const {test} = require('node:test');
3
+ const {readStoredSessionToken} = require('../../../utils/storedSessionToken');
4
+
5
+ for (const prefix of ['', '__q_objt|']) {
6
+ for (const field of ['token', 'api_key']) {
7
+ test(`reads ${field} from ${prefix ? 'wrapped' : 'plain'} session`, () => {
8
+ const storage = {getItem: key => key === 'session' ? prefix + JSON.stringify({[field]: 'synthetic-token'}) : null};
9
+ assert.equal(readStoredSessionToken(storage), 'synthetic-token');
10
+ });
11
+ }
12
+ }
13
+ test('prefers the canonical token field when both are present', () => {
14
+ assert.equal(readStoredSessionToken({getItem: () => JSON.stringify({token: 'current', api_key: 'old'})}), 'current');
15
+ });
16
+ test('missing, malformed and inaccessible sessions have no token', () => {
17
+ for (const raw of [null, '', 'broken', '__q_objt|broken', '{}', 'null']) {
18
+ assert.equal(readStoredSessionToken({getItem: () => raw}), '');
19
+ }
20
+ assert.equal(readStoredSessionToken({getItem: () => {throw new Error('unavailable');}}), '');
21
+ assert.equal(readStoredSessionToken(null), '');
22
+ });
@@ -0,0 +1,18 @@
1
+ // Quasar LocalStorage serializes objects with this marker; plain JSON is also used.
2
+ const OBJECT_PREFIX = '__q_objt|';
3
+
4
+ const readStoredSessionToken = (storage = typeof localStorage === 'undefined' ? null : localStorage) => {
5
+ try {
6
+ const raw = storage?.getItem?.('session');
7
+ if (!raw) return '';
8
+ const json = typeof raw === 'string' && raw.startsWith(OBJECT_PREFIX)
9
+ ? raw.slice(OBJECT_PREFIX.length)
10
+ : raw;
11
+ const session = JSON.parse(json);
12
+ return session?.token || session?.api_key || '';
13
+ } catch {
14
+ return '';
15
+ }
16
+ };
17
+
18
+ module.exports = {readStoredSessionToken};
@@ -1,16 +0,0 @@
1
- # Homepage ADMIN/MANAGER — app-community#1056
2
-
3
- Soft appearance is opt-in for HomePage only. Existing menu routes/parameters, permissions, stores and translations are preserved. App-provided homepage colors and components are used only in this appearance. Default consumers retain their current behavior.
4
-
5
- Source: https://github.com/ControleOnline/app-community/issues/1056
6
- Local evidence: 40 homepage tests and 34 runtime footer/zoom tests passed. No production authorization.
7
-
8
- ## Web login translation bootstrap
9
-
10
- Keep the navigation tree mounted while translations initialize. Hide the shell and disable pointer/accessibility interaction until the existing translation readiness contract succeeds. Unmounting navigation during login resets its route and repeatedly mounts/registers the bottom bar, causing React maximum update depth. No route, permission, translation source, theme or zoom changes. Native behavior is unchanged.
11
-
12
- Regression evidence: app-community homepage bootstrapNavigation.test.js reproduced maximum update depth before the fix, then passed after it. 44 tests across 9 homepage suites passed. Runtime Dev reproduced the unfixed bug; new runtime verification is required after consuming ui-common 1.2.96.
13
-
14
- Release 1.2.96 uses the task-only c91510d source plus version metadata. The integration merge into dev preserves independent team work, but those changes are excluded from this homepage package. Publication uses the existing tag-triggered trusted publishing workflow.
15
-
16
- Release 1.2.97 completes the web bootstrap gate with visibility:hidden and aria-hidden: pending descendants cannot receive keyboard focus while navigation stays mounted. Native behavior remains unchanged.