@autobusal/common 1.25.0 → 1.26.0

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,54 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.26.0
4
+
5
+ ### Added
6
+
7
+ - **`HandoffBanner`** — "You are managing *X* as its administrator", shown on a
8
+ brand's own domain for the length of a brand handoff, with a **Finish**
9
+ button that ends the session. Rendered by both apps' private layouts; renders
10
+ nothing at all unless `/bff/session` reports a handoff, so an ordinary
11
+ session costs one request. See `magus/BRAND_HANDOFF_PLAN.md`.
12
+
13
+ Not the same component as magus's `ImpersonationBanner` and not driven by the
14
+ same signal: that one reads `localStorage['impersonating']`, which page
15
+ JavaScript sets and can therefore be wrong. This reads the BFF session, which
16
+ is the only thing that actually knows. There is also nothing on this origin to
17
+ *revert* to — the admin's own session is alive and untouched on a different
18
+ origin — so the action is an explicit end, not a revert.
19
+
20
+ - **`handoff` action on `Table`** — its own icon and label rather than borrowing
21
+ `login_as`, for the same reason `reply` is not `approve`: `login_as` changes
22
+ who you are *within* a brand, a handoff leaves for another brand's domain in a
23
+ new tab.
24
+
25
+ ### Notes
26
+
27
+ - **There is deliberately no `pagehide`/`sendBeacon` revoke.** One was built, to
28
+ shorten the window when somebody closes the tab, and testing removed it:
29
+ `pagehide` with `persisted === false` also fires on a plain **page refresh**,
30
+ so F5 revoked the token and dumped the admin on the logged-out screen.
31
+ Measured — an identical reload driven by curl, with no beacon in play, keeps
32
+ the session. No browser API distinguishes closing from reloading at that
33
+ moment, so what remains is two mechanisms that both work (the Finish button
34
+ and the two-hour token expiry) rather than three where one misfires.
35
+
36
+ - The banner puts **Finish next to the message** rather than at the far edge.
37
+ `space-between` reads better but the whitelabel admin's content column is a
38
+ fixed 1491px that overflows the viewport at ordinary window sizes, which put
39
+ the button off-screen at x=1856 — the one control that reliably ends the
40
+ session cannot be the one you have to scroll sideways to find.
41
+
42
+ ## 1.25.1
43
+
44
+ **The SEO override wears the site's own form clothes.**
45
+
46
+ `SeoOverride` declared its own `Field`/`Label`/`Input`/`Textarea` and restated the global field styling with *different* values — transparent instead of `inputs.background`, a primary-coloured border, its own font size — then sat on the bare page rather than a card. On a country or city screen that made the two SEO fields the only ones on screen not matching the form directly beneath them, which is the opposite of what its own comment claimed.
47
+
48
+ It uses `.box` and `.row` now, the site's own card and field classes, with plain inputs so `GlobalStyles` reaches them exactly as it reaches every `Viewer` row. Being controlled is the only thing that genuinely differs from a Viewer row, and that is a state concern, not a visual one. The textarea keeps a single rule, for height: `GlobalStyles` gives every text input 38px, right for one line and useless for a description.
49
+
50
+ Reaches the label SEO tab too, which had the same problem. Checked it does not land inside another card — `admin-label`'s `Options` is the tab strip, not a wrapper around the tab bodies.
51
+
3
52
  ## 1.25.0
4
53
 
5
54
  **New `setDepartureZone(zone)` — the date picker floors on today WHERE THE COACH LEAVES.**
