@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 +49 -0
- package/HandoffBanner/HandoffBanner.tsx +144 -0
- package/HandoffBanner/styles.ts +77 -0
- package/SeoOverride/SeoOverride.tsx +17 -10
- package/SeoOverride/styles.ts +27 -34
- package/Table/Actions/Icon.tsx +9 -1
- package/index.ts +2 -0
- package/package.json +1 -1
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,
|
|
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
|
-
<
|
|
92
|
-
|
|
98
|
+
<div className="row">
|
|
99
|
+
{ t('seo_override.title', { ns: 'common' }) }
|
|
93
100
|
|
|
94
|
-
<
|
|
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
|
-
</
|
|
107
|
+
</div>
|
|
101
108
|
|
|
102
|
-
<
|
|
103
|
-
|
|
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
|
-
</
|
|
111
|
-
|
|
117
|
+
</div>
|
|
118
|
+
</Container>
|
|
112
119
|
);
|
|
113
120
|
};
|
|
114
121
|
|
package/SeoOverride/styles.ts
CHANGED
|
@@ -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
|
-
*
|
|
65
|
+
* Height, and nothing else.
|
|
51
66
|
*
|
|
52
|
-
* Ferjolt Ozuni - Date: 2026-08-
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
-
|
|
86
|
-
|
|
78
|
+
height: 90px;
|
|
79
|
+
padding: 8px 10px;
|
|
87
80
|
resize: vertical;
|
|
88
81
|
`;
|
package/Table/Actions/Icon.tsx
CHANGED
|
@@ -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,
|