@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 +107 -0
- package/CHANGELOG.md +19 -0
- package/Manage.tsx +144 -0
- package/Stats.tsx +78 -0
- package/index.ts +9 -0
- package/package.json +7 -0
- package/services.ts +114 -0
- package/styles.ts +55 -0
- package/types.ts +34 -0
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
package/package.json
ADDED
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
|
+
}
|