@jjlmoya/utils-travel 1.1.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.
Files changed (58) hide show
  1. package/package.json +60 -0
  2. package/src/category/i18n/en.ts +185 -0
  3. package/src/category/i18n/es.ts +187 -0
  4. package/src/category/i18n/fr.ts +100 -0
  5. package/src/category/index.ts +12 -0
  6. package/src/category/seo.astro +15 -0
  7. package/src/components/PreviewNavSidebar.astro +116 -0
  8. package/src/components/PreviewToolbar.astro +143 -0
  9. package/src/data.ts +15 -0
  10. package/src/env.d.ts +5 -0
  11. package/src/index.ts +22 -0
  12. package/src/layouts/PreviewLayout.astro +117 -0
  13. package/src/pages/[locale]/[slug].astro +146 -0
  14. package/src/pages/[locale].astro +278 -0
  15. package/src/pages/index.astro +4 -0
  16. package/src/tests/faq_count.test.ts +8 -0
  17. package/src/tests/locale_completeness.test.ts +21 -0
  18. package/src/tests/mocks/astro_mock.js +2 -0
  19. package/src/tests/no_h1_in_components.test.ts +8 -0
  20. package/src/tests/seo_length.test.ts +8 -0
  21. package/src/tests/tool_validation.test.ts +17 -0
  22. package/src/tool/luggage-calculator/bibliography.astro +14 -0
  23. package/src/tool/luggage-calculator/component.astro +560 -0
  24. package/src/tool/luggage-calculator/i18n/en.ts +617 -0
  25. package/src/tool/luggage-calculator/i18n/es.ts +617 -0
  26. package/src/tool/luggage-calculator/i18n/fr.ts +549 -0
  27. package/src/tool/luggage-calculator/index.ts +53 -0
  28. package/src/tool/luggage-calculator/seo.astro +14 -0
  29. package/src/tool/mini-adventures/bibliography.astro +14 -0
  30. package/src/tool/mini-adventures/component.astro +665 -0
  31. package/src/tool/mini-adventures/i18n/en.ts +161 -0
  32. package/src/tool/mini-adventures/i18n/es.ts +286 -0
  33. package/src/tool/mini-adventures/i18n/fr.ts +144 -0
  34. package/src/tool/mini-adventures/index.ts +70 -0
  35. package/src/tool/mini-adventures/seo.astro +14 -0
  36. package/src/tool/optimal-routes/bibliography.astro +14 -0
  37. package/src/tool/optimal-routes/component.astro +439 -0
  38. package/src/tool/optimal-routes/i18n/en.ts +42 -0
  39. package/src/tool/optimal-routes/i18n/es.ts +52 -0
  40. package/src/tool/optimal-routes/index.ts +63 -0
  41. package/src/tool/optimal-routes/lib/RouteManager.ts +181 -0
  42. package/src/tool/optimal-routes/seo.astro +14 -0
  43. package/src/tool/suitcase-checklist/bibliography.astro +14 -0
  44. package/src/tool/suitcase-checklist/component.astro +710 -0
  45. package/src/tool/suitcase-checklist/i18n/en.ts +261 -0
  46. package/src/tool/suitcase-checklist/i18n/es.ts +261 -0
  47. package/src/tool/suitcase-checklist/i18n/fr.ts +259 -0
  48. package/src/tool/suitcase-checklist/index.ts +75 -0
  49. package/src/tool/suitcase-checklist/seo.astro +14 -0
  50. package/src/tool/tip-calculator/bibliography.astro +14 -0
  51. package/src/tool/tip-calculator/component.astro +683 -0
  52. package/src/tool/tip-calculator/i18n/en.ts +264 -0
  53. package/src/tool/tip-calculator/i18n/es.ts +264 -0
  54. package/src/tool/tip-calculator/i18n/fr.ts +264 -0
  55. package/src/tool/tip-calculator/index.ts +53 -0
  56. package/src/tool/tip-calculator/seo.astro +14 -0
  57. package/src/tools.ts +13 -0
  58. package/src/types.ts +73 -0
