@autobusal/providers 1.29.2 → 1.29.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.29.4
4
+
5
+ ### Added
6
+
7
+ - **`OrderData.addons`** (`OrderAddonData[]`) - the extra services bought at
8
+ checkout. There is no `data` field by design: the API withholds it because
9
+ the Telegram add-on's copy holds a bind token.
10
+ - **`OrderData.fare_display`** - the fare WITHOUT add-ons. Optional, because
11
+ only the single-order endpoint opts into it; `amount_display` remains fare +
12
+ add-ons.
13
+
14
+ ## 1.29.3
15
+
16
+ ### Fixed
17
+
18
+ - **A failed settings fetch white-screened the entire app.** `useGetSettings`
19
+ is a *suspense* query, so a failure throws during render rather than
20
+ resolving to an error state — and nothing in `Providers` caught it, so the
21
+ whole tree unmounted and left a blank page with no message, no retry and
22
+ nothing in the console. A rate limit, a brief API blip or a restart
23
+ mid-deploy all produced the same silent nothing. Reproduced in a browser,
24
+ not deduced.
25
+
26
+ Fixed with a new **`BootstrapError`** boundary wrapping everything. It
27
+ cannot be the existing `ErrorBoundary`: that renders `Errors/Message`,
28
+ which calls `useGetSettings()` itself and `useNavigate` — above the
29
+ settings fetch it would rethrow inside its own fallback. An error screen
30
+ for "nothing loaded" cannot depend on anything having loaded, so this one
31
+ uses no settings, no i18next, no router and no theme — inline styles and
32
+ the static `@lang/errors` map, with a reload button.
33
+
34
+ ### Changed
35
+
36
+ - **Queries retry what can plausibly differ, and only that.** `retry: false`
37
+ was wrong in both directions: a single dropped connection or 500 was fatal
38
+ (and on the suspense bootstrap path, fatal meant the whole app), while
39
+ nothing distinguished that from a 401/403/404 — a decided answer that
40
+ repeats identically while spending the rate-limit budget a 429 exists to
41
+ protect. Now 5xx, 408, 425, 429 and network-level failures get two retries
42
+ at 700ms then 1400ms; everything else fails at once, so a real 403 surfaces
43
+ as a real 403 instead of three seconds of spinner.
44
+
3
45
 
4
46
  ## 1.29.2
5
47
 
@@ -0,0 +1,107 @@
1
+ import React from 'react';
2
+ import { getLanguage } from '../Setup/languages';
3
+ import errors from '@lang/errors';
4
+
5
+ interface Props {
6
+ children: JSX.Element[] | JSX.Element
7
+ }
8
+
9
+ /**
10
+ * The last thing standing when the app cannot start at all.
11
+ *
12
+ * Ferjolt Ozuni - Date: 2026-08-06
13
+ *
14
+ * `useGetSettings` is a SUSPENSE query, so a failure throws during render
15
+ * rather than resolving to an error state. It sits above everything in
16
+ * Providers, and there was no error boundary over it - so any failure of
17
+ * /api/settings/get unmounted the entire tree and left a **blank white page**
18
+ * with no message, no retry and nothing in the console. A rate limit, a
19
+ * five-second API blip or a restart mid-deploy all produced the same silent
20
+ * nothing. Reproduced in the browser, not deduced.
21
+ *
22
+ * Why this is not @autobusal/providers' own ErrorBoundary: that one renders
23
+ * `Errors/Message`, and Message calls `useGetSettings()` itself - the very
24
+ * query that just failed - plus `useNavigate`, which needs a Router. Putting
25
+ * it above the settings fetch would rethrow inside the fallback. An error
26
+ * screen for "nothing loaded" cannot depend on anything having loaded.
27
+ *
28
+ * So this deliberately uses NO settings, NO i18next, NO router and NO
29
+ * styled-components theme - inline styles and the static `@lang/errors` map,
30
+ * which is a plain object kept for exactly this purpose (it is what the
31
+ * toast and Message already read before i18next is guaranteed to exist).
32
+ *
33
+ * Reload rather than a retry button wired to react-query: whatever failed
34
+ * happened during bootstrap, so there is no reliable app state left to retry
35
+ * *into*. A full reload is the honest recovery, and it is what a visitor
36
+ * would do anyway.
37
+ */
38
+ class BootstrapError extends React.Component<Props, { failed: boolean }> {
39
+ state = { failed: false };
40
+
41
+ static getDerivedStateFromError() {
42
+ return { failed: true };
43
+ }
44
+
45
+ componentDidCatch(error: Error, info: React.ErrorInfo): void {
46
+ // Kept as console.error, not console.log: this is the one place that
47
+ // knows the app failed to start, and a white page with a `log` entry is
48
+ // easy to miss when somebody finally does open the console.
49
+ console.error('[bootstrap] the application could not start', error, info);
50
+ }
51
+
52
+ render() {
53
+ if (!this.state.failed) {
54
+ return this.props.children;
55
+ }
56
+
57
+ // Same fallback rule as Message: any language without an entry reads
58
+ // English rather than rendering `undefined`.
59
+ const language = getLanguage('en');
60
+ const copy = errors[language as keyof typeof errors] ?? errors.en;
61
+
62
+ return (
63
+ <div
64
+ role="alert"
65
+ style={ {
66
+ minHeight: '100vh',
67
+ display: 'flex',
68
+ alignItems: 'center',
69
+ justifyContent: 'center',
70
+ padding: '24px',
71
+ fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, sans-serif',
72
+ background: '#1c1c1c',
73
+ color: '#f5f5f5'
74
+ } }
75
+ >
76
+ <div style={ { maxWidth: '460px', textAlign: 'center' } }>
77
+ <h1 style={ { fontSize: '20px', lineHeight: 1.4, margin: '0 0 12px' } }>
78
+ { copy.system }
79
+ </h1>
80
+
81
+ <p style={ { fontSize: '15px', lineHeight: 1.6, margin: '0 0 24px', opacity: 0.8 } }>
82
+ { copy.system_detail }
83
+ </p>
84
+
85
+ <button
86
+ type="button"
87
+ onClick={ () => window.location.reload() }
88
+ style={ {
89
+ font: 'inherit',
90
+ fontWeight: 600,
91
+ padding: '10px 24px',
92
+ borderRadius: '999px',
93
+ border: 0,
94
+ cursor: 'pointer',
95
+ background: '#f0b323',
96
+ color: '#1c1c1c'
97
+ } }
98
+ >
99
+ { copy.back }
100
+ </button>
101
+ </div>
102
+ </div>
103
+ );
104
+ }
105
+ }
106
+
107
+ export default BootstrapError;
package/Providers.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Suspense, useEffect } from 'react';
2
2
  import Queries from './Queries/Queries';
