@autobusal/admin-external 1.0.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/Browse.tsx ADDED
@@ -0,0 +1,107 @@
1
+ import { TFunction } from 'i18next';
2
+ import { Link } from 'react-router-dom';
3
+ import { Meta, Button, Box } from '@autobusal/common';
4
+ import { usePage, useBreadcrumbs } from '@autobusal/hooks';
5
+ import { success } from '@autobusal/utilities';
6
+ import { Table, Actions, SectionTitle, AvailableList, AvailableLink, TopActions } from './styles';
7
+ import { useGetExternalProviders, useDeleteExternalProvider } from './services';
8
+
9
+ interface Props {
10
+ url: string
11
+ t: TFunction<'common'>
12
+ }
13
+
14
+ const Browse = ({ url, t }: Props): JSX.Element => {
15
+ usePage('external');
16
+
17
+ useBreadcrumbs(t('admin_external.browse.title', { ns: 'common' }));
18
+
19
+ const { data, isLoading, refetch } = useGetExternalProviders();
20
+
21
+ const { mutate: Delete } = useDeleteExternalProvider();
22
+
23
+ const onDelete = (provider: string): void => {
24
+ if (!window.confirm(t('table.confirm', { ns: 'common' }))) {
25
+ return;
26
+ }
27
+
28
+ Delete(provider, {
29
+ onSuccess: () => {
30
+ success(t('admin_external.manage.messages.deleted', { ns: 'common' }));
31
+
32
+ refetch();
33
+ }
34
+ });
35
+ };
36
+
37
+ if (isLoading) {
38
+ return <Box />;
39
+ }
40
+
41
+ return (
42
+ <Meta title={ t('admin_external.browse.title', { ns: 'common' }) }>
43
+ <h1>{ t('admin_external.browse.title', { ns: 'common' }) }</h1>
44
+
45
+ <TopActions>
46
+ <Link to={ `${ url }/stats` }>
47
+ <Button type="button" size="medium" subtype="secondary" text={ t('admin_external.browse.view_stats', { ns: 'common' }) } />
48
+ </Link>
49
+ </TopActions>
50
+
51
+ <div className="box">
52
+ <Table>
53
+ <thead>
54
+ <tr>
55
+ <th>{ t('admin_external.browse.provider', { ns: 'common' }) }</th>
56
+ <th>{ t('admin_external.browse.status', { ns: 'common' }) }</th>
57
+ <th>{ t('admin_external.browse.mode', { ns: 'common' }) }</th>
58
+ <th>{ t('admin_external.browse.markup', { ns: 'common' }) }</th>
59
+ <th>{ t('admin_external.browse.cache_ttl', { ns: 'common' }) }</th>
60
+ <th />
61
+ </tr>
62
+ </thead>
63
+
64
+ <tbody>
65
+ { data?.providers.map(row => (
66
+ <tr key={ row.provider }>
67
+ <td>{ row.provider }</td>
68
+ <td>{ t(`admin_external.status.${ row.status }`, { ns: 'common' }) }</td>
69
+ <td>{ t(`admin_external.mode.${ row.mode }`, { ns: 'common' }) }</td>
70
+ <td>{ row.markup_percent }% + { row.markup_fixed }</td>
71
+ <td>{ row.cache_ttl }s</td>
72
+ <td>
73
+ <Actions>
74
+ <Link to={ `${ url }/manage/${ row.provider }` }>{ t('table.actions.view', { ns: 'common' }) }</Link>
75
+ <button type="button" onClick={ () => onDelete(row.provider) }>{ t('table.actions.delete', { ns: 'common' }) }</button>
76
+ </Actions>
77
+ </td>
78
+ </tr>
79
+ )) }
80
+
81
+ { data?.providers.length === 0 && (
82
+ <tr>
83
+ <td colSpan={ 6 }>{ t('admin_external.browse.empty', { ns: 'common' }) }</td>
84
+ </tr>
85
+ ) }
86
+ </tbody>
87
+ </Table>
88
+ </div>
89
+
90
+ { (data?.available.length ?? 0) > 0 && (
91
+ <>
92
+ <SectionTitle>{ t('admin_external.browse.available', { ns: 'common' }) }</SectionTitle>
93
+
94
+ <AvailableList>
95
+ { data?.available.map(item => (
96
+ <AvailableLink key={ item.provider } to={ `${ url }/manage/${ item.provider }` }>
97
+ { item.name }
98
+ </AvailableLink>
99
+ )) }
100
+ </AvailableList>
101
+ </>
102
+ ) }
103
+ </Meta>
104
+ );
105
+ };
106
+
107
+ export default Browse;
package/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@autobusal/admin-external` are documented here.
4
+ This project adheres to [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
5
+ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [1.0.0] - 2026-07-27
8
+
9
+ ### Added
10
+
11
+ - Initial release. Admin config + stats for the external bus-route
12
+ provider integration (Mode A: search + redirect). `Browse.tsx` lists
13
+ configured providers plus known providers still available to add;
14
+ `Manage.tsx` is the per-provider config form (status/mode/credentials/
15
+ markup/commission/cache_ttl/resellable), keyed by provider slug rather
16
+ than a numeric id since `external_providers` rows are looked up by
17
+ `provider`; `Stats.tsx` shows the search->click funnel per provider
18
+ over a date range. Backed by obtapi's
19
+ `App\Http\Controllers\External\AdminController`.
package/Manage.tsx ADDED
@@ -0,0 +1,144 @@
1
+ import { TFunction } from 'i18next';
2
+ import { useParams, useNavigate } from 'react-router-dom';
3
+ import { Meta, BackWithTitle, Viewer } from '@autobusal/common';
4
+ import { usePage, useBreadcrumbs } from '@autobusal/hooks';
5
+ import { getAllowed, success } from '@autobusal/utilities';
6
+ import { useGetExternalProvider, usePostExternalProvider, useDeleteExternalProvider } from './services';
7
+
8
+ interface Props {
9
+ url: string
10
+ t: TFunction<'common'>
11
+ }
12
+
13
+ const STATUS_VALUES = (t: TFunction<'common'>) => ([
14
+ { id: 'disabled', name: t('admin_external.status.disabled', { ns: 'common' }) },
15
+ { id: 'dedicated', name: t('admin_external.status.dedicated', { ns: 'common' }) },
16
+ { id: 'main', name: t('admin_external.status.main', { ns: 'common' }) }
17
+ ]);
18
+
19
+ const MODE_VALUES = (t: TFunction<'common'>) => ([
20
+ { id: 'redirect', name: t('admin_external.mode.redirect', { ns: 'common' }) },
21
+ { id: 'booking', name: t('admin_external.mode.booking', { ns: 'common' }) }
22
+ ]);
23
+
24
+ const COMMISSION_TYPE_VALUES = (t: TFunction<'common'>) => ([
25
+ { id: 'percent', name: t('admin_external.manage.commission_percent', { ns: 'common' }) },
26
+ { id: 'value', name: t('admin_external.manage.commission_value', { ns: 'common' }) }
27
+ ]);
28
+
29
+ const Manage = ({ url, t }: Props): JSX.Element => {
30
+ const params = useParams();
31
+
32
+ const provider = String(params.provider);
33
+
34
+ usePage('external');
35
+
36
+ const navigate = useNavigate();
37
+
38
+ const { data, isFetching } = useGetExternalProvider(provider);
39
+
40
+ const title = t('admin_external.manage.title', { provider, ns: 'common' });
41
+
42
+ useBreadcrumbs(title, [{ name: t('admin_external.browse.title', { ns: 'common' }), url }]);
43
+
44
+ const { mutate: Update, isPending: isPendingSave } = usePostExternalProvider(provider);
45
+
46
+ const { mutate: Delete, isPending: isPendingDelete } = useDeleteExternalProvider();
47
+
48
+ const onSave = (formData: Record<string, any>): void => {
49
+ Update(formData, {
50
+ onSuccess: () => {
51
+ success(t('admin_external.manage.messages.saved', { ns: 'common' }));
52
+
53
+ navigate(url);
54
+ }
55
+ });
56
+ };
57
+
58
+ const onDelete = (): void => {
59
+ Delete(provider, {
60
+ onSuccess: () => {
61
+ success(t('admin_external.manage.messages.deleted', { ns: 'common' }));
62
+
63
+ navigate(url);
64
+ }
65
+ });
66
+ };
67
+
68
+ return (
69
+ <Meta title={ title }>
70
+ <BackWithTitle title={ title } to={ url } t={ t } />
71
+
72
+ <Viewer
73
+ id={ data?.id ?? 0 }
74
+ data={ [{
75
+ label: t('admin_external.manage.status', { ns: 'common' }),
76
+ name: 'status',
77
+ type: 'select',
78
+ value: data?.status ?? 'disabled',
79
+ values: STATUS_VALUES(t),
80
+ rules: 'required'
81
+ }, {
82
+ label: t('admin_external.manage.mode', { ns: 'common' }),
83
+ name: 'mode',
84
+ type: 'select',
85
+ value: data?.mode ?? 'redirect',
86
+ values: MODE_VALUES(t),
87
+ rules: 'required'
88
+ }, {}, {
89
+ label: t('admin_external.manage.credentials', { ns: 'common' }),
90
+ name: 'credentials',
91
+ type: 'textarea',
92
+ notice: data?.has_credentials
93
+ ? t('admin_external.manage.credentials_notice_set', { ns: 'common' })
94
+ : t('admin_external.manage.credentials_notice_unset', { ns: 'common' })
95
+ }, {}, {
96
+ label: t('admin_external.manage.markup_percent', { ns: 'common' }),
97
+ name: 'markup_percent',
98
+ type: 'text',
99
+ value: data?.markup_percent ?? 0,
100
+ rules: 'required|numeric'
101
+ }, {
102
+ label: t('admin_external.manage.markup_fixed', { ns: 'common' }),
103
+ name: 'markup_fixed',
104
+ type: 'text',
105
+ value: data?.markup_fixed ?? 0,
106
+ rules: 'required|numeric'
107
+ }, {}, {
108
+ label: t('admin_external.manage.our_commission_type', { ns: 'common' }),
109
+ name: 'our_commission_type',
110
+ type: 'select',
111
+ value: data?.our_commission?.type ?? 'percent',
112
+ values: COMMISSION_TYPE_VALUES(t)
113
+ }, {
114
+ label: t('admin_external.manage.our_commission_value', { ns: 'common' }),
115
+ name: 'our_commission_value',
116
+ type: 'text',
117
+ value: data?.our_commission?.value ?? 0
118
+ }, {}, {
119
+ label: t('admin_external.manage.cache_ttl', { ns: 'common' }),
120
+ name: 'cache_ttl',
121
+ type: 'text',
122
+ value: data?.cache_ttl ?? 300,
123
+ rules: 'required|numeric'
124
+ }, {
125
+ label: t('admin_external.manage.resellable', { ns: 'common' }),
126
+ name: 'resellable',
127
+ type: 'select',
128
+ value: data?.resellable ? 1 : 0,
129
+ values: getAllowed(t)
130
+ }] }
131
+ fetching={ isFetching }
132
+ pending={ isPendingSave || isPendingDelete }
133
+ t={ t }
134
+ actions={
135
+ data ? ['save', 'delete'] : ['save']
136
+ }
137
+ onSave={ onSave }
138
+ onDelete={ onDelete }
139
+ />
140
+ </Meta>
141
+ );
142
+ };
143
+
144
+ export default Manage;
package/Stats.tsx ADDED
@@ -0,0 +1,78 @@
1
+ import { useState } from 'react';
2
+ import { TFunction } from 'i18next';
3
+ import { Meta, BackWithTitle, Box } from '@autobusal/common';
4
+ import { usePage, useBreadcrumbs } from '@autobusal/hooks';
5
+ import { Table, Filters } from './styles';
6
+ import { useGetExternalStats } from './services';
7
+
8
+ interface Props {
9
+ url: string
10
+ t: TFunction<'common'>
11
+ }
12
+
13
+ const Stats = ({ url, t }: Props): JSX.Element => {
14
+ usePage('external');
15
+
16
+ useBreadcrumbs(t('admin_external.stats.title', { ns: 'common' }), [{ name: t('admin_external.browse.title', { ns: 'common' }), url }]);
17
+
18
+ const [ from, setFrom ] = useState<string>('');
19
+ const [ to, setTo ] = useState<string>('');
20
+
21
+ const { data, isLoading } = useGetExternalStats({ from: from || undefined, to: to || undefined });
22
+
23
+ return (
24
+ <Meta title={ t('admin_external.stats.title', { ns: 'common' }) }>
25
+ <BackWithTitle title={ t('admin_external.stats.title', { ns: 'common' }) } to={ url } t={ t } />
26
+
27
+ <Filters>
28
+ <div>
29
+ <label>{ t('admin_external.stats.from', { ns: 'common' }) }</label>
30
+ <input type="date" value={ from } onChange={ (e) => setFrom(e.target.value) } />
31
+ </div>
32
+
33
+ <div>
34
+ <label>{ t('admin_external.stats.to', { ns: 'common' }) }</label>
35
+ <input type="date" value={ to } onChange={ (e) => setTo(e.target.value) } />
36
+ </div>
37
+ </Filters>
38
+
39
+ { isLoading ? <Box /> : (
40
+ <div className="box">
41
+ <Table>
42
+ <thead>
43
+ <tr>
44
+ <th>{ t('admin_external.stats.provider', { ns: 'common' }) }</th>
45
+ <th>{ t('admin_external.stats.searches', { ns: 'common' }) }</th>
46
+ <th>{ t('admin_external.stats.results', { ns: 'common' }) }</th>
47
+ <th>{ t('admin_external.stats.successes', { ns: 'common' }) }</th>
48
+ <th>{ t('admin_external.stats.avg_duration_ms', { ns: 'common' }) }</th>
49
+ <th>{ t('admin_external.stats.clicks', { ns: 'common' }) }</th>
50
+ </tr>
51
+ </thead>
52
+
53
+ <tbody>
54
+ { data?.map(row => (
55
+ <tr key={ row.provider }>
56
+ <td>{ row.provider }</td>
57
+ <td>{ row.searches }</td>
58
+ <td>{ row.results }</td>
59
+ <td>{ row.successes }</td>
60
+ <td>{ row.avg_duration_ms }ms</td>
61
+ <td>{ row.clicks }</td>
62
+ </tr>
63
+ )) }
64
+
65
+ { data?.length === 0 && (
66
+ <tr>
67
+ <td colSpan={ 6 }>{ t('admin_external.stats.empty', { ns: 'common' }) }</td>
68
+ </tr>
69
+ ) }
70
+ </tbody>
71
+ </Table>
72
+ </div>
73
+ ) }
74
+ </Meta>
75
+ );
76
+ };
77
+
78
+ export default Stats;
package/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ import Browse from './Browse';
2
+ import Manage from './Manage';
3
+ import Stats from './Stats';
4
+
5
+ export {
6
+ Browse,
7
+ Manage,
8
+ Stats
9
+ };
package/package.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "@autobusal/admin-external",
3
+ "version": "1.0.0",
4
+ "author": "Ferjolt Ozuni",
5
+ "type": "module",
6
+ "main": "index.ts"
7
+ }
package/services.ts ADDED
@@ -0,0 +1,114 @@
1
+ import { UseMutationResult, UseQueryResult, UseSuspenseQueryResult, useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query';
2
+ import { apiClient } from '@autobusal/providers';
3
+ import { DropdownData } from '@autobusal/providers/types/other';
4
+ import { ExternalProviderBrowseData, ExternalProviderData, ExternalStatData } from './types';
5
+
6
+ export const useGetExternalProviders = (): UseQueryResult<ExternalProviderBrowseData> => (
7
+ useQuery({
8
+ queryKey: ['admin-external-browse'],
9
+ queryFn: async () => (
10
+ await apiClient
11
+ .get('/api/external/admin/browse')
12
+ .then(response => (
13
+ response.data
14
+ ))
15
+ )
16
+ })
17
+ );
18
+
19
+ export const useGetExternalProvider = (provider: string): UseSuspenseQueryResult<ExternalProviderData | null> => (
20
+ useSuspenseQuery({
21
+ queryKey: ['admin-external-manage', { provider }],
22
+ queryFn: async () => (
23
+ await apiClient
24
+ .get('/api/external/admin/get', { params: { provider } })
25
+ .then(response => (
26
+ response.data
27
+ ))
28
+ )
29
+ })
30
+ );
31
+
32
+ // every provider slug the app knows how to talk to, for the create form's
33
+ // dropdown when landing on a provider that isn't configured yet
34
+ export const useGetAvailableProviders = (): UseQueryResult<DropdownData[]> => (
35
+ useQuery({
36
+ queryKey: ['admin-external-providers'],
37
+ queryFn: async () => (
38
+ await apiClient
39
+ .get('/api/external/admin/providers')
40
+ .then(response => (
41
+ response.data
42
+ ))
43
+ )
44
+ })
45
+ );
46
+
47
+ export const usePostExternalProvider = (provider: string): UseMutationResult<void, Error, Record<string, any>, unknown> => (
48
+ useMutation({
49
+ mutationKey: ['admin-external-save', { provider }],
50
+ mutationFn: async (data: Record<string, any>) => {
51
+ const formData = new FormData();
52
+
53
+ formData.append('provider', provider);
54
+
55
+ Object.keys(data).map(name => {
56
+ if (name === 'credentials') {
57
+ // Edited: Ferjolt Ozuni - Date: 2026-07-27
58
+ // Sent as a flat JSON string (same convention as operators/
59
+ // external_providers - see Traits\Arrayable::arraySave()), NOT
60
+ // bracket-notation fields - those broke going through magus's
61
+ // BFF proxy, which rebuilds the multipart body from $_POST via
62
+ // curl's array-based CURLOPT_POSTFIELDS and doesn't flatten
63
+ // nested arrays back into bracket notation (confirmed live: the
64
+ // backend received the literal string "Array"). AdminController
65
+ // json_decode()s this string server-side. Leaving it blank keeps
66
+ // the existing stored credentials untouched.
67
+ if (data.credentials) {
68
+ formData.append('credentials', data.credentials);
69
+ }
70
+ } else if (data[name] !== undefined && data[name] !== null) {
71
+ formData.append(name, String(data[name]));
72
+ }
73
+ });
74
+
75
+ return await apiClient
76
+ .post('/api/external/admin/update', formData, {
77
+ headers: {
78
+ 'Content-Type': 'multipart/form-data'
79
+ }
80
+ })
81
+ .then(response => (
82
+ response.data
83
+ ));
84
+ }
85
+ })
86
+ );
87
+
88
+ export const useDeleteExternalProvider = (): UseMutationResult<void, Error, string, unknown> => (
89
+ useMutation({
90
+ mutationKey: ['admin-external-delete'],
91
+ mutationFn: async (provider: string) => (
92
+ await apiClient
93
+ .delete('/api/external/admin/delete', {
94
+ params: { provider }
95
+ })
96
+ .then(response => (
97
+ response.data
98
+ ))
99
+ )
100
+ })
101
+ );
102
+
103
+ export const useGetExternalStats = (params: { from?: string, to?: string }): UseQueryResult<ExternalStatData[]> => (
104
+ useQuery({
105
+ queryKey: ['admin-external-stats', params],
106
+ queryFn: async () => (
107
+ await apiClient
108
+ .get('/api/external/admin/stats', { params })
109
+ .then(response => (
110
+ response.data
111
+ ))
112
+ )
113
+ })
114
+ );
package/styles.ts ADDED
@@ -0,0 +1,55 @@
1
+ import styled from 'styled-components';
2
+ import { Link } from 'react-router-dom';
3
+
4
+ export const Table = styled.table`
5
+ width: 100%;
6
+ border-collapse: collapse;
7
+
8
+ th, td {
9
+ text-align: left;
10
+ padding: 10px 12px;
11
+ border-bottom: 1px solid ${ props => props.theme.primary.neutral };
12
+ }
13
+
14
+ th {
15
+ font-size: ${ props => props.theme.size.sm };
16
+ color: ${ props => props.theme.font.faded };
17
+ }
18
+ `;
19
+
20
+ export const Actions = styled.div`
21
+ display: flex;
22
+ gap: 10px;
23
+ `;
24
+
25
+ export const SectionTitle = styled.h2`
26
+ margin: 25px 0 10px;
27
+ `;
28
+
29
+ export const AvailableList = styled.div`
30
+ display: flex;
31
+ flex-direction: column;
32
+ gap: 8px;
33
+ `;
34
+
35
+ export const AvailableLink = styled(Link)`
36
+ color: ${ props => props.theme.primary.normal };
37
+ `;
38
+
39
+ export const TopActions = styled.div`
40
+ display: flex;
41
+ justify-content: flex-end;
42
+ margin-bottom: 15px;
43
+ `;
44
+
45
+ export const Filters = styled.div`
46
+ display: flex;
47
+ gap: 15px;
48
+ align-items: flex-end;
49
+ margin-bottom: 20px;
50
+
51
+ input {
52
+ padding: 8px 10px;
53
+ border-radius: 6px;
54
+ }
55
+ `;
package/types.ts ADDED
@@ -0,0 +1,34 @@
1
+ export interface ExternalProviderData {
2
+ id: number
3
+ label_id: number
4
+ provider: string
5
+ status: 'disabled' | 'dedicated' | 'main'
6
+ mode: 'redirect' | 'booking'
7
+ has_credentials: boolean
8
+ our_commission?: { type: 'percent' | 'value', value: number }
9
+ markup_percent: number
10
+ markup_fixed: number
11
+ cache_ttl: number
12
+ resellable: boolean
13
+ created_at: string
14
+ updated_at: string
15
+ }
16
+
17
+ export interface ExternalProviderAvailable {
18
+ provider: string
19
+ name: string
20
+ }
21
+
22
+ export interface ExternalProviderBrowseData {
23
+ providers: ExternalProviderData[]
24
+ available: ExternalProviderAvailable[]
25
+ }
26
+
27
+ export interface ExternalStatData {
28
+ provider: string
29
+ searches: number
30
+ results: number
31
+ successes: number
32
+ avg_duration_ms: number
33
+ clicks: number
34
+ }