@@ -0,0 +1,70 @@
1
+ import type { TravelToolEntry, ToolDefinition } from '../../types';
2
+ import MiniAdventures from './component.astro';
3
+ import MiniAdventuresSEO from './seo.astro';
4
+ import MiniAdventuresBibliography from './bibliography.astro';
5
+
6
+ export interface AdventureCategory {
7
+ id: string;
8
+ label: string;
9
+ icon: string;
10
+ color: string;
11
+ styling: string;
12
+ }
13
+
14
+ export interface Adventure {
15
+ id: number;
16
+ text: string;
17
+ categoryId: string;
18
+ }
19
+
20
+ export interface Achievement {
21
+ id: string;
22
+ milestone: number;
23
+ label: string;
24
+ categoryId: string | 'global';
25
+ icon: string;
26
+ description: string;
27
+ }
28
+
29
+ export interface MiniAdventuresUI {
30
+ [key: string]: unknown;
31
+ title: string;
32
+ homeTitle: string;
33
+ homeDesc: string;
34
+ generateBtn: string;
35
+ anotherBtn: string;
36
+ doneBtn: string;
37
+ shareBtn: string;
38
+ categories: Record<string, AdventureCategory>;
39
+ adventures: Adventure[];
40
+ achievements: Achievement[];
41
+ stats: {
42
+ adventures: string;
43
+ badges: string;
44
+ progress: string;
45
+ trophies: string;
46
+ };
47
+ shareTemplate: string;
48
+ }
49
+
50
+ export const miniAdventures: TravelToolEntry<MiniAdventuresUI> = {
51
+ id: 'mini-adventures',
52
+ icons: {
53
+ bg: 'mdi:compass-outline',
54
+ fg: 'mdi:map-marker-distance',
55
+ },
56
+ i18n: {
57
+ es: () => import('./i18n/es').then((m) => m.content),
58
+ en: () => import('./i18n/en').then((m) => m.content),
59
+ fr: () => import('./i18n/fr').then((m) => m.content),
60
+ },
61
+ };
62
+
63
+ export { MiniAdventures, MiniAdventuresSEO, MiniAdventuresBibliography };
64
+
65
+ export const MINI_ADVENTURES_TOOL: ToolDefinition = {
66
+ entry: miniAdventures,
67
+ Component: MiniAdventures,
68
+ SEOComponent: MiniAdventuresSEO,
69
+ BibliographyComponent: MiniAdventuresBibliography,
70
+ };
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { SEORenderer } from "@jjlmoya/utils-shared";
3
+ import { miniAdventures } from "./index";
4
+ import type { KnownLocale } from "../../types";
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = "es" } = Astro.props;
11
+ const content = await miniAdventures.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from "@jjlmoya/utils-shared";
3
+ import { optimalRoutes } from "./index";
4
+ import type { KnownLocale } from "../../types";
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = "es" } = Astro.props;
11
+ const content = await optimalRoutes.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,439 @@
1
+ ---
2
+ import { Icon } from "astro-icon/components";
3
+ import type { OptimalRoutesUI } from "./index";
4
+
5
+ interface Props {
6
+ ui: OptimalRoutesUI;
7
+ }
8
+
9
+ const { ui } = Astro.props;
10
+ ---
11
+
12
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.css" integrity="sha512-Zcn6bjR/8RZbHGRqb9iE6RE9asEafS0p/+ftT39eit5Q754u+4nS0N8H6z69D5j5U4y2N8hUf6f0f5j9Q==" crossorigin="anonymous" referrerpolicy="no-referrer" />
13
+
14
+ <div class="optimal-routes">
15
+ <div class="or-container">
16
+ <div class="or-sidebar">
17
+ <header class="or-sidebar-header">
18
+ <h3>{ui.sidebar.title}</h3>
19
+ <button id="or-clear-btn" class="or-btn-clear" title={ui.sidebar.clearBtn}>
20
+ <Icon name="mdi:trash-can-outline" />
21
+ </button>
22
+ </header>
23
+
24
+ <div class="or-points-container">
25
+ <p class="or-instructions">{ui.sidebar.addPoint}</p>
26
+ <div id="or-empty-state" class="or-empty">
27
+ <Icon name="mdi:map-marker-plus-outline" />
28
+ <p>{ui.sidebar.emptyState}</p>
29
+ </div>
30
+ <ul id="or-points-list" class="or-list"></ul>
31
+ </div>
32
+
33
+ <div id="or-stats-panel" class="or-stats hidden">
34
+ <div class="or-stat-item">
35
+ <span>{ui.sidebar.stats.distance}</span>
36
+ <strong id="or-total-dist">0 km</strong>
37
+ </div>
38
+ </div>
39
+
40
+ <footer class="or-sidebar-footer">
41
+ <button id="or-optimize-btn" class="or-btn-primary" disabled>
42
+ <Icon name="mdi:auto-fix" />
43
+ <span>{ui.sidebar.optimizeBtn}</span>
44
+ </button>
45
+ </footer>
46
+ </div>
47
+
48
+ <div class="or-map-wrapper">
49
+ <div id="or-map" class="or-map-el">
50
+ <div class="or-map-loader">
51
+ <Icon name="mdi:loading" class="animate-spin" />
52
+ <p>{ui.map.placeholder}</p>
53
+ </div>
54
+ </div>
55
+ </div>
56
+ </div>
57
+ </div>
58
+
59
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js" integrity="sha512-VP/K7V41mX7K3o5u4p0p5U4y2N8hUf6f0f5j9Q==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
60
+
61
+ <script is:inline define:vars={{ ui }}>
62
+ class SimpleRouteManager extends EventTarget {
63
+ constructor(L) {
64
+ super();
65
+ this.L = L;
66
+ this.points = [];
67
+ this.routeLine = null;
68
+ }
69
+
70
+ initMap(id) {
71
+ this.map = this.L.map(id).setView([40.4167, -3.7038], 6);
72
+ this.L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png').addTo(this.map);
73
+ this.map.on('click', e => this.addPoint(e.latlng.lat, e.latlng.lng));
74
+ }
75
+
76
+ getNumberedIcon(n) {
77
+ return this.L.divIcon({
78
+ className: 'or-marker-icon',
79
+ html: `<div class="or-marker-num">${n}</div>`,
80
+ iconSize: [30, 30],
81
+ iconAnchor: [15, 30]
82
+ });
83
+ }
84
+
85
+ async addPoint(lat, lng) {
86
+ const id = Date.now();
87
+ const marker = this.L.marker([lat, lng], {
88
+ draggable: true,
89
+ icon: this.getNumberedIcon(this.points.length + 1)
90
+ }).addTo(this.map);
91
+
92
+ const point = { id, lat, lng, marker, name: `Punto` };
93
+ this.points.push(point);
94
+ this.notify();
95
+
96
+ marker.on('dragend', () => {
97
+ const pos = marker.getLatLng();
98
+ point.lat = pos.lat;
99
+ point.lng = pos.lng;
100
+ this.notify();
101
+ });
102
+
103
+ try {
104
+ const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}`);
105
+ const data = await res.json();
106
+ point.name = data.display_name.split(',')[0] || 'Punto';
107
+ this.notify();
108
+ } catch {}
109
+ }
110
+
111
+ deletePoint(id) {
112
+ const idx = this.points.findIndex(p => p.id === id);
113
+ if (idx !== -1) {
114
+ this.map.removeLayer(this.points[idx].marker);
115
+ this.points.splice(idx, 1);
116
+ this.points.forEach((p, i) => p.marker.setIcon(this.getNumberedIcon(i+1)));
117
+ this.notify();
118
+ }
119
+ }
120
+
121
+ notify() { this.dispatchEvent(new CustomEvent('update', { detail: this.points })); }
122
+
123
+ async optimize() {
124
+ if (this.points.length < 2) return;
125
+ this.dispatchEvent(new CustomEvent('loading', { detail: true }));
126
+
127
+ try {
128
+ const coords = this.points.map(p => `${p.lng},${p.lat}`).join(';');
129
+ const res = await fetch(`https://router.project-osrm.org/trip/v1/driving/${coords}?roundtrip=true&geometries=geojson&overview=full`);
130
+ const data = await res.json();
131
+ const waypoints = data.waypoints;
132
+ const loop = waypoints.map(wp => this.points[wp.waypoint_index]);
133
+
134
+ this.points = loop;
135
+ this.points.forEach((p, i) => p.marker.setIcon(this.getNumberedIcon(i+1)));
136
+ this.notify();
137
+
138
+ if (this.routeLine) this.map.removeLayer(this.routeLine);
139
+ this.routeLine = this.L.geoJSON(data.trips[0].geometry, {
140
+ style: { color: '#0891b2', weight: 5 }
141
+ }).addTo(this.map);
142
+ this.map.fitBounds(this.routeLine.getBounds(), { padding: [50, 50] });
143
+
144
+ document.getElementById('or-total-dist').textContent = `${(data.trips[0].distance / 1000).toFixed(2)} km`;
145
+ document.getElementById('or-stats-panel').classList.remove('hidden');
146
+ } catch {
147
+ } finally {
148
+ this.dispatchEvent(new CustomEvent('loading', { detail: false }));
149
+ }
150
+ }
151
+ }
152
+
153
+ function setupUpdateListener(manager, btnOpt, listEl, emptyEl) {
154
+ manager.addEventListener('update', e => {
155
+ const points = e.detail;
156
+ btnOpt.disabled = points.length < 2;
157
+ emptyEl.style.display = points.length > 0 ? 'none' : 'flex';
158
+ listEl.innerHTML = '';
159
+ points.forEach((p, i) => {
160
+ const li = document.createElement('li');
161
+ li.className = 'or-li';
162
+ li.innerHTML = `<span class="or-li-num">${i+1}</span><span class="or-li-name">${p.name}</span><button class="or-li-del" data-id="${p.id}">&times;</button>`;
163
+ li.querySelector('.or-li-del').onclick = () => manager.deletePoint(p.id);
164
+ listEl.appendChild(li);
165
+ });
166
+ });
167
+ }
168
+
169
+ function setupRouteManager() {
170
+ if (!window.L) return;
171
+ const manager = new SimpleRouteManager(window.L);
172
+ manager.initMap('or-map');
173
+ const btnOpt = document.getElementById('or-optimize-btn');
174
+ const listEl = document.getElementById('or-points-list');
175
+ const emptyEl = document.getElementById('or-empty-state');
176
+ setupUpdateListener(manager, btnOpt, listEl, emptyEl);
177
+ manager.addEventListener('loading', e => {
178
+ btnOpt.classList.toggle('loading', e.detail);
179
+ btnOpt.querySelector('span').textContent = e.detail ? ui.labels.loading : ui.sidebar.optimizeBtn;
180
+ });
181
+ btnOpt.onclick = () => manager.optimize();
182
+ document.getElementById('or-clear-btn').onclick = () => {
183
+ manager.points.forEach(p => manager.map.removeLayer(p.marker));
184
+ manager.points = [];
185
+ if (manager.routeLine) manager.map.removeLayer(manager.routeLine);
186
+ document.getElementById('or-stats-panel').classList.add('hidden');
187
+ manager.notify();
188
+ };
189
+ }
190
+
191
+ if (document.readyState === 'loading') {
192
+ document.addEventListener('DOMContentLoaded', setupRouteManager);
193
+ } else {
194
+ setupRouteManager();
195
+ }
196
+ </script>
197
+
198
+ <style>
199
+ .optimal-routes {
200
+ --or-primary: #0891b2;
201
+ --or-bg: #f8fafc;
202
+ --or-sidebar-bg: #fff;
203
+ --or-text: #1e293b;
204
+ --or-border: #e2e8f0;
205
+ --or-accent: #06b6d4;
206
+ }
207
+
208
+ :global(.theme-dark) .optimal-routes {
209
+ --or-bg: #0f172a;
210
+ --or-sidebar-bg: #1e293b;
211
+ --or-text: #f1f5f9;
212
+ --or-border: #334155;
213
+ }
214
+
215
+ .or-container {
216
+ display: flex;
217
+ height: 100%;
218
+ }
219
+
220
+ .or-sidebar {
221
+ width: 320px;
222
+ background: var(--or-sidebar-bg);
223
+ border-right: 1px solid var(--or-border);
224
+ display: flex;
225
+ flex-direction: column;
226
+ }
227
+
228
+ .or-sidebar-header {
229
+ padding: 1.5rem;
230
+ border-bottom: 1px solid var(--or-border);
231
+ display: flex;
232
+ justify-content: space-between;
233
+ align-items: center;
234
+ }
235
+
236
+ .or-sidebar-header h3 {
237
+ margin: 0;
238
+ font-size: 1.25rem;
239
+ font-weight: 800;
240
+ color: var(--or-text);
241
+ }
242
+
243
+ .or-btn-clear {
244
+ background: transparent;
245
+ border: none;
246
+ color: #ef4444;
247
+ cursor: pointer;
248
+ font-size: 1.2rem;
249
+ padding: 0.5rem;
250
+ border-radius: 0.5rem;
251
+ }
252
+ .or-btn-clear:hover {
253
+ background: #fee2e2;
254
+ }
255
+
256
+ .or-points-container {
257
+ flex: 1;
258
+ overflow-y: auto;
259
+ padding: 1rem;
260
+ display: flex;
261
+ flex-direction: column;
262
+ gap: 1rem;
263
+ }
264
+
265
+ .or-instructions {
266
+ font-size: 0.75rem;
267
+ color: #64748b;
268
+ margin: 0;
269
+ text-transform: uppercase;
270
+ letter-spacing: 0.05em;
271
+ font-weight: 700;
272
+ }
273
+
274
+ .or-empty {
275
+ flex: 1;
276
+ display: flex;
277
+ flex-direction: column;
278
+ align-items: center;
279
+ justify-content: center;
280
+ text-align: center;
281
+ gap: 1rem;
282
+ color: #94a3b8;
283
+ }
284
+ .or-empty svg {
285
+ font-size: 3rem;
286
+ opacity: 0.5;
287
+ }
288
+
289
+ .or-list {
290
+ list-style: none;
291
+ padding: 0;
292
+ margin: 0;
293
+ display: flex;
294
+ flex-direction: column;
295
+ gap: 0.5rem;
296
+ }
297
+
298
+ .or-li {
299
+ background: var(--or-bg);
300
+ border: 1px solid var(--or-border);
301
+ padding: 0.75rem;
302
+ border-radius: 0.75rem;
303
+ display: flex;
304
+ align-items: center;
305
+ gap: 0.75rem;
306
+ font-size: 0.85rem;
307
+ transition: 0.2s;
308
+ }
309
+ .or-li-num {
310
+ width: 24px;
311
+ height: 24px;
312
+ background: var(--or-accent);
313
+ color: white;
314
+ border-radius: 50%;
315
+ display: flex;
316
+ align-items: center;
317
+ justify-content: center;
318
+ font-weight: 800;
319
+ font-size: 0.7rem;
320
+ }
321
+ .or-li-name {
322
+ flex: 1;
323
+ white-space: nowrap;
324
+ overflow: hidden;
325
+ text-overflow: ellipsis;
326
+ }
327
+ .or-li-del {
328
+ background: transparent;
329
+ border: none;
330
+ font-size: 1.2rem;
331
+ color: #94a3b8;
332
+ cursor: pointer;
333
+ }
334
+ .or-li-del:hover {
335
+ color: #ef4444;
336
+ }
337
+
338
+ .or-stats {
339
+ padding: 1rem;
340
+ background: #ecfeff;
341
+ border-top: 1px solid #cffafe;
342
+ }
343
+ :global(.theme-dark) .or-stats {
344
+ background: #083344;
345
+ border-top-color: #164e63;
346
+ }
347
+ .or-stat-item {
348
+ display: flex;
349
+ justify-content: space-between;
350
+ font-size: 0.85rem;
351
+ }
352
+
353
+ .or-sidebar-footer {
354
+ padding: 1rem;
355
+ border-top: 1px solid var(--or-border);
356
+ }
357
+
358
+ .or-btn-primary {
359
+ width: 100%;
360
+ padding: 1rem;
361
+ background: var(--or-text);
362
+ color: var(--or-sidebar-bg);
363
+ border: none;
364
+ border-radius: 1rem;
365
+ font-weight: 800;
366
+ cursor: pointer;
367
+ display: flex;
368
+ align-items: center;
369
+ justify-content: center;
370
+ gap: 0.5rem;
371
+ }
372
+ :global(.theme-dark) .or-btn-primary {
373
+ background: white;
374
+ color: black;
375
+ }
376
+ .or-btn-primary[disabled] {
377
+ opacity: 0.5;
378
+ cursor: not-allowed;
379
+ }
380
+
381
+ .or-map-wrapper {
382
+ flex: 1;
383
+ position: relative;
384
+ }
385
+ .or-map-el {
386
+ width: 100%;
387
+ height: 100%;
388
+ z-index: 10;
389
+ }
390
+
391
+ .or-map-loader {
392
+ position: absolute;
393
+ inset: 0;
394
+ display: flex;
395
+ flex-direction: column;
396
+ align-items: center;
397
+ justify-content: center;
398
+ gap: 1rem;
399
+ background: var(--or-bg);
400
+ z-index: 20;
401
+ color: var(--or-text);
402
+ }
403
+
404
+ .hidden { display: none; }
405
+
406
+ :global(.or-marker-icon) {
407
+ display: flex;
408
+ align-items: center;
409
+ justify-content: center;
410
+ }
411
+ :global(.or-marker-num) {
412
+ width: 30px;
413
+ height: 30px;
414
+ background: #0891b2;
415
+ color: white;
416
+ border-radius: 50%;
417
+ display: flex;
418
+ align-items: center;
419
+ justify-content: center;
420
+ font-weight: 900;
421
+ border: 3px solid white;
422
+ box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
423
+ }
424
+
425
+ @media (max-width: 768px) {
426
+ .or-container {
427
+ flex-direction: column;
428
+ }
429
+ .or-sidebar {
430
+ width: 100%;
431
+ height: 300px;
432
+ order: 2;
433
+ }
434
+ .or-map-wrapper {
435
+ height: 300px;
436
+ order: 1;
437
+ }
438
+ }
439
+ </style>
@@ -0,0 +1,42 @@
1
+ import type { ToolLocaleContent } from '../../../types';
2
+ import type { OptimalRoutesUI } from '../index';
3
+
4
+ export const content: ToolLocaleContent<OptimalRoutesUI> = {
5
+ slug: 'optimal-route-calculator',
6
+ title: 'Optimal Route Calculator | Smart Stop Reordering',
7
+ description: 'Optimize your delivery or travel routes for free. Our tool reorders your stops automatically to find the shortest and most efficient path.',
8
+ ui: {
9
+ title: "Optimal Routes",
10
+ sidebar: {
11
+ title: "Route Plan",
12
+ addPoint: "Add stop (click map)",
13
+ pointsList: "Your stops",
14
+ emptyState: "Click on the map to add stops to your route.",
15
+ optimizeBtn: "Optimize Sequence",
16
+ clearBtn: "Clear All",
17
+ stats: {
18
+ distance: "Total distance:",
19
+ time: "Est. time:"
20
+ }
21
+ },
22
+ map: {
23
+ placeholder: "Loading map...",
24
+ attribution: "© OpenStreetMap contributors"
25
+ },
26
+ errors: {
27
+ minPoints: "At least two points are needed to calculate a route.",
28
+ calcError: "Error calculating the optimal route. Please try again."
29
+ },
30
+ labels: {
31
+ origin: "Start",
32
+ destination: "End",
33
+ stop: "Stop",
34
+ loading: "Calculating..."
35
+ }
36
+ },
37
+ seo: [],
38
+ faq: [],
39
+ howTo: [],
40
+ bibliography: [],
41
+ schemas: []
42
+ };
@@ -0,0 +1,52 @@
1
+ import type { ToolLocaleContent } from '../../../types';
2
+ import type { OptimalRoutesUI } from '../index';
3
+
4
+ export const content: ToolLocaleContent<OptimalRoutesUI> = {
5
+ slug: 'calculadora-rutas-optimas',
6
+ title: 'Calculadora de Rutas Óptimas Gratis | Reordena Paradas Inteligente',
7
+ description: 'Optimiza tus rutas de reparto o viaje gratis. Nuestra herramienta reordena tus paradas automáticamente para encontrar el camino más corto y eficiente.',
8
+ ui: {
9
+ title: "Rutas Óptimas",
10
+ sidebar: {
11
+ title: "Plan de Ruta",
12
+ addPoint: "Añadir parada (clic en mapa)",
13
+ pointsList: "Tus paradas",
14
+ emptyState: "Haz clic en el mapa para añadir paradas a tu ruta.",
15
+ optimizeBtn: "Optimizar Secuencia",
16
+ clearBtn: "Borrar Todo",
17
+ stats: {
18
+ distance: "Distancia total:",
19
+ time: "Tiempo estimado:"
20
+ }
21
+ },
22
+ map: {
23
+ placeholder: "Mapa cargando...",
24
+ attribution: "© OpenStreetMap contributors"
25
+ },
26
+ errors: {
27
+ minPoints: "Se necesitan al menos dos puntos para calcular una ruta.",
28
+ calcError: "Error al calcular la ruta óptima. Inténtalo de nuevo."
29
+ },
30
+ labels: {
31
+ origin: "Inicio",
32
+ destination: "Fin",
33
+ stop: "Parada",
34
+ loading: "Calculando..."
35
+ }
36
+ },
37
+ seo: [
38
+ { type: "title", text: "Optimización de Rutas Inteligente", level: 2 },
39
+ { type: "paragraph", html: "Utiliza algoritmos avanzados para resolver el Problema del Viajante (TSP) en segundos. Ideal para logística, repartos o simplemente para ahorrar combustible en tus vacaciones." }
40
+ ],
41
+ faq: [
42
+ { question: "¿Cómo funciona la optimización?", answer: "Analizamos todas tus paradas y determinamos el orden secuencial que minimiza la distancia total recorrida." },
43
+ { question: "¿Puedo usarlo en el móvil?", answer: "Sí, la herramienta es totalmente responsiva y permite abrir la ruta resultante directamente en Google Maps." }
44
+ ],
45
+ howTo: [
46
+ { name: "Añadir", text: "Haz clic en el mapa para fijar tu posición de inicio y las paradas intermedias." },
47
+ { name: "Optimizar", text: "Pulsa el botón de optimizar para que el sistema reordene las paradas de la forma más eficiente." },
48
+ { name: "Navegar", text: "Usa el enlace generado para navegar siguiendo la ruta sugerida." }
49
+ ],
50
+ bibliography: [],
51
+ schemas: []
52
+ };
@@ -0,0 +1,63 @@
1
+ import type { TravelToolEntry, ToolDefinition } from '../../types';
2
+ import OptimalRoutes from './component.astro';
3
+ import OptimalRoutesSEO from './seo.astro';
4
+ import OptimalRoutesBibliography from './bibliography.astro';
5
+
6
+ export interface RoutePoint {
7
+ id: number;
8
+ lat: number;
9
+ lng: number;
10
+ name: string;
11
+ address?: string;
12
+ }
13
+
14
+ export interface OptimalRoutesUI {
15
+ title: string;
16
+ sidebar: {
17
+ title: string;
18
+ addPoint: string;
19
+ pointsList: string;
20
+ emptyState: string;
21
+ optimizeBtn: string;
22
+ clearBtn: string;
23
+ stats: {
24
+ distance: string;
25
+ time: string;
26
+ };
27
+ };
28
+ map: {
29
+ placeholder: string;
30
+ attribution: string;
31
+ };
32
+ errors: {
33
+ minPoints: string;
34
+ calcError: string;
35
+ };
36
+ labels: {
37
+ origin: string;
38
+ destination: string;
39
+ stop: string;
40
+ loading: string;
41
+ }
42
+ }
43
+
44
+ export const optimalRoutes: TravelToolEntry<OptimalRoutesUI> = {
45
+ id: 'optimal-routes',
46
+ icons: {
47
+ bg: 'mdi:map-marker-path',
48
+ fg: 'mdi:navigation-variant',
49
+ },
50
+ i18n: {
51
+ es: () => import('./i18n/es').then((m) => m.content),
52
+ en: () => import('./i18n/en').then((m) => m.content),
53
+ },
54
+ };
55
+
56
+ export { OptimalRoutes, OptimalRoutesSEO, OptimalRoutesBibliography };
57
+
58
+ export const OPTIMAL_ROUTES_TOOL: ToolDefinition = {
59
+ entry: optimalRoutes,
60
+ Component: OptimalRoutes,
61
+ SEOComponent: OptimalRoutesSEO,
62
+ BibliographyComponent: OptimalRoutesBibliography,
63
+ };