3
+ import BootstrapError from './Errors/BootstrapError';
3
4
  import Styles from './Styles/Styles';
4
5
  import Notifications from './Notifications/Notifications';
5
6
  import Setup from './Setup/Setup';
@@ -78,6 +79,17 @@ const Providers = ({ type, loading, children }: Props): JSX.Element => {
78
79
  // remount it once ready - harmless for Styles/Notifications themselves,
79
80
  // but a needless remount of anything stateful sitting there is worth
80
81
  // avoiding on principle.
82
+ // Edited: Ferjolt Ozuni - Date: 2026-08-06
83
+ // OUTERMOST, above the settings fetch. useGetSettings is a suspense
84
+ // query, so a failure throws during render rather than resolving to an
85
+ // error state - and with nothing catching it the whole tree unmounted
86
+ // and left a blank white page. A rate limit, a brief API blip or a
87
+ // restart mid-deploy all looked identical: nothing at all.
88
+ //
89
+ // Not providers' own ErrorBoundary, which renders Errors/Message -
90
+ // Message calls useGetSettings() itself and useNavigate, so above this
91
+ // point it would rethrow inside its own fallback. See BootstrapError.
92
+ <BootstrapError>
81
93
  <Suspense fallback={ loading }>
82
94
  <Queries>
83
95
  <Styles>
@@ -93,6 +105,7 @@ const Providers = ({ type, loading, children }: Props): JSX.Element => {
93
105
  </Styles>
94
106
  </Queries>
95
107
  </Suspense>
108
+ </BootstrapError>
96
109
  );
97
110
  };
98
111
 
@@ -1,10 +1,56 @@
1
1
  import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
2
+ import { AxiosError } from 'axios';
3
+
4
+ /**
5
+ * Edited: Ferjolt Ozuni - Date: 2026-08-06
6
+ *
7
+ * Was `retry: false` for everything, which is wrong in both directions.
8
+ *
9
+ * Too strict: a single blip - a restart mid-deploy, a dropped connection, a
10
+ * 500 - was fatal to any query, and for the SUSPENSE queries on the bootstrap
11
+ * path (settings, menu) fatal meant the whole app. One unlucky request and
12
+ * the visitor got nothing.
13
+ *
14
+ * Too loose: it also said nothing about WHICH failures are worth repeating. A
15
+ * 401, 403 or 404 is a decided answer - the server has considered the request
16
+ * and refused it, and asking again produces the same refusal while spending
17
+ * the rate-limit budget that a 429 exists to protect.
18
+ *
19
+ * So: repeat only what can plausibly differ next time - a 5xx, a timeout, a
20
+ * rate limit, or a network error with no response at all - and back off
21
+ * between attempts so a burst is not answered with more burst. Everything
22
+ * else fails immediately, which is what makes a real 403 surface as a real
23
+ * 403 rather than as three seconds of spinner.
24
+ */
25
+ const RETRYABLE = [ 408, 425, 429, 500, 502, 503, 504 ];
26
+
27
+ const retry = (failureCount: number, error: Error): boolean => {
28
+ if (failureCount >= 2) {
29
+ return false;
30
+ }
31
+
32
+ const status = (error as AxiosError)?.response?.status;
33
+
34
+ // No response at all - a dropped connection or a DNS hiccup, not an answer.
35
+ if (status === undefined) {
36
+ return true;
37
+ }
38
+
39
+ return RETRYABLE.includes(status);
40
+ };
41
+
42
+ // 700ms, then 1400ms. Deliberately short: these sit behind a suspense
43
+ // fallback the visitor is watching, so the ceiling on "how long may this look
44
+ // broken before it gives up and says so" is about two seconds, not the ~30
45
+ // an unbounded exponential backoff reaches.
46
+ const retryDelay = (attempt: number): number => 700 * 2 ** attempt;
2
47
 