@@ -0,0 +1,144 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { TFunction } from 'i18next';
3
+ import apiClient, { isBffMode } from '@autobusal/providers/Queries/apiClient';
4
+ import { Banner, Text, Brand, Origin, FinishButton } from './styles';
5
+
6
+ export interface HandoffState {
7
+ /**
8
+ * The brand whose admin started this - shown so it is obvious this session
9
+ * did not begin here
10
+ */
11
+ from?: string | null
12
+
13
+ /**
14
+ * The brand being managed
15
+ */
16
+ brand?: string | null
17
+ }
18
+
19
+ interface Props {
20
+ /**
21
+ * Both apps render this from their own private layout, and they load
22
+ * different primary namespaces - magus 'normal', alvavel 'whitelabel' - so
23
+ * this is typed to the one they share and every string below asks for
24
+ * `common` explicitly. Same shape as Breadcrumbs, which both also render.
25
+ */
26
+ t: TFunction<'common'>
27
+ }
28
+
29
+ /**
30
+ * "You are managing X as its administrator."
31
+ *
32
+ * Ferjolt Ozuni - Date: 2026-08-05
33
+ *
34
+ * Shown on the TARGET brand's own domain for the length of a brand handoff -
35
+ * see magus/BRAND_HANDOFF_PLAN.md. Deliberately not the same component as
36
+ * ImpersonationBanner and not driven by the same signal:
37
+ *
38
+ * - ImpersonationBanner reads localStorage['impersonating'], which page
39
+ * JavaScript sets and can therefore be wrong. This reads the BFF session
40
+ * itself, which is the only thing that actually knows.
41
+ * - There is no `impersonator_token` on this origin to revert to. The
42
+ * admin's own session is alive and untouched on a DIFFERENT origin, so
43
+ * the action here is an explicit END, not a revert.
44
+ *
45
+ * The domain in the address bar, the logo and the colour scheme have already
46
+ * told the user where they are - every brand carries its own identity, which
47
+ * is most of why this design was chosen over a label switcher. The bar is
48
+ * the part that says the session is temporary and how to end it.
49
+ */
50
+ const HandoffBanner = ({ t }: Props): (JSX.Element | null) => {
51
+ const [ handoff, setHandoff ] = useState<HandoffState | null>(null);
52
+
53
+ useEffect(() => {
54
+ // bearer-mode brands have no BFF, so there is no session endpoint to ask
55
+ // and no handoff that could have happened.
56
+ if (!isBffMode) {
57
+ return;
58
+ }
59
+
60
+ let cancelled = false;
61
+
62
+ apiClient
63
+ .get('/session')
64
+ .then(response => {
65
+ if (!cancelled && response.data?.handoff) {
66
+ setHandoff(response.data.handoff);
67
+ }
68
+ })
69
+ // A failure here means "no handoff", which is the default. It must not
70
+ // surface as an error toast on every page load of every ordinary
71
+ // session.
72
+ .catch(() => undefined);
73
+
74
+ return () => {
75
+ cancelled = true;
76
+ };
77
+ }, []);
78
+
79
+ /**
80
+ * THERE IS DELIBERATELY NO pagehide/sendBeacon REVOKE HERE.
81
+ *
82
+ * Ferjolt Ozuni - Date: 2026-08-05
83
+ *
84
+ * The plan called for one, to shorten the window when somebody closes the
85
+ * tab: the BFF cookie is `lifetime => 0`, a BROWSER-session cookie, so it
86
+ * survives a closed tab and dies only with the whole browser, and
87
+ * sendBeacon is the only request a browser reliably delivers from a dying
88
+ * page. It was built, and TESTING KILLED IT.
89
+ *
90
+ * `pagehide` does not mean "the user is leaving". With
91
+ * `event.persisted === false` it also fires on a plain PAGE REFRESH and on
92
+ * any hard navigation within the same tab - so pressing F5 revoked the
93
+ * token and dumped the admin on the logged-out screen. Measured: an
94
+ * identical reload driven by curl, with no beacon in play, keeps the
95
+ * session; the browser one destroyed it every time.
96
+ *
97
+ * There is no browser API that distinguishes closing from reloading at
98
+ * pagehide time - PerformanceNavigationTiming only knows AFTER the next
99
+ * load, which is too late to have not revoked. So the choice was a
100
+ * shorter window in one case against a refresh silently ending the session
101
+ * in every case, and the window was never the bigger risk: it is already
102
+ * bounded at two hours by the token's own expiry.
103
+ *
104
+ * What remains is two mechanisms that both work, rather than three where
105
+ * one misfires: the Finish button below, and the two-hour expiry.
106
+ */
107
+
108
+ if (!handoff) {
109
+ return null;
110
+ }
111
+
112
+ const onFinish = (): void => {
113
+ // /bff/logout already revokes the token upstream and destroys the local
114
+ // session, so this needs no endpoint of its own. A hard assignment
115
+ // rather than a router navigation, because every cached query in this
116
+ // tab belongs to a session that no longer exists.
117
+ apiClient
118
+ .post('/logout')
119
+ .catch(() => undefined)
120
+ .finally(() => {
121
+ window.location.href = '/';
122
+ });
123
+ };
124
+
125
+ return (
126
+ <Banner>
127
+ <Text>
128
+ { t('handoff.managing', { ns: 'common' }) }
129
+
130
+ <Brand>{ handoff.brand }</Brand>
131
+
132
+ { t('handoff.as_admin', { ns: 'common' }) }
133
+
134
+ { handoff.from ? <Origin>{ t('handoff.from', { brand: handoff.from, ns: 'common' }) }</Origin> : null }
135
+ </Text>
136
+
137
+ <FinishButton type="button" onClick={ onFinish }>
138
+ { t('handoff.finish', { ns: 'common' }) }
139
+ </FinishButton>
140
+ </Banner>
141
+ );
142
+ };
143
+
144
+ export default HandoffBanner;
@@ -0,0 +1,77 @@
1
+ import styled from 'styled-components';
2
+
3
+ /**
4
+ * Ferjolt Ozuni - Date: 2026-08-05
5
+ *
6
+ * Full width, above everything, and NOT dismissible - a session that is
7
+ * writing to somebody else's brand should not be possible to forget you are
8
+ * in. It uses the warning colour rather than the info colour that
9
+ * ImpersonationBanner uses, because the two states can never both be true on
10
+ * one screen and reading differently at a glance is the point.
11
+ */
12
+ export const Banner = styled.div`
13
+ display: flex;
14
+ flex-wrap: wrap;
15
+ align-items: center;
16
+
17
+ /**
18
+ * Finish sits NEXT TO the message, not pushed to the far edge.
19
+ *
20
+ * space-between reads better on a normal container and is wrong here. The
21
+ * whitelabel admin's content column is a fixed 1491px that overflows the
22
+ * viewport at every ordinary window size, so a right-aligned button landed
23
+ * at x=1856 - off-screen, reachable only by scrolling sideways. The one
24
+ * control that reliably ends a session on somebody else's brand cannot be
25
+ * the one you have to go looking for.
26
+ */
27
+ justify-content: flex-start;
28
+ gap: 14px;
29
+ margin-bottom: 20px;
30
+ padding: 12px 16px;
31
+ color: ${ props => props.theme.font.warning };
32
+ background: ${ props => props.theme.background.neutral };
33
+ border: 1px solid ${ props => props.theme.font.warning };
34
+ border-radius: ${ props => props.theme.borderRadius };
35
+ font-size: ${ props => props.theme.size.s };
36
+ `;
37
+
38
+ export const Text = styled.div`
39
+ display: flex;
40
+ flex-wrap: wrap;
41
+ align-items: baseline;
42
+ gap: 6px;
43
+ `;
44
+
45
+ export const Brand = styled.strong`
46
+ color: ${ props => props.theme.font.normal };
47
+ font-weight: 700;
48
+ `;
49
+
50
+ /**
51
+ * Where the session came from - quieter than the rest, because it answers a
52
+ * question somebody might have rather than one they are being asked.
53
+ */
54
+ export const Origin = styled.span`
55
+ color: ${ props => props.theme.font.faded };
56
+ font-size: ${ props => props.theme.size.xs };
57
+ `;
58
+
59
+ export const FinishButton = styled.button`
60
+ flex-shrink: 0;
61
+ padding: 6px 16px;
62
+ color: ${ props => props.theme.font.warning };
63
+ background: ${ props => props.theme.background.normal };
64
+ border: 1px solid ${ props => props.theme.font.warning };
65
+ border-radius: ${ props => props.theme.borderRadius };
66
+ font-family: inherit;
67
+ font-size: ${ props => props.theme.size.s };
68
+ font-weight: 700;
69
+ white-space: nowrap;
70
+ cursor: pointer;
71
+ transition: all 0.3s ease;
72
+
73
+ &:hover {
74
+ color: ${ props => props.theme.background.normal };
75
+ background: ${ props => props.theme.font.warning };
76
+ }
77
+ `;
@@ -1,6 +1,6 @@
1
1
  import { useState } from 'react';
