@controleonline/ui-common 1.2.98 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@controleonline/ui-common",
3
- "version": "1.2.98",
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
@@ -1,10 +1,13 @@
1
1
  import React, {useLayoutEffect, useMemo} from 'react';
2
2
  import {Pressable, Text, View} from 'react-native';
3
- import Icon from 'react-native-vector-icons/Feather';
3
+ import LegacyIcon from 'react-native-vector-icons/Feather';
4
+ import HomeIcon from '@controleonline/../../src/components/HomeIcon';
4
5
  import {useTheme} from './DefaultProvider';
5
6
  import RuntimeInfoFooter from './RuntimeInfoFooter';
6
7
  import {resolveMenuRouteParams} from '../utils/menuNavigation';
7
8
  import createStyles from './BottomNavigationBar.styles';
9
+ import softenStyles from './BottomNavigationBar.soft.styles';
10
+ import {resolveHomepagePalette} from '@controleonline/../../src/styles/homepagePalette';
8
11
 
9
12
  const BottomNavigationBar = ({
10
13
  navigation,
@@ -14,7 +17,11 @@ const BottomNavigationBar = ({
14
17
  colors = {},
15
18
  testID = 'bottom-navigation',
16
19
  useModernWebChromeProps = false,
20
+ appearance = 'soft',
21
+ placement = 'overlay',
17
22
  }) => {
23
+ const Icon = appearance === 'soft' ? HomeIcon : LegacyIcon;
24
+ colors = appearance === 'soft' ? resolveHomepagePalette(colors) : colors;
18
25
  const theme = useTheme?.() || {};
19
26
  const runtimeFooter = theme?.runtimeFooter || null;
20
27
  const registerBottomNavigation =
@@ -31,14 +38,14 @@ const BottomNavigationBar = ({
31
38
 
32
39
  const styles = useMemo(
33
40
  () =>
34
- createStyles({
41
+ softenStyles(createStyles({
35
42
  activeBackground,
36
43
  dockBackground,
37
44
  dockBorder,
38
45
  dockShadow,
39
46
  activeBorder,
40
47
  useModernWebChromeProps,
41
- }),
48
+ }), colors, appearance),
42
49
  [
43
50
  activeBackground,
44
51
  activeBorder,
@@ -46,6 +53,7 @@ const BottomNavigationBar = ({
46
53
  dockBorder,
47
54
  dockShadow,
48
55
  useModernWebChromeProps,
56
+ appearance,
49
57
  ],
50
58
  );
51
59
 
@@ -97,9 +105,7 @@ const BottomNavigationBar = ({
97
105
 
98
106
  const routeItems = Array.isArray(items) ? items : [];
99
107
  const knownRoute = routeItems.some(item => item?.route === activeRouteName);
100
- const effectiveActiveRoute = knownRoute
101
- ? activeRouteName
102
- : routeItems[0]?.route || '';
108
+ const effectiveActiveRoute = knownRoute ? activeRouteName : '';
103
109
 
104
110
  const navigateTo = item => {
105
111
  try {
@@ -116,15 +122,18 @@ const BottomNavigationBar = ({
116
122
  ? [styles.host, styles.hostPointerEventsBoxNone]
117
123
  : styles.host;
118
124
  const footerProps = {
125
+ appearance,
119
126
  appVersion: runtimeFooter?.appVersion,
120
- colors: runtimeFooter?.colors || {},
127
+ colors: appearance === 'soft'
128
+ ? {...runtimeFooter?.colors, footerBackground: colors.footerBackground, footerText: colors.footerText}
129
+ : runtimeFooter?.colors || {},
121
130
  mainCompany: runtimeFooter?.mainCompany,
122
131
  device: runtimeFooter?.device,
123
132
  useModernWebChromeProps,
124
133
  };
125
134
 
126
135
  return (
127
- <View {...hostProps} style={hostStyle}>
136
+ <View {...hostProps} style={[hostStyle, placement === 'inline' ? {position: 'relative', flexShrink: 0, zIndex: 0} : null]}>
128
137
  <View style={styles.stack}>
129
138
  <View style={styles.dock} testID={testID}>
130
139
  <View style={styles.itemsRow}>
@@ -141,6 +150,8 @@ const BottomNavigationBar = ({
141
150
  <Pressable
142
151
  key={item.route}
143
152
  accessibilityRole="button"
153
+ accessibilityLabel={item.label}
154
+ accessibilityState={{selected: isActive, disabled: Boolean(isDisabled)}}
144
155
  disabled={isDisabled}
145
156
  onPress={() => navigateTo(item)}
146
157
  style={({pressed}) => [
@@ -0,0 +1,29 @@
1
+ import {Platform} from 'react-native';
2
+
3
+ // Shared compact chrome on every route; explicit legacy consumers can opt out.
4
+ export default (styles, colors, appearance) => appearance !== 'soft' ? styles : {
5
+ ...styles,
6
+ dock: [styles.dock, {
7
+ paddingHorizontal: 0,
8
+ paddingTop: 0,
9
+ paddingBottom: 0,
10
+ borderWidth: 0,
11
+ borderTopWidth: 1,
12
+ borderColor: colors.navigationBorder,
13
+ borderTopLeftRadius: 0,
14
+ borderTopRightRadius: 0,
15
+ elevation: 0,
16
+ shadowOpacity: 0,
17
+ ...(Platform.OS === 'web' ? {boxShadow: 'none'} : {}),
18
+ }],
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'}],
23
+ footerSlot: [styles.footerSlot, {
24
+ borderTopWidth: 1,
25
+ borderColor: colors.navigationBorder,
26
+ paddingHorizontal: 0,
27
+ minHeight: 28,
28
+ }],
29
+ };
@@ -12,6 +12,10 @@ const styles = StyleSheet.create({
12
12
  shell: {
13
13
  flex: 1,
14
14
  },
15
+ translationPending: {
16
+ visibility: 'hidden',
17
+ opacity: 0,
18
+ },
15
19
  content: {
16
20
  flex: 1,
17
21
  minHeight: 0,
@@ -395,15 +395,18 @@ export const DefaultProvider = ({
395
395
 
396
396
  useWebThemePalette({resolveDomainThemeColors, baseThemeColors, mainCompany, resolveThemePalette, runtimeColors, applyPaletteToRuntimeColors, applyThemeCssVariables, actions, currentCompany, isShopClientApp});
397
397
 
398
- if (requiresTranslateBootstrap && !isTranslateBootstrapReady) {
399
- return <View style={providerStyles.loadingContainer} />;
400
- }
398
+ const translationPending = requiresTranslateBootstrap && !isTranslateBootstrapReady;
401
399
 
402
400
  return (
403
401
  <ThemeContext.Provider value={themeContextValue}>
404
402
  <View
403
+ pointerEvents={translationPending ? 'none' : 'auto'}
404
+ aria-hidden={translationPending}
405
405
  style={[
406
406
  providerStyles.shell,
407
+ // Keep navigation mounted while translations load. Unmounting it
408
+ // resets the login route and repeatedly registers the bottom bar.
409
+ translationPending && providerStyles.translationPending,
407
410
  {
408
411
  backgroundColor: colors?.background || runtimeColors.background,
409
412
  },
@@ -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,6 +19,8 @@ const {
19
19
  } = require('@controleonline/ui-common/src/react/utils/bottomNavigationToolbar');
20
20
 
21
21
  const RuntimeBottomNavigationBar = ({
22
+ appearance = 'soft',
23
+ placement = 'overlay',
22
24
  activeRouteName: activeRouteNameProp,
23
25
  colors: colorsOverride,
24
26
  disabled: disabledOverride,
@@ -41,13 +43,16 @@ const RuntimeBottomNavigationBar = ({
41
43
  const peopleGetters = peopleStore?.getters || {};
42
44
  const currentCompany = peopleGetters.currentCompany || {};
43
45
  const themeColors = themeGetters.colors || {};
44
- const [runtimeMenus, setRuntimeMenus] = useState([]);
46
+ const [menuState, setMenuState] = useState({scope: '', menus: []});
45
47
  const preset = presetKey ? getBottomNavigationPreset(presetKey) : null;
46
48
  const routeAliases = routeAliasesProp || preset?.routeAliases || {};
47
49
  const appType = normalizeAppType(app_type);
50
+ const menuScope = JSON.stringify([appType, currentCompany?.id, menuType, presetKey]);
51
+ const runtimeMenus = menuState.scope === menuScope ? menuState.menus : [];
48
52
 
49
53
  // Always consult menus-people for role-aware toolbar; preset is fallback only.
50
54
  useEffect(() => {
55
+ const setRuntimeMenus = menus => setMenuState({scope: menuScope, menus});
51
56
  if (!currentCompany?.id || !menuType) {
52
57
  setRuntimeMenus(
53
58
  resolveToolbarRuntimeMenus({
@@ -104,7 +109,7 @@ const RuntimeBottomNavigationBar = ({
104
109
  return () => {
105
110
  cancelled = true;
106
111
  };
107
- }, [appType, currentCompany?.id, menuType, preset, presetKey]);
112
+ }, [appType, currentCompany?.id, menuType, preset, presetKey, menuScope]);
108
113
 
109
114
  const colors = useMemo(() => {
110
115
  if (colorsOverride) {
@@ -150,15 +155,19 @@ const RuntimeBottomNavigationBar = ({
150
155
  const knownRoute = navItems.some(item => item.route === resolvedActiveRoute);
151
156
  const effectiveActiveRoute = knownRoute
152
157
  ? resolvedActiveRoute
153
- : navItems[0]?.route || activeRouteName;
158
+ : activeRouteName;
154
159
 
155
160
  const isDisabled =
156
161
  typeof disabledOverride === 'boolean'
157
162
  ? disabledOverride
158
163
  : !currentCompany || Object.keys(currentCompany).length === 0;
159
164
 
165
+ if (!navItems.length) return null;
166
+
160
167
  return (
161
168
  <BottomNavigationBar
169
+ appearance={appearance}
170
+ placement={placement}
162
171
  activeRouteName={effectiveActiveRoute}
163
172
  colors={colors}
164
173
  disabled={isDisabled}
@@ -0,0 +1,5 @@
1
+ // Keep the native safe area outside the centered content row.
2
+ export default (appearance, platform, bottomInset) => appearance === 'soft' ? {
3
+ contentPadding: 4,
4
+ safeAreaBottom: platform === 'web' ? 0 : Math.max(Number(bottomInset) || 0, 0),
5
+ } : null;
@@ -22,6 +22,7 @@ import {
22
22
  } from '@controleonline/ui-common/src/react/utils/runtimeFooter';
23
23
  import styles from './RuntimeInfoFooter.styles';
24
24
  import RuntimeFooterMarqueeText from './RuntimeFooterMarqueeText';
25
+ import resolveHomeFooterSpacing from './RuntimeInfoFooter.home.spacing';
25
26
 
26
27
  const ROTATION_INTERVAL_MS = 4000;
27
28
  const COMPACT_BREAKPOINT = 720;
@@ -66,6 +67,7 @@ const RuntimeInfoFooter = ({
66
67
  device,
67
68
  colors,
68
69
  useModernWebChromeProps = false,
70
+ appearance = 'legacy',
69
71
  }) => {
70
72
  const {width} = useWindowDimensions();
71
73
  const insets = useSafeAreaInsets();
@@ -258,6 +260,7 @@ const RuntimeInfoFooter = ({
258
260
  : inlineText
259
261
  ) || primaryText || device?.id || '';
260
262
  const backgroundColor = colors?.footerBackground;
263
+ const homeSpacing = resolveHomeFooterSpacing(appearance, Platform.OS, insets.bottom);
261
264
  const borderColor = colors?.footerBorder;
262
265
  const textColor = resolveVisibleFooterTextColor(colors);
263
266
  const loadingColor = colors?.footerLink || colors?.primary || textColor;
@@ -267,7 +270,9 @@ const RuntimeInfoFooter = ({
267
270
  : styles.shell;
268
271
 
269
272
  return (
270
- <View {...shellProps} style={shellStyle}>
273
+ <View {...shellProps} style={[shellStyle, homeSpacing && {
274
+ paddingBottom: homeSpacing.safeAreaBottom, backgroundColor,
275
+ }]}>
271
276
  <View
272
277
  testID="runtime-info-footer"
273
278
  style={[
@@ -276,7 +281,8 @@ const RuntimeInfoFooter = ({
276
281
  {
277
282
  backgroundColor,
278
283
  borderTopColor: borderColor,
279
- paddingBottom: bottomInset,
284
+ paddingBottom: homeSpacing ? homeSpacing.contentPadding : bottomInset,
285
+ ...(homeSpacing && !showDebugInfo ? {paddingTop: homeSpacing.contentPadding} : {}),
280
286
  },
281
287
  ]}>
282
288
  <View style={styles.primaryRow}>
@@ -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) {
@@ -156,6 +145,7 @@ const useDisplayUri = uri => {
156
145
 
157
146
  const UserAvatar = ({
158
147
  imageUrl,
148
+ imageHeaders,
159
149
  email,
160
150
  name,
161
151
  size = 40,
@@ -215,7 +205,8 @@ const UserAvatar = ({
215
205
  <View style={containerStyle}>
216
206
  {displayUri ? (
217
207
  <Image
218
- source={{uri: displayUri}}
208
+ source={imageHeaders && Platform.OS !== 'web' && displayUri === imageUrl
209
+ ? {uri: displayUri, headers: imageHeaders} : {uri: displayUri}}
219
210
  style={styles.image}
220
211
  onError={() => setSourceIndex(index => index + 1)}
221
212
  />
@@ -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
+ };
@@ -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};