3
48
  const queryClient = new QueryClient({
4
49
  defaultOptions: {
5
50
  queries: {
6
51
  refetchOnWindowFocus: false,
7
- retry: false
52
+ retry,
53
+ retryDelay
8
54
  }
9
55
  }
10
56
  });
@@ -19,4 +65,4 @@ const Queries = ({ children }: Props): JSX.Element => (
19
65
  </QueryClientProvider>
20
66
  );
21
67
 
22
- export default Queries;
68
+ export default Queries;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/providers",
3
- "version": "1.29.2",
3
+ "version": "1.29.4",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"
package/stores/user.ts CHANGED
@@ -20,7 +20,19 @@ export interface UserStore {
20
20
  // login/activate/refresh); it's just never written to disk. A bootstrap
21
21
  // fetch (see Setup/useUserBootstrap.ts) re-hydrates the full in-memory
22
22
  // object shortly after app load using this same reduced signal.
23
- type PersistedUser = Pick<UserData, 'id' | 'name' | 'name_display' | 'type' | 'status'>;
23
+ // Edited: Ferjolt Ozuni - Date: 2026-08-07
24
+ // `verified` joins the subset because `status` cannot be READ without it.
25
+ // Status 0 means "activation pending" for a self-registered account and
26
+ // "switched off by staff" for one an admin created - and the two get
27
+ // different screens (see @autobusal/auth's Secure). Persisting the status
28
+ // but not the field that disambiguates it meant the first paint after a
29
+ // reload had to guess, and guessed activation: a deactivated employee was
30
+ // shown "enter the 5-digit code we emailed you" for a code that does not
31
+ // exist.
32
+ //
33
+ // It is a verification timestamp, not personal data - it says nothing about
34
+ // who the person is, which is what the rest of this redaction is for.
35
+ type PersistedUser = Pick<UserData, 'id' | 'name' | 'name_display' | 'type' | 'status' | 'verified'>;
24
36
 
25
37
  const STORAGE_KEY = 'user';
26
38
 
@@ -29,7 +41,8 @@ const redact = (user: UserData): PersistedUser => ({
29
41
  name: user.name,
30
42
  name_display: user.name_display,
31
43
  type: user.type,
32
- status: user.status
44
+ status: user.status,
45
+ verified: user.verified
33
46
  });
34
47
 
35
48
  const stored = localStorage.getItem(STORAGE_KEY);
package/types/orders.ts CHANGED
@@ -33,6 +33,12 @@ export interface OrderData {
33
33
  passengers: TicketData[]
34
34
  tickets: number
35
35
  amount_display: string
36
+ /*
37
+ * Edited: Ferjolt Ozuni - Date: 2026-08-07
38
+ * The fare WITHOUT add-ons. Optional: only the single-order endpoint opts
39
+ * into it (it costs a sum query), so the listing does not carry it.
40
+ */
41
+ fare_display?: string
36
42
  note_cancel: string
37
43
  actions: string[]
38
44
  created_at: string
@@ -42,6 +48,29 @@ export interface OrderData {
42
48
  reference?: OrderData
43
49
  updated_at: string
44
50
  account?: any
51
+
52
+ /*
53
+ * Edited: Ferjolt Ozuni - Date: 2026-08-07
54
+ * The extra services bought at checkout. Optional: only the single-order
55
+ * endpoint loads them, and the listing does not.
56
+ *
57
+ * There is no `data` field by design - the API withholds it because the
58
+ * Telegram add-on's copy holds a bind token. See Libraries\Orders\View.
59
+ */
60
+ addons?: OrderAddonData[]
61
+ }
62
+
63
+ export interface OrderAddonData {
64
+ id: number
65
+ // 'flex' | 'whatsapp' | 'telegram' | 'sms' - open, since the registry is
66
+ // a table and a brand can be given a key this build has never heard of.
67
+ key: string
68
+ price: string
69
+ // The same value with the brand's currency on it. The currency is a label
70
+ // setting the client is never sent, so this has to be formatted server
71
+ // side - see Orders\OrderAddon.
72
+ price_display: string
73
+ created_at: string
45
74
  }
46
75
 
47
76
  export interface ContactData {