2
2
  import { TFunction } from 'i18next';
3
- import { ContainerLocales, LocaleButton, Notice, Field, Label, Input, Textarea } from './styles';
3
+ import { Container, ContainerLocales, LocaleButton, Notice, Textarea } from './styles';
4
4
  import { useGetSettings } from '@autobusal/providers/services';
5
5
 
6
6
  export interface SeoEntry {
@@ -70,8 +70,15 @@ const SeoOverride = ({ value, t, onChange }: Props): JSX.Element => {
70
70
  return !!(entry?.title?.trim() || entry?.description?.trim());
71
71
  };
72
72
 
73
+ // Edited: Ferjolt Ozuni - Date: 2026-08-05
74
+ // `.box` and `.row` are the site's own card and field classes, and the
75
+ // inputs are left plain so GlobalStyles reaches them - the same shape
76
+ // Viewer/Item renders. Being controlled is the only way this differs from
77
+ // a Viewer row, and that is a state concern, not a visual one; styling
78
+ // them separately is what made the two SEO fields the odd ones out on
79
+ // every country and city screen.
73
80
  return (
74
- <>
81
+ <Container className="box">
75
82
  <ContainerLocales>
76
83
  { available.map(language => (
77
84
  <LocaleButton
@@ -88,27 +95,27 @@ const SeoOverride = ({ value, t, onChange }: Props): JSX.Element => {
88
95
 
89
96
  <Notice>{ t('seo_override.notice', { ns: 'common' }) }</Notice>
90
97
 
91
- <Field>
92
- <Label>{ t('seo_override.title', { ns: 'common' }) }</Label>
98
+ <div className="row">
99
+ { t('seo_override.title', { ns: 'common' }) }
93
100
 
94
- <Input
101
+ <input
95
102
  type="text"
96
103
  maxLength={ 250 }
97
104
  value={ current.title ?? '' }
98
105
  onChange={ (event) => update('title', event.target.value) }
99
106
  />
100
- </Field>
107
+ </div>
101
108
 
102
- <Field>
103
- <Label>{ t('seo_override.description', { ns: 'common' }) }</Label>
109
+ <div className="row row-nomargin">
110
+ { t('seo_override.description', { ns: 'common' }) }
104
111
 
105
112
  <Textarea
106
113
  maxLength={ 500 }
107
114
  value={ current.description ?? '' }
108
115
  onChange={ (event) => update('description', event.target.value) }
109
116
  />
110
- </Field>
111
- </>
117
+ </div>
118
+ </Container>
112
119
  );
113
120
  };
114
121
 
@@ -1,5 +1,20 @@
1
1
  import styled, { css } from 'styled-components';
2
2
 
3
+ /**
4
+ * The editor's own card.
5
+ *
6
+ * Ferjolt Ozuni - Date: 2026-08-05
7
+ * `.box` is the site's card surface (GlobalStyles), the same one Viewer
8
+ * draws itself on. This block sits directly above a Viewer on the country
9
+ * and city screens, so on the bare page background it read as loose
10
+ * furniture that happened to be above the form rather than part of it -
11
+ * which is exactly backwards, since the form's Save button is what submits
12
+ * these fields.
13
+ */
14
+ export const Container = styled.div`
15
+ margin-bottom: 20px;
16
+ `;
17
+
3
18
  export const ContainerLocales = styled.div`
4
19
  display: flex;
5
20
  flex-wrap: wrap;
@@ -47,42 +62,20 @@ export const Notice = styled.p`
47
62
  `;
48
63
 
49
64
  /**
50
- * The editor's own fields, rather than Viewer rows.
65
+ * Height, and nothing else.
51
66
  *
52
- * Ferjolt Ozuni - Date: 2026-08-02
53
- * Viewer's inputs are uncontrolled (defaultValue + a react-hook-form ref),
54
- * which cannot hold thirteen languages' worth of state while showing one -
55
- * switching language would discard whatever had just been typed. These are
56
- * controlled, and styled to match the rows they sit among.
67
+ * Ferjolt Ozuni - Date: 2026-08-05
68
+ * How a field looks - fill, border, radius, weight, colour, width - belongs
69
+ * to GlobalStyles, which already styles `textarea` alongside every text
70
+ * input on the site. This file used to restate all of it with DIFFERENT
71
+ * values (transparent fill, a primary-coloured border, its own font size),
72
+ * which is the whole reason these two fields were the only ones on the
73
+ * screen that did not match the form beneath them. The single thing
74
+ * GlobalStyles cannot get right here is the height: 38px is correct for a
75
+ * one-line input and useless for a description.
57
76
  */
58
- export const Field = styled.div`
59
- margin-bottom: 15px;
60
- `;
61
-
62
- export const Label = styled.label`
63
- display: block;
64
- margin-bottom: 5px;
65
- font-weight: 600;
66
- font-size: ${ props => props.theme.size.m };
67
- `;
68
-
69
- const field = css`
70
- width: 100%;
71
- padding: 10px;
72
- border: 1px solid ${ props => props.theme.primary.neutral };
73
- border-radius: ${ props => props.theme.borderRadius };
74
- background: transparent;
75
- color: ${ props => props.theme.font.normal };
76
- font-family: inherit;
77
- font-size: ${ props => props.theme.size.m };
78
- `;
79
-
80
- export const Input = styled.input`
81
- ${ field }
82
- `;
83
-
84
77
  export const Textarea = styled.textarea`
85
- ${ field }
86
- min-height: 90px;
78
+ height: 90px;
79
+ padding: 8px 10px;
87
80
  resize: vertical;
88
81
  `;
@@ -4,7 +4,7 @@ import { BiEditAlt, BiTransfer, BiTrash } from 'react-icons/bi';
4
4
  import { FaTrash } from 'react-icons/fa';
5
5
  import { GiMoneyStack } from 'react-icons/gi';
6
6
  import { IoDocumentLockOutline } from 'react-icons/io5';
7
- import { RiBusLine, RiEyeLine, RiLoginCircleLine, RiReplyLine } from 'react-icons/ri';
7
+ import { RiBusLine, RiExternalLinkLine, RiEyeLine, RiLoginCircleLine, RiReplyLine } from 'react-icons/ri';
8
8
 
9
9
  interface Props {
10
10
  type: string
@@ -23,6 +23,14 @@ const Icon = ({ type, t }: Props): (JSX.Element | null) => {
23
23
  case 'login_as':
24
24
  return <RiLoginCircleLine title={ t('table.actions.custom.login_as', { ns: 'common' }) } />;
25
25
 
26
+ // Edited: Ferjolt Ozuni - Date: 2026-08-05
27
+ // Its own action rather than borrowing 'login_as', for the same reason
28
+ // 'reply' below is not 'approve': the verbs differ. login_as changes who
29
+ // you are WITHIN this brand; a handoff leaves for another brand's own
30
+ // domain in a new tab, and the leaving is the part worth showing.
31
+ case 'handoff':
32
+ return <RiExternalLinkLine title={ t('table.actions.custom.handoff', { ns: 'common' }) } />;
33
+
26
34
  case 'approve':
27
35
  return <AiOutlineCheck title={ t('table.actions.custom.approve', { ns: 'common' }) } />;
28
36
 
package/index.ts CHANGED
@@ -34,6 +34,7 @@ import Required from './Required/Required';
34
34
  import Road from './Road/Road';
35
35
  import LocaleGate from './Locale/LocaleGate';
36
36
  import SeoOverride from './SeoOverride/SeoOverride';
37
+ import HandoffBanner from './HandoffBanner/HandoffBanner';
37
38
  import localiseRoutes from './Locale/routes';
38
39
  import { splitLocale, withLocale, localeFromPath } from './Locale/locale';
39
40
  import RouteItem from './RouteItem/RouteItem';
@@ -85,6 +86,7 @@ export {
85
86
  Road,
86
87
  LocaleGate,
87
88
  SeoOverride,
89
+ HandoffBanner,
88
90
  localiseRoutes,
89
91
  splitLocale,
90
92
  withLocale,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/common",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"