From 3f55925df7b40ab65c8aa1c62117b5f1870fa2ca Mon Sep 17 00:00:00 2001 From: Giannis Fraidakis Date: Fri, 26 Dec 2025 13:44:38 +0200 Subject: [PATCH 01/23] refactor: convert i18n locale files from JS to JSON for MI improvement --- src/i18n/locales/el.js | 30 ++++---- src/i18n/locales/el/common.js | 58 --------------- src/i18n/locales/el/common.json | 107 +++++++++++++++++++++++++++ src/i18n/locales/el/features.js | 59 --------------- src/i18n/locales/el/features.json | 118 ++++++++++++++++++++++++++++++ src/i18n/locales/el/pages.js | 40 ---------- src/i18n/locales/el/pages.json | 71 ++++++++++++++++++ src/i18n/locales/en.js | 30 ++++---- src/i18n/locales/en/common.js | 61 --------------- src/i18n/locales/en/common.json | 107 +++++++++++++++++++++++++++ src/i18n/locales/en/features.js | 57 --------------- src/i18n/locales/en/features.json | 118 ++++++++++++++++++++++++++++++ src/i18n/locales/en/pages.js | 40 ---------- src/i18n/locales/en/pages.json | 71 ++++++++++++++++++ 14 files changed, 622 insertions(+), 345 deletions(-) delete mode 100644 src/i18n/locales/el/common.js create mode 100644 src/i18n/locales/el/common.json delete mode 100644 src/i18n/locales/el/features.js create mode 100644 src/i18n/locales/el/features.json delete mode 100644 src/i18n/locales/el/pages.js create mode 100644 src/i18n/locales/el/pages.json delete mode 100644 src/i18n/locales/en/common.js create mode 100644 src/i18n/locales/en/common.json delete mode 100644 src/i18n/locales/en/features.js create mode 100644 src/i18n/locales/en/features.json delete mode 100644 src/i18n/locales/en/pages.js create mode 100644 src/i18n/locales/en/pages.json diff --git a/src/i18n/locales/el.js b/src/i18n/locales/el.js index 0700f8c3..bf8bd9b7 100644 --- a/src/i18n/locales/el.js +++ b/src/i18n/locales/el.js @@ -1,21 +1,21 @@ /** * Greek Translation Resources - Index file */ -import { common, homePage, auth, validation } from './el/common'; -import { userPages, preferencesPage, favouritesPage } from './el/pages'; -import { navigationPage, recommendationsPage, placeDetails, categories, footer } from './el/features'; +import common from './el/common.json'; +import features from './el/features.json'; +import pages from './el/pages.json'; export const el = { - ...common, - ...homePage, - ...auth, - ...validation, - ...userPages, - ...preferencesPage, - ...favouritesPage, - ...navigationPage, - ...recommendationsPage, - ...placeDetails, - ...categories, - ...footer, + ...common.common, + ...common.homePage, + ...common.auth, + ...common.validation, + ...pages.userPages, + ...pages.preferencesPage, + ...pages.favouritesPage, + ...features.navigationPage, + ...features.recommendationsPage, + ...features.placeDetails, + ...features.categories, + ...features.footer, }; diff --git a/src/i18n/locales/el/common.js b/src/i18n/locales/el/common.js deleted file mode 100644 index e58eb6be..00000000 --- a/src/i18n/locales/el/common.js +++ /dev/null @@ -1,58 +0,0 @@ -/** - * @fileoverview Greek (el) translations for common UI elements. - * Contains translations for: header, home page, auth, and validation messages. - * @module i18n/locales/el/common - */ - -// Common/Header translations - Greek -export const common = { - home: 'Αρχική', recommendations: 'Προτάσεις', navigation: 'Πλοήγηση', - favourites: 'Αγαπημένα', preferences: 'Προτιμήσεις', profile: 'Προφίλ', - login: 'Σύνδεση', signup: 'Εγγραφή', logout: 'Αποσύνδεση', - error: 'Σφάλμα', success: 'Επιτυχία', confirm: 'Επιβεβαίωση', delete: 'Διαγραφή', - edit: 'Επεξεργασία', save: 'Αποθήκευση', back: 'Πίσω', next: 'Επόμενο', close: 'Κλείσιμο', - noServerConnection: 'Δεν υπάρχει σύνδεση με τον διακομιστή', errorOccurred: 'Παρουσιάστηκε σφάλμα', - cancel: 'Ακύρωση', loading: 'Φόρτωση...', enabled: 'Ενεργοποιημένες', disabled: 'Απενεργοποιημένες', -}; - -export const homePage = { - welcomeTitle: 'Καλώς ήρθατε στο myWorld Travel', welcomeSubtitle: 'Ανακαλύψτε τον κόσμο με εξατομικευμένες προτάσεις', - searchPlaceholder: 'Αναζητήστε προορισμούς...', searchButton: 'Αναζήτηση', - recommendationsForYou: 'Προτάσεις για Εσάς', navigationBtn: 'Πλοήγηση', - recommendedDestinations: 'Προτεινόμενοι Προορισμοί', searchResults: 'Αποτελέσματα Αναζήτησης', - clearButton: 'Καθαρισμός', noResults: 'Δεν βρέθηκαν αποτελέσματα για', viewAll: 'Προβολή Όλων', - ctaTitle: 'Έτοιμοι να Ξεκινήσετε το Ταξίδι σας;', ctaDescription: 'Γίνετε μέλος χιλιάδων ταξιδιωτών που ανακαλύπτουν εκπληκτικούς προορισμούς κάθε μέρα.', - exploreDestinations: 'Εξερευνήστε Προορισμούς', whatWeOffer: 'Τι προσφέρουμε', - personalizedRecommendations: 'Εξατομικευμένες Προτάσεις', personalizedRecommendationsDesc: 'Λάβετε προτάσεις βασισμένες στις προτιμήσεις σας', - reviews: 'Αξιολογήσεις', reviewsDesc: 'Διαβάστε και γράψτε αξιολογήσεις για τόπους', - favouritesFeature: 'Αγαπημένα', favouritesFeatureDesc: 'Αποθηκεύστε τους αγαπημένους σας προορισμούς', - navigationFeature: 'Πλοήγηση', navigationFeatureDesc: 'Βρείτε τη διαδρομή προς τον προορισμό σας', -}; - -export const auth = { - welcomeBack: 'Καλως ορίσατε στο myWorld Travel', email: 'Email', password: 'Κωδικός', - loginButton: 'Σύνδεση', noAccount: 'Δεν έχετε λογαριασμό;', signupLink: 'Εγγραφή εδώ', - loginError: 'Σφάλμα σύνδεσης', demoAccounts: 'Demo Λογαριασμοί', demoUser: 'Χρήστης', admin: 'Admin', - createAccount: 'Δημιουργήστε τον λογαριασμό σας', username: 'Όνομα Χρήστη', confirmPassword: 'Επιβεβαίωση Κωδικού', - signupButton: 'Εγγραφή', haveAccount: 'Έχετε ήδη λογαριασμό;', loginLink: 'Συνδεθείτε εδώ', - signupError: 'Σφάλμα εγγραφής. Το email μπορεί να χρησιμοποιείται ήδη', - passwordRequirement: 'Τουλάχιστον 6 χαρακτήρες (κεφαλαίο, πεζό, αριθμό)', -}; - -export const validation = { - invalidEmail: 'Μη έγκυρη διεύθυνση email', emailRequired: 'Το email είναι υποχρεωτικό', - passwordMinLength: 'Ο κωδικός πρέπει να έχει τουλάχιστον 6 χαρακτήρες', passwordRequired: 'Ο κωδικός είναι υποχρεωτικός', - nameMinLength: 'Το όνομα πρέπει να έχει τουλάχιστον 2 χαρακτήρες', nameMaxLength: 'Το όνομα δεν μπορεί να υπερβαίνει τους 50 χαρακτήρες', - nameRequired: 'Το όνομα είναι υποχρεωτικό', passwordComplexity: 'Ο κωδικός πρέπει να περιέχει τουλάχιστον ένα κεφαλαίο, ένα πεζό και έναν αριθμό', - passwordsNotMatch: 'Οι κωδικοί δεν ταιριάζουν', confirmPasswordRequired: 'Η επιβεβαίωση κωδικού είναι υποχρεωτική', - phoneInvalid: 'Το τηλέφωνο πρέπει να έχει 10 ψηφία', profileNameRequired: 'Το όνομα προφίλ είναι υποχρεωτικό', - categoryMinOne: 'Επιλέξτε τουλάχιστον μία κατηγορία', categoriesRequired: 'Οι κατηγορίες είναι υποχρεωτικές', - minPriceNegative: 'Η ελάχιστη τιμή δεν μπορεί να είναι αρνητική', minPriceRequired: 'Η ελάχιστη τιμή είναι υποχρεωτική', - maxPriceGreaterThanMin: 'Η μέγιστη τιμή πρέπει να είναι μεγαλύτερη από την ελάχιστη', maxPriceRequired: 'Η μέγιστη τιμή είναι υποχρεωτική', - radiusMinOne: 'Η ακτίνα πρέπει να είναι τουλάχιστον 1 km', radiusMaxHundred: 'Η ακτίνα δεν μπορεί να υπερβαίνει τα 100 km', radiusRequired: 'Η ακτίνα είναι υποχρεωτική', - ratingMinMax: 'Η βαθμολογία πρέπει να είναι από 1 έως 5', ratingRequired: 'Η βαθμολογία είναι υποχρεωτική', - commentMinLength: 'Το σχόλιο πρέπει να έχει τουλάχιστον 10 χαρακτήρες', commentMaxLength: 'Το σχόλιο δεν μπορεί να υπερβαίνει τους 500 χαρακτήρες', - commentRequired: 'Το σχόλιο είναι υποχρεωτικό', reasonRequired: 'Ο λόγος αναφοράς είναι υποχρεωτικός', - descriptionMinLength: 'Η περιγραφή πρέπει να έχει τουλάχιστον 20 χαρακτήρες', descriptionMaxLength: 'Η περιγραφή δεν μπορεί να υπερβαίνει τους 500 χαρακτήρες', - descriptionRequired: 'Η περιγραφή είναι υποχρεωτική', searchMinLength: 'Η αναζήτηση πρέπει να έχει τουλάχιστον 2 χαρακτήρες', searchMaxLength: 'Η αναζήτηση δεν μπορεί να υπερβαίνει τους 100 χαρακτήρες', -}; diff --git a/src/i18n/locales/el/common.json b/src/i18n/locales/el/common.json new file mode 100644 index 00000000..9c405930 --- /dev/null +++ b/src/i18n/locales/el/common.json @@ -0,0 +1,107 @@ +{ + "common": { + "home": "Αρχική", + "recommendations": "Προτάσεις", + "navigation": "Πλοήγηση", + "favourites": "Αγαπημένα", + "preferences": "Προτιμήσεις", + "profile": "Προφίλ", + "login": "Σύνδεση", + "signup": "Εγγραφή", + "logout": "Αποσύνδεση", + "error": "Σφάλμα", + "success": "Επιτυχία", + "confirm": "Επιβεβαίωση", + "delete": "Διαγραφή", + "edit": "Επεξεργασία", + "save": "Αποθήκευση", + "back": "Πίσω", + "next": "Επόμενο", + "close": "Κλείσιμο", + "noServerConnection": "Δεν υπάρχει σύνδεση με τον διακομιστή", + "errorOccurred": "Παρουσιάστηκε σφάλμα", + "cancel": "Ακύρωση", + "loading": "Φόρτωση...", + "enabled": "Ενεργοποιημένες", + "disabled": "Απενεργοποιημένες" + }, + "homePage": { + "welcomeTitle": "Καλώς ήρθατε στο myWorld Travel", + "welcomeSubtitle": "Ανακαλύψτε τον κόσμο με εξατομικευμένες προτάσεις", + "searchPlaceholder": "Αναζητήστε προορισμούς...", + "searchButton": "Αναζήτηση", + "recommendationsForYou": "Προτάσεις για Εσάς", + "navigationBtn": "Πλοήγηση", + "recommendedDestinations": "Προτεινόμενοι Προορισμοί", + "searchResults": "Αποτελέσματα Αναζήτησης", + "clearButton": "Καθαρισμός", + "noResults": "Δεν βρέθηκαν αποτελέσματα για", + "viewAll": "Προβολή Όλων", + "ctaTitle": "Έτοιμοι να Ξεκινήσετε το Ταξίδι σας;", + "ctaDescription": "Γίνετε μέλος χιλιάδων ταξιδιωτών που ανακαλύπτουν εκπληκτικούς προορισμούς κάθε μέρα.", + "exploreDestinations": "Εξερευνήστε Προορισμούς", + "whatWeOffer": "Τι προσφέρουμε", + "personalizedRecommendations": "Εξατομικευμένες Προτάσεις", + "personalizedRecommendationsDesc": "Λάβετε προτάσεις βασισμένες στις προτιμήσεις σας", + "reviews": "Αξιολογήσεις", + "reviewsDesc": "Διαβάστε και γράψτε αξιολογήσεις για τόπους", + "favouritesFeature": "Αγαπημένα", + "favouritesFeatureDesc": "Αποθηκεύστε τους αγαπημένους σας προορισμούς", + "navigationFeature": "Πλοήγηση", + "navigationFeatureDesc": "Βρείτε τη διαδρομή προς τον προορισμό σας" + }, + "auth": { + "welcomeBack": "Καλως ορίσατε στο myWorld Travel", + "email": "Email", + "password": "Κωδικός", + "loginButton": "Σύνδεση", + "noAccount": "Δεν έχετε λογαριασμό;", + "signupLink": "Εγγραφή εδώ", + "loginError": "Σφάλμα σύνδεσης", + "demoAccounts": "Demo Λογαριασμοί", + "demoUser": "Χρήστης", + "admin": "Admin", + "createAccount": "Δημιουργήστε τον λογαριασμό σας", + "username": "Όνομα Χρήστη", + "confirmPassword": "Επιβεβαίωση Κωδικού", + "signupButton": "Εγγραφή", + "haveAccount": "Έχετε ήδη λογαριασμό;", + "loginLink": "Συνδεθείτε εδώ", + "signupError": "Σφάλμα εγγραφής. Το email μπορεί να χρησιμοποιείται ήδη", + "passwordRequirement": "Τουλάχιστον 6 χαρακτήρες (κεφαλαίο, πεζό, αριθμό)" + }, + "validation": { + "invalidEmail": "Μη έγκυρη διεύθυνση email", + "emailRequired": "Το email είναι υποχρεωτικό", + "passwordMinLength": "Ο κωδικός πρέπει να έχει τουλάχιστον 6 χαρακτήρες", + "passwordRequired": "Ο κωδικός είναι υποχρεωτικός", + "nameMinLength": "Το όνομα πρέπει να έχει τουλάχιστον 2 χαρακτήρες", + "nameMaxLength": "Το όνομα δεν μπορεί να υπερβαίνει τους 50 χαρακτήρες", + "nameRequired": "Το όνομα είναι υποχρεωτικό", + "passwordComplexity": "Ο κωδικός πρέπει να περιέχει τουλάχιστον ένα κεφαλαίο, ένα πεζό και έναν αριθμό", + "passwordsNotMatch": "Οι κωδικοί δεν ταιριάζουν", + "confirmPasswordRequired": "Η επιβεβαίωση κωδικού είναι υποχρεωτική", + "phoneInvalid": "Το τηλέφωνο πρέπει να έχει 10 ψηφία", + "profileNameRequired": "Το όνομα προφίλ είναι υποχρεωτικό", + "categoryMinOne": "Επιλέξτε τουλάχιστον μία κατηγορία", + "categoriesRequired": "Οι κατηγορίες είναι υποχρεωτικές", + "minPriceNegative": "Η ελάχιστη τιμή δεν μπορεί να είναι αρνητική", + "minPriceRequired": "Η ελάχιστη τιμή είναι υποχρεωτική", + "maxPriceGreaterThanMin": "Η μέγιστη τιμή πρέπει να είναι μεγαλύτερη από την ελάχιστη", + "maxPriceRequired": "Η μέγιστη τιμή είναι υποχρεωτική", + "radiusMinOne": "Η ακτίνα πρέπει να είναι τουλάχιστον 1 km", + "radiusMaxHundred": "Η ακτίνα δεν μπορεί να υπερβαίνει τα 100 km", + "radiusRequired": "Η ακτίνα είναι υποχρεωτική", + "ratingMinMax": "Η βαθμολογία πρέπει να είναι από 1 έως 5", + "ratingRequired": "Η βαθμολογία είναι υποχρεωτική", + "commentMinLength": "Το σχόλιο πρέπει να έχει τουλάχιστον 10 χαρακτήρες", + "commentMaxLength": "Το σχόλιο δεν μπορεί να υπερβαίνει τους 500 χαρακτήρες", + "commentRequired": "Το σχόλιο είναι υποχρεωτικό", + "reasonRequired": "Ο λόγος αναφοράς είναι υποχρεωτικός", + "descriptionMinLength": "Η περιγραφή πρέπει να έχει τουλάχιστον 20 χαρακτήρες", + "descriptionMaxLength": "Η περιγραφή δεν μπορεί να υπερβαίνει τους 500 χαρακτήρες", + "descriptionRequired": "Η περιγραφή είναι υποχρεωτική", + "searchMinLength": "Η αναζήτηση πρέπει να έχει τουλάχιστον 2 χαρακτήρες", + "searchMaxLength": "Η αναζήτηση δεν μπορεί να υπερβαίνει τους 100 χαρακτήρες" + } +} \ No newline at end of file diff --git a/src/i18n/locales/el/features.js b/src/i18n/locales/el/features.js deleted file mode 100644 index 5c32f090..00000000 --- a/src/i18n/locales/el/features.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @fileoverview Greek (el) translations for feature pages. - * Contains translations for: navigation, recommendations, place details, categories, footer. - * @module i18n/locales/el/features - */ - -// Navigation, Recommendations, Places translations - Greek -export const navigationPage = { - from: 'Από', to: 'Προς', getRoute: 'Εύρεση Διαδρομής', navigationSubtitle: 'Βρείτε τη διαδρομή προς τον προορισμό σας', - calculateRoute: 'Υπολογισμός Διαδρομής', startPoint: 'Σημείο Εκκίνησης', destination: 'Προορισμός', - transportMode: 'Μέσο Μεταφοράς', walking: 'Περπάτημα', driving: 'Αυτοκίνητο', publicTransport: 'Δημόσια Μέσα', - calculating: 'Υπολογισμός...', errorCalculatingRoute: 'Σφάλμα κατά τον υπολογισμό της διαδρομής', routeResults: 'Αποτελέσματα Διαδρομής', - distance: 'Απόσταση', duration: 'Διάρκεια', transport: 'Μέσο', routeDetails: 'Λεπτομέρειες Διαδρομής', - origin: 'Αφετηρία', minutes: 'λεπτά', mapView: 'Προβολή χάρτη (θα ενσωματωθεί σε μελλοντική έκδοση)', - quickRoutes: 'Γρήγορες Διαδρομές', resolvingLocation: 'Εύρεση τοποθεσίας...', enterLocation: 'Τοποθεσία', - searchLocationPlaceholder: 'π.χ., Αθήνα, Ελλάδα', searchingLocations: 'Αναζήτηση τοποθεσιών...', - locationNotFound: 'Η τοποθεσία δεν βρέθηκε', planYourJourney: 'Σχεδιάστε το Ταξίδι σας', - enterLocationsToStart: 'Εισάγετε τοποθεσίες ή επιλέξτε μια γρήγορη διαδρομή', -}; - -export const recommendationsPage = { - recommendationsTitle: '✨ Προτάσεις για Εσάς', recommendationsSubtitle: 'Εξατομικευμένες προτάσεις βασισμένες στις προτιμήσεις σας', - filtersTitle: 'Φίλτρα Αναζήτησης', latitude: 'Γεωγραφικό Πλάτος', longitude: 'Γεωγραφικό Μήκος', maxDistance: 'Μέγιστη Απόσταση', - applyFilters: 'Εφαρμογή Φίλτρων', notAuthenticated: 'Δεν είστε συνδεδεμένος', - errorLoadingRecommendations: 'Σφάλμα κατά τη φόρτωση των προτάσεων', noRecommendationsFound: 'Δεν βρέθηκαν προτάσεις', - tryAgain: 'Προσπάθεια Ξανά', goToPreferences: 'Πηγαίνετε στο "⚙️ Προφίλ Προτιμήσεων" για να δημιουργήσετε ένα προφίλ', - tryDifferentFilters: 'Δοκιμάστε να αλλάξετε τα φίλτρα αναζήτησης ή τις κατηγορίες στο προφίλ σας', -}; - -export const placeDetails = { - location: 'Τοποθεσία', openingHours: 'Ωράριο Λειτουργίας', contact: 'Επικοινωνία', website: 'Ιστοσελίδα', - reviewsSection: 'Αξιολογήσεις', submitReview: 'Υποβολή Αξιολόγησης', description: 'Περιγραφή', - writeReview: 'Γράψτε Αξιολόγηση', submit: 'Υποβολή', loadingDetails: 'Φορτώνουμε τα στοιχεία...', - errorLoadingPlace: 'Σφάλμα κατά τη φόρτωση των στοιχείων του τόπου', placeNotFound: 'Ο τόπος δεν βρέθηκε', - removeFromDisliked: 'Αφαίρεση από Μη Αρεστά', addToDisliked: 'Μη Αρεστό', report: 'Αναφορά', - information: 'Πληροφορίες', address: 'Διεύθυνση', coordinates: 'Συντεταγμένες', - excellent: 'Εξαιρετικό', veryGood: 'Πολύ Καλό', good: 'Καλό', fair: 'Μέτριο', poor: 'Κακό', - comment: 'Σχόλιο', noReviews: 'Δεν υπάρχουν αξιολογήσεις ακόμα', user: 'Χρήστης', reportProblem: 'Αναφορά Προβλήματος', - reason: 'Λόγος', selectReason: 'Επιλέξτε λόγο', incorrectInfo: 'Λανθασμένες Πληροφορίες', - closed: 'Κλειστός', inappropriate: 'Ακατάλληλο Περιεχόμενο', other: 'Άλλο', - reviewSubmittedSuccess: 'Η αξιολόγησή σας καταχωρήθηκε!', mustLoginToReview: 'Πρέπει να συνδεθείτε για να υποβάλετε αξιολόγηση', - errorSubmittingReview: 'Σφάλμα κατά την υποβολή της αξιολόγησης', reportSubmittedSuccess: 'Η αναφορά σας καταχωρήθηκε!', - mustLoginToReport: 'Πρέπει να συνδεθείτε για να υποβάλετε αναφορά', errorSubmittingReport: 'Σφάλμα κατά την υποβολή της αναφοράς', - mustLoginToFavourite: 'Πρέπει να συνδεθείτε για να προσθέσετε στα αγαπημένα', errorAddingToFavourites: 'Σφάλμα κατά την προσθήκη στα αγαπημένα', - mustLoginToDislike: 'Πρέπει να συνδεθείτε για να προσθέσετε στα μη αρεστά', errorAddingToDisliked: 'Σφάλμα κατά την προσθήκη στα μη αρεστά', - viewDetails: 'Προβολή Λεπτομερειών', addToFavourites: 'Προσθήκη στα Αγαπημένα', removeFromFavourites: 'Αφαίρεση από τα Αγαπημένα', rating: 'Αξιολόγηση', -}; - -export const categories = { - RESTAURANT: 'Εστιατόριο', MUSEUM: 'Μουσείο', PARK: 'Πάρκο', BEACH: 'Παραλία', CULTURE: 'Πολιτισμός', - ENTERTAINMENT: 'Διασκέδαση', SHOPPING: 'Αγορές', HOTEL: 'Ξενοδοχείο', CAFE: 'Καφετέρια', BAR: 'Μπαρ', - cheap: 'Οικονομικό', moderate: 'Μέτριο', expensive: 'Ακριβό', -}; - -export const footer = { - explore: 'Εξερεύνηση', account: 'Λογαριασμός', - discoverAmazingPlaces: 'Ανακαλύψτε προορισμούς σε όλο τον κόσμο με εξατομικευνένες προτάσεις!', - allRightsReserved: 'Με επιφύλαξη παντός δικαιώματος', -}; diff --git a/src/i18n/locales/el/features.json b/src/i18n/locales/el/features.json new file mode 100644 index 00000000..22a47b14 --- /dev/null +++ b/src/i18n/locales/el/features.json @@ -0,0 +1,118 @@ +{ + "navigationPage": { + "from": "Από", + "to": "Προς", + "getRoute": "Εύρεση Διαδρομής", + "navigationSubtitle": "Βρείτε τη διαδρομή προς τον προορισμό σας", + "calculateRoute": "Υπολογισμός Διαδρομής", + "startPoint": "Σημείο Εκκίνησης", + "destination": "Προορισμός", + "transportMode": "Μέσο Μεταφοράς", + "walking": "Περπάτημα", + "driving": "Αυτοκίνητο", + "publicTransport": "Δημόσια Μέσα", + "calculating": "Υπολογισμός...", + "errorCalculatingRoute": "Σφάλμα κατά τον υπολογισμό της διαδρομής", + "routeResults": "Αποτελέσματα Διαδρομής", + "distance": "Απόσταση", + "duration": "Διάρκεια", + "transport": "Μέσο", + "routeDetails": "Λεπτομέρειες Διαδρομής", + "origin": "Αφετηρία", + "minutes": "λεπτά", + "mapView": "Προβολή χάρτη (θα ενσωματωθεί σε μελλοντική έκδοση)", + "quickRoutes": "Γρήγορες Διαδρομές", + "resolvingLocation": "Εύρεση τοποθεσίας...", + "enterLocation": "Τοποθεσία", + "searchLocationPlaceholder": "π.χ., Αθήνα, Ελλάδα", + "searchingLocations": "Αναζήτηση τοποθεσιών...", + "locationNotFound": "Η τοποθεσία δεν βρέθηκε", + "planYourJourney": "Σχεδιάστε το Ταξίδι σας", + "enterLocationsToStart": "Εισάγετε τοποθεσίες ή επιλέξτε μια γρήγορη διαδρομή" + }, + "recommendationsPage": { + "recommendationsTitle": "✨ Προτάσεις για Εσάς", + "recommendationsSubtitle": "Εξατομικευμένες προτάσεις βασισμένες στις προτιμήσεις σας", + "filtersTitle": "Φίλτρα Αναζήτησης", + "latitude": "Γεωγραφικό Πλάτος", + "longitude": "Γεωγραφικό Μήκος", + "maxDistance": "Μέγιστη Απόσταση", + "applyFilters": "Εφαρμογή Φίλτρων", + "notAuthenticated": "Δεν είστε συνδεδεμένος", + "errorLoadingRecommendations": "Σφάλμα κατά τη φόρτωση των προτάσεων", + "noRecommendationsFound": "Δεν βρέθηκαν προτάσεις", + "tryAgain": "Προσπάθεια Ξανά", + "goToPreferences": "Πηγαίνετε στο \"⚙️ Προφίλ Προτιμήσεων\" για να δημιουργήσετε ένα προφίλ", + "tryDifferentFilters": "Δοκιμάστε να αλλάξετε τα φίλτρα αναζήτησης ή τις κατηγορίες στο προφίλ σας" + }, + "placeDetails": { + "location": "Τοποθεσία", + "openingHours": "Ωράριο Λειτουργίας", + "contact": "Επικοινωνία", + "website": "Ιστοσελίδα", + "reviewsSection": "Αξιολογήσεις", + "submitReview": "Υποβολή Αξιολόγησης", + "description": "Περιγραφή", + "writeReview": "Γράψτε Αξιολόγηση", + "submit": "Υποβολή", + "loadingDetails": "Φορτώνουμε τα στοιχεία...", + "errorLoadingPlace": "Σφάλμα κατά τη φόρτωση των στοιχείων του τόπου", + "placeNotFound": "Ο τόπος δεν βρέθηκε", + "removeFromDisliked": "Αφαίρεση από Μη Αρεστά", + "addToDisliked": "Μη Αρεστό", + "report": "Αναφορά", + "information": "Πληροφορίες", + "address": "Διεύθυνση", + "coordinates": "Συντεταγμένες", + "excellent": "Εξαιρετικό", + "veryGood": "Πολύ Καλό", + "good": "Καλό", + "fair": "Μέτριο", + "poor": "Κακό", + "comment": "Σχόλιο", + "noReviews": "Δεν υπάρχουν αξιολογήσεις ακόμα", + "user": "Χρήστης", + "reportProblem": "Αναφορά Προβλήματος", + "reason": "Λόγος", + "selectReason": "Επιλέξτε λόγο", + "incorrectInfo": "Λανθασμένες Πληροφορίες", + "closed": "Κλειστός", + "inappropriate": "Ακατάλληλο Περιεχόμενο", + "other": "Άλλο", + "reviewSubmittedSuccess": "Η αξιολόγησή σας καταχωρήθηκε!", + "mustLoginToReview": "Πρέπει να συνδεθείτε για να υποβάλετε αξιολόγηση", + "errorSubmittingReview": "Σφάλμα κατά την υποβολή της αξιολόγησης", + "reportSubmittedSuccess": "Η αναφορά σας καταχωρήθηκε!", + "mustLoginToReport": "Πρέπει να συνδεθείτε για να υποβάλετε αναφορά", + "errorSubmittingReport": "Σφάλμα κατά την υποβολή της αναφοράς", + "mustLoginToFavourite": "Πρέπει να συνδεθείτε για να προσθέσετε στα αγαπημένα", + "errorAddingToFavourites": "Σφάλμα κατά την προσθήκη στα αγαπημένα", + "mustLoginToDislike": "Πρέπει να συνδεθείτε για να προσθέσετε στα μη αρεστά", + "errorAddingToDisliked": "Σφάλμα κατά την προσθήκη στα μη αρεστά", + "viewDetails": "Προβολή Λεπτομερειών", + "addToFavourites": "Προσθήκη στα Αγαπημένα", + "removeFromFavourites": "Αφαίρεση από τα Αγαπημένα", + "rating": "Αξιολόγηση" + }, + "categories": { + "RESTAURANT": "Εστιατόριο", + "MUSEUM": "Μουσείο", + "PARK": "Πάρκο", + "BEACH": "Παραλία", + "CULTURE": "Πολιτισμός", + "ENTERTAINMENT": "Διασκέδαση", + "SHOPPING": "Αγορές", + "HOTEL": "Ξενοδοχείο", + "CAFE": "Καφετέρια", + "BAR": "Μπαρ", + "cheap": "Οικονομικό", + "moderate": "Μέτριο", + "expensive": "Ακριβό" + }, + "footer": { + "explore": "Εξερεύνηση", + "account": "Λογαριασμός", + "discoverAmazingPlaces": "Ανακαλύψτε προορισμούς σε όλο τον κόσμο με εξατομικευνένες προτάσεις!", + "allRightsReserved": "Με επιφύλαξη παντός δικαιώματος" + } +} \ No newline at end of file diff --git a/src/i18n/locales/el/pages.js b/src/i18n/locales/el/pages.js deleted file mode 100644 index 24b8f0cd..00000000 --- a/src/i18n/locales/el/pages.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @fileoverview Greek (el) translations for user pages. - * Contains: profile, preferences, and favourites page strings. - * @module i18n/locales/el/pages - */ - -// User/Profile/Preferences translations - Greek -export const userPages = { - userProfile: 'Προφίλ Χρήστη', editProfile: 'Επεξεργασία Προφίλ', saveChanges: 'Αποθήκευση Αλλαγών', - personalInfo: 'Προσωπικές Πληροφορίες', accountSettings: 'Ρυθμίσεις Λογαριασμού', - language: 'Γλώσσα', english: 'Αγγλικά', greek: 'Ελληνικά', theme: 'Θέμα', light: 'Φωτεινό', dark: 'Σκοτεινό', - notifications: 'Ειδοποιήσεις', myProfile: 'Το Προφίλ μου', manageYourInfo: 'Διαχειριστείτε τις πληροφορίες και τις ρυθμίσεις σας', - loadingProfile: 'Φορτώνουμε το προφίλ σας...', name: 'Όνομα', phone: 'Τηλέφωνο', dateOfBirth: 'Ημερομηνία Γέννησης', - notSet: 'Δεν έχει οριστεί', settings: 'Ρυθμίσεις', emailNotifications: 'Ειδοποιήσεις Email', pushNotifications: 'Push Ειδοποιήσεις', - profileUpdatedSuccess: 'Το προφίλ ενημερώθηκε επιτυχώς!', settingsUpdatedSuccess: 'Οι ρυθμίσεις ενημερώθηκαν επιτυχώς!', - errorUpdatingSettings: 'Σφάλμα κατά την ενημέρωση των ρυθμίσεων', -}; - -export const preferencesPage = { - preferencesTitle: 'Οι Προτιμήσεις σας', preferencesSubtitle: 'Ορίστε τις προτιμήσεις ταξιδιού σας για καλύτερες προτάσεις', - categories: 'Κατηγορίες', minRating: 'Ελάχιστη Αξιολόγηση', savePreferences: 'Αποθήκευση Προτιμήσεων', - preferenceProfiles: 'Προφίλ Προτιμήσεων', managePreferenceProfiles: 'Δημιουργήστε και διαχειριστείτε τα προφίλ προτιμήσεών σας', - newProfile: 'Νέο Προφίλ', newPreferenceProfile: 'Νέο Προφίλ Προτιμήσεων', profileName: 'Όνομα Περίστασης', - priceRangeEuro: 'Εύρος Τιμών (€)', update: 'Ενημέρωση', create: 'Δημιουργία', unnamed: 'Άνευ Ονόματος', - active: 'Ενεργό', activate: 'Ενεργοποίηση', noPreferenceProfiles: 'Δεν έχετε προφίλ προτιμήσεων', - createFirstProfile: 'Δημιουργήστε το πρώτο σας προφίλ για να λαμβάνετε εξατομικευμένες προτάσεις', - createProfile: 'Δημιουργία Προφίλ', errorCreatingProfile: 'Σφάλμα κατά τη δημιουργία του προφίλ', - errorProfileIdNotFound: 'Σφάλμα: Δεν βρέθηκε το ID του προφίλ', errorUpdatingProfile: 'Σφάλμα κατά την ενημέρωση του προφίλ', - confirmDeleteProfile: 'Είστε σίγουροι ότι θέλετε να διαγράψετε αυτό το προφίλ;', errorDeletingProfile: 'Σφάλμα κατά τη διαγραφή του προφίλ', - errorActivatingProfile: 'Σφάλμα κατά την ενεργοποίηση του προφίλ', -}; - -export const favouritesPage = { - favouritesTitle: 'Τα Αγαπημένα σας Μέρη', noFavourites: 'Δεν έχετε αγαπημένα μέρη ακόμα', - myLists: 'Οι Λίστες μου', manageFavourites: 'Διαχειριστείτε τους αγαπημένους και μη αρεστούς προορισμούς', disliked: 'Μη Αρεστά', - loadingLists: 'Φορτώνουμε τις λίστες σας...', remove: 'Αφαίρεση', added: 'Προστέθηκε', - startExploring: 'Αρχίστε να εξερευνάτε και προσθέστε τους αγαπημένους σας τόπους!', - noDisliked: 'Δεν έχετε μη αρεστούς προορισμούς', dislikedWillAppearHere: 'Οι τόποι που δεν σας ενδιαφέρουν θα εμφανίζονται εδώ', - errorRemovingFavourite: 'Σφάλμα κατά την αφαίρεση από τα αγαπημένα', errorRemovingDisliked: 'Σφάλμα κατά την αφαίρεση από τα μη αρεστά', -}; diff --git a/src/i18n/locales/el/pages.json b/src/i18n/locales/el/pages.json new file mode 100644 index 00000000..5930a20c --- /dev/null +++ b/src/i18n/locales/el/pages.json @@ -0,0 +1,71 @@ +{ + "userPages": { + "userProfile": "Προφίλ Χρήστη", + "editProfile": "Επεξεργασία Προφίλ", + "saveChanges": "Αποθήκευση Αλλαγών", + "personalInfo": "Προσωπικές Πληροφορίες", + "accountSettings": "Ρυθμίσεις Λογαριασμού", + "language": "Γλώσσα", + "english": "Αγγλικά", + "greek": "Ελληνικά", + "theme": "Θέμα", + "light": "Φωτεινό", + "dark": "Σκοτεινό", + "notifications": "Ειδοποιήσεις", + "myProfile": "Το Προφίλ μου", + "manageYourInfo": "Διαχειριστείτε τις πληροφορίες και τις ρυθμίσεις σας", + "loadingProfile": "Φορτώνουμε το προφίλ σας...", + "name": "Όνομα", + "phone": "Τηλέφωνο", + "dateOfBirth": "Ημερομηνία Γέννησης", + "notSet": "Δεν έχει οριστεί", + "settings": "Ρυθμίσεις", + "emailNotifications": "Ειδοποιήσεις Email", + "pushNotifications": "Push Ειδοποιήσεις", + "profileUpdatedSuccess": "Το προφίλ ενημερώθηκε επιτυχώς!", + "settingsUpdatedSuccess": "Οι ρυθμίσεις ενημερώθηκαν επιτυχώς!", + "errorUpdatingSettings": "Σφάλμα κατά την ενημέρωση των ρυθμίσεων" + }, + "preferencesPage": { + "preferencesTitle": "Οι Προτιμήσεις σας", + "preferencesSubtitle": "Ορίστε τις προτιμήσεις ταξιδιού σας για καλύτερες προτάσεις", + "categories": "Κατηγορίες", + "minRating": "Ελάχιστη Αξιολόγηση", + "savePreferences": "Αποθήκευση Προτιμήσεων", + "preferenceProfiles": "Προφίλ Προτιμήσεων", + "managePreferenceProfiles": "Δημιουργήστε και διαχειριστείτε τα προφίλ προτιμήσεών σας", + "newProfile": "Νέο Προφίλ", + "newPreferenceProfile": "Νέο Προφίλ Προτιμήσεων", + "profileName": "Όνομα Περίστασης", + "priceRangeEuro": "Εύρος Τιμών (€)", + "update": "Ενημέρωση", + "create": "Δημιουργία", + "unnamed": "Άνευ Ονόματος", + "active": "Ενεργό", + "activate": "Ενεργοποίηση", + "noPreferenceProfiles": "Δεν έχετε προφίλ προτιμήσεων", + "createFirstProfile": "Δημιουργήστε το πρώτο σας προφίλ για να λαμβάνετε εξατομικευμένες προτάσεις", + "createProfile": "Δημιουργία Προφίλ", + "errorCreatingProfile": "Σφάλμα κατά τη δημιουργία του προφίλ", + "errorProfileIdNotFound": "Σφάλμα: Δεν βρέθηκε το ID του προφίλ", + "errorUpdatingProfile": "Σφάλμα κατά την ενημέρωση του προφίλ", + "confirmDeleteProfile": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτό το προφίλ;", + "errorDeletingProfile": "Σφάλμα κατά τη διαγραφή του προφίλ", + "errorActivatingProfile": "Σφάλμα κατά την ενεργοποίηση του προφίλ" + }, + "favouritesPage": { + "favouritesTitle": "Τα Αγαπημένα σας Μέρη", + "noFavourites": "Δεν έχετε αγαπημένα μέρη ακόμα", + "myLists": "Οι Λίστες μου", + "manageFavourites": "Διαχειριστείτε τους αγαπημένους και μη αρεστούς προορισμούς", + "disliked": "Μη Αρεστά", + "loadingLists": "Φορτώνουμε τις λίστες σας...", + "remove": "Αφαίρεση", + "added": "Προστέθηκε", + "startExploring": "Αρχίστε να εξερευνάτε και προσθέστε τους αγαπημένους σας τόπους!", + "noDisliked": "Δεν έχετε μη αρεστούς προορισμούς", + "dislikedWillAppearHere": "Οι τόποι που δεν σας ενδιαφέρουν θα εμφανίζονται εδώ", + "errorRemovingFavourite": "Σφάλμα κατά την αφαίρεση από τα αγαπημένα", + "errorRemovingDisliked": "Σφάλμα κατά την αφαίρεση από τα μη αρεστά" + } +} \ No newline at end of file diff --git a/src/i18n/locales/en.js b/src/i18n/locales/en.js index ca891179..191155e1 100644 --- a/src/i18n/locales/en.js +++ b/src/i18n/locales/en.js @@ -1,21 +1,21 @@ /** * English Translation Resources - Index file */ -import { common, homePage, auth, validation } from './en/common'; -import { userPages, preferencesPage, favouritesPage } from './en/pages'; -import { navigationPage, recommendationsPage, placeDetails, categories, footer } from './en/features'; +import common from './en/common.json'; +import features from './en/features.json'; +import pages from './en/pages.json'; export const en = { - ...common, - ...homePage, - ...auth, - ...validation, - ...userPages, - ...preferencesPage, - ...favouritesPage, - ...navigationPage, - ...recommendationsPage, - ...placeDetails, - ...categories, - ...footer, + ...common.common, + ...common.homePage, + ...common.auth, + ...common.validation, + ...pages.userPages, + ...pages.preferencesPage, + ...pages.favouritesPage, + ...features.navigationPage, + ...features.recommendationsPage, + ...features.placeDetails, + ...features.categories, + ...features.footer, }; diff --git a/src/i18n/locales/en/common.js b/src/i18n/locales/en/common.js deleted file mode 100644 index 4a653322..00000000 --- a/src/i18n/locales/en/common.js +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @fileoverview English (en) translations for common UI elements. - * Contains translations for: header, home page, auth, and validation messages. - * @module i18n/locales/en/common - */ - -// Common/Header translations -export const common = { - home: 'Home', recommendations: 'Recommendations', navigation: 'Navigation', - favourites: 'Favourites', preferences: 'Preferences', profile: 'Profile', - login: 'Login', signup: 'Sign Up', logout: 'Logout', - error: 'Error', success: 'Success', confirm: 'Confirm', delete: 'Delete', - edit: 'Edit', save: 'Save', back: 'Back', next: 'Next', close: 'Close', - noServerConnection: 'No connection to server', errorOccurred: 'An error occurred', - cancel: 'Cancel', loading: 'Loading...', enabled: 'Enabled', disabled: 'Disabled', -}; - -// HomePage translations -export const homePage = { - welcomeTitle: 'Welcome to myWorld Travel', welcomeSubtitle: 'Discover the world with personalized recommendations', - searchPlaceholder: 'Search destinations...', searchButton: 'Search', - recommendationsForYou: 'Recommendations for You', navigationBtn: 'Navigation', - recommendedDestinations: 'Featured Destinations', searchResults: 'Search Results', - clearButton: 'Clear', noResults: 'No results found for', viewAll: 'View All', - ctaTitle: 'Ready to Start Your Journey?', ctaDescription: 'Join thousands of travelers discovering amazing destinations every day.', - exploreDestinations: 'Explore Destinations', whatWeOffer: 'What We Offer', - personalizedRecommendations: 'Personalized Recommendations', personalizedRecommendationsDesc: 'Get recommendations based on your preferences', - reviews: 'Reviews', reviewsDesc: 'Read and write reviews for places', - favouritesFeature: 'Favourites', favouritesFeatureDesc: 'Save your favourite destinations', - navigationFeature: 'Navigation', navigationFeatureDesc: 'Find the route to your destination', -}; - -// Auth translations -export const auth = { - welcomeBack: 'Welcome back to myWorld Travel', email: 'Email', password: 'Password', - loginButton: 'Login', noAccount: "Don't have an account?", signupLink: 'Sign up here', - loginError: 'Login error', demoAccounts: 'Demo Accounts', demoUser: 'User', admin: 'Admin', - createAccount: 'Create your account', username: 'Username', confirmPassword: 'Confirm Password', - signupButton: 'Sign Up', haveAccount: 'Already have an account?', loginLink: 'Login here', - signupError: 'Signup error. Email may already be in use', - passwordRequirement: 'At least 6 characters (uppercase, lowercase, number)', -}; - -// Validation translations -export const validation = { - invalidEmail: 'Invalid email address', emailRequired: 'Email is required', - passwordMinLength: 'Password must be at least 6 characters', passwordRequired: 'Password is required', - nameMinLength: 'Name must be at least 2 characters', nameMaxLength: 'Name cannot exceed 50 characters', - nameRequired: 'Name is required', passwordComplexity: 'Password must contain at least one uppercase, one lowercase, and one number', - passwordsNotMatch: 'Passwords do not match', confirmPasswordRequired: 'Confirm password is required', - phoneInvalid: 'Phone must be 10 digits', profileNameRequired: 'Profile name is required', - categoryMinOne: 'Select at least one category', categoriesRequired: 'Categories are required', - minPriceNegative: 'Minimum price cannot be negative', minPriceRequired: 'Minimum price is required', - maxPriceGreaterThanMin: 'Maximum price must be greater than minimum', maxPriceRequired: 'Maximum price is required', - radiusMinOne: 'Radius must be at least 1 km', radiusMaxHundred: 'Radius cannot exceed 100 km', radiusRequired: 'Radius is required', - ratingMinMax: 'Rating must be from 1 to 5', ratingRequired: 'Rating is required', - commentMinLength: 'Comment must be at least 10 characters', commentMaxLength: 'Comment cannot exceed 500 characters', - commentRequired: 'Comment is required', reasonRequired: 'Reason for report is required', - descriptionMinLength: 'Description must be at least 20 characters', descriptionMaxLength: 'Description cannot exceed 500 characters', - descriptionRequired: 'Description is required', searchMinLength: 'Search must be at least 2 characters', searchMaxLength: 'Search cannot exceed 100 characters', -}; diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json new file mode 100644 index 00000000..3d308a95 --- /dev/null +++ b/src/i18n/locales/en/common.json @@ -0,0 +1,107 @@ +{ + "common": { + "home": "Home", + "recommendations": "Recommendations", + "navigation": "Navigation", + "favourites": "Favourites", + "preferences": "Preferences", + "profile": "Profile", + "login": "Login", + "signup": "Sign Up", + "logout": "Logout", + "error": "Error", + "success": "Success", + "confirm": "Confirm", + "delete": "Delete", + "edit": "Edit", + "save": "Save", + "back": "Back", + "next": "Next", + "close": "Close", + "noServerConnection": "No connection to server", + "errorOccurred": "An error occurred", + "cancel": "Cancel", + "loading": "Loading...", + "enabled": "Enabled", + "disabled": "Disabled" + }, + "homePage": { + "welcomeTitle": "Welcome to myWorld Travel", + "welcomeSubtitle": "Discover the world with personalized recommendations", + "searchPlaceholder": "Search destinations...", + "searchButton": "Search", + "recommendationsForYou": "Recommendations for You", + "navigationBtn": "Navigation", + "recommendedDestinations": "Featured Destinations", + "searchResults": "Search Results", + "clearButton": "Clear", + "noResults": "No results found for", + "viewAll": "View All", + "ctaTitle": "Ready to Start Your Journey?", + "ctaDescription": "Join thousands of travelers discovering amazing destinations every day.", + "exploreDestinations": "Explore Destinations", + "whatWeOffer": "What We Offer", + "personalizedRecommendations": "Personalized Recommendations", + "personalizedRecommendationsDesc": "Get recommendations based on your preferences", + "reviews": "Reviews", + "reviewsDesc": "Read and write reviews for places", + "favouritesFeature": "Favourites", + "favouritesFeatureDesc": "Save your favourite destinations", + "navigationFeature": "Navigation", + "navigationFeatureDesc": "Find the route to your destination" + }, + "auth": { + "welcomeBack": "Welcome back to myWorld Travel", + "email": "Email", + "password": "Password", + "loginButton": "Login", + "noAccount": "Don't have an account?", + "signupLink": "Sign up here", + "loginError": "Login error", + "demoAccounts": "Demo Accounts", + "demoUser": "User", + "admin": "Admin", + "createAccount": "Create your account", + "username": "Username", + "confirmPassword": "Confirm Password", + "signupButton": "Sign Up", + "haveAccount": "Already have an account?", + "loginLink": "Login here", + "signupError": "Signup error. Email may already be in use", + "passwordRequirement": "At least 6 characters (uppercase, lowercase, number)" + }, + "validation": { + "invalidEmail": "Invalid email address", + "emailRequired": "Email is required", + "passwordMinLength": "Password must be at least 6 characters", + "passwordRequired": "Password is required", + "nameMinLength": "Name must be at least 2 characters", + "nameMaxLength": "Name cannot exceed 50 characters", + "nameRequired": "Name is required", + "passwordComplexity": "Password must contain at least one uppercase, one lowercase, and one number", + "passwordsNotMatch": "Passwords do not match", + "confirmPasswordRequired": "Confirm password is required", + "phoneInvalid": "Phone must be 10 digits", + "profileNameRequired": "Profile name is required", + "categoryMinOne": "Select at least one category", + "categoriesRequired": "Categories are required", + "minPriceNegative": "Minimum price cannot be negative", + "minPriceRequired": "Minimum price is required", + "maxPriceGreaterThanMin": "Maximum price must be greater than minimum", + "maxPriceRequired": "Maximum price is required", + "radiusMinOne": "Radius must be at least 1 km", + "radiusMaxHundred": "Radius cannot exceed 100 km", + "radiusRequired": "Radius is required", + "ratingMinMax": "Rating must be from 1 to 5", + "ratingRequired": "Rating is required", + "commentMinLength": "Comment must be at least 10 characters", + "commentMaxLength": "Comment cannot exceed 500 characters", + "commentRequired": "Comment is required", + "reasonRequired": "Reason for report is required", + "descriptionMinLength": "Description must be at least 20 characters", + "descriptionMaxLength": "Description cannot exceed 500 characters", + "descriptionRequired": "Description is required", + "searchMinLength": "Search must be at least 2 characters", + "searchMaxLength": "Search cannot exceed 100 characters" + } +} \ No newline at end of file diff --git a/src/i18n/locales/en/features.js b/src/i18n/locales/en/features.js deleted file mode 100644 index 615df5fb..00000000 --- a/src/i18n/locales/en/features.js +++ /dev/null @@ -1,57 +0,0 @@ -// Navigation page translations -export const navigationPage = { - from: 'From', to: 'To', getRoute: 'Get Route', navigationSubtitle: 'Find the route to your destination', - calculateRoute: 'Calculate Route', startPoint: 'Starting Point', destination: 'Destination', - transportMode: 'Transportation Mode', walking: 'Walking', driving: 'Driving', publicTransport: 'Public Transport', - calculating: 'Calculating...', errorCalculatingRoute: 'Error calculating route', routeResults: 'Route Results', - distance: 'Distance', duration: 'Duration', transport: 'Transport', routeDetails: 'Route Details', - origin: 'Origin', minutes: 'minutes', mapView: 'Map view (will be integrated in future version)', - quickRoutes: 'Quick Routes', resolvingLocation: 'Resolving location...', enterLocation: 'Location', - searchLocationPlaceholder: 'e.g., Athens, Greece', searchingLocations: 'Searching locations...', - locationNotFound: 'Location not found', planYourJourney: 'Plan Your Journey', - enterLocationsToStart: 'Enter locations or select a quick route to get started', -}; - -// Recommendations page translations -export const recommendationsPage = { - recommendationsTitle: '✨ Recommendations for You', recommendationsSubtitle: 'Personalized recommendations based on your preferences', - filtersTitle: 'Search Filters', latitude: 'Latitude', longitude: 'Longitude', maxDistance: 'Max Distance', - applyFilters: 'Apply Filters', notAuthenticated: 'You are not logged in', - errorLoadingRecommendations: 'Error loading recommendations', noRecommendationsFound: 'No recommendations found', - tryAgain: 'Try Again', goToPreferences: 'Go to "⚙️ Preference Profile" to create a profile', - tryDifferentFilters: 'Try changing the search filters or categories in your profile', -}; - -// Place details page translations -export const placeDetails = { - location: 'Location', openingHours: 'Opening Hours', contact: 'Contact', website: 'Website', - reviewsSection: 'Reviews', submitReview: 'Submit Review', description: 'Description', - writeReview: 'Write a Review', submit: 'Submit', loadingDetails: 'Loading details...', - errorLoadingPlace: 'Error loading place details', placeNotFound: 'Place not found', - removeFromDisliked: 'Remove from Disliked', addToDisliked: 'Dislike', report: 'Report', - information: 'Information', address: 'Address', coordinates: 'Coordinates', - excellent: 'Excellent', veryGood: 'Very Good', good: 'Good', fair: 'Fair', poor: 'Poor', - comment: 'Comment', noReviews: 'No reviews yet', user: 'User', reportProblem: 'Report Problem', - reason: 'Reason', selectReason: 'Select reason', incorrectInfo: 'Incorrect Information', - closed: 'Closed', inappropriate: 'Inappropriate Content', other: 'Other', - reviewSubmittedSuccess: 'Your review was submitted!', mustLoginToReview: 'You must log in to submit a review', - errorSubmittingReview: 'Error submitting review', reportSubmittedSuccess: 'Your report was submitted!', - mustLoginToReport: 'You must log in to submit a report', errorSubmittingReport: 'Error submitting report', - mustLoginToFavourite: 'You must log in to add to favourites', errorAddingToFavourites: 'Error adding to favourites', - mustLoginToDislike: 'You must log in to add to disliked', errorAddingToDisliked: 'Error adding to disliked', - viewDetails: 'View Details', addToFavourites: 'Add to Favourites', removeFromFavourites: 'Remove from Favourites', rating: 'Rating', -}; - -// Categories and misc -export const categories = { - RESTAURANT: 'Restaurant', MUSEUM: 'Museum', PARK: 'Park', BEACH: 'Beach', CULTURE: 'Culture', - ENTERTAINMENT: 'Entertainment', SHOPPING: 'Shopping', HOTEL: 'Hotel', CAFE: 'Cafe', BAR: 'Bar', - cheap: 'Cheap', moderate: 'Moderate', expensive: 'Expensive', -}; - -// Footer -export const footer = { - explore: 'Explore', account: 'Account', - discoverAmazingPlaces: 'Discover amazing destinations around the world with personalized recommendations!', - allRightsReserved: 'All rights reserved', -}; diff --git a/src/i18n/locales/en/features.json b/src/i18n/locales/en/features.json new file mode 100644 index 00000000..6ad1ba4d --- /dev/null +++ b/src/i18n/locales/en/features.json @@ -0,0 +1,118 @@ +{ + "navigationPage": { + "from": "From", + "to": "To", + "getRoute": "Get Route", + "navigationSubtitle": "Find the route to your destination", + "calculateRoute": "Calculate Route", + "startPoint": "Starting Point", + "destination": "Destination", + "transportMode": "Transportation Mode", + "walking": "Walking", + "driving": "Driving", + "publicTransport": "Public Transport", + "calculating": "Calculating...", + "errorCalculatingRoute": "Error calculating route", + "routeResults": "Route Results", + "distance": "Distance", + "duration": "Duration", + "transport": "Transport", + "routeDetails": "Route Details", + "origin": "Origin", + "minutes": "minutes", + "mapView": "Map view (will be integrated in future version)", + "quickRoutes": "Quick Routes", + "resolvingLocation": "Resolving location...", + "enterLocation": "Location", + "searchLocationPlaceholder": "e.g., Athens, Greece", + "searchingLocations": "Searching locations...", + "locationNotFound": "Location not found", + "planYourJourney": "Plan Your Journey", + "enterLocationsToStart": "Enter locations or select a quick route to get started" + }, + "recommendationsPage": { + "recommendationsTitle": "✨ Recommendations for You", + "recommendationsSubtitle": "Personalized recommendations based on your preferences", + "filtersTitle": "Search Filters", + "latitude": "Latitude", + "longitude": "Longitude", + "maxDistance": "Max Distance", + "applyFilters": "Apply Filters", + "notAuthenticated": "You are not logged in", + "errorLoadingRecommendations": "Error loading recommendations", + "noRecommendationsFound": "No recommendations found", + "tryAgain": "Try Again", + "goToPreferences": "Go to \"⚙️ Preference Profile\" to create a profile", + "tryDifferentFilters": "Try changing the search filters or categories in your profile" + }, + "placeDetails": { + "location": "Location", + "openingHours": "Opening Hours", + "contact": "Contact", + "website": "Website", + "reviewsSection": "Reviews", + "submitReview": "Submit Review", + "description": "Description", + "writeReview": "Write a Review", + "submit": "Submit", + "loadingDetails": "Loading details...", + "errorLoadingPlace": "Error loading place details", + "placeNotFound": "Place not found", + "removeFromDisliked": "Remove from Disliked", + "addToDisliked": "Dislike", + "report": "Report", + "information": "Information", + "address": "Address", + "coordinates": "Coordinates", + "excellent": "Excellent", + "veryGood": "Very Good", + "good": "Good", + "fair": "Fair", + "poor": "Poor", + "comment": "Comment", + "noReviews": "No reviews yet", + "user": "User", + "reportProblem": "Report Problem", + "reason": "Reason", + "selectReason": "Select reason", + "incorrectInfo": "Incorrect Information", + "closed": "Closed", + "inappropriate": "Inappropriate Content", + "other": "Other", + "reviewSubmittedSuccess": "Your review was submitted!", + "mustLoginToReview": "You must log in to submit a review", + "errorSubmittingReview": "Error submitting review", + "reportSubmittedSuccess": "Your report was submitted!", + "mustLoginToReport": "You must log in to submit a report", + "errorSubmittingReport": "Error submitting report", + "mustLoginToFavourite": "You must log in to add to favourites", + "errorAddingToFavourites": "Error adding to favourites", + "mustLoginToDislike": "You must log in to add to disliked", + "errorAddingToDisliked": "Error adding to disliked", + "viewDetails": "View Details", + "addToFavourites": "Add to Favourites", + "removeFromFavourites": "Remove from Favourites", + "rating": "Rating" + }, + "categories": { + "RESTAURANT": "Restaurant", + "MUSEUM": "Museum", + "PARK": "Park", + "BEACH": "Beach", + "CULTURE": "Culture", + "ENTERTAINMENT": "Entertainment", + "SHOPPING": "Shopping", + "HOTEL": "Hotel", + "CAFE": "Cafe", + "BAR": "Bar", + "cheap": "Cheap", + "moderate": "Moderate", + "expensive": "Expensive" + }, + "footer": { + "explore": "Explore", + "account": "Account", + "discoverAmazingPlaces": "Discover amazing destinations around the world with personalized recommendations!", + "allRightsReserved": "All rights reserved" + } +} \ No newline at end of file diff --git a/src/i18n/locales/en/pages.js b/src/i18n/locales/en/pages.js deleted file mode 100644 index df0af11f..00000000 --- a/src/i18n/locales/en/pages.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @fileoverview English (en) translations for user pages. - * Contains: profile, preferences, and favourites page strings. - * @module i18n/locales/en/pages - */ - -// User/Profile/Preferences translations -export const userPages = { - userProfile: 'User Profile', editProfile: 'Edit Profile', saveChanges: 'Save Changes', - personalInfo: 'Personal Information', accountSettings: 'Account Settings', - language: 'Language', english: 'English', greek: 'Greek', theme: 'Theme', light: 'Light', dark: 'Dark', - notifications: 'Notifications', myProfile: 'My Profile', manageYourInfo: 'Manage your information and settings', - loadingProfile: 'Loading your profile...', name: 'Name', phone: 'Phone', dateOfBirth: 'Date of Birth', - notSet: 'Not set', settings: 'Settings', emailNotifications: 'Email Notifications', pushNotifications: 'Push Notifications', - profileUpdatedSuccess: 'Profile updated successfully!', settingsUpdatedSuccess: 'Settings updated successfully!', - errorUpdatingSettings: 'Error updating settings', -}; - -export const preferencesPage = { - preferencesTitle: 'Your Preferences', preferencesSubtitle: 'Set your travel preferences to get better recommendations', - categories: 'Categories', minRating: 'Minimum Rating', savePreferences: 'Save Preferences', - preferenceProfiles: 'Preference Profiles', managePreferenceProfiles: 'Create and manage your preference profiles', - newProfile: 'New Profile', newPreferenceProfile: 'New Preference Profile', profileName: 'Occasion Name', - priceRangeEuro: 'Price Range (€)', update: 'Update', create: 'Create', unnamed: 'Unnamed', - active: 'Active', activate: 'Activate', noPreferenceProfiles: 'You have no preference profiles', - createFirstProfile: 'Create your first profile to receive personalized recommendations', - createProfile: 'Create Profile', errorCreatingProfile: 'Error creating profile', - errorProfileIdNotFound: 'Error: Profile ID not found', errorUpdatingProfile: 'Error updating profile', - confirmDeleteProfile: 'Are you sure you want to delete this profile?', errorDeletingProfile: 'Error deleting profile', - errorActivatingProfile: 'Error activating profile', -}; - -export const favouritesPage = { - favouritesTitle: 'Your Favourite Places', noFavourites: 'You have no favourite places yet', - myLists: 'My Lists', manageFavourites: 'Manage your favourite and disliked destinations', disliked: 'Disliked', - loadingLists: 'Loading your lists...', remove: 'Remove', added: 'Added', - startExploring: 'Start exploring and add your favourite places!', - noDisliked: 'You have no disliked destinations', dislikedWillAppearHere: 'Places you don\'t like will appear here', - errorRemovingFavourite: 'Error removing from favourites', errorRemovingDisliked: 'Error removing from disliked', -}; diff --git a/src/i18n/locales/en/pages.json b/src/i18n/locales/en/pages.json new file mode 100644 index 00000000..8db8f443 --- /dev/null +++ b/src/i18n/locales/en/pages.json @@ -0,0 +1,71 @@ +{ + "userPages": { + "userProfile": "User Profile", + "editProfile": "Edit Profile", + "saveChanges": "Save Changes", + "personalInfo": "Personal Information", + "accountSettings": "Account Settings", + "language": "Language", + "english": "English", + "greek": "Greek", + "theme": "Theme", + "light": "Light", + "dark": "Dark", + "notifications": "Notifications", + "myProfile": "My Profile", + "manageYourInfo": "Manage your information and settings", + "loadingProfile": "Loading your profile...", + "name": "Name", + "phone": "Phone", + "dateOfBirth": "Date of Birth", + "notSet": "Not set", + "settings": "Settings", + "emailNotifications": "Email Notifications", + "pushNotifications": "Push Notifications", + "profileUpdatedSuccess": "Profile updated successfully!", + "settingsUpdatedSuccess": "Settings updated successfully!", + "errorUpdatingSettings": "Error updating settings" + }, + "preferencesPage": { + "preferencesTitle": "Your Preferences", + "preferencesSubtitle": "Set your travel preferences to get better recommendations", + "categories": "Categories", + "minRating": "Minimum Rating", + "savePreferences": "Save Preferences", + "preferenceProfiles": "Preference Profiles", + "managePreferenceProfiles": "Create and manage your preference profiles", + "newProfile": "New Profile", + "newPreferenceProfile": "New Preference Profile", + "profileName": "Occasion Name", + "priceRangeEuro": "Price Range (€)", + "update": "Update", + "create": "Create", + "unnamed": "Unnamed", + "active": "Active", + "activate": "Activate", + "noPreferenceProfiles": "You have no preference profiles", + "createFirstProfile": "Create your first profile to receive personalized recommendations", + "createProfile": "Create Profile", + "errorCreatingProfile": "Error creating profile", + "errorProfileIdNotFound": "Error: Profile ID not found", + "errorUpdatingProfile": "Error updating profile", + "confirmDeleteProfile": "Are you sure you want to delete this profile?", + "errorDeletingProfile": "Error deleting profile", + "errorActivatingProfile": "Error activating profile" + }, + "favouritesPage": { + "favouritesTitle": "Your Favourite Places", + "noFavourites": "You have no favourite places yet", + "myLists": "My Lists", + "manageFavourites": "Manage your favourite and disliked destinations", + "disliked": "Disliked", + "loadingLists": "Loading your lists...", + "remove": "Remove", + "added": "Added", + "startExploring": "Start exploring and add your favourite places!", + "noDisliked": "You have no disliked destinations", + "dislikedWillAppearHere": "Places you don't like will appear here", + "errorRemovingFavourite": "Error removing from favourites", + "errorRemovingDisliked": "Error removing from disliked" + } +} \ No newline at end of file From 2a7ae75e2e27cf909f641e760c6ab84ccf9fdd3d Mon Sep 17 00:00:00 2001 From: Giannis Fraidakis Date: Fri, 26 Dec 2025 13:52:29 +0200 Subject: [PATCH 02/23] refactor: extract place type map, simplify placeTypes.js --- src/utils/geocoding/placeTypeMap.js | 92 +++++++++++++++ src/utils/geocoding/placeTypes.js | 168 ++++++++++------------------ 2 files changed, 151 insertions(+), 109 deletions(-) create mode 100644 src/utils/geocoding/placeTypeMap.js diff --git a/src/utils/geocoding/placeTypeMap.js b/src/utils/geocoding/placeTypeMap.js new file mode 100644 index 00000000..5c674c7e --- /dev/null +++ b/src/utils/geocoding/placeTypeMap.js @@ -0,0 +1,92 @@ +/** + * Place Type Mapping Data + * + * Maps Nominatim class/type to user-friendly place types with icons. + */ + +export const PLACE_TYPE_MAP = { + // Tourism + 'attraction': '🏛️ Attraction', + 'museum': '🏛️ Museum', + 'monument': '🗿 Monument', + 'archaeological_site': '🏺 Archaeological Site', + 'castle': '🏰 Castle', + 'ruins': '🏚️ Ruins', + 'viewpoint': '👁️ Viewpoint', + 'beach': '🏖️ Beach', + 'hotel': '🏨 Hotel', + 'guest_house': '🏠 Guest House', + 'hostel': '🛏️ Hostel', + 'camp_site': '⛺ Campsite', + 'theme_park': '🎢 Theme Park', + 'zoo': '🦁 Zoo', + 'aquarium': '🐠 Aquarium', + + // Natural features + 'peak': '⛰️ Peak', + 'volcano': '🌋 Volcano', + 'bay': '🌊 Bay', + 'island': '🏝️ Island', + 'lake': '💧 Lake', + 'river': '🌊 River', + 'waterfall': '💦 Waterfall', + 'cave_entrance': '🕳️ Cave', + 'nature_reserve': '🌲 Nature Reserve', + 'national_park': '🏞️ National Park', + 'forest': '🌳 Forest', + + // Places + 'city': '🏙️ City', + 'town': '🏘️ Town', + 'village': '🏡 Village', + 'hamlet': '🏡 Hamlet', + 'suburb': '🏘️ Suburb', + 'neighbourhood': '🏘️ Neighbourhood', + + // Transport + 'airport': '✈️ Airport', + 'port': '⚓ Port', + 'ferry_terminal': '⛴️ Ferry Terminal', + 'bus_station': '🚌 Bus Station', + 'train_station': '🚂 Train Station', + + // Amenities + 'restaurant': '🍽️ Restaurant', + 'cafe': '☕ Cafe', + 'bar': '🍺 Bar', + 'fast_food': '🍔 Fast Food', + 'hospital': '🏥 Hospital', + 'pharmacy': '💊 Pharmacy', + 'school': '🏫 School', + 'university': '🎓 University', + 'library': '📚 Library', + 'place_of_worship': '⛪ Place of Worship', + 'church': '⛪ Church', + 'monastery': '⛪ Monastery', + 'mosque': '🕌 Mosque', + + // Shopping + 'marketplace': '🛒 Market', + 'shopping_centre': '🛍️ Shopping Center', + 'supermarket': '🛒 Supermarket', + + // Leisure + 'park': '🌳 Park', + 'garden': '🌷 Garden', + 'playground': '🎠 Playground', + 'sports_centre': '🏟️ Sports Center', + 'stadium': '🏟️ Stadium', + 'swimming_pool': '🏊 Swimming Pool', + 'marina': '⛵ Marina', +}; + +export const CLASS_FALLBACK_MAP = { + 'tourism': '📍 Point of Interest', + 'natural': '🌿 Natural Feature', + 'historic': '🏛️ Historic Site', + 'amenity': '📍 Amenity', + 'place': '📍 Place', + 'leisure': '🎯 Leisure', +}; + +export const DEFAULT_PLACE_TYPE = '📍 Location'; diff --git a/src/utils/geocoding/placeTypes.js b/src/utils/geocoding/placeTypes.js index f7c05796..f3b03f99 100644 --- a/src/utils/geocoding/placeTypes.js +++ b/src/utils/geocoding/placeTypes.js @@ -4,104 +4,76 @@ * Maps Nominatim class/type to user-friendly place types with icons. */ +import { PLACE_TYPE_MAP, CLASS_FALLBACK_MAP, DEFAULT_PLACE_TYPE } from './placeTypeMap'; + /** * Get a user-friendly place type from Nominatim class/type * @param {Object} item - Nominatim result item * @returns {string} Human-readable place type */ export const getPlaceType = (item) => { - const typeMap = { - // Tourism - 'attraction': '🏛️ Attraction', - 'museum': '🏛️ Museum', - 'monument': '🗿 Monument', - 'archaeological_site': '🏺 Archaeological Site', - 'castle': '🏰 Castle', - 'ruins': '🏚️ Ruins', - 'viewpoint': '👁️ Viewpoint', - 'beach': '🏖️ Beach', - 'hotel': '🏨 Hotel', - 'guest_house': '🏠 Guest House', - 'hostel': '🛏️ Hostel', - 'camp_site': '⛺ Campsite', - 'theme_park': '🎢 Theme Park', - 'zoo': '🦁 Zoo', - 'aquarium': '🐠 Aquarium', - - // Natural features - 'peak': '⛰️ Peak', - 'volcano': '🌋 Volcano', - 'bay': '🌊 Bay', - 'island': '🏝️ Island', - 'lake': '💧 Lake', - 'river': '🌊 River', - 'waterfall': '💦 Waterfall', - 'cave_entrance': '🕳️ Cave', - 'nature_reserve': '🌲 Nature Reserve', - 'national_park': '🏞️ National Park', - 'forest': '🌳 Forest', + const type = item.type || ''; + const category = item.class || ''; - // Places - 'city': '🏙️ City', - 'town': '🏘️ Town', - 'village': '🏡 Village', - 'hamlet': '🏡 Hamlet', - 'suburb': '🏘️ Suburb', - 'neighbourhood': '🏘️ Neighbourhood', + return PLACE_TYPE_MAP[type] || PLACE_TYPE_MAP[category] || CLASS_FALLBACK_MAP[category] || DEFAULT_PLACE_TYPE; +}; - // Transport - 'airport': '✈️ Airport', - 'port': '⚓ Port', - 'ferry_terminal': '⛴️ Ferry Terminal', - 'bus_station': '🚌 Bus Station', - 'train_station': '🚂 Train Station', +/** + * Get primary location from address + * @param {Object} address - Nominatim address object + * @returns {string|null} Primary location name + */ +const getPrimaryLocation = (address) => { + return address.city || address.town || address.village || address.municipality || null; +}; - // Amenities - 'restaurant': '🍽️ Restaurant', - 'cafe': '☕ Cafe', - 'bar': '🍺 Bar', - 'fast_food': '🍔 Fast Food', - 'hospital': '🏥 Hospital', - 'pharmacy': '💊 Pharmacy', - 'school': '🏫 School', - 'university': '🎓 University', - 'library': '📚 Library', - 'place_of_worship': '⛪ Place of Worship', - 'church': '⛪ Church', - 'monastery': '⛪ Monastery', - 'mosque': '🕌 Mosque', +/** + * Get region from address + * @param {Object} address - Nominatim address object + * @returns {string|null} Region or state name + */ +const getRegion = (address) => { + return address.state || address.region || null; +}; - // Shopping - 'marketplace': '🛒 Market', - 'shopping_centre': '🛍️ Shopping Center', - 'supermarket': '🛒 Supermarket', +/** + * Build location context array from address + * @param {Object} address - Nominatim address object + * @returns {Array} Location context parts + */ +const buildLocationContext = (address) => { + const context = []; - // Leisure - 'park': '🌳 Park', - 'garden': '🌷 Garden', - 'playground': '🎠 Playground', - 'sports_centre': '🏟️ Sports Center', - 'stadium': '🏟️ Stadium', - 'swimming_pool': '🏊 Swimming Pool', - 'marina': '⛵ Marina', - }; + const primary = getPrimaryLocation(address); + if (primary) context.push(primary); - const type = item.type || ''; - const category = item.class || ''; + const region = getRegion(address); + if (region) context.push(region); - // Check type first, then class - if (typeMap[type]) return typeMap[type]; - if (typeMap[category]) return typeMap[category]; + return context; +}; - // Default based on class - if (category === 'tourism') return '📍 Point of Interest'; - if (category === 'natural') return '🌿 Natural Feature'; - if (category === 'historic') return '🏛️ Historic Site'; - if (category === 'amenity') return '📍 Amenity'; - if (category === 'place') return '📍 Place'; - if (category === 'leisure') return '🎯 Leisure'; +/** + * Format display name with context + * @param {string} name - Place name + * @param {Array} context - Location context parts + * @returns {string} Formatted display name + */ +const formatWithContext = (name, context) => { + if (name && !context.includes(name)) { + return context.length > 0 ? `${name}, ${context.join(', ')}` : name; + } + return context.length > 0 ? context.join(', ') : null; +}; - return '📍 Location'; +/** + * Truncate display name to first 3 parts + * @param {string} displayName - Full display name + * @returns {string} Truncated display name + */ +const truncateDisplayName = (displayName) => { + const parts = displayName.split(',').slice(0, 3).map(s => s.trim()); + return parts.join(', '); }; /** @@ -112,35 +84,13 @@ export const getPlaceType = (item) => { export const extractSearchDisplayName = (item) => { const address = item.address || {}; const name = item.name || ''; + const context = buildLocationContext(address); - // Build location context - const context = []; - if (address.city) context.push(address.city); - else if (address.town) context.push(address.town); - else if (address.village) context.push(address.village); - else if (address.municipality) context.push(address.municipality); - - if (address.state || address.region) { - context.push(address.state || address.region); - } - - // If we have a name different from the city/town, use it - if (name && !context.includes(name)) { - if (context.length > 0) { - return `${name}, ${context.join(', ')}`; - } - return name; - } - - // Otherwise use the context or full display name - if (context.length > 0) { - return context.join(', '); - } + const formatted = formatWithContext(name, context); + if (formatted) return formatted; - // Fallback: use display_name but truncate it if (item.display_name) { - const parts = item.display_name.split(',').slice(0, 3).map(s => s.trim()); - return parts.join(', '); + return truncateDisplayName(item.display_name); } return 'Unknown location'; From 8c3b98443f0f2071400e9a76725f563c2cac3c40 Mon Sep 17 00:00:00 2001 From: Giannis Fraidakis Date: Fri, 26 Dec 2025 13:53:15 +0200 Subject: [PATCH 03/23] refactor: extract helpers in searchLocations.js --- src/utils/geocoding/searchLocations.js | 166 ++++++++++++++----------- 1 file changed, 92 insertions(+), 74 deletions(-) diff --git a/src/utils/geocoding/searchLocations.js b/src/utils/geocoding/searchLocations.js index fb3f6dd4..7f0235ef 100644 --- a/src/utils/geocoding/searchLocations.js +++ b/src/utils/geocoding/searchLocations.js @@ -7,6 +7,89 @@ import { waitForRateLimit } from './cache'; import { getPlaceType, extractSearchDisplayName } from './placeTypes'; +const NOMINATIM_BASE_URL = 'https://nominatim.openstreetmap.org/search'; +const GREECE_VIEWBOX = '19.3,34.8,29.7,41.8'; +const DEFAULT_HEADERS = { + 'Accept': 'application/json', + 'User-Agent': 'myWorld-Travel-App/1.0' +}; + +/** + * Build URL for Nominatim search + * @param {string} query - Search query + * @param {number} limit - Max results + * @param {boolean} useGreece - Whether to bias towards Greece + * @returns {string} Complete URL + */ +const buildSearchUrl = (query, limit, useGreece = true) => { + const params = new URLSearchParams({ + format: 'json', + q: query, + limit: limit.toString(), + addressdetails: '1' + }); + + if (useGreece) { + params.set('viewbox', GREECE_VIEWBOX); + params.set('bounded', '0'); + params.set('countrycodes', 'gr'); + } + + return `${NOMINATIM_BASE_URL}?${params.toString()}`; +}; + +/** + * Generate unique coordinate key for deduplication + * @param {Object} item - Nominatim result item + * @returns {string} Coordinate key + */ +const getCoordKey = (item) => { + return `${parseFloat(item.lat).toFixed(4)},${parseFloat(item.lon).toFixed(4)}`; +}; + +/** + * Transform Nominatim item to result format + * @param {Object} item - Nominatim result item + * @returns {Object} Transformed result + */ +const transformResult = (item) => ({ + lat: parseFloat(item.lat), + lng: parseFloat(item.lon), + displayName: extractSearchDisplayName(item), + type: getPlaceType(item), + importance: item.importance || 0 +}); + +/** + * Process and deduplicate search results + * @param {Array} data - Raw Nominatim results + * @param {Set} seenCoords - Set of already seen coordinates + * @returns {Array} Processed unique results + */ +const processResults = (data, seenCoords) => { + const results = []; + + for (const item of data) { + const coordKey = getCoordKey(item); + if (!seenCoords.has(coordKey)) { + seenCoords.add(coordKey); + results.push(transformResult(item)); + } + } + + return results; +}; + +/** + * Fetch results from Nominatim API + * @param {string} url - Request URL + * @returns {Promise} Results array or empty on error + */ +const fetchNominatim = async (url) => { + const response = await fetch(url, { headers: DEFAULT_HEADERS }); + return response.ok ? response.json() : []; +}; + /** * Search for location suggestions (for autocomplete) * Supports fuzzy/partial matching and includes all types of places @@ -24,90 +107,25 @@ export const searchLocations = async (query, limit = 8) => { const trimmedQuery = query.trim(); try { - // Wait for rate limit - await waitForRateLimit(); - - // Use multiple search strategies for better results const results = []; const seenCoords = new Set(); - // Strategy 1: Direct search in Greece (works best for exact/near-exact matches) - // Use viewbox for Greece to bias results but not force it - const viewbox = '19.3,34.8,29.7,41.8'; // Greece bounding box (lon_min,lat_min,lon_max,lat_max) - - const response1 = await fetch( - `https://nominatim.openstreetmap.org/search?` + - `format=json&` + - `q=${encodeURIComponent(trimmedQuery)}&` + - `limit=${limit}&` + - `addressdetails=1&` + - `viewbox=${viewbox}&` + - `bounded=0&` + // Don't strictly bound, just bias - `countrycodes=gr`, - { - headers: { - 'Accept': 'application/json', - 'User-Agent': 'myWorld-Travel-App/1.0' - } - } - ); - - if (response1.ok) { - const data1 = await response1.json(); - for (const item of data1) { - const coordKey = `${parseFloat(item.lat).toFixed(4)},${parseFloat(item.lon).toFixed(4)}`; - if (!seenCoords.has(coordKey)) { - seenCoords.add(coordKey); - results.push({ - lat: parseFloat(item.lat), - lng: parseFloat(item.lon), - displayName: extractSearchDisplayName(item), - type: getPlaceType(item), - importance: item.importance || 0 - }); - } - } - } + // Strategy 1: Direct search in Greece + await waitForRateLimit(); + const url1 = buildSearchUrl(trimmedQuery, limit, true); + const data1 = await fetchNominatim(url1); + results.push(...processResults(data1, seenCoords)); - // Strategy 2: If we have few results, try with ", Greece" suffix for partial matches + // Strategy 2: If few results, try with ", Greece" suffix if (results.length < limit / 2) { await waitForRateLimit(); - - const response2 = await fetch( - `https://nominatim.openstreetmap.org/search?` + - `format=json&` + - `q=${encodeURIComponent(trimmedQuery + ', Greece')}&` + - `limit=${limit}&` + - `addressdetails=1`, - { - headers: { - 'Accept': 'application/json', - 'User-Agent': 'myWorld-Travel-App/1.0' - } - } - ); - - if (response2.ok) { - const data2 = await response2.json(); - for (const item of data2) { - const coordKey = `${parseFloat(item.lat).toFixed(4)},${parseFloat(item.lon).toFixed(4)}`; - if (!seenCoords.has(coordKey)) { - seenCoords.add(coordKey); - results.push({ - lat: parseFloat(item.lat), - lng: parseFloat(item.lon), - displayName: extractSearchDisplayName(item), - type: getPlaceType(item), - importance: item.importance || 0 - }); - } - } - } + const url2 = buildSearchUrl(`${trimmedQuery}, Greece`, limit, false); + const data2 = await fetchNominatim(url2); + results.push(...processResults(data2, seenCoords)); } // Sort by importance and limit results results.sort((a, b) => (b.importance || 0) - (a.importance || 0)); - return results.slice(0, limit); } catch (error) { console.warn('Location search failed:', error.message); From 070e4da5038ac01b60d2ae6b0b82b0b597802d1b Mon Sep 17 00:00:00 2001 From: Giannis Fraidakis Date: Fri, 26 Dec 2025 13:53:48 +0200 Subject: [PATCH 04/23] refactor: extract helpers in forwardGeocode.js --- src/utils/geocoding/forwardGeocode.js | 70 +++++++++++++++++---------- 1 file changed, 44 insertions(+), 26 deletions(-) diff --git a/src/utils/geocoding/forwardGeocode.js b/src/utils/geocoding/forwardGeocode.js index 4078a869..5946a56d 100644 --- a/src/utils/geocoding/forwardGeocode.js +++ b/src/utils/geocoding/forwardGeocode.js @@ -10,6 +10,47 @@ import { extractDisplayName } from './cache'; +const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search'; +const DEFAULT_HEADERS = { + 'Accept': 'application/json', + 'User-Agent': 'myWorld-Travel-App/1.0' +}; + +/** + * Validate query string + * @param {string} query - Query to validate + * @returns {boolean} True if valid + */ +const isValidQuery = (query) => { + return query && query.trim().length >= 2; +}; + +/** + * Build Nominatim search URL + * @param {string} query - Search query + * @returns {string} Complete URL + */ +const buildSearchUrl = (query) => { + return `${NOMINATIM_SEARCH_URL}?format=json&q=${encodeURIComponent(query)}&limit=1&addressdetails=1`; +}; + +/** + * Parse geocode response into result object + * @param {Array} data - Nominatim response data + * @returns {Object|null} Parsed result or null + */ +const parseGeocodeResult = (data) => { + if (!data || data.length === 0) { + return null; + } + + return { + lat: parseFloat(data[0].lat), + lng: parseFloat(data[0].lon), + displayName: extractDisplayName(data[0]) || data[0].display_name + }; +}; + /** * Forward geocode a place name to coordinates * Uses OpenStreetMap Nominatim API (free, no API key required) @@ -18,52 +59,29 @@ import { * @returns {Promise<{lat: number, lng: number, displayName: string} | null>} Coordinates or null if not found */ export const forwardGeocode = async (query) => { - if (!query || query.trim().length < 2) { + if (!isValidQuery(query)) { return null; } const cacheKey = query.trim().toLowerCase(); - // Check cache first if (forwardGeocodeCache.has(cacheKey)) { return forwardGeocodeCache.get(cacheKey); } try { - // Wait for rate limit await waitForRateLimit(); - const response = await fetch( - `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1&addressdetails=1`, - { - headers: { - 'Accept': 'application/json', - 'User-Agent': 'myWorld-Travel-App/1.0' - } - } - ); + const response = await fetch(buildSearchUrl(query), { headers: DEFAULT_HEADERS }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); + const result = parseGeocodeResult(data); - if (!data || data.length === 0) { - // Cache null result to avoid repeated lookups - forwardGeocodeCache.set(cacheKey, null); - return null; - } - - const result = { - lat: parseFloat(data[0].lat), - lng: parseFloat(data[0].lon), - displayName: extractDisplayName(data[0]) || data[0].display_name - }; - - // Cache the result forwardGeocodeCache.set(cacheKey, result); - return result; } catch (error) { console.warn('Forward geocoding failed:', error.message); From 7d5e6a152f4a4ff51b0bcbe926c54dd614065657 Mon Sep 17 00:00:00 2001 From: Giannis Fraidakis Date: Fri, 26 Dec 2025 13:54:31 +0200 Subject: [PATCH 05/23] refactor: extract helpers in reverseGeocode.js --- src/utils/geocoding/reverseGeocode.js | 74 ++++++++++++++++----------- 1 file changed, 45 insertions(+), 29 deletions(-) diff --git a/src/utils/geocoding/reverseGeocode.js b/src/utils/geocoding/reverseGeocode.js index b5daad97..08e58cc5 100644 --- a/src/utils/geocoding/reverseGeocode.js +++ b/src/utils/geocoding/reverseGeocode.js @@ -12,6 +12,48 @@ import { extractDisplayName } from './cache'; +const NOMINATIM_REVERSE_URL = 'https://nominatim.openstreetmap.org/reverse'; +const DEFAULT_HEADERS = { + 'Accept': 'application/json', + 'User-Agent': 'myWorld-Travel-App/1.0' +}; + +/** + * Build reverse geocode URL + * @param {number} lat - Latitude + * @param {number} lng - Longitude + * @returns {string} Complete URL + */ +const buildReverseUrl = (lat, lng) => { + return `${NOMINATIM_REVERSE_URL}?format=json&lat=${lat}&lon=${lng}&zoom=10&addressdetails=1`; +}; + +/** + * Create result object from geocode data + * @param {Object} data - Nominatim response + * @param {number} lat - Original latitude + * @param {number} lng - Original longitude + * @returns {Object} Result with name and isCoordinates flag + */ +const createResult = (data, lat, lng) => { + const displayName = extractDisplayName(data); + return { + name: displayName || formatCoordinates(lat, lng), + isCoordinates: !displayName + }; +}; + +/** + * Create fallback result with formatted coordinates + * @param {number} lat - Latitude + * @param {number} lng - Longitude + * @returns {Object} Fallback result + */ +const createFallback = (lat, lng) => ({ + name: formatCoordinates(lat, lng), + isCoordinates: true +}); + /** * Reverse geocode coordinates to a human-readable location name * Uses OpenStreetMap Nominatim API (free, no API key required) @@ -23,24 +65,14 @@ import { export const reverseGeocode = async (lat, lng) => { const cacheKey = getCacheKey(lat, lng); - // Check cache first if (geocodeCache.has(cacheKey)) { return geocodeCache.get(cacheKey); } try { - // Wait for rate limit await waitForRateLimit(); - const response = await fetch( - `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=10&addressdetails=1`, - { - headers: { - 'Accept': 'application/json', - 'User-Agent': 'myWorld-Travel-App/1.0' - } - } - ); + const response = await fetch(buildReverseUrl(lat, lng), { headers: DEFAULT_HEADERS }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); @@ -52,29 +84,13 @@ export const reverseGeocode = async (lat, lng) => { throw new Error(data.error); } - const displayName = extractDisplayName(data); - - const result = { - name: displayName || formatCoordinates(lat, lng), - isCoordinates: !displayName - }; - - // Cache the result + const result = createResult(data, lat, lng); geocodeCache.set(cacheKey, result); - return result; } catch (error) { console.warn('Reverse geocoding failed:', error.message); - - // Return formatted coordinates as fallback - const fallback = { - name: formatCoordinates(lat, lng), - isCoordinates: true - }; - - // Cache the fallback too to avoid repeated failed requests + const fallback = createFallback(lat, lng); geocodeCache.set(cacheKey, fallback); - return fallback; } }; From 9ac4bf1203d9af8f102ab55ed2aacfccc949bb24 Mon Sep 17 00:00:00 2001 From: Giannis Fraidakis Date: Fri, 26 Dec 2025 14:03:35 +0200 Subject: [PATCH 06/23] refactor(polyline): extract decodeValue helper to reduce complexity --- src/utils/routing/polyline.js | 51 +++++++++++++++++------------------ 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/src/utils/routing/polyline.js b/src/utils/routing/polyline.js index 0b639d79..0f899fda 100644 --- a/src/utils/routing/polyline.js +++ b/src/utils/routing/polyline.js @@ -4,6 +4,26 @@ * Decode Google polyline encoded strings to coordinate arrays. */ +/** + * Decode a single coordinate value from the encoded string + * @param {string} encoded - Encoded polyline string + * @param {Object} indexRef - Reference object containing current index + * @returns {number} Decoded delta value + */ +const decodeValue = (encoded, indexRef) => { + let shift = 0; + let result = 0; + let byte; + + do { + byte = encoded.charCodeAt(indexRef.value++) - 63; + result |= (byte & 0x1f) << shift; + shift += 5; + } while (byte >= 0x20); + + return (result & 1) ? ~(result >> 1) : (result >> 1); +}; + /** * Decode a polyline encoded string to coordinates array * OSRM uses Google's polyline encoding @@ -14,36 +34,13 @@ export const decodePolyline = (encoded, precision = 5) => { const factor = Math.pow(10, precision); const coordinates = []; - let index = 0; + const indexRef = { value: 0 }; let lat = 0; let lng = 0; - while (index < encoded.length) { - let shift = 0; - let result = 0; - let byte; - - do { - byte = encoded.charCodeAt(index++) - 63; - result |= (byte & 0x1f) << shift; - shift += 5; - } while (byte >= 0x20); - - const dlat = ((result & 1) ? ~(result >> 1) : (result >> 1)); - lat += dlat; - - shift = 0; - result = 0; - - do { - byte = encoded.charCodeAt(index++) - 63; - result |= (byte & 0x1f) << shift; - shift += 5; - } while (byte >= 0x20); - - const dlng = ((result & 1) ? ~(result >> 1) : (result >> 1)); - lng += dlng; - + while (indexRef.value < encoded.length) { + lat += decodeValue(encoded, indexRef); + lng += decodeValue(encoded, indexRef); coordinates.push([lat / factor, lng / factor]); } From 8cfd5cc74b1681875ebb5df18d7f634d0141ffb3 Mon Sep 17 00:00:00 2001 From: Giannis Fraidakis Date: Fri, 26 Dec 2025 14:04:31 +0200 Subject: [PATCH 07/23] refactor(validation): split into schema factory functions --- src/utils/validationSchemas.js | 209 +++++++++++++++++---------------- 1 file changed, 108 insertions(+), 101 deletions(-) diff --git a/src/utils/validationSchemas.js b/src/utils/validationSchemas.js index 3769f1ee..2c01d967 100644 --- a/src/utils/validationSchemas.js +++ b/src/utils/validationSchemas.js @@ -4,113 +4,120 @@ */ import * as Yup from 'yup'; -/** - * Get validation schemas with translated error messages - * @param {Function} t - Translation function from useTranslation hook - * @returns {Object} Object containing all validation schemas - */ -export const getValidationSchemas = (t) => ({ - // Login Form Validation Schema - loginSchema: Yup.object().shape({ - email: Yup.string() - .email(t('invalidEmail')) - .required(t('emailRequired')), - password: Yup.string() - .min(6, t('passwordMinLength')) - .required(t('passwordRequired')), - }), +/** Create login form validation schema */ +const createLoginSchema = (t) => Yup.object().shape({ + email: Yup.string() + .email(t('invalidEmail')) + .required(t('emailRequired')), + password: Yup.string() + .min(6, t('passwordMinLength')) + .required(t('passwordRequired')), +}); - // Signup Form Validation Schema - signupSchema: Yup.object().shape({ - name: Yup.string() - .min(2, t('nameMinLength')) - .max(50, t('nameMaxLength')) - .required(t('nameRequired')), - email: Yup.string() - .email(t('invalidEmail')) - .required(t('emailRequired')), - password: Yup.string() - .min(6, t('passwordMinLength')) - .matches( - /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, - t('passwordComplexity') - ) - .required(t('passwordRequired')), - confirmPassword: Yup.string() - .oneOf([Yup.ref('password'), null], t('passwordsNotMatch')) - .required(t('confirmPasswordRequired')), - }), +/** Create signup form validation schema */ +const createSignupSchema = (t) => Yup.object().shape({ + name: Yup.string() + .min(2, t('nameMinLength')) + .max(50, t('nameMaxLength')) + .required(t('nameRequired')), + email: Yup.string() + .email(t('invalidEmail')) + .required(t('emailRequired')), + password: Yup.string() + .min(6, t('passwordMinLength')) + .matches( + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, + t('passwordComplexity') + ) + .required(t('passwordRequired')), + confirmPassword: Yup.string() + .oneOf([Yup.ref('password'), null], t('passwordsNotMatch')) + .required(t('confirmPasswordRequired')), +}); - // User Profile Update Validation Schema - profileUpdateSchema: Yup.object().shape({ - name: Yup.string() - .min(2, t('nameMinLength')) - .max(50, t('nameMaxLength')) - .required(t('nameRequired')), - email: Yup.string() - .email(t('invalidEmail')) - .required(t('emailRequired')), - phoneNumber: Yup.string() - .matches(/^[0-9]{10}$/, t('phoneInvalid')) - .nullable(), - }), +/** Create profile update validation schema */ +const createProfileUpdateSchema = (t) => Yup.object().shape({ + name: Yup.string() + .min(2, t('nameMinLength')) + .max(50, t('nameMaxLength')) + .required(t('nameRequired')), + email: Yup.string() + .email(t('invalidEmail')) + .required(t('emailRequired')), + phoneNumber: Yup.string() + .matches(/^[0-9]{10}$/, t('phoneInvalid')) + .nullable(), +}); - // Preference Profile Validation Schema - preferenceProfileSchema: Yup.object().shape({ - name: Yup.string() - .min(2, t('nameMinLength')) - .max(50, t('nameMaxLength')) - .required(t('profileNameRequired')), - categories: Yup.array() - .of(Yup.string()) - .min(1, t('categoryMinOne')) - .required(t('categoriesRequired')), - priceRange: Yup.object().shape({ - min: Yup.number() - .min(0, t('minPriceNegative')) - .required(t('minPriceRequired')), - max: Yup.number() - .min(Yup.ref('min'), t('maxPriceGreaterThanMin')) - .required(t('maxPriceRequired')), - }), - radius: Yup.number() - .min(1, t('radiusMinOne')) - .max(100, t('radiusMaxHundred')) - .required(t('radiusRequired')), +/** Create preference profile validation schema */ +const createPreferenceProfileSchema = (t) => Yup.object().shape({ + name: Yup.string() + .min(2, t('nameMinLength')) + .max(50, t('nameMaxLength')) + .required(t('profileNameRequired')), + categories: Yup.array() + .of(Yup.string()) + .min(1, t('categoryMinOne')) + .required(t('categoriesRequired')), + priceRange: Yup.object().shape({ + min: Yup.number() + .min(0, t('minPriceNegative')) + .required(t('minPriceRequired')), + max: Yup.number() + .min(Yup.ref('min'), t('maxPriceGreaterThanMin')) + .required(t('maxPriceRequired')), }), + radius: Yup.number() + .min(1, t('radiusMinOne')) + .max(100, t('radiusMaxHundred')) + .required(t('radiusRequired')), +}); - // Review Validation Schema - reviewSchema: Yup.object().shape({ - rating: Yup.number() - .min(1, t('ratingMinMax')) - .max(5, t('ratingMinMax')) - .required(t('ratingRequired')), - comment: Yup.string() - .min(10, t('commentMinLength')) - .max(500, t('commentMaxLength')) - .required(t('commentRequired')), - }), +/** Create review validation schema */ +const createReviewSchema = (t) => Yup.object().shape({ + rating: Yup.number() + .min(1, t('ratingMinMax')) + .max(5, t('ratingMinMax')) + .required(t('ratingRequired')), + comment: Yup.string() + .min(10, t('commentMinLength')) + .max(500, t('commentMaxLength')) + .required(t('commentRequired')), +}); - // Report Validation Schema - reportSchema: Yup.object().shape({ - reason: Yup.string() - .required(t('reasonRequired')), - description: Yup.string() - .min(20, t('descriptionMinLength')) - .max(500, t('descriptionMaxLength')) - .required(t('descriptionRequired')), - }), +/** Create report validation schema */ +const createReportSchema = (t) => Yup.object().shape({ + reason: Yup.string() + .required(t('reasonRequired')), + description: Yup.string() + .min(20, t('descriptionMinLength')) + .max(500, t('descriptionMaxLength')) + .required(t('descriptionRequired')), +}); - // Search Form Validation Schema - searchSchema: Yup.object().shape({ - query: Yup.string() - .min(2, t('searchMinLength')) - .max(100, t('searchMaxLength')), - category: Yup.string().nullable(), - radius: Yup.number() - .min(1, t('radiusMinOne')) - .max(100, t('radiusMaxHundred')) - .nullable(), - }), +/** Create search form validation schema */ +const createSearchSchema = (t) => Yup.object().shape({ + query: Yup.string() + .min(2, t('searchMinLength')) + .max(100, t('searchMaxLength')), + category: Yup.string().nullable(), + radius: Yup.number() + .min(1, t('radiusMinOne')) + .max(100, t('radiusMaxHundred')) + .nullable(), }); +/** + * Get validation schemas with translated error messages + * @param {Function} t - Translation function from useTranslation hook + * @returns {Object} Object containing all validation schemas + */ +export const getValidationSchemas = (t) => ({ + loginSchema: createLoginSchema(t), + signupSchema: createSignupSchema(t), + profileUpdateSchema: createProfileUpdateSchema(t), + preferenceProfileSchema: createPreferenceProfileSchema(t), + reviewSchema: createReviewSchema(t), + reportSchema: createReportSchema(t), + searchSchema: createSearchSchema(t), +}); From 0f7d52b53903b731ed7a0c9342dc579dd71959d6 Mon Sep 17 00:00:00 2001 From: Giannis Fraidakis Date: Fri, 26 Dec 2025 14:05:31 +0200 Subject: [PATCH 08/23] refactor(routing): extract response builder helpers --- src/utils/routing.js | 85 +++++++++++++++++++------------------------- 1 file changed, 36 insertions(+), 49 deletions(-) diff --git a/src/utils/routing.js b/src/utils/routing.js index b6f81fc1..4c9f5613 100644 --- a/src/utils/routing.js +++ b/src/utils/routing.js @@ -40,6 +40,36 @@ const getRouteNotFoundMessage = (transportMode) => { } }; +/** Create error response object */ +const createErrorResponse = (error, message, transportMode) => ({ + success: false, + error, + message, + transportMode +}); + +/** Create success response object */ +const createSuccessResponse = (geometry, route, transportMode) => ({ + success: true, + geometry, + distance: route.distance / 1000, + duration: route.duration / 60, + transportMode, + requiresFerry: false +}); + +/** Create ferry warning response object */ +const createFerryResponse = (geometry, route, transportMode, seaCrossing) => ({ + success: true, + geometry, + distance: route.distance / 1000, + duration: route.duration / 60, + transportMode, + requiresFerry: true, + ferryWarning: seaCrossing.message, + islandName: seaCrossing.islandName +}); + /** * Fetch route from OSRM * @@ -59,19 +89,13 @@ export const fetchRoute = async ({ transportMode = 'DRIVING' }) => { const profile = OSRM_PROFILES[transportMode] || 'car'; - - // Check for sea crossing first const seaCrossing = detectSeaCrossing(startLat, startLng, endLat, endLng); - - // OSRM expects lon,lat order const coordinates = `${startLng},${startLat};${endLng},${endLat}`; const url = `${OSRM_BASE_URL}/route/v1/${profile}/${coordinates}?overview=full&geometries=polyline`; try { const response = await fetch(url, { - headers: { - 'Accept': 'application/json', - } + headers: { 'Accept': 'application/json' } }); if (!response.ok) { @@ -80,62 +104,25 @@ export const fetchRoute = async ({ const data = await response.json(); - // Check if route was found if (data.code !== 'Ok') { - return { - success: false, - error: 'ROUTE_NOT_FOUND', - message: getRouteNotFoundMessage(transportMode), - transportMode - }; + return createErrorResponse('ROUTE_NOT_FOUND', getRouteNotFoundMessage(transportMode), transportMode); } - // Check if we got valid routes if (!data.routes || data.routes.length === 0) { - return { - success: false, - error: 'NO_ROUTES', - message: getRouteNotFoundMessage(transportMode), - transportMode - }; + return createErrorResponse('NO_ROUTES', getRouteNotFoundMessage(transportMode), transportMode); } const route = data.routes[0]; - - // Decode the polyline geometry const geometry = decodePolyline(route.geometry, 5); - // If this route requires a ferry crossing, return with warning if (seaCrossing.requiresFerry) { - return { - success: true, - geometry, - distance: route.distance / 1000, - duration: route.duration / 60, - transportMode, - requiresFerry: true, - ferryWarning: seaCrossing.message, - islandName: seaCrossing.islandName - }; + return createFerryResponse(geometry, route, transportMode, seaCrossing); } - return { - success: true, - geometry, // Array of [lat, lng] coordinates - distance: route.distance / 1000, // Convert meters to km - duration: route.duration / 60, // Convert seconds to minutes - transportMode, - requiresFerry: false - }; + return createSuccessResponse(geometry, route, transportMode); } catch (error) { console.warn('OSRM routing failed:', error.message); - - return { - success: false, - error: 'ROUTING_FAILED', - message: 'Could not fetch route. Showing straight line.', - transportMode - }; + return createErrorResponse('ROUTING_FAILED', 'Could not fetch route. Showing straight line.', transportMode); } }; From 72b10c74dccb470d19171bddc3082e8c0fbad3a1 Mon Sep 17 00:00:00 2001 From: Giannis Fraidakis Date: Fri, 26 Dec 2025 14:06:14 +0200 Subject: [PATCH 09/23] refactor(constants): extract data to JSON for better MI --- src/utils/constants.js | 120 ++++++--------------------------------- src/utils/constants.json | 95 +++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 102 deletions(-) create mode 100644 src/utils/constants.json diff --git a/src/utils/constants.js b/src/utils/constants.js index 3919eb38..8b23c3ff 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -1,108 +1,24 @@ /** * Application Constants + * Data loaded from JSON for better maintainability */ -// API Base URL -export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001/myWorld/api'; - -// Place Categories -export const PLACE_CATEGORIES = { - RESTAURANT: 'Εστιατόριο', - MUSEUM: 'Μουσείο', - PARK: 'Πάρκο', - BEACH: 'Παραλία', - CULTURE: 'Πολιτισμός', - ENTERTAINMENT: 'Διασκέδαση', - SHOPPING: 'Αγορές', - HOTEL: 'Ξενοδοχείο', - CAFE: 'Καφετέρια', - BAR: 'Μπαρ' -}; - -// Place Categories for English (backend compatibility) -export const PLACE_CATEGORIES_EN = [ - 'RESTAURANT', - 'MUSEUM', - 'PARK', - 'BEACH', - 'CULTURE', - 'ENTERTAINMENT', - 'SHOPPING', - 'HOTEL', - 'CAFE', - 'BAR' -]; - -// Preference Types -export const PREFERENCE_TYPES = { - CATEGORY: 'category', - PRICE: 'price', - RATING: 'rating', - DISTANCE: 'distance' -}; - -// Price Ranges -export const PRICE_RANGES = [ - { value: 1, label: '€ - Οικονομικό' }, - { value: 2, label: '€€ - Μέτριο' }, - { value: 3, label: '€€€ - Ακριβό' } -]; - -// Rating Options -export const RATING_OPTIONS = [1, 2, 3, 4, 5]; +import data from './constants.json'; -// Navigation Modes -export const NAVIGATION_MODES = { - DRIVING: 'Αυτοκίνητο', - WALKING: 'Πεζοπορία', - CYCLING: 'Ποδήλατο', - TRANSIT: 'Δημόσια Συγκοινωνία' -}; - -// Toast Messages Duration -export const TOAST_DURATION = 3000; - -// Pagination -export const ITEMS_PER_PAGE = 10; -export const DEFAULT_PAGE = 1; - -// Local Storage Keys -export const STORAGE_KEYS = { - TOKEN: 'token', - USER: 'user', - PREFERENCES: 'preferences' -}; - -// Validation Rules -export const VALIDATION_RULES = { - PASSWORD_MIN_LENGTH: 8, - NAME_MIN_LENGTH: 2, - NAME_MAX_LENGTH: 50, - EMAIL_MAX_LENGTH: 100, - DESCRIPTION_MAX_LENGTH: 500, - REVIEW_MAX_LENGTH: 1000 -}; - -// Map Configuration -export const MAP_CONFIG = { - DEFAULT_CENTER: { - lat: 40.6401, - lng: 22.9444 // Thessaloniki, Greece - }, - DEFAULT_ZOOM: 13, - MAX_ZOOM: 18, - MIN_ZOOM: 3 -}; +export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001/myWorld/api'; -// HTTP Status Codes -export const HTTP_STATUS = { - OK: 200, - CREATED: 201, - NO_CONTENT: 204, - BAD_REQUEST: 400, - UNAUTHORIZED: 401, - FORBIDDEN: 403, - NOT_FOUND: 404, - CONFLICT: 409, - INTERNAL_SERVER_ERROR: 500 -}; +export const { + PLACE_CATEGORIES, + PLACE_CATEGORIES_EN, + PREFERENCE_TYPES, + PRICE_RANGES, + RATING_OPTIONS, + NAVIGATION_MODES, + TOAST_DURATION, + ITEMS_PER_PAGE, + DEFAULT_PAGE, + STORAGE_KEYS, + VALIDATION_RULES, + MAP_CONFIG, + HTTP_STATUS +} = data; diff --git a/src/utils/constants.json b/src/utils/constants.json new file mode 100644 index 00000000..f9e078c3 --- /dev/null +++ b/src/utils/constants.json @@ -0,0 +1,95 @@ +{ + "PLACE_CATEGORIES": { + "RESTAURANT": "Εστιατόριο", + "MUSEUM": "Μουσείο", + "PARK": "Πάρκο", + "BEACH": "Παραλία", + "CULTURE": "Πολιτισμός", + "ENTERTAINMENT": "Διασκέδαση", + "SHOPPING": "Αγορές", + "HOTEL": "Ξενοδοχείο", + "CAFE": "Καφετέρια", + "BAR": "Μπαρ" + }, + "PLACE_CATEGORIES_EN": [ + "RESTAURANT", + "MUSEUM", + "PARK", + "BEACH", + "CULTURE", + "ENTERTAINMENT", + "SHOPPING", + "HOTEL", + "CAFE", + "BAR" + ], + "PREFERENCE_TYPES": { + "CATEGORY": "category", + "PRICE": "price", + "RATING": "rating", + "DISTANCE": "distance" + }, + "PRICE_RANGES": [ + { + "value": 1, + "label": "€ - Οικονομικό" + }, + { + "value": 2, + "label": "€€ - Μέτριο" + }, + { + "value": 3, + "label": "€€€ - Ακριβό" + } + ], + "RATING_OPTIONS": [ + 1, + 2, + 3, + 4, + 5 + ], + "NAVIGATION_MODES": { + "DRIVING": "Αυτοκίνητο", + "WALKING": "Πεζοπορία", + "CYCLING": "Ποδήλατο", + "TRANSIT": "Δημόσια Συγκοινωνία" + }, + "TOAST_DURATION": 3000, + "ITEMS_PER_PAGE": 10, + "DEFAULT_PAGE": 1, + "STORAGE_KEYS": { + "TOKEN": "token", + "USER": "user", + "PREFERENCES": "preferences" + }, + "VALIDATION_RULES": { + "PASSWORD_MIN_LENGTH": 8, + "NAME_MIN_LENGTH": 2, + "NAME_MAX_LENGTH": 50, + "EMAIL_MAX_LENGTH": 100, + "DESCRIPTION_MAX_LENGTH": 500, + "REVIEW_MAX_LENGTH": 1000 + }, + "MAP_CONFIG": { + "DEFAULT_CENTER": { + "lat": 40.6401, + "lng": 22.9444 + }, + "DEFAULT_ZOOM": 13, + "MAX_ZOOM": 18, + "MIN_ZOOM": 3 + }, + "HTTP_STATUS": { + "OK": 200, + "CREATED": 201, + "NO_CONTENT": 204, + "BAD_REQUEST": 400, + "UNAUTHORIZED": 401, + "FORBIDDEN": 403, + "NOT_FOUND": 404, + "CONFLICT": 409, + "INTERNAL_SERVER_ERROR": 500 + } +} \ No newline at end of file From 954eb486e19d1128076571c62ce9e3c68dbd07c1 Mon Sep 17 00:00:00 2001 From: Giannis Fraidakis Date: Fri, 26 Dec 2025 14:06:57 +0200 Subject: [PATCH 10/23] refactor(placeTypeMap): extract data to JSON for better MI --- src/utils/geocoding/placeTypeMap.js | 88 +-------------------------- src/utils/geocoding/placeTypeMap.json | 73 ++++++++++++++++++++++ 2 files changed, 76 insertions(+), 85 deletions(-) create mode 100644 src/utils/geocoding/placeTypeMap.json diff --git a/src/utils/geocoding/placeTypeMap.js b/src/utils/geocoding/placeTypeMap.js index 5c674c7e..a138e745 100644 --- a/src/utils/geocoding/placeTypeMap.js +++ b/src/utils/geocoding/placeTypeMap.js @@ -2,91 +2,9 @@ * Place Type Mapping Data * * Maps Nominatim class/type to user-friendly place types with icons. + * Data loaded from JSON for better maintainability. */ -export const PLACE_TYPE_MAP = { - // Tourism - 'attraction': '🏛️ Attraction', - 'museum': '🏛️ Museum', - 'monument': '🗿 Monument', - 'archaeological_site': '🏺 Archaeological Site', - 'castle': '🏰 Castle', - 'ruins': '🏚️ Ruins', - 'viewpoint': '👁️ Viewpoint', - 'beach': '🏖️ Beach', - 'hotel': '🏨 Hotel', - 'guest_house': '🏠 Guest House', - 'hostel': '🛏️ Hostel', - 'camp_site': '⛺ Campsite', - 'theme_park': '🎢 Theme Park', - 'zoo': '🦁 Zoo', - 'aquarium': '🐠 Aquarium', +import data from './placeTypeMap.json'; - // Natural features - 'peak': '⛰️ Peak', - 'volcano': '🌋 Volcano', - 'bay': '🌊 Bay', - 'island': '🏝️ Island', - 'lake': '💧 Lake', - 'river': '🌊 River', - 'waterfall': '💦 Waterfall', - 'cave_entrance': '🕳️ Cave', - 'nature_reserve': '🌲 Nature Reserve', - 'national_park': '🏞️ National Park', - 'forest': '🌳 Forest', - - // Places - 'city': '🏙️ City', - 'town': '🏘️ Town', - 'village': '🏡 Village', - 'hamlet': '🏡 Hamlet', - 'suburb': '🏘️ Suburb', - 'neighbourhood': '🏘️ Neighbourhood', - - // Transport - 'airport': '✈️ Airport', - 'port': '⚓ Port', - 'ferry_terminal': '⛴️ Ferry Terminal', - 'bus_station': '🚌 Bus Station', - 'train_station': '🚂 Train Station', - - // Amenities - 'restaurant': '🍽️ Restaurant', - 'cafe': '☕ Cafe', - 'bar': '🍺 Bar', - 'fast_food': '🍔 Fast Food', - 'hospital': '🏥 Hospital', - 'pharmacy': '💊 Pharmacy', - 'school': '🏫 School', - 'university': '🎓 University', - 'library': '📚 Library', - 'place_of_worship': '⛪ Place of Worship', - 'church': '⛪ Church', - 'monastery': '⛪ Monastery', - 'mosque': '🕌 Mosque', - - // Shopping - 'marketplace': '🛒 Market', - 'shopping_centre': '🛍️ Shopping Center', - 'supermarket': '🛒 Supermarket', - - // Leisure - 'park': '🌳 Park', - 'garden': '🌷 Garden', - 'playground': '🎠 Playground', - 'sports_centre': '🏟️ Sports Center', - 'stadium': '🏟️ Stadium', - 'swimming_pool': '🏊 Swimming Pool', - 'marina': '⛵ Marina', -}; - -export const CLASS_FALLBACK_MAP = { - 'tourism': '📍 Point of Interest', - 'natural': '🌿 Natural Feature', - 'historic': '🏛️ Historic Site', - 'amenity': '📍 Amenity', - 'place': '📍 Place', - 'leisure': '🎯 Leisure', -}; - -export const DEFAULT_PLACE_TYPE = '📍 Location'; +export const { PLACE_TYPE_MAP, CLASS_FALLBACK_MAP, DEFAULT_PLACE_TYPE } = data; diff --git a/src/utils/geocoding/placeTypeMap.json b/src/utils/geocoding/placeTypeMap.json new file mode 100644 index 00000000..d23b53a8 --- /dev/null +++ b/src/utils/geocoding/placeTypeMap.json @@ -0,0 +1,73 @@ +{ + "PLACE_TYPE_MAP": { + "attraction": "🏛️ Attraction", + "museum": "🏛️ Museum", + "monument": "🗿 Monument", + "archaeological_site": "🏺 Archaeological Site", + "castle": "🏰 Castle", + "ruins": "🏚️ Ruins", + "viewpoint": "👁️ Viewpoint", + "beach": "🏖️ Beach", + "hotel": "🏨 Hotel", + "guest_house": "🏠 Guest House", + "hostel": "🛏️ Hostel", + "camp_site": "⛺ Campsite", + "theme_park": "🎢 Theme Park", + "zoo": "🦁 Zoo", + "aquarium": "🐠 Aquarium", + "peak": "⛰️ Peak", + "volcano": "🌋 Volcano", + "bay": "🌊 Bay", + "island": "🏝️ Island", + "lake": "💧 Lake", + "river": "🌊 River", + "waterfall": "💦 Waterfall", + "cave_entrance": "🕳️ Cave", + "nature_reserve": "🌲 Nature Reserve", + "national_park": "🏞️ National Park", + "forest": "🌳 Forest", + "city": "🏙️ City", + "town": "🏘️ Town", + "village": "🏡 Village", + "hamlet": "🏡 Hamlet", + "suburb": "🏘️ Suburb", + "neighbourhood": "🏘️ Neighbourhood", + "airport": "✈️ Airport", + "port": "⚓ Port", + "ferry_terminal": "⛴️ Ferry Terminal", + "bus_station": "🚌 Bus Station", + "train_station": "🚂 Train Station", + "restaurant": "🍽️ Restaurant", + "cafe": "☕ Cafe", + "bar": "🍺 Bar", + "fast_food": "🍔 Fast Food", + "hospital": "🏥 Hospital", + "pharmacy": "💊 Pharmacy", + "school": "🏫 School", + "university": "🎓 University", + "library": "📚 Library", + "place_of_worship": "⛪ Place of Worship", + "church": "⛪ Church", + "monastery": "⛪ Monastery", + "mosque": "🕌 Mosque", + "marketplace": "🛒 Market", + "shopping_centre": "🛍️ Shopping Center", + "supermarket": "🛒 Supermarket", + "park": "🌳 Park", + "garden": "🌷 Garden", + "playground": "🎠 Playground", + "sports_centre": "🏟️ Sports Center", + "stadium": "🏟️ Stadium", + "swimming_pool": "🏊 Swimming Pool", + "marina": "⛵ Marina" + }, + "CLASS_FALLBACK_MAP": { + "tourism": "📍 Point of Interest", + "natural": "🌿 Natural Feature", + "historic": "🏛️ Historic Site", + "amenity": "📍 Amenity", + "place": "📍 Place", + "leisure": "🎯 Leisure" + }, + "DEFAULT_PLACE_TYPE": "📍 Location" +} \ No newline at end of file From 21953b6a278ea2449ea5fc032e58199e807ddee4 Mon Sep 17 00:00:00 2001 From: Giannis Fraidakis Date: Fri, 26 Dec 2025 14:12:50 +0200 Subject: [PATCH 11/23] refactor: extract reusable field validators in validationSchemas.js --- src/utils/validationSchemas.js | 114 +++++++++++++-------------------- 1 file changed, 45 insertions(+), 69 deletions(-) diff --git a/src/utils/validationSchemas.js b/src/utils/validationSchemas.js index 2c01d967..581000e5 100644 --- a/src/utils/validationSchemas.js +++ b/src/utils/validationSchemas.js @@ -4,32 +4,43 @@ */ import * as Yup from 'yup'; +/** Reusable name field validator */ +const createNameField = (t, requiredKey = 'nameRequired') => Yup.string() + .min(2, t('nameMinLength')) + .max(50, t('nameMaxLength')) + .required(t(requiredKey)); + +/** Reusable email field validator */ +const createEmailField = (t) => Yup.string() + .email(t('invalidEmail')) + .required(t('emailRequired')); + +/** Reusable password field validator */ +const createPasswordField = (t, complex = false) => { + let schema = Yup.string().min(6, t('passwordMinLength')); + if (complex) { + schema = schema.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, t('passwordComplexity')); + } + return schema.required(t('passwordRequired')); +}; + +/** Reusable radius field validator */ +const createRadiusField = (t, required = true) => { + const schema = Yup.number().min(1, t('radiusMinOne')).max(100, t('radiusMaxHundred')); + return required ? schema.required(t('radiusRequired')) : schema.nullable(); +}; + /** Create login form validation schema */ const createLoginSchema = (t) => Yup.object().shape({ - email: Yup.string() - .email(t('invalidEmail')) - .required(t('emailRequired')), - password: Yup.string() - .min(6, t('passwordMinLength')) - .required(t('passwordRequired')), + email: createEmailField(t), + password: Yup.string().min(6, t('passwordMinLength')).required(t('passwordRequired')), }); /** Create signup form validation schema */ const createSignupSchema = (t) => Yup.object().shape({ - name: Yup.string() - .min(2, t('nameMinLength')) - .max(50, t('nameMaxLength')) - .required(t('nameRequired')), - email: Yup.string() - .email(t('invalidEmail')) - .required(t('emailRequired')), - password: Yup.string() - .min(6, t('passwordMinLength')) - .matches( - /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, - t('passwordComplexity') - ) - .required(t('passwordRequired')), + name: createNameField(t), + email: createEmailField(t), + password: createPasswordField(t, true), confirmPassword: Yup.string() .oneOf([Yup.ref('password'), null], t('passwordsNotMatch')) .required(t('confirmPasswordRequired')), @@ -37,74 +48,39 @@ const createSignupSchema = (t) => Yup.object().shape({ /** Create profile update validation schema */ const createProfileUpdateSchema = (t) => Yup.object().shape({ - name: Yup.string() - .min(2, t('nameMinLength')) - .max(50, t('nameMaxLength')) - .required(t('nameRequired')), - email: Yup.string() - .email(t('invalidEmail')) - .required(t('emailRequired')), - phoneNumber: Yup.string() - .matches(/^[0-9]{10}$/, t('phoneInvalid')) - .nullable(), + name: createNameField(t), + email: createEmailField(t), + phoneNumber: Yup.string().matches(/^[0-9]{10}$/, t('phoneInvalid')).nullable(), }); /** Create preference profile validation schema */ const createPreferenceProfileSchema = (t) => Yup.object().shape({ - name: Yup.string() - .min(2, t('nameMinLength')) - .max(50, t('nameMaxLength')) - .required(t('profileNameRequired')), - categories: Yup.array() - .of(Yup.string()) - .min(1, t('categoryMinOne')) - .required(t('categoriesRequired')), + name: createNameField(t, 'profileNameRequired'), + categories: Yup.array().of(Yup.string()).min(1, t('categoryMinOne')).required(t('categoriesRequired')), priceRange: Yup.object().shape({ - min: Yup.number() - .min(0, t('minPriceNegative')) - .required(t('minPriceRequired')), - max: Yup.number() - .min(Yup.ref('min'), t('maxPriceGreaterThanMin')) - .required(t('maxPriceRequired')), + min: Yup.number().min(0, t('minPriceNegative')).required(t('minPriceRequired')), + max: Yup.number().min(Yup.ref('min'), t('maxPriceGreaterThanMin')).required(t('maxPriceRequired')), }), - radius: Yup.number() - .min(1, t('radiusMinOne')) - .max(100, t('radiusMaxHundred')) - .required(t('radiusRequired')), + radius: createRadiusField(t), }); /** Create review validation schema */ const createReviewSchema = (t) => Yup.object().shape({ - rating: Yup.number() - .min(1, t('ratingMinMax')) - .max(5, t('ratingMinMax')) - .required(t('ratingRequired')), - comment: Yup.string() - .min(10, t('commentMinLength')) - .max(500, t('commentMaxLength')) - .required(t('commentRequired')), + rating: Yup.number().min(1, t('ratingMinMax')).max(5, t('ratingMinMax')).required(t('ratingRequired')), + comment: Yup.string().min(10, t('commentMinLength')).max(500, t('commentMaxLength')).required(t('commentRequired')), }); /** Create report validation schema */ const createReportSchema = (t) => Yup.object().shape({ - reason: Yup.string() - .required(t('reasonRequired')), - description: Yup.string() - .min(20, t('descriptionMinLength')) - .max(500, t('descriptionMaxLength')) - .required(t('descriptionRequired')), + reason: Yup.string().required(t('reasonRequired')), + description: Yup.string().min(20, t('descriptionMinLength')).max(500, t('descriptionMaxLength')).required(t('descriptionRequired')), }); /** Create search form validation schema */ const createSearchSchema = (t) => Yup.object().shape({ - query: Yup.string() - .min(2, t('searchMinLength')) - .max(100, t('searchMaxLength')), + query: Yup.string().min(2, t('searchMinLength')).max(100, t('searchMaxLength')), category: Yup.string().nullable(), - radius: Yup.number() - .min(1, t('radiusMinOne')) - .max(100, t('radiusMaxHundred')) - .nullable(), + radius: createRadiusField(t, false), }); /** From 6b02077351d32ef5f12481fc9d0a548f3fd9cbfc Mon Sep 17 00:00:00 2001 From: Giannis Fraidakis Date: Fri, 26 Dec 2025 14:14:32 +0200 Subject: [PATCH 12/23] refactor: split handleSubmit and extract utilities in NavigationPage.jsx --- src/pages/NavigationPage.jsx | 184 +++++++++++++++++------------------ 1 file changed, 88 insertions(+), 96 deletions(-) diff --git a/src/pages/NavigationPage.jsx b/src/pages/NavigationPage.jsx index be7f0471..d4403c32 100644 --- a/src/pages/NavigationPage.jsx +++ b/src/pages/NavigationPage.jsx @@ -34,6 +34,42 @@ import { reverseGeocode, forwardGeocode, formatCoordinates } from '../utils/geoc import { fetchRoute } from '../utils/routing'; import './NavigationPage.css'; +/** Predefined quick routes for popular destinations */ +const QUICK_ROUTES = [ + { name: 'Athens → Thessaloniki', icon: '🏛️', from: 'Athens, Greece', to: 'Thessaloniki, Greece' }, + { name: 'Thessaloniki → Athens', icon: '🏛️', from: 'Thessaloniki, Greece', to: 'Athens, Greece' }, + { name: 'Heraklion → Athens', icon: '🏖️', from: 'Heraklion, Crete, Greece', to: 'Athens, Greece' }, +]; + +/** Geocode input locations to coordinates */ +const geocodeInputLocations = async (fromLocation, toLocation, t) => { + const [fromResult, toResult] = await Promise.all([ + forwardGeocode(fromLocation), + forwardGeocode(toLocation) + ]); + if (!fromResult) throw new Error(t('locationNotFound', { location: fromLocation }) || `Could not find: ${fromLocation}`); + if (!toResult) throw new Error(t('locationNotFound', { location: toLocation }) || `Could not find: ${toLocation}`); + return { fromResult, toResult }; +}; + +/** Calculate route using OSRM */ +const calculateOsrmRoute = async (fromResult, toResult, transportMode) => { + return await fetchRoute({ + startLat: fromResult.lat, startLng: fromResult.lng, + endLat: toResult.lat, endLng: toResult.lng, + transportMode, + }); +}; + +/** Merge OSRM data with backend route info */ +const mergeRouteData = (routeInfo, osrmResult) => { + if (osrmResult.success && osrmResult.distance && osrmResult.duration) { + routeInfo.distance = osrmResult.distance; + routeInfo.estimatedTime = osrmResult.duration; + } + return routeInfo; +}; + /** * NavigationPage Component * @@ -98,46 +134,33 @@ const NavigationPage = () => { // Ref for scrolling to results after route calculation const resultsRef = useRef(null); + /** + * Performs reverse geocoding on a single point. + * @param {Object} point - Point with latitude and longitude + * @param {Function} setName - State setter for the name + * @param {Function} setLoading - State setter for loading flag + */ + const geocodePoint = useCallback(async (point, setName, setLoading) => { + if (!point?.latitude || !point?.longitude) return; + setLoading(true); + try { + const result = await reverseGeocode(point.latitude, point.longitude); + setName(result.name); + } catch { + setName(formatCoordinates(point.latitude, point.longitude)); + } finally { + setLoading(false); + } + }, []); + /** * Performs reverse geocoding on route start and end points. - * Converts coordinates to human-readable place names for display. - * Falls back to formatted coordinates if geocoding fails. - * - * @function geocodeLocations - * @async */ const geocodeLocations = useCallback(async () => { if (!routeData) return; - const { startPoint, endPoint } = routeData; - - // Geocode start point if coordinates are available - if (startPoint?.latitude && startPoint?.longitude) { - setGeocodingStart(true); - try { - const result = await reverseGeocode(startPoint.latitude, startPoint.longitude); - setStartName(result.name); - } catch { - // Fallback to formatted coordinates on error - setStartName(formatCoordinates(startPoint.latitude, startPoint.longitude)); - } finally { - setGeocodingStart(false); - } - } - - // Geocode end point if coordinates are available - if (endPoint?.latitude && endPoint?.longitude) { - setGeocodingEnd(true); - try { - const result = await reverseGeocode(endPoint.latitude, endPoint.longitude); - setEndName(result.name); - } catch { - // Fallback to formatted coordinates on error - setEndName(formatCoordinates(endPoint.latitude, endPoint.longitude)); - } finally { - setGeocodingEnd(false); - } - } - }, [routeData]); + geocodePoint(routeData.startPoint, setStartName, setGeocodingStart); + geocodePoint(routeData.endPoint, setEndName, setGeocodingEnd); + }, [routeData, geocodePoint]); // Trigger geocoding when route data changes useEffect(() => { geocodeLocations(); }, [geocodeLocations]); @@ -152,13 +175,29 @@ const NavigationPage = () => { } }, [loading]); + /** Process OSRM result and update state */ + const processOsrmResult = (osrmResult) => { + if (!osrmResult.success) { + setRouteError(osrmResult.message); + } else { + setRouteGeometry(osrmResult.geometry); + if (osrmResult.requiresFerry && osrmResult.ferryWarning) { + setRouteError(osrmResult.ferryWarning); + } + } + }; + + /** Scroll to results section */ + const scrollToResults = () => { + setTimeout(() => { + if (resultsRef.current) { + resultsRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, 100); + }; + /** * Handles form submission for route calculation. - * Performs forward geocoding on input locations, fetches route from OSRM, - * and retrieves additional route info from the backend API. - * - * @function handleSubmit - * @async * @param {React.FormEvent} e - Form submit event */ const handleSubmit = async (e) => { @@ -170,57 +209,23 @@ const NavigationPage = () => { setRouteGeometry(null); try { - // Step 1: Forward geocode both locations to get coordinates - const [fromResult, toResult] = await Promise.all([ - forwardGeocode(formData.fromLocation), - forwardGeocode(formData.toLocation) - ]); - - // Validate geocoding results - if (!fromResult) throw new Error(t('locationNotFound', { location: formData.fromLocation }) || `Could not find: ${formData.fromLocation}`); - if (!toResult) throw new Error(t('locationNotFound', { location: formData.toLocation }) || `Could not find: ${formData.toLocation}`); - + // Step 1: Forward geocode both locations + const { fromResult, toResult } = await geocodeInputLocations(formData.fromLocation, formData.toLocation, t); setGeocodingInput(false); - // Step 2: Fetch route geometry from OSRM routing service - const osrmResult = await fetchRoute({ - startLat: fromResult.lat, startLng: fromResult.lng, - endLat: toResult.lat, endLng: toResult.lng, - transportMode: formData.transportMode, - }); + // Step 2: Fetch route geometry from OSRM + const osrmResult = await calculateOsrmRoute(fromResult, toResult, formData.transportMode); + processOsrmResult(osrmResult); - // Handle OSRM result - may include ferry warnings - if (!osrmResult.success) { - setRouteError(osrmResult.message); - } else { - setRouteGeometry(osrmResult.geometry); - if (osrmResult.requiresFerry && osrmResult.ferryWarning) { - setRouteError(osrmResult.ferryWarning); - } - } - - // Step 3: Fetch additional route information from backend API + // Step 3: Fetch route info from backend API const response = await navigationAPI.getRoute({ userLatitude: fromResult.lat, userLongitude: fromResult.lng, placeLatitude: toResult.lat, placeLongitude: toResult.lng, transportationMode: formData.transportMode, }); - // Merge OSRM distance/duration with backend route info - const routeInfo = response.data.route; - if (osrmResult.success && osrmResult.distance && osrmResult.duration) { - routeInfo.distance = osrmResult.distance; - routeInfo.estimatedTime = osrmResult.duration; - } - - setRouteData(routeInfo); - - // Smooth scroll to results after a short delay - setTimeout(() => { - if (resultsRef.current) { - resultsRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }, 100); + setRouteData(mergeRouteData(response.data.route, osrmResult)); + scrollToResults(); } catch (err) { setError(err.message || t('errorCalculatingRoute')); } finally { @@ -229,18 +234,6 @@ const NavigationPage = () => { } }; - /** - * Predefined quick route options for popular Greek destinations. - * Each route includes a display name, icon, and from/to locations. - * - * @constant {Array} quickRoutes - */ - const quickRoutes = [ - { name: 'Athens → Thessaloniki', icon: '🏛️', from: 'Athens, Greece', to: 'Thessaloniki, Greece' }, - { name: 'Thessaloniki → Athens', icon: '🏛️', from: 'Thessaloniki, Greece', to: 'Athens, Greece' }, - { name: 'Heraklion → Athens', icon: '🏖️', from: 'Heraklion, Crete, Greece', to: 'Athens, Greece' }, - ]; - return (
{/* Hero section with page title */} @@ -265,7 +258,7 @@ const NavigationPage = () => {

{t('quickRoutes')}

- {quickRoutes.map((route, index) => ( + {QUICK_ROUTES.map((route, index) => ( +
+
+
+); + /** * PlaceDetailsPage Component * @@ -68,28 +106,22 @@ const PlaceDetailsPage = () => { const [showReportForm, setShowReportForm] = useState(false); // Review form data state - const [reviewForm, setReviewForm] = useState({ rating: 5, comment: '' }); + const [reviewForm, setReviewForm] = useState(INITIAL_REVIEW_FORM); // Report form data state - const [reportForm, setReportForm] = useState({ reason: '', description: '' }); + const [reportForm, setReportForm] = useState(INITIAL_REPORT_FORM); // Calculate average rating from all reviews - const averageRating = reviews.length > 0 ? reviews.reduce((acc, r) => acc + r.rating, 0) / reviews.length : null; + const averageRating = calculateAverageRating(reviews); + + // Back navigation handler + const handleBack = () => navigate(-1); // Loading state UI - if (loading) return ( -
-

{t('loadingDetails')}

-
- ); + if (loading) return ; // Error state UI - shown when place fetch fails or place doesn't exist - if (error || !place) return ( -
-

{t('placeNotFound')}

{error}

- -
- ); + if (error || !place) return ; return (
@@ -99,17 +131,20 @@ const PlaceDetailsPage = () => {
{/* Back navigation button */} - {/* Category badge display */} -
{t(place.category) || place.category}
+
+ {t(place.category) || place.category} +
{/* Place title */}

{place.name}

{/* Meta information: location and rating */}
📍{place.city}, {place.country} - + + {averageRating ? averageRating.toFixed(1) : 'N/A'} ({reviews.length} {t('reviews')}) @@ -150,4 +185,3 @@ const PlaceDetailsPage = () => { }; export default PlaceDetailsPage; - From 201780d2c0cfb848013a72d81daf815321297169 Mon Sep 17 00:00:00 2001 From: Giannis Fraidakis Date: Fri, 26 Dec 2025 14:17:07 +0200 Subject: [PATCH 14/23] refactor: split useEffect into smaller utility functions in RouteMap.jsx --- src/components/RouteMap.jsx | 210 +++++++++++++++--------------------- 1 file changed, 85 insertions(+), 125 deletions(-) diff --git a/src/components/RouteMap.jsx b/src/components/RouteMap.jsx index 2a33cd6b..643308a9 100644 --- a/src/components/RouteMap.jsx +++ b/src/components/RouteMap.jsx @@ -16,11 +16,77 @@ import './RouteMap.css'; import L from 'leaflet'; -// Route line colors for different transport modes +/** Route line colors for different transport modes */ const ROUTE_LINE_COLORS = { - 'WALKING': '#10b981', // Green for walking - 'DRIVING': '#3b82f6', // Blue for driving - 'PUBLIC_TRANSPORT': '#f59e0b', // Orange for public transport + 'WALKING': '#10b981', + 'DRIVING': '#3b82f6', + 'PUBLIC_TRANSPORT': '#f59e0b', +}; + +/** Default map configuration */ +const DEFAULT_CENTER = [39.0742, 21.8243]; +const DEFAULT_ZOOM = 6; + +/** Create custom marker icon */ +const createMarkerIcon = (color, label) => L.divIcon({ + className: 'custom-marker', + html: `
${label}
`, + iconSize: [30, 42], + iconAnchor: [15, 42], + popupAnchor: [0, -42], +}); + +/** Create popup content for marker */ +const createPopupContent = (title, name, lat, lng) => ` +
+ ${title} + ${name || 'Loading...'} + ${lat.toFixed(4)}, ${lng.toFixed(4)} +
+`; + +/** Add route polyline to map */ +const addRouteLine = (map, geometry, transportMode) => { + const lineColor = ROUTE_LINE_COLORS[transportMode] || ROUTE_LINE_COLORS['DRIVING']; + + // Shadow line for visibility + const shadowLine = L.polyline(geometry, { + color: '#1e293b', weight: 8, opacity: 0.3, lineCap: 'round', lineJoin: 'round', + }).addTo(map); + + // Main route line + const routeLine = L.polyline(geometry, { + color: lineColor, weight: 5, opacity: 0.8, lineCap: 'round', lineJoin: 'round', + }).addTo(map); + + return { routeLine, shadowLine }; +}; + +/** Add fallback straight line when no route geometry available */ +const addStraightLine = (map, start, end) => { + return L.polyline( + [[start.lat, start.lng], [end.lat, end.lng]], + { color: '#94a3b8', weight: 3, opacity: 0.6, dashArray: '10, 10' } + ).addTo(map); +}; + +/** Add marker to map with popup */ +const addMarker = (map, position, name, color, label, title) => { + const marker = L.marker([position.lat, position.lng], { + icon: createMarkerIcon(color, label), + zIndexOffset: 1000, + }).addTo(map); + + marker.bindPopup(createPopupContent(title, name, position.lat, position.lng)); + return marker; +}; + +/** Parse coordinate point object */ +const parsePoint = (point) => { + if (point?.latitude && point?.longitude) { + return { lat: parseFloat(point.latitude), lng: parseFloat(point.longitude) }; + } + return null; }; /** @@ -32,7 +98,7 @@ const RouteMap = ({ endPoint, startName, endName, - routeGeometry = null, // Array of [lat, lng] coordinates for route line + routeGeometry = null, transportMode = 'DRIVING', className = '', }) => { @@ -42,43 +108,19 @@ const RouteMap = ({ const routeLineRef = useRef(null); // Parse coordinates - const start = useMemo(() => { - if (startPoint?.latitude && startPoint?.longitude) { - return { - lat: parseFloat(startPoint.latitude), - lng: parseFloat(startPoint.longitude), - }; - } - return null; - }, [startPoint]); - - const end = useMemo(() => { - if (endPoint?.latitude && endPoint?.longitude) { - return { - lat: parseFloat(endPoint.latitude), - lng: parseFloat(endPoint.longitude), - }; - } - return null; - }, [endPoint]); + const start = useMemo(() => parsePoint(startPoint), [startPoint]); + const end = useMemo(() => parsePoint(endPoint), [endPoint]); // Initialize map useEffect(() => { if (!L || !mapRef.current || mapInstanceRef.current) return; - // Default center: Greece - const defaultCenter = [39.0742, 21.8243]; - const defaultZoom = 6; - - // Initialize map - mapInstanceRef.current = L.map(mapRef.current).setView(defaultCenter, defaultZoom); + mapInstanceRef.current = L.map(mapRef.current).setView(DEFAULT_CENTER, DEFAULT_ZOOM); - // Add tile layer L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' }).addTo(mapInstanceRef.current); - // Cleanup on unmount return () => { if (mapInstanceRef.current) { mapInstanceRef.current.remove(); @@ -93,117 +135,36 @@ const RouteMap = ({ const map = mapInstanceRef.current; - // Remove existing markers and route line + // Cleanup existing layers markersRef.current.forEach(marker => marker.remove()); markersRef.current = []; - if (routeLineRef.current) { routeLineRef.current.remove(); routeLineRef.current = null; } - // Create custom icons - const createIcon = (color, label) => { - return L.divIcon({ - className: 'custom-marker', - html: ` -
- ${label} -
- `, - iconSize: [30, 42], - iconAnchor: [15, 42], - popupAnchor: [0, -42], - }); - }; - - // Add route polyline if geometry is available + // Add route polyline if (routeGeometry && routeGeometry.length > 0) { - const lineColor = ROUTE_LINE_COLORS[transportMode] || ROUTE_LINE_COLORS['DRIVING']; - - routeLineRef.current = L.polyline(routeGeometry, { - color: lineColor, - weight: 5, - opacity: 0.8, - lineCap: 'round', - lineJoin: 'round', - }).addTo(map); - - // Add a subtle shadow/outline for better visibility - const shadowLine = L.polyline(routeGeometry, { - color: '#1e293b', - weight: 8, - opacity: 0.3, - lineCap: 'round', - lineJoin: 'round', - }).addTo(map); - - // Add shadow to cleanup ref + const { routeLine, shadowLine } = addRouteLine(map, routeGeometry, transportMode); + routeLineRef.current = routeLine; markersRef.current.push(shadowLine); - - // Fit bounds to route - map.fitBounds(routeLineRef.current.getBounds(), { - padding: [50, 50], - maxZoom: 14 - }); + map.fitBounds(routeLine.getBounds(), { padding: [50, 50], maxZoom: 14 }); } else if (start && end) { - // Fallback: draw a dashed straight line if no route geometry - const straightLine = L.polyline( - [[start.lat, start.lng], [end.lat, end.lng]], - { - color: '#94a3b8', - weight: 3, - opacity: 0.6, - dashArray: '10, 10', - } - ).addTo(map); + const straightLine = addStraightLine(map, start, end); markersRef.current.push(straightLine); - - // Fit bounds - const bounds = L.latLngBounds([ - [start.lat, start.lng], - [end.lat, end.lng], - ]); + const bounds = L.latLngBounds([[start.lat, start.lng], [end.lat, end.lng]]); map.fitBounds(bounds, { padding: [50, 50], maxZoom: 10 }); } - // Add start marker + // Add markers if (start) { - const startMarker = L.marker([start.lat, start.lng], { - icon: createIcon('green', 'A'), - zIndexOffset: 1000, // Make sure markers are above route line - }).addTo(map); - - startMarker.bindPopup(` -
- Start Point - ${startName || 'Loading...'} - ${start.lat.toFixed(4)}, ${start.lng.toFixed(4)} -
- `); - - markersRef.current.push(startMarker); + markersRef.current.push(addMarker(map, start, startName, 'green', 'A', 'Start Point')); } - - // Add end marker if (end) { - const endMarker = L.marker([end.lat, end.lng], { - icon: createIcon('red', 'B'), - zIndexOffset: 1000, - }).addTo(map); - - endMarker.bindPopup(` -
- Destination - ${endName || 'Loading...'} - ${end.lat.toFixed(4)}, ${end.lng.toFixed(4)} -
- `); - - markersRef.current.push(endMarker); + markersRef.current.push(addMarker(map, end, endName, 'red', 'B', 'Destination')); } - // If only start or end, center on that + // Center on single point if only one available if (start && !end && !routeGeometry) { map.setView([start.lat, start.lng], 10); } else if (end && !start && !routeGeometry) { @@ -214,7 +175,6 @@ const RouteMap = ({ return (
- {!start && !end && (
🗺️ From 231fa558a7a9f6a32ec6e32967cf48fc1f86e8f6 Mon Sep 17 00:00:00 2001 From: Giannis Fraidakis Date: Fri, 26 Dec 2025 14:17:50 +0200 Subject: [PATCH 15/23] refactor: extract class mappings to module level in Hero.jsx --- src/components/Hero.jsx | 84 +++++++++++++++-------------------------- 1 file changed, 31 insertions(+), 53 deletions(-) diff --git a/src/components/Hero.jsx b/src/components/Hero.jsx index e1640f26..7553ad83 100644 --- a/src/components/Hero.jsx +++ b/src/components/Hero.jsx @@ -2,6 +2,30 @@ import React from 'react'; import PropTypes from 'prop-types'; import './Hero.css'; +/** Size class mappings */ +const SIZE_CLASSES = { + small: 'hero--small', + medium: 'hero--medium', + large: 'hero--large', +}; + +/** Alignment class mappings */ +const ALIGN_CLASSES = { + left: 'hero--align-left', + center: 'hero--align-center', + right: 'hero--align-right', +}; + +/** Build hero CSS classes from props */ +const buildHeroClasses = ({ size, align, overlayColor, backgroundImage, className }) => [ + 'hero', + SIZE_CLASSES[size] || SIZE_CLASSES.large, + ALIGN_CLASSES[align] || ALIGN_CLASSES.center, + overlayColor === 'light' ? 'hero--overlay-light' : 'hero--overlay-dark', + backgroundImage ? 'hero--has-image' : '', + className, +].filter(Boolean).join(' '); + /** * Hero Component - Reusable hero section with background image support * @@ -28,48 +52,16 @@ const Hero = ({ className = '', ...rest }) => { - const sizeClasses = { - small: 'hero--small', - medium: 'hero--medium', - large: 'hero--large', - }; - - const alignClasses = { - left: 'hero--align-left', - center: 'hero--align-center', - right: 'hero--align-right', - }; - - const overlayStyle = { - '--hero-overlay-opacity': overlayOpacity, - }; - - const heroClasses = [ - 'hero', - sizeClasses[size] || sizeClasses.large, - alignClasses[align] || alignClasses.center, - overlayColor === 'light' ? 'hero--overlay-light' : 'hero--overlay-dark', - backgroundImage ? 'hero--has-image' : '', - className, - ].filter(Boolean).join(' '); - + const heroClasses = buildHeroClasses({ size, align, overlayColor, backgroundImage, className }); + const overlayStyle = { '--hero-overlay-opacity': overlayOpacity }; const TravelGlobe = React.useMemo(() => React.lazy(() => import('./3d/TravelGlobe')), []); return ( -
+
{/* Background Layer */}
{backgroundImage ? ( - + ) : ( From c1fe621bd66f2c9ec938b5bf4da53002c37af05a Mon Sep 17 00:00:00 2001 From: Giannis Fraidakis Date: Fri, 26 Dec 2025 14:19:08 +0200 Subject: [PATCH 17/23] refactor: extract nav items and utilities in BottomNavigation.jsx --- src/components/ui/BottomNavigation.jsx | 31 +++++++++++++++----------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/components/ui/BottomNavigation.jsx b/src/components/ui/BottomNavigation.jsx index 472ebf74..a842fd74 100644 --- a/src/components/ui/BottomNavigation.jsx +++ b/src/components/ui/BottomNavigation.jsx @@ -10,6 +10,22 @@ import PropTypes from 'prop-types'; import Icon from './Icon'; import './BottomNavigation.css'; +/** Navigation items configuration */ +const NAV_ITEMS = [ + { path: '/', icon: 'home', label: 'Home' }, + { path: '/recommendations', icon: 'star', label: 'Recs', requiresAuth: true }, + { path: '/favourites', icon: 'heart', label: 'Saved', requiresAuth: true }, + { path: '/navigation', icon: 'navigation', label: 'Navigate' }, +]; + +/** Filter navigation items based on authentication */ +const filterNavItems = (items, isAuthenticated) => + items.filter(item => !item.requiresAuth || isAuthenticated); + +/** Get class name for nav item */ +const getNavItemClassName = ({ isActive }) => + `bottom-nav-item ${isActive ? 'active' : ''}`; + /** * BottomNavigation Component * Mobile-friendly bottom navigation bar @@ -17,16 +33,7 @@ import './BottomNavigation.css'; */ const BottomNavigation = ({ isAuthenticated = false }) => { const location = useLocation(); - - const navItems = [ - { path: '/', icon: 'home', label: 'Home' }, - { path: '/recommendations', icon: 'star', label: 'Recs', requiresAuth: true }, - { path: '/favourites', icon: 'heart', label: 'Saved', requiresAuth: true }, - { path: '/navigation', icon: 'navigation', label: 'Navigate' }, - // Profile button removed - accessible via header user dropdown - ]; - - const filteredItems = navItems.filter(item => !item.requiresAuth || isAuthenticated); + const filteredItems = filterNavItems(NAV_ITEMS, isAuthenticated); return (