sprintify-ui 0.12.26 → 0.12.28
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/README.md +13 -0
- package/dist/sprintify-ui.es.js +10175 -9935
- package/dist/tailwindcss/table.js +27 -0
- package/dist/types/components/BaseAutocompleteDrawer.vue.d.ts +1 -1
- package/dist/types/components/BaseBelongsTo.vue.d.ts +1 -1
- package/dist/types/components/BaseBelongsToFetch.vue.d.ts +1 -1
- package/dist/types/components/BaseHasMany.vue.d.ts +1 -1
- package/dist/types/composables/placesAutocomplete.d.ts +31 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/svg/BaseGoogleMapsLogo.vue.d.ts +2 -0
- package/package.json +2 -2
- package/src/components/BaseAddressForm.stories.js +25 -0
- package/src/components/BaseAddressForm.vue +244 -51
- package/src/components/BaseCard.stories.js +57 -0
- package/src/composables/placesAutocomplete.ts +194 -0
- package/src/index.ts +8 -1
- package/src/svg/BaseGoogleMapsLogo.vue +63 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/* eslint-disable no-undef */
|
|
2
|
+
import { Ref } from 'vue';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Which Google Places API the address autocomplete uses.
|
|
6
|
+
* - `auto`: try Places API (New) first, fall back to the legacy Places API
|
|
7
|
+
* when the API key is not allowed to use the new one.
|
|
8
|
+
* - `new`: always use Places API (New).
|
|
9
|
+
* - `legacy`: always use the legacy Places API (google.maps.places.Autocomplete widget).
|
|
10
|
+
*/
|
|
11
|
+
export type GooglePlacesApi = 'auto' | 'new' | 'legacy';
|
|
12
|
+
|
|
13
|
+
export type ResolvedGooglePlacesApi = 'new' | 'legacy' | 'unavailable';
|
|
14
|
+
|
|
15
|
+
export interface PlaceSuggestion {
|
|
16
|
+
id: string;
|
|
17
|
+
mainText: string;
|
|
18
|
+
secondaryText: string;
|
|
19
|
+
prediction: google.maps.places.PlacePrediction;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Address component, normalized across the legacy and new Places APIs. */
|
|
23
|
+
export interface PlaceAddressComponent {
|
|
24
|
+
types: string[];
|
|
25
|
+
longText: string;
|
|
26
|
+
shortText: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Closest equivalent of the legacy `types: ['address']` collection (max 5 types)
|
|
30
|
+
const ADDRESS_TYPES = ['street_address', 'premise', 'subpremise', 'route'];
|
|
31
|
+
|
|
32
|
+
// Detected once per page load, shared by every address form
|
|
33
|
+
const detectedApi = ref<ResolvedGooglePlacesApi | null>(null);
|
|
34
|
+
|
|
35
|
+
// gRPC status code for PERMISSION_DENIED
|
|
36
|
+
const GRPC_PERMISSION_DENIED = 7;
|
|
37
|
+
|
|
38
|
+
function isPermissionDenied(error: unknown): boolean {
|
|
39
|
+
if (!(error instanceof Error)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
// When the API is not enabled, Google throws an `RpcError` with the numeric
|
|
43
|
+
// gRPC code (7), not the documented `MapsRequestError` with 'PERMISSION_DENIED'.
|
|
44
|
+
const code = (error as { code?: string | number }).code;
|
|
45
|
+
return code === 'PERMISSION_DENIED'
|
|
46
|
+
|| code === GRPC_PERMISSION_DENIED
|
|
47
|
+
|| error.message.includes('PERMISSION_DENIED');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function usePlacesAutocomplete(
|
|
51
|
+
regionCode: Ref<string | null>,
|
|
52
|
+
mode: GooglePlacesApi
|
|
53
|
+
) {
|
|
54
|
+
/** null while `auto` has not detected the API yet */
|
|
55
|
+
const api = computed((): ResolvedGooglePlacesApi | null => {
|
|
56
|
+
if (mode === 'legacy') {
|
|
57
|
+
return 'legacy';
|
|
58
|
+
}
|
|
59
|
+
if (mode === 'new') {
|
|
60
|
+
return detectedApi.value === 'unavailable' ? 'unavailable' : 'new';
|
|
61
|
+
}
|
|
62
|
+
return detectedApi.value;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** Suggestions come from Places API (New), or the API is still being detected */
|
|
66
|
+
const usesNewApi = computed(() => api.value === 'new' || api.value === null);
|
|
67
|
+
|
|
68
|
+
const usesLegacyApi = computed(() => api.value === 'legacy');
|
|
69
|
+
|
|
70
|
+
let sessionToken = null as google.maps.places.AutocompleteSessionToken | null;
|
|
71
|
+
let requestId = 0;
|
|
72
|
+
|
|
73
|
+
async function loadPlacesLibrary(): Promise<google.maps.PlacesLibrary | null> {
|
|
74
|
+
if (!window.google.maps.importLibrary) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return (await window.google.maps.importLibrary('places')) as google.maps.PlacesLibrary;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Fetch suggestions from Places API (New).
|
|
82
|
+
* Returns null when the response is stale or the new API is unavailable.
|
|
83
|
+
*/
|
|
84
|
+
async function fetchSuggestions(input: string): Promise<PlaceSuggestion[] | null> {
|
|
85
|
+
const currentRequestId = ++requestId;
|
|
86
|
+
|
|
87
|
+
// The Google Maps script is loaded by the host app and may not be ready yet
|
|
88
|
+
if (!window.google?.maps) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const placesLibrary = await loadPlacesLibrary();
|
|
94
|
+
|
|
95
|
+
if (!placesLibrary) {
|
|
96
|
+
onNewApiDenied();
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
sessionToken ??= new placesLibrary.AutocompleteSessionToken();
|
|
101
|
+
|
|
102
|
+
const request: google.maps.places.AutocompleteRequest = {
|
|
103
|
+
input,
|
|
104
|
+
sessionToken,
|
|
105
|
+
includedPrimaryTypes: ADDRESS_TYPES,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (regionCode.value) {
|
|
109
|
+
request.includedRegionCodes = [regionCode.value];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const { suggestions } = await placesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions(request);
|
|
113
|
+
|
|
114
|
+
if (mode === 'auto') {
|
|
115
|
+
detectedApi.value = 'new';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (currentRequestId !== requestId) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return suggestions
|
|
123
|
+
.map((suggestion) => suggestion.placePrediction)
|
|
124
|
+
.filter((prediction): prediction is google.maps.places.PlacePrediction => !!prediction)
|
|
125
|
+
.map((prediction) => ({
|
|
126
|
+
id: prediction.placeId,
|
|
127
|
+
mainText: prediction.mainText?.text ?? prediction.text.text,
|
|
128
|
+
secondaryText: prediction.secondaryText?.text ?? '',
|
|
129
|
+
prediction,
|
|
130
|
+
}));
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (isPermissionDenied(error)) {
|
|
133
|
+
onNewApiDenied();
|
|
134
|
+
} else {
|
|
135
|
+
// Quota, network or server error: keep the current API, the next keystroke will retry
|
|
136
|
+
console.warn('[BaseAddressForm] Places API (New) request failed', error);
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function onNewApiDenied() {
|
|
143
|
+
// Several requests (or forms) can be denied at once: the first one switches to legacy,
|
|
144
|
+
// the others must not turn autocomplete off.
|
|
145
|
+
if (mode === 'auto' && (detectedApi.value === null || detectedApi.value === 'legacy')) {
|
|
146
|
+
detectedApi.value = 'legacy';
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
detectedApi.value = 'unavailable';
|
|
150
|
+
console.warn('[BaseAddressForm] Places API (New) is not enabled for this API key. Address autocomplete is disabled.');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Fetch the address of a suggestion. Ends the billing session. */
|
|
154
|
+
async function fetchAddressComponents(suggestion: PlaceSuggestion): Promise<PlaceAddressComponent[]> {
|
|
155
|
+
const place = suggestion.prediction.toPlace();
|
|
156
|
+
sessionToken = null;
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
await place.fetchFields({ fields: ['addressComponents'] });
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.warn('[BaseAddressForm] Places API (New) place details request failed', error);
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return (place.addressComponents ?? []).map((component) => ({
|
|
166
|
+
types: component.types,
|
|
167
|
+
longText: component.longText ?? '',
|
|
168
|
+
shortText: component.shortText ?? '',
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function cancelPendingSuggestions() {
|
|
173
|
+
requestId++;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
api,
|
|
178
|
+
usesNewApi,
|
|
179
|
+
usesLegacyApi,
|
|
180
|
+
fetchSuggestions,
|
|
181
|
+
fetchAddressComponents,
|
|
182
|
+
cancelPendingSuggestions,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function normalizeLegacyAddressComponents(
|
|
187
|
+
components: google.maps.GeocoderAddressComponent[]
|
|
188
|
+
): PlaceAddressComponent[] {
|
|
189
|
+
return components.map((component) => ({
|
|
190
|
+
types: component.types,
|
|
191
|
+
longText: component.long_name,
|
|
192
|
+
shortText: component.short_name,
|
|
193
|
+
}));
|
|
194
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { Region } from './types/Region';
|
|
|
23
23
|
import { useI18nStore } from './stores/i18n';
|
|
24
24
|
import { Size } from './utils/sizes';
|
|
25
25
|
import { SettingsStorage } from './utils/storage';
|
|
26
|
+
import type { GooglePlacesApi } from './composables/placesAutocomplete';
|
|
26
27
|
|
|
27
28
|
export interface OptionProps {
|
|
28
29
|
size?: Size;
|
|
@@ -39,7 +40,8 @@ export interface Options {
|
|
|
39
40
|
settingsStorage?: SettingsStorage;
|
|
40
41
|
countries?: Country[];
|
|
41
42
|
regions?: Region[];
|
|
42
|
-
props?: OptionProps
|
|
43
|
+
props?: OptionProps;
|
|
44
|
+
googlePlacesApi?: GooglePlacesApi;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
const config = {
|
|
@@ -65,6 +67,7 @@ const config = {
|
|
|
65
67
|
settingsStorage: new SettingsStorage(),
|
|
66
68
|
countries: [] as Country[],
|
|
67
69
|
regions: [] as Region[],
|
|
70
|
+
googlePlacesApi: 'auto' as GooglePlacesApi,
|
|
68
71
|
props: {
|
|
69
72
|
size: 'md',
|
|
70
73
|
} as OptionProps | undefined
|
|
@@ -112,6 +115,10 @@ function install(app: App, options?: Options) {
|
|
|
112
115
|
config.props = options.props;
|
|
113
116
|
}
|
|
114
117
|
|
|
118
|
+
if (options?.googlePlacesApi) {
|
|
119
|
+
config.googlePlacesApi = options.googlePlacesApi;
|
|
120
|
+
}
|
|
121
|
+
|
|
115
122
|
if (app.config.globalProperties.$i18n) {
|
|
116
123
|
watch(
|
|
117
124
|
() => app.config.globalProperties.$i18n.locale,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<!-- Official Google Maps attribution logo (gray, non-outlined), required when showing Places data without a map. -->
|
|
2
|
+
<template>
|
|
3
|
+
<svg
|
|
4
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
5
|
+
viewBox="0 0 98 18"
|
|
6
|
+
role="img"
|
|
7
|
+
aria-label="Google Maps"
|
|
8
|
+
>
|
|
9
|
+
<g>
|
|
10
|
+
<path
|
|
11
|
+
fill="#5E5E5E"
|
|
12
|
+
d="M7.08,13.96c-1.93,0-3.59-0.68-4.99-2.05C0.7,10.54,0,8.9,0,6.98c0-1.92,0.7-3.56,2.09-4.93
|
|
13
|
+
C3.49,0.68,5.15,0,7.08,0c1.85,0,3.45,0.64,4.79,1.92l-1.35,1.35C9.56,2.36,8.41,1.91,7.08,1.91c-1.4,0-2.58,0.49-3.55,1.48
|
|
14
|
+
C2.56,4.38,2.08,5.57,2.08,6.98c0,1.41,0.49,2.61,1.46,3.59c0.97,0.99,2.15,1.48,3.55,1.48c1.42,0,2.59-0.47,3.53-1.4
|
|
15
|
+
c0.56-0.56,0.9-1.36,1.04-2.4H7.08V6.35h6.42c0.07,0.34,0.1,0.74,0.1,1.19c0,1.87-0.55,3.36-1.65,4.46
|
|
16
|
+
C10.71,13.31,9.08,13.96,7.08,13.96z M22,12.68c-0.87,0.86-1.94,1.29-3.2,1.29c-1.26,0-2.33-0.43-3.2-1.29
|
|
17
|
+
c-0.87-0.86-1.31-1.93-1.31-3.21c0-1.28,0.44-2.35,1.31-3.21c0.87-0.86,1.94-1.29,3.2-1.29c1.26,0,2.33,0.43,3.2,1.29
|
|
18
|
+
c0.87,0.86,1.31,1.93,1.31,3.21C23.31,10.75,22.88,11.82,22,12.68z M17.01,11.42c0.5,0.52,1.1,0.77,1.79,0.77
|
|
19
|
+
c0.69,0,1.29-0.26,1.79-0.77c0.5-0.52,0.75-1.17,0.75-1.95c0-0.79-0.25-1.45-0.74-1.96C20.1,7,19.5,6.74,18.8,6.74
|
|
20
|
+
c-0.7,0-1.3,0.26-1.8,0.77c-0.49,0.51-0.74,1.16-0.74,1.96C16.26,10.25,16.51,10.9,17.01,11.42z M31.85,12.68
|
|
21
|
+
c-0.87,0.86-1.94,1.29-3.2,1.29s-2.33-0.43-3.2-1.29c-0.87-0.86-1.31-1.93-1.31-3.21c0-1.28,0.44-2.35,1.31-3.21
|
|
22
|
+
c0.87-0.86,1.94-1.29,3.2-1.29s2.33,0.43,3.2,1.29c0.87,0.86,1.31,1.93,1.31,3.21C33.16,10.75,32.72,11.82,31.85,12.68z
|
|
23
|
+
M26.86,11.42c0.5,0.52,1.1,0.77,1.79,0.77c0.69,0,1.29-0.26,1.79-0.77c0.5-0.52,0.75-1.17,0.75-1.95c0-0.79-0.25-1.45-0.74-1.96
|
|
24
|
+
s-1.09-0.77-1.8-0.77c-0.7,0-1.3,0.26-1.8,0.77c-0.49,0.51-0.74,1.16-0.74,1.96C26.11,10.25,26.36,10.9,26.86,11.42z M38.32,18
|
|
25
|
+
c-1,0-1.84-0.27-2.52-0.8c-0.68-0.53-1.17-1.15-1.46-1.86l1.72-0.72c0.18,0.43,0.47,0.81,0.85,1.12c0.39,0.32,0.86,0.48,1.41,0.48
|
|
26
|
+
c0.75,0,1.34-0.23,1.76-0.68c0.43-0.45,0.64-1.11,0.64-1.96v-0.65h-0.07c-0.56,0.68-1.35,1.02-2.37,1.02
|
|
27
|
+
c-1.15,0-2.15-0.44-3.01-1.31c-0.86-0.87-1.29-1.93-1.29-3.17c0-1.25,0.43-2.31,1.29-3.19c0.86-0.88,1.87-1.32,3.01-1.32
|
|
28
|
+
c0.51,0,0.97,0.1,1.39,0.29c0.41,0.19,0.74,0.43,0.98,0.72h0.07V5.25h1.87v8.07c0,1.57-0.4,2.74-1.2,3.52
|
|
29
|
+
C40.59,17.61,39.57,18,38.32,18z M38.45,12.19c0.68,0,1.25-0.26,1.71-0.77c0.46-0.52,0.69-1.16,0.69-1.93
|
|
30
|
+
c0-0.78-0.23-1.44-0.69-1.96C39.7,7,39.13,6.74,38.45,6.74c-0.69,0-1.28,0.26-1.77,0.78c-0.49,0.52-0.73,1.18-0.73,1.96
|
|
31
|
+
c0,0.77,0.24,1.42,0.73,1.93C37.17,11.93,37.76,12.19,38.45,12.19z M45.93,0.48v13.21h-1.98V0.48H45.93z M51.34,13.96
|
|
32
|
+
c-1.27,0-2.33-0.43-3.18-1.29c-0.85-0.86-1.28-1.93-1.28-3.2c0-1.32,0.41-2.4,1.23-3.24c0.82-0.84,1.82-1.26,3.01-1.26
|
|
33
|
+
c0.55,0,1.05,0.1,1.52,0.3c0.47,0.2,0.85,0.45,1.16,0.77c0.31,0.31,0.55,0.62,0.73,0.91c0.18,0.3,0.33,0.59,0.44,0.89l0.2,0.51
|
|
34
|
+
l-6.01,2.49c0.47,0.91,1.19,1.36,2.18,1.36c0.91,0,1.65-0.41,2.21-1.24l1.53,1.02c-0.34,0.51-0.82,0.97-1.45,1.38
|
|
35
|
+
C53.02,13.76,52.25,13.96,51.34,13.96z M48.84,9.33l4.02-1.67c-0.11-0.28-0.32-0.51-0.63-0.69c-0.31-0.18-0.65-0.26-1.04-0.26
|
|
36
|
+
c-0.58,0-1.12,0.24-1.63,0.72C49.06,7.9,48.82,8.54,48.84,9.33z"
|
|
37
|
+
></path>
|
|
38
|
+
<path
|
|
39
|
+
fill="#5E5E5E"
|
|
40
|
+
d="M60.05,13.69V1.5h1.57l4.24,7.42h0.07l4.24-7.42h1.57v12.19h-1.57V6.45l0.07-2.04h-0.07l-3.81,6.69h-0.92
|
|
41
|
+
l-3.81-6.69h-0.07l0.07,2.04v7.24H60.05z M76.36,13.96c-0.89,0-1.63-0.26-2.22-0.77c-0.6-0.51-0.89-1.19-0.89-2.03
|
|
42
|
+
c0-0.91,0.35-1.62,1.06-2.14c0.7-0.52,1.57-0.77,2.61-0.77c0.92,0,1.67,0.17,2.26,0.51V8.53c0-0.61-0.21-1.1-0.63-1.47
|
|
43
|
+
c-0.42-0.37-0.94-0.55-1.55-0.55c-0.45,0-0.87,0.11-1.23,0.32C75.39,7.04,75.13,7.34,75,7.71L73.57,7.1
|
|
44
|
+
c0.19-0.5,0.57-0.96,1.14-1.39c0.57-0.43,1.32-0.64,2.25-0.64c1.07,0,1.95,0.31,2.66,0.94c0.7,0.62,1.06,1.5,1.06,2.64v5.04h-1.5
|
|
45
|
+
v-1.16H79.1C78.48,13.49,77.56,13.96,76.36,13.96z M76.61,12.53c0.65,0,1.23-0.24,1.76-0.72c0.53-0.48,0.79-1.05,0.79-1.71
|
|
46
|
+
c-0.44-0.36-1.11-0.54-1.99-0.54c-0.76,0-1.33,0.16-1.72,0.49c-0.39,0.33-0.58,0.72-0.58,1.16c0,0.41,0.18,0.73,0.53,0.97
|
|
47
|
+
C75.76,12.41,76.16,12.53,76.61,12.53z M86.58,13.96c-0.64,0-1.21-0.14-1.71-0.41c-0.51-0.27-0.88-0.61-1.13-1.02h-0.07l0.07,1.16
|
|
48
|
+
v3.68h-1.57V5.35h1.5v1.16h0.07c0.25-0.41,0.63-0.75,1.13-1.02c0.51-0.27,1.08-0.41,1.71-0.41c1.08,0,2.01,0.43,2.79,1.28
|
|
49
|
+
c0.78,0.85,1.18,1.91,1.18,3.17c0,1.26-0.39,2.32-1.18,3.17C88.59,13.54,87.66,13.96,86.58,13.96z M86.32,12.53
|
|
50
|
+
c0.73,0,1.35-0.28,1.87-0.83c0.52-0.55,0.78-1.28,0.78-2.19c0-0.91-0.26-1.64-0.78-2.19c-0.52-0.55-1.15-0.83-1.87-0.83
|
|
51
|
+
c-0.74,0-1.37,0.27-1.88,0.82c-0.52,0.54-0.77,1.28-0.77,2.2s0.26,1.65,0.77,2.2C84.96,12.26,85.58,12.53,86.32,12.53z
|
|
52
|
+
M94.71,13.96c-0.93,0-1.7-0.23-2.31-0.68c-0.61-0.45-1.05-1.02-1.34-1.7l1.4-0.58c0.44,1.04,1.2,1.57,2.26,1.57
|
|
53
|
+
c0.49,0,0.89-0.11,1.2-0.32c0.31-0.22,0.47-0.5,0.47-0.85c0-0.54-0.38-0.91-1.14-1.11l-1.69-0.41c-0.53-0.14-1.04-0.39-1.52-0.77
|
|
54
|
+
c-0.48-0.38-0.72-0.89-0.72-1.54c0-0.74,0.33-1.34,0.98-1.8c0.65-0.46,1.43-0.69,2.32-0.69c0.74,0,1.4,0.17,1.98,0.5
|
|
55
|
+
c0.58,0.34,0.99,0.81,1.24,1.44L96.5,7.58c-0.31-0.74-0.94-1.11-1.91-1.11c-0.47,0-0.86,0.1-1.18,0.29
|
|
56
|
+
c-0.32,0.19-0.48,0.45-0.48,0.78c0,0.48,0.37,0.8,1.11,0.97l1.65,0.39c0.78,0.18,1.36,0.49,1.74,0.94c0.37,0.44,0.56,0.94,0.56,1.5
|
|
57
|
+
c0,0.75-0.31,1.37-0.92,1.87C96.47,13.71,95.68,13.96,94.71,13.96z"
|
|
58
|
+
></path>
|
|
59
|
+
</g>
|
|
60
|
+
</svg>
|
|
61
|
+
</template>
|
|
62
|
+
|
|
63
|
+
<script lang="ts" setup></script>
|