@autobusal/routes-order 1.40.1 → 1.40.3

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.
@@ -16,6 +16,7 @@ import Addons from '../Step5/Addons/Addons';
16
16
  import Billing from '../Step5/Billing/Billing';
17
17
  import Payments from '../Step5/Payments/Payments';
18
18
  import Sidebar from './Sidebar/Sidebar';
19
+ import { useLocalPrice } from './LocalPrice';
19
20
  import { getTotal, convertCoupon, getAvailablePayments, currencyOf, surchargeOf } from '../Step5/utilities';
20
21
  import { Title, Actions, Results, Listing } from '../styles';
21
22
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
@@ -396,6 +397,10 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
396
397
  value: owed
397
398
  } : grandTotal;
398
399
 
400
+ // Claude - 2026-09-26: the same total in the currency Google quoted, for
401
+ // a traveller who arrived from Google Travel on this route (LocalPrice)
402
+ const local = useLocalPrice(step2.id as number | undefined, payable.value);
403
+
399
404
  /**
400
405
  * Edited: Ferjolt Ozuni - Date: 2026-08-05
401
406
  *
@@ -634,6 +639,7 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
634
639
  addons={ addonItems.map(item => ({ label: item.label, value: `${ item.price } ${ currency }` })) }
635
640
  coupon={ coupon }
636
641
  total={ payable }
642
+ local={ local }
637
643
  surcharge={ surcharge > 0 ? `${ surcharge } ${ currency }` : undefined }
638
644
  /* the seller's own cut, taken off what they settle - the same line
639
645
  their invoice carries (audit A4-17), so the two documents for one
@@ -0,0 +1,78 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { apiClient } from '@autobusal/providers';
3
+
4
+ /**
5
+ * The price Google showed, beside the price we charge.
6
+ *
7
+ * Claude - 2026-09-26 (Ferjolt: "build the checkout price display"). Google
8
+ * Travel quotes a departure in its origin's currency (obtapi Google\Transport
9
+ * - lekë from Albania) and requires the landing page to show the price the
10
+ * traveller picked. The ticket link names that currency (`local=ALL`, next
11
+ * to `route=`); this remembers it for the session, and the checkout shows
12
+ * "≈ 1,840 ALL" under the euro total for THAT route only - a later booking
13
+ * from Greece in the same tab shows nothing. Display only: the charge stays
14
+ * in the brand's currency. No rate known: no line.
15
+ */
16
+
17
+ const KEY = 'checkout_local_currency';
18
+
19
+ // whole units where prices are quoted whole - the same rule as the quote
20
+ const WHOLE = ['ALL', 'MKD', 'RSD', 'HUF', 'ISK'];
21
+
22
+ interface Stored {
23
+ currency: string
24
+ route: number
25
+ }
26
+
27
+ /** called on every wizard render: keeps the currency the landing URL named */
28
+ export const rememberLocalCurrency = (params: URLSearchParams): void => {
29
+ const currency = params.get('local');
30
+ const route = Number(params.get('route'));
31
+
32
+ if (!currency || !/^[A-Z]{3}$/.test(currency) || !route) {
33
+ return;
34
+ }
35
+
36
+ try {
37
+ sessionStorage.setItem(KEY, JSON.stringify({ currency, route }));
38
+ } catch {
39
+ // private mode / storage blocked: the line is a nicety, not a feature
40
+ }
41
+ };
42
+
43
+ const stored = (): Stored | null => {
44
+ try {
45
+ const value = JSON.parse(sessionStorage.getItem(KEY) ?? 'null');
46
+
47
+ return value && typeof value.currency === 'string' && typeof value.route === 'number' ? value : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ };
52
+
53
+ /** "1,840 ALL" for this route's total, or undefined */
54
+ export const useLocalPrice = (routeId: number | undefined, total: number): string | undefined => {
55
+ const local = stored();
56
+
57
+ const currency = local && routeId && local.route === routeId ? local.currency : null;
58
+
59
+ const { data: rate } = useQuery({
60
+ queryKey: ['exchange-rate-local', currency],
61
+ queryFn: async () => (
62
+ await apiClient.get('/api/exchange-rates/local', { params: { currency } }).then(response => response.data?.rate as number | null)
63
+ ),
64
+ enabled: currency !== null,
65
+ staleTime: 10 * 60 * 1000,
66
+ retry: false
67
+ });
68
+
69
+ if (!currency || !rate || !(total > 0)) {
70
+ return undefined;
71
+ }
72
+
73
+ const decimals = WHOLE.includes(currency) ? 0 : 2;
74
+
75
+ const amount = Number((total * rate).toFixed(decimals));
76
+
77
+ return `${ amount.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) } ${ currency }`;
78
+ };
@@ -4,7 +4,7 @@ import { AiOutlineInfoCircle } from 'react-icons/ai';
4
4
  import { Button } from '@autobusal/common';
5
5
  import About from '../../Step5/Summary/About/About';
6
6
  import Amount from '../../Step5/Summary/Amount/Amount';
7
- import { Container, Panel, Actions, Notice } from './styles';
7
+ import { Container, Panel, Actions, Notice, Local } from './styles';
8
8
  import { SubTitle } from '../../styles';
9
9
  import { Information } from '../../Step5/Summary/styles';
10
10
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
@@ -25,6 +25,11 @@ interface Props {
25
25
  addons: AddonItem[]
26
26
  coupon?: CouponData
27
27
  total: TotalData
28
+ /**
29
+ * Claude - 2026-09-26: the total in the currency Google Travel quoted
30
+ * ("1,840 ALL"), shown under it for a Google arrival - display only.
31
+ */
32
+ local?: string
28
33
  /**
29
34
  * The card surcharge already included in `total`, formatted with its
30
35
  * currency - undefined when the chosen method carries none.
@@ -65,7 +70,7 @@ interface Props {
65
70
  * page and scrolled away while add-ons were being ticked - so the number
66
71
  * changed out of sight of the person it was changing for.
67
72
  */
68
- const Sidebar = ({ step1, step2, step3, fare, addons, coupon, total, surcharge, comission, action, single, pending, t, onPay, children, payments }: Props): JSX.Element => (
73
+ const Sidebar = ({ step1, step2, step3, fare, addons, coupon, total, local, surcharge, comission, action, single, pending, t, onPay, children, payments }: Props): JSX.Element => (
69
74
  <Container>
70
75
  <Panel className="box">
71
76
  <SubTitle>
@@ -116,6 +121,10 @@ const Sidebar = ({ step1, step2, step3, fare, addons, coupon, total, surcharge,
116
121
  ) }
117
122
 
118
123
  <Amount type="total" value={ total.display } t={ t } />
124
+
125
+ { local && (
126
+ <Local>{ t('routes_order.step5.summary.local', { ns: 'common', amount: local }) }</Local>
127
+ ) }
119
128
  </div>
120
129
 
121
130
  <Actions>
@@ -50,3 +50,11 @@ export const Notice = styled.p`
50
50
  color: ${ props => props.theme.font.faded };
51
51
  text-align: center;
52
52
  `;
53
+
54
+ // Claude - 2026-09-26: the total in the currency Google Travel quoted
55
+ export const Local = styled.div`
56
+ margin-top: -4px;
57
+ text-align: right;
58
+ color: ${ props => props.theme.font.faded };
59
+ font-size: ${ props => props.theme.size.s };
60
+ `;
@@ -3,12 +3,12 @@ import { TFunction } from 'i18next';
3
3
  import { HiOutlineArrowNarrowRight, HiOutlineDocumentText, HiChevronDown, HiChevronUp } from 'react-icons/hi';
4
4
  import { BiHourglass } from 'react-icons/bi';
5
5
  import { RiBus2Line } from 'react-icons/ri';
6
- import { MdOutlineAltRoute } from 'react-icons/md';
6
+ import { MdOutlineAltRoute, MdEventSeat } from 'react-icons/md';
7
7
  import { formatDuration, splitPrice } from '../refine';
8
8
  import { Button, RouteFeature, Information, Rating } from '@autobusal/common';
9
9
  import { useGetSettings } from '@autobusal/providers/services';
10
10
  import { createDate, getDayParts } from '@autobusal/utilities';
11
- import { Container, ButtonDetails, SpecialOffer, FavoriteStop, Trip, Company, Location, Stop, Iso, Time, Price, Amount, Decimals, Currency, PriceNotice, SeatsLeft, LinkCompany, CodeCompany, Details, Detail, ButtonPolicy, TitleDetail, FeaturesItems, Leaves, Day, NextDay, Standard, Stops, StopsItems, StopRow, StopTime, StopDay, StopBorder } from './styles';
11
+ import { Container, ButtonDetails, SpecialOffer, FavoriteStop, Trip, Company, Location, Stop, Iso, Time, Price, Amount, Decimals, Currency, PriceNotice, SeatsLeft, SeatsCompact, LinkCompany, CodeCompany, Details, Detail, ButtonPolicy, TitleDetail, FeaturesItems, Leaves, Day, NextDay, Standard, Stops, StopsItems, StopRow, StopTime, StopDay, StopBorder } from './styles';
12
12
  import { FoundData } from '@autobusal/providers/types/routes';
13
13
 
14
14
  /**
@@ -195,6 +195,25 @@ const Route = ({ data, type, date, passengers, t, onSave }: Props): JSX.Element
195
195
  <MdOutlineAltRoute />
196
196
  { data.code }
197
197
  </CodeCompany>
198
+
199
+ { /* Claude - 2026-09-27 (Ferjolt: "on small screens, can we show the
200
+ Seats left inline with operator logo and route code, on the
201
+ right side of the row?"). A seat and the number rather than the
202
+ sentence: the row is short on a phone, and a seat icon ALONE
203
+ could be read as "choose your seat" (a paid add-on) - the
204
+ number is what makes it a count. The full sentence stays for
205
+ screen readers and as the tooltip. Phones only; wider screens
206
+ keep the sentence under the price. */ }
207
+ { seats !== null && (
208
+ <SeatsCompact
209
+ $low={ seats <= LOW_SEATS }
210
+ title={ t('routes_order.step2.route.seats_left', { count: seats }) }
211
+ aria-label={ t('routes_order.step2.route.seats_left', { count: seats }) }
212
+ >
213
+ <MdEventSeat aria-hidden="true" />
214
+ { seats }
215
+ </SeatsCompact>
216
+ ) }
198
217
  </Company>
199
218
 
200
219
  <Location $side="from">
@@ -157,6 +157,18 @@ export const Company = styled.div`
157
157
  flex-wrap: wrap;
158
158
  gap: 8px;
159
159
  margin-bottom: 4px;
160
+
161
+ /*
162
+ * Claude - 2026-09-27: the operator's rating as ONE star and the number
163
+ * ("★ 4.3 (3)") on a phone. Measured at 375px: logo 96 + five stars and
164
+ * the score 120 + route code 77 + the seats count 48 is 365px on a 315px
165
+ * row, so the seats count wrapped to a line of its own. The full five
166
+ * stars stay on wider screens, and the whole summary is the tooltip
167
+ * (Rating's title). The rating is the only span before the seats count.
168
+ */
169
+ & > span:first-of-type svg:not(:first-child) {
170
+ display: none;
171
+ }
160
172
  }
161
173
 
162
174
  /**
@@ -1051,7 +1063,41 @@ export const SeatsLeft = styled.span<{ $low: boolean }>`
1051
1063
  font-weight: ${ props => (props.$low ? 700 : 400) };
1052
1064
  color: ${ props => (props.$low ? props.theme.font.error : props.theme.font.faded) };
1053
1065
 
1066
+ // Claude - 2026-09-27: on a phone the count moves up to the operator row
1067
+ // (SeatsCompact) - one place to read it, not two
1068
+ @media (max-width: 639px) {
1069
+ display: none;
1070
+ }
1071
+
1054
1072
  @media (min-width: 768px) {
1055
1073
  text-align: center;
1056
1074
  }
1057
1075
  `;
1076
+
1077
+ /*
1078
+ * Claude - 2026-09-27 (Ferjolt): the seats count on a phone - a seat and the
1079
+ * number, pushed to the right end of the operator row (logo, rating, route
1080
+ * code). Same emphasis as SeatsLeft when few are left.
1081
+ */
1082
+ export const SeatsCompact = styled.span<{ $low: boolean }>`
1083
+ display: none;
1084
+
1085
+ @media (max-width: 639px) {
1086
+ display: inline-flex;
1087
+ align-items: center;
1088
+ gap: 4px;
1089
+ margin-left: auto;
1090
+ padding: 2px 8px;
1091
+ border-radius: 100px;
1092
+ background: ${ props => (props.$low ? 'rgba(204, 51, 51, .1)' : props.theme.background.neutral) };
1093
+ color: ${ props => (props.$low ? props.theme.font.error : props.theme.font.faded) };
1094
+ font-size: ${ props => props.theme.size.xs };
1095
+ font-weight: 700;
1096
+ line-height: 1.4;
1097
+ white-space: nowrap;
1098
+
1099
+ svg {
1100
+ font-size: 1.15em;
1101
+ }
1102
+ }
1103
+ `;
package/RoutesOrder.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { useState, useEffect, useMemo, useRef } from 'react';
2
2
  import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
3
+ import { rememberLocalCurrency } from './Checkout/LocalPrice';
3
4
  import { TFunction } from 'i18next';
4
5
  import { AiOutlineSearch, AiOutlineCaretDown, AiOutlineCaretUp } from 'react-icons/ai';
5
6
  import { Meta, PageBreadcrumbs } from '@autobusal/common';
@@ -109,6 +110,10 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
109
110
  */
110
111
  const [ searchParams, setSearchParams ] = useSearchParams();
111
112
 
113
+ // Claude - 2026-09-26: a Google Travel arrival names the currency Google
114
+ // quoted (`local=ALL`); the checkout shows the price in it too
115
+ rememberLocalCurrency(searchParams);
116
+
112
117
  const navigate = useNavigate();
113
118
 
114
119
  const location = useLocation();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.40.1",
3
+ "version": "1.40.3",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts",