Initial commit: SIBU 2.0 MISSION
2
frontend/.env.development
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://127.0.0.1:8000
|
||||
VITE_GOOGLE_MAPS_API_KEY=AIzaSyD1yFqWNtGzHgmjgW0jnaDYaCnLCs9rHdQ
|
||||
2
frontend/.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://localhost:8000
|
||||
VITE_GOOGLE_MAPS_API_KEY=your-google-maps-api-key-here
|
||||
2
frontend/.env.production
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://10.0.2.2:8000
|
||||
VITE_GOOGLE_MAPS_API_KEY=AIzaSyD1yFqWNtGzHgmjgW0jnaDYaCnLCs9rHdQ
|
||||
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
8
frontend/.vite/deps/_metadata.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "69e445e7",
|
||||
"configHash": "f38005ec",
|
||||
"lockfileHash": "e3b0c442",
|
||||
"browserHash": "7f4b7699",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
3
frontend/.vite/deps/package.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
5
frontend/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
101
frontend/android/.gitignore
vendored
Normal file
@ -0,0 +1,101 @@
|
||||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
2
frontend/android/app/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
54
frontend/android/app/build.gradle
Normal file
@ -0,0 +1,54 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
namespace = "com.sibu.app"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "com.sibu.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
19
frontend/android/app/capacitor.build.gradle
Normal file
@ -0,0 +1,19 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-geolocation')
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
21
frontend/android/app/proguard-rules.pro
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@ -0,0 +1,26 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
48
frontend/android/app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
android:value="AIzaSyD1yFqWNtGzHgmjgW0jnaDYaCnLCs9rHdQ" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
</manifest>
|
||||
@ -0,0 +1,5 @@
|
||||
package com.sibu.app;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
BIN
frontend/android/app/src/main/res/drawable-land-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
frontend/android/app/src/main/res/drawable-land-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
frontend/android/app/src/main/res/drawable-land-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/android/app/src/main/res/drawable-port-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
frontend/android/app/src/main/res/drawable-port-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
frontend/android/app/src/main/res/drawable-port-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
@ -0,0 +1,34 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
||||
@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillColor="#26A69A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
</vector>
|
||||
BIN
frontend/android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
12
frontend/android/app/src/main/res/layout/activity_main.xml
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
BIN
frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
BIN
frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
BIN
frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
BIN
frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
7
frontend/android/app/src/main/res/values/strings.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">SIBU</string>
|
||||
<string name="title_activity_main">SIBU</string>
|
||||
<string name="package_name">com.sibu.app</string>
|
||||
<string name="custom_url_scheme">com.sibu.app</string>
|
||||
</resources>
|
||||
22
frontend/android/app/src/main/res/values/styles.xml
Normal file
@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
</style>
|
||||
</resources>
|
||||
5
frontend/android/app/src/main/res/xml/file_paths.xml
Normal file
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||
<domain includeSubdomains="true">192.168.1.19</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@ -0,0 +1,18 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||
29
frontend/android/build.gradle
Normal file
@ -0,0 +1,29 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.13.2'
|
||||
classpath 'com.google.gms:google-services:4.4.4'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "variables.gradle"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
6
frontend/android/capacitor.settings.gradle
Normal file
@ -0,0 +1,6 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-geolocation'
|
||||
project(':capacitor-geolocation').projectDir = new File('../node_modules/@capacitor/geolocation/android')
|
||||
22
frontend/android/gradle.properties
Normal file
@ -0,0 +1,22 @@
|
||||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
BIN
frontend/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
frontend/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
frontend/android/gradlew
vendored
Normal file
@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
frontend/android/gradlew.bat
vendored
Normal file
@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
5
frontend/android/settings.gradle
Normal file
@ -0,0 +1,5 @@
|
||||
include ':app'
|
||||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
16
frontend/android/variables.gradle
Normal file
@ -0,0 +1,16 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.11.0'
|
||||
androidxAppCompatVersion = '1.7.1'
|
||||
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||
androidxCoreVersion = '1.17.0'
|
||||
androidxFragmentVersion = '1.8.9'
|
||||
coreSplashScreenVersion = '1.2.0'
|
||||
androidxWebkitVersion = '1.14.0'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.3.0'
|
||||
androidxEspressoCoreVersion = '3.7.0'
|
||||
cordovaAndroidVersion = '14.0.1'
|
||||
}
|
||||
1213
frontend/bun.lock
Normal file
13
frontend/capacitor.config.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.sibu.app',
|
||||
appName: 'SIBU',
|
||||
webDir: 'dist',
|
||||
server: {
|
||||
androidScheme: 'http',
|
||||
cleartext: true
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
25
frontend/index.html
Normal file
@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="description" content="Sistema de Transporte Público" />
|
||||
<meta name="theme-color" content="#fee715" />
|
||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap" rel="stylesheet">
|
||||
<title>SIBU - Sistema de Transporte</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
5
frontend/nixpacks.toml
Normal file
@ -0,0 +1,5 @@
|
||||
[phases.setup]
|
||||
nixPkgs = ["nodejs_22", "bun"]
|
||||
|
||||
[phases.build]
|
||||
cmds = ["bun install", "bun run build"]
|
||||
8466
frontend/package-lock.json
generated
Normal file
38
frontend/package.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.0.2",
|
||||
"@capacitor/cli": "^8.0.0",
|
||||
"@capacitor/core": "^8.0.0",
|
||||
"@capacitor/geolocation": "^8.0.0",
|
||||
"@googlemaps/js-api-loader": "^2.0.2",
|
||||
"axios": "^1.13.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.1.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.24",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-i18n": "^9.14.5",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/google.maps": "^3.58.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.4",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-vue-devtools": "^8.0.5",
|
||||
"vue-tsc": "^3.1.4"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/default-coupon.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
frontend/public/favicon-16.png
Normal file
|
After Width: | Height: | Size: 898 B |
BIN
frontend/public/favicon-32.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
frontend/public/icon-1024.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
frontend/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
frontend/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
14
frontend/public/icon.svg
Normal file
@ -0,0 +1,14 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Yellow background with rounded corners -->
|
||||
<rect width="512" height="512" rx="80" ry="80" fill="#FEE715"/>
|
||||
<!-- Bus icon - front view -->
|
||||
<g transform="translate(256, 256)">
|
||||
<!-- Bus body (black rectangle) -->
|
||||
<rect x="-140" y="-100" width="280" height="200" rx="20" ry="20" fill="#101820"/>
|
||||
<!-- Large windshield (yellow cutout) -->
|
||||
<rect x="-120" y="-80" width="240" height="120" rx="15" ry="15" fill="#FEE715"/>
|
||||
<!-- Two circular headlights (yellow cutouts) -->
|
||||
<circle cx="-60" cy="80" r="30" fill="#FEE715"/>
|
||||
<circle cx="60" cy="80" r="30" fill="#FEE715"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 705 B |
BIN
frontend/public/sibu.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
1
frontend/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
160
frontend/src/App.vue
Normal file
@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { RouterView, useRoute } from "vue-router";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MainLayout from "./components/layouts/MainLayout.vue";
|
||||
import { useThemeStore } from './stores/theme'
|
||||
import { analyticsService } from '@/services/analyticsService'
|
||||
|
||||
// Initialize theme store
|
||||
const route = useRoute()
|
||||
const { locale } = useI18n()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const isSplashScreen = computed(() => route.name === 'splash')
|
||||
const isAuthScreen = computed(() => route.name === 'auth' || route.path === '/login')
|
||||
|
||||
onMounted(() => {
|
||||
themeStore.applyTheme()
|
||||
analyticsService.logEvent({
|
||||
event_name: 'app_open',
|
||||
properties: { language: locale.value }
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainLayout v-if="!isSplashScreen && !isAuthScreen">
|
||||
<RouterView />
|
||||
</MainLayout>
|
||||
<RouterView v-else />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Common Variables */
|
||||
--safe-area-top: env(safe-area-inset-top, 0px);
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--transition-speed: 0.3s;
|
||||
}
|
||||
|
||||
/* DARK THEME (Default & .dark) */
|
||||
:root,
|
||||
html.dark {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #020617;
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
--border-color: rgba(255, 255, 255, 0.12);
|
||||
--shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.6);
|
||||
|
||||
--header-bg: rgba(15, 23, 42, 0.9);
|
||||
--header-text: #ffffff;
|
||||
|
||||
--card-bg: rgba(30, 41, 59, 0.85); /* Increased opacity for better legibility */
|
||||
--hover-bg: rgba(255, 255, 255, 0.08);
|
||||
--active-bg: rgba(254, 231, 21, 0.15);
|
||||
--active-color: #fee715;
|
||||
--accent-color: #fee715;
|
||||
--accent-hover: #fde047;
|
||||
|
||||
--glass-bg: rgba(255, 255, 255, 0.05);
|
||||
--glass-border: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* LIGHT THEME */
|
||||
html.light-theme {
|
||||
--bg-primary: #f1f5f9; /* Slightly darker light background */
|
||||
--bg-secondary: #ffffff;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--border-color: #cbd5e1; /* More visible borders */
|
||||
--header-bg: #ffffff;
|
||||
--header-text: #0f172a;
|
||||
--card-bg: #ffffff;
|
||||
--hover-bg: #f1f5f9;
|
||||
--glass-bg: rgba(255, 255, 255, 0.9);
|
||||
--glass-border: #e2e8f0;
|
||||
--shadow: 0 8px 30px rgba(0, 0, 0, 0.12); /* Stronger shadow in light mode */
|
||||
--active-bg: rgba(16, 24, 32, 0.1);
|
||||
--active-color: #101820;
|
||||
--accent-color: #101820;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.3s ease;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: var(--safe-area-top);
|
||||
}
|
||||
|
||||
/* Global Utilities */
|
||||
.glass-effect {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
/* App Transition */
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.page-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--active-color);
|
||||
}
|
||||
</style>
|
||||
1
frontend/src/assets/vue.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
583
frontend/src/components/AppHeader.vue
Normal file
@ -0,0 +1,583 @@
|
||||
<template>
|
||||
<header class="app-header" :class="{ 'admin-mode': authStore.isAdmin }">
|
||||
<div class="header-left">
|
||||
<button class="menu-btn-custom" @click="toggleMenu">
|
||||
<span class="icon">
|
||||
<svg viewBox="0 0 80 75" width="24" height="24">
|
||||
<rect width="80" height="15" fill="currentColor" rx="10"></rect>
|
||||
<rect y="30" width="80" height="15" fill="currentColor" rx="10"></rect>
|
||||
<rect y="60" width="80" height="15" fill="currentColor" rx="10"></rect>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="authStore.isAdmin" class="admin-badge">ADMIN</div>
|
||||
<div v-if="authStore.isDriver" class="driver-badge">CONDUCTOR</div>
|
||||
|
||||
<ReportModal :is-open="showReportModal" @close="showReportModal = false" />
|
||||
|
||||
<!-- Menu Overlay -->
|
||||
<Transition name="overlay-fade">
|
||||
<div v-if="showMenu" class="menu-overlay" @click="showMenu = false"></div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="menu-slide">
|
||||
<div v-if="showMenu" class="menu-dropdown">
|
||||
<div class="menu-header" v-if="authStore.isAuthenticated">
|
||||
<span class="user-name">{{ authStore.userName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Unified Menu Items -->
|
||||
<div class="menu-item" @click="navigateTo('/profile')">
|
||||
<span class="material-icons">person</span> {{ t('navigation.profile') }}
|
||||
</div>
|
||||
|
||||
<template v-if="authStore.isAdmin">
|
||||
<div class="menu-item" @click="navigateTo('/admin')">
|
||||
<span class="material-icons">admin_panel_settings</span> {{ t('navigation.admin') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="authStore.isPromoter">
|
||||
<div class="menu-item" @click="navigateTo('/promoter')">
|
||||
<span class="material-icons">store</span> Panel Promotor
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="authStore.isDriver">
|
||||
<div class="menu-item" @click="navigateTo('/driver')">
|
||||
<span class="material-icons">speed</span> Panel Conductor
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="menu-item" @click="navigateTo('/favorites')">
|
||||
<span class="material-icons">favorite</span> {{ t('navigation.favorites') }}
|
||||
</div>
|
||||
|
||||
<div class="menu-divider"></div>
|
||||
|
||||
<div class="menu-divider"></div>
|
||||
|
||||
<!-- Bottom Menu Group -->
|
||||
<div class="menu-bottom-group">
|
||||
<div class="menu-item language-toggle" @click="toggleLanguage">
|
||||
<span class="material-icons">translate</span>
|
||||
{{ locale === 'es' ? 'English (EN)' : 'Español (ES)' }}
|
||||
</div>
|
||||
|
||||
<div class="menu-item theme-toggle-container">
|
||||
<span class="material-icons">palette</span>
|
||||
<span class="toggle-label">Modo Oscuro</span>
|
||||
<ThemeToggle class="theme-switch-btn" />
|
||||
</div>
|
||||
|
||||
<div class="menu-item report-menu-item" @click="openReportModal">
|
||||
<span class="material-icons">report_problem</span> Enviar Reporte
|
||||
</div>
|
||||
|
||||
<div v-if="!authStore.isAuthenticated" class="menu-item login-item" @click="navigateTo('/login')">
|
||||
<span class="material-icons">account_circle</span> Iniciar Sesión
|
||||
</div>
|
||||
<div v-else class="logout-container" @click="handleLogout">
|
||||
<button class="logout-btn-custom">
|
||||
<div class="sign">
|
||||
<svg viewBox="0 0 512 512"><path d="M377.9 105.9L500.7 228.7c7.2 7.2 11.3 17.1 11.3 27.3s-4.1 20.1-11.3 27.3L377.9 406.1c-6.4 6.4-15 9.9-24 9.9c-18.7 0-33.9-15.2-33.9-33.9l0-62.1-128 0c-17.7 0-32-14.3-32-32l0-64c0-17.7 14.3-32 32-32l128 0 0-62.1c0-18.7 15.2-33.9 33.9-33.9c9 0 17.6 3.6 24 9.9zM160 96L96 96c-17.7 0-32 14.3-32 32l0 256c0 17.7 14.3 32 32 32l64 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-64 0c-53 0-96-43-96-96L0 128C0 75 43 32 96 32l64 0c17.7 0 32 14.3 32 32s-14.3 32-32 32z"></path></svg>
|
||||
</div>
|
||||
<div class="btn-text">Cerrar Sesión</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<h1 class="header-title" @click="goToHome">{{ t('header.title') }}</h1>
|
||||
|
||||
<div class="header-actions">
|
||||
<!-- Buttons moved to side menu for cleaner UI -->
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ReportModal from './ReportModal.vue'
|
||||
import ThemeToggle from './common/ThemeToggle.vue'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const showMenu = ref(false)
|
||||
const showReportModal = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
// Load saved language preference
|
||||
const savedLang = localStorage.getItem('user_lang')
|
||||
if (savedLang) {
|
||||
locale.value = savedLang
|
||||
}
|
||||
})
|
||||
|
||||
const toggleLanguage = () => {
|
||||
locale.value = locale.value === 'es' ? 'en' : 'es'
|
||||
localStorage.setItem('user_lang', locale.value)
|
||||
}
|
||||
|
||||
defineEmits<{
|
||||
feedbackClick: [];
|
||||
}>();
|
||||
|
||||
const toggleMenu = () => {
|
||||
showMenu.value = !showMenu.value
|
||||
};
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
router.push(path)
|
||||
showMenu.value = false
|
||||
}
|
||||
|
||||
const goToHome = () => {
|
||||
router.push('/map')
|
||||
}
|
||||
|
||||
const openReportModal = () => {
|
||||
showReportModal.value = true
|
||||
showMenu.value = false
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
showMenu.value = false
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
// toggleDarkMode removed as it was unused and causing TS errors
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
background-color: var(--header-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
color: var(--header-text);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
height: 64px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2000;
|
||||
width: 100%;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.app-header.admin-mode {
|
||||
background-color: rgba(30, 41, 59, 0.9);
|
||||
border-bottom: 2px solid var(--active-color);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%);
|
||||
color: white;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.driver-badge {
|
||||
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
|
||||
color: #101820;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: 0 4px 12px rgba(254, 231, 21, 0.3);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
grid-column: 2;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* En modo oscuro, resaltar un poco más */
|
||||
:global(.dark) .header-title {
|
||||
color: var(--active-color);
|
||||
}
|
||||
|
||||
.header-title:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: inherit;
|
||||
transition: all 0.2s;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.header-button:hover {
|
||||
background-color: var(--hover-bg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.report-btn-right {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
color: #fbbf24;
|
||||
padding: 8px 16px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.report-btn-right:hover {
|
||||
background: var(--active-color);
|
||||
color: #101820;
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow: 0 4px 15px rgba(254, 231, 21, 0.3);
|
||||
border-color: var(--active-color);
|
||||
}
|
||||
|
||||
.report-text {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.report-text {
|
||||
display: none;
|
||||
}
|
||||
.report-btn-right {
|
||||
padding: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.logout-header-btn {
|
||||
color: #f87171 !important;
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
border: 1px solid rgba(248, 113, 113, 0.15);
|
||||
margin-left: 8px;
|
||||
box-shadow: 0 0 15px rgba(248, 113, 113, 0.05);
|
||||
}
|
||||
|
||||
.logout-header-btn:hover {
|
||||
background: rgba(248, 113, 113, 0.2);
|
||||
border-color: rgba(248, 113, 113, 0.4);
|
||||
box-shadow: 0 4px 15px rgba(248, 113, 113, 0.2);
|
||||
color: #ef4444 !important;
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
height: 100vh;
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: 32px 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 20px 0 50px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.menu-header {
|
||||
padding: 0 32px 32px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 900;
|
||||
color: var(--active-color);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background-color: var(--hover-bg);
|
||||
padding-left: 48px;
|
||||
color: var(--active-color);
|
||||
}
|
||||
|
||||
.menu-item .material-icons {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.menu-item:hover .material-icons {
|
||||
color: var(--active-color);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.menu-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 24px 32px;
|
||||
}
|
||||
|
||||
.menu-bottom-group {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.report-menu-item {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.report-menu-item:hover {
|
||||
background-color: rgba(251, 191, 36, 0.1);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.report-menu-item .material-icons {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.login-item {
|
||||
color: #4ade80;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 20px 32px calc(20px + var(--safe-area-bottom));
|
||||
background: rgba(74, 222, 128, 0.03);
|
||||
}
|
||||
|
||||
.login-item:hover {
|
||||
background: rgba(74, 222, 128, 0.1);
|
||||
color: #22c55e;
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
.login-item .material-icons {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.login-item:hover .material-icons {
|
||||
color: #22c55e;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.menu-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.menu-slide-enter-active,
|
||||
.menu-slide-leave-active {
|
||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.menu-slide-enter-from,
|
||||
.menu-slide-leave-to {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.overlay-fade-enter-active,
|
||||
.overlay-fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.overlay-fade-enter-from,
|
||||
.overlay-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-size: 24px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.theme-toggle-container {
|
||||
justify-content: space-between !important;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.theme-switch-btn {
|
||||
transform: scale(0.9);
|
||||
margin-right: -2px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.app-header {
|
||||
height: 72px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Menu Button */
|
||||
.menu-btn-custom {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
border: none;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 14px;
|
||||
font-family: 'Inter', Verdana, sans-serif;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.menu-btn-custom:hover {
|
||||
transform: scale(1.1);
|
||||
color: var(--active-color);
|
||||
}
|
||||
|
||||
.menu-btn-custom .icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Logout Custom Button */
|
||||
.logout-container {
|
||||
padding: 24px 32px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.logout-btn-custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition-duration: .3s;
|
||||
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.2);
|
||||
background-color: var(--active-color);
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
}
|
||||
|
||||
.sign {
|
||||
width: 45px;
|
||||
transition-duration: .3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sign svg {
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.sign svg path {
|
||||
fill: white;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
position: absolute;
|
||||
right: 0%;
|
||||
width: 0%;
|
||||
opacity: 0;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
transition-duration: .3s;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
|
||||
.logout-btn-custom:hover {
|
||||
width: 180px;
|
||||
border-radius: 40px;
|
||||
transition-duration: .3s;
|
||||
}
|
||||
|
||||
.logout-btn-custom:hover .sign {
|
||||
width: 30%;
|
||||
transition-duration: .3s;
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.logout-btn-custom:hover .btn-text {
|
||||
opacity: 1;
|
||||
width: 70%;
|
||||
transition-duration: .3s;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.logout-btn-custom:active {
|
||||
transform: translate(2px ,2px);
|
||||
}
|
||||
</style>
|
||||
148
frontend/src/components/BottomNav.vue
Normal file
@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
const navItems = [
|
||||
{ name: 'map', path: '/map', icon: 'map' },
|
||||
{ name: 'schedules', path: '/schedules', icon: 'schedule' },
|
||||
{ name: 'discover', path: '/discover', icon: 'explore' },
|
||||
{ name: 'taxi', path: '/taxi', icon: 'directions_bus' } // Cambiado a ícono de transporte más general
|
||||
]
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return route.path === path
|
||||
}
|
||||
|
||||
// Scroll detection logic
|
||||
const isVisible = ref(true)
|
||||
let lastScrollPosition = 0
|
||||
|
||||
const handleScroll = () => {
|
||||
const currentScrollPosition = window.pageYOffset || document.documentElement.scrollTop
|
||||
if (currentScrollPosition < 0) return // For iOS elastic scroll
|
||||
|
||||
if (Math.abs(currentScrollPosition - lastScrollPosition) < 10) return
|
||||
|
||||
isVisible.value = currentScrollPosition < lastScrollPosition || currentScrollPosition < 50
|
||||
lastScrollPosition = currentScrollPosition
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bottom-nav" :class="{ 'nav-hidden': !isVisible }">
|
||||
<div
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
class="nav-item"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="navigateTo(item.path)"
|
||||
>
|
||||
<span class="material-icons">{{ item.icon }}</span>
|
||||
<span class="nav-label">{{ t('navigation.' + item.name) }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(70px + var(--safe-area-bottom));
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding-bottom: var(--safe-area-bottom);
|
||||
z-index: 1000;
|
||||
box-shadow: 0 -10px 30px rgba(0,0,0,0.3);
|
||||
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.nav-hidden {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
padding: 8px 12px;
|
||||
border-radius: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--active-color);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-size: 26px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.nav-item.active .material-icons {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.bottom-nav {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
width: 600px;
|
||||
bottom: 24px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.4);
|
||||
height: 80px;
|
||||
padding: 0 20px;
|
||||
/* En desktop no la ocultamos para mantener la UX de cursor */
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.nav-hidden {
|
||||
transform: translate(-50%, 150%);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
267
frontend/src/components/BusStopEditor.vue
Normal file
@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<div class="editor-container">
|
||||
<div class="editor-content">
|
||||
<h2>{{ isEditing ? 'Editar Parada' : 'Crear Parada' }}</h2>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>Nombre</label>
|
||||
<input v-model="formData.name" type="text" placeholder="Nombre de la Parada" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Tipo</label>
|
||||
<select v-model="formData.stop_type">
|
||||
<option value="regular">Regular</option>
|
||||
<option value="terminal">Terminal</option>
|
||||
<option value="express_only">Solo Expreso</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" v-model="formData.has_shelter"> Techo
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="formData.has_seating"> Asientos
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="formData.is_accessible"> Accesible
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="gps-section">
|
||||
<div class="coordinates">
|
||||
Lat: {{ formData.latitude.toFixed(6) }}, Lon: {{ formData.longitude.toFixed(6) }}
|
||||
</div>
|
||||
<button @click="getCurrentLocation" class="gps-button" :disabled="isLoadingGps">
|
||||
<span class="material-icons">my_location</span>
|
||||
{{ isLoadingGps ? 'Localizando...' : 'Usar GPS Preciso' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-wrapper">
|
||||
<div id="editor-map" class="editor-map"></div>
|
||||
<div v-if="!isMapLoaded" class="map-loading">Cargando Mapa...</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button @click="$emit('cancel')" class="cancel-button">Cancelar</button>
|
||||
<button @click="handleSave" class="save-button" :disabled="isSaving">
|
||||
{{ isSaving ? 'Guardando...' : 'Guardar Parada' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { Geolocation } from '@capacitor/geolocation'
|
||||
import { useGoogleMaps } from '@/composables/useGoogleMaps'
|
||||
import type { BusStop } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
initialStop?: BusStop | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['save', 'cancel'])
|
||||
|
||||
const isEditing = ref(!!props.initialStop)
|
||||
const isSaving = ref(false)
|
||||
const isLoadingGps = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
latitude: 8.4284, // Default (David)
|
||||
longitude: -82.4309,
|
||||
stop_type: 'regular',
|
||||
has_shelter: false,
|
||||
has_seating: false,
|
||||
is_accessible: false,
|
||||
city: 'David', // Default
|
||||
})
|
||||
|
||||
const { initMap, addMarker, setCenter, isLoaded: isMapLoaded } = useGoogleMaps()
|
||||
let marker: google.maps.Marker | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.initialStop) {
|
||||
formData.value = {
|
||||
name: props.initialStop.name,
|
||||
latitude: props.initialStop.latitude,
|
||||
longitude: props.initialStop.longitude,
|
||||
stop_type: props.initialStop.stop_type,
|
||||
has_shelter: props.initialStop.has_shelter,
|
||||
has_seating: props.initialStop.has_seating,
|
||||
is_accessible: props.initialStop.is_accessible,
|
||||
city: props.initialStop.city || 'David',
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize map
|
||||
initMap('editor-map', { lat: formData.value.latitude, lng: formData.value.longitude }, 16)
|
||||
updateMarker()
|
||||
})
|
||||
|
||||
// Watch for map load to add marker if missed
|
||||
watch(isMapLoaded, (loaded) => {
|
||||
if (loaded) {
|
||||
updateMarker()
|
||||
}
|
||||
})
|
||||
|
||||
function updateMarker() {
|
||||
if (!isMapLoaded.value) return
|
||||
|
||||
if (marker) {
|
||||
marker.setPosition({ lat: formData.value.latitude, lng: formData.value.longitude })
|
||||
} else {
|
||||
marker = addMarker(
|
||||
{ lat: formData.value.latitude, lng: formData.value.longitude },
|
||||
{
|
||||
draggable: true,
|
||||
onDragEnd: (pos) => {
|
||||
formData.value.latitude = pos.lat
|
||||
formData.value.longitude = pos.lng
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
setCenter(formData.value.latitude, formData.value.longitude)
|
||||
}
|
||||
|
||||
async function getCurrentLocation() {
|
||||
isLoadingGps.value = true
|
||||
try {
|
||||
const coordinates = await Geolocation.getCurrentPosition({
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000
|
||||
})
|
||||
formData.value.latitude = coordinates.coords.latitude
|
||||
formData.value.longitude = coordinates.coords.longitude
|
||||
updateMarker()
|
||||
} catch (e) {
|
||||
console.error('GPS Error', e)
|
||||
error.value = 'Error al obtener la ubicación GPS. Asegúrate de dar los permisos.'
|
||||
} finally {
|
||||
isLoadingGps.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!formData.value.name) {
|
||||
error.value = 'El nombre es obligatorio'
|
||||
return
|
||||
}
|
||||
|
||||
emit('save', {
|
||||
...formData.value,
|
||||
id: props.initialStop?.id
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor-container {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
input, select {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.gps-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #f8f9fa;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.gps-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: #2ecc71;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gps-button:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.map-wrapper {
|
||||
height: 300px;
|
||||
background: #eee;
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background: #95a5a6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: red;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
442
frontend/src/components/BusStopInfoModal.vue
Normal file
@ -0,0 +1,442 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import type { BusStop } from '@/types'
|
||||
import { busStopsService } from '@/services/busStopsService'
|
||||
import { favoritesService } from '@/services/favoritesService'
|
||||
import { formatTo12Hour } from '@/utils/timeFormatter'
|
||||
|
||||
interface Props {
|
||||
busStop: BusStop | null
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(['close', 'navigate'])
|
||||
|
||||
const upcomingArrivals = ref<{ routeName: string; arrivalTime: string }[]>([])
|
||||
const isLoading = ref(false)
|
||||
const isFavorited = ref(false)
|
||||
const favoriteId = ref<string | null>(null)
|
||||
|
||||
// Function to fetch arrivals
|
||||
async function loadArrivals() {
|
||||
if (props.busStop) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
upcomingArrivals.value = await busStopsService.getNextBusArrivals(props.busStop.id)
|
||||
} catch (e) {
|
||||
console.error('Failed to load arrivals', e)
|
||||
upcomingArrivals.value = []
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkFavoriteStatus() {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
if (!token || !props.busStop) {
|
||||
isFavorited.value = false
|
||||
favoriteId.value = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const favorites = await favoritesService.getMyFavorites()
|
||||
const found = favorites.find(f => f.item_type === 'stop' && f.item_id === props.busStop?.id)
|
||||
isFavorited.value = !!found
|
||||
favoriteId.value = found ? found.id : null
|
||||
} catch (e) {
|
||||
console.error("Error checking favorite status", e)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
if (!token) {
|
||||
alert("Debes iniciar sesión para guardar favoritos")
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.busStop) return
|
||||
|
||||
try {
|
||||
if (isFavorited.value && props.busStop) {
|
||||
await favoritesService.removeFavorite('stop', props.busStop.id)
|
||||
isFavorited.value = false
|
||||
favoriteId.value = null
|
||||
} else {
|
||||
const fav = await favoritesService.addFavorite('stop', props.busStop.id)
|
||||
isFavorited.value = true
|
||||
favoriteId.value = fav.id
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Error al actualizar favorito")
|
||||
}
|
||||
}
|
||||
|
||||
function startInternalNavigation() {
|
||||
if (props.busStop) {
|
||||
emit('navigate', props.busStop)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes in busStop or isOpen to reload data
|
||||
watch(() => props.busStop, async (newStop) => {
|
||||
if (newStop && props.isOpen) {
|
||||
await loadArrivals()
|
||||
await checkFavoriteStatus()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.isOpen, async (isOpen) => {
|
||||
if (isOpen && props.busStop) {
|
||||
await loadArrivals()
|
||||
await checkFavoriteStatus()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="modal-fade">
|
||||
<div v-if="isOpen" class="modal-overlay" @click="emit('close')">
|
||||
<div class="modal-content" @click.stop>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<div v-if="busStop" class="header-info">
|
||||
<div class="title-row">
|
||||
<h3 class="stop-name">{{ busStop.name }}</h3>
|
||||
<button class="fav-btn" @click="toggleFavorite">
|
||||
<span class="material-icons" :class="{ 'favorited': isFavorited }">
|
||||
{{ isFavorited ? 'favorite' : 'favorite_border' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="busStop.address" class="stop-address">
|
||||
<span class="material-icons text-sm">location_on</span>
|
||||
{{ busStop.address }}
|
||||
</p>
|
||||
</div>
|
||||
<button class="close-btn" @click="emit('close')">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Amenities Chips -->
|
||||
<div v-if="busStop" class="amenities-container">
|
||||
<div v-if="busStop.has_shelter" class="amenity-chip" title="Shelter available">
|
||||
<span class="material-icons md-16">roofing</span>
|
||||
<span>Shelter</span>
|
||||
</div>
|
||||
<div v-if="busStop.has_seating" class="amenity-chip" title="Seating available">
|
||||
<span class="material-icons md-16">event_seat</span>
|
||||
<span>Seating</span>
|
||||
</div>
|
||||
<div v-if="busStop.is_accessible" class="amenity-chip" title="Wheelchair accessible">
|
||||
<span class="material-icons md-16">accessible</span>
|
||||
<span>Accessible</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="modal-body">
|
||||
<h4 class="section-title">Next Arrivals</h4>
|
||||
|
||||
<div v-if="isLoading" class="loading-state">
|
||||
<span class="material-icons spin">refresh</span>
|
||||
<p>Loading arrivals...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="upcomingArrivals.length > 0" class="arrivals-list">
|
||||
<div
|
||||
v-for="(arrival, index) in upcomingArrivals"
|
||||
:key="index"
|
||||
class="arrival-item"
|
||||
:style="{ animationDelay: `${index * 0.1}s` }"
|
||||
>
|
||||
<div class="route-info">
|
||||
<span class="material-icons bus-icon">directions_bus</span>
|
||||
<span class="route-name">{{ arrival.routeName }}</span>
|
||||
</div>
|
||||
<div class="arrival-time">
|
||||
{{ formatTo12Hour(arrival.arrivalTime) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<p>No upcoming arrivals found.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer / Actions -->
|
||||
<div class="modal-footer">
|
||||
<button class="action-btn secondary" @click="startInternalNavigation">
|
||||
<span class="material-icons md-18">navigation</span>
|
||||
Navigate
|
||||
</button>
|
||||
<button class="action-btn primary" @click="loadArrivals">
|
||||
<span class="material-icons md-18">refresh</span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end; /* Bottom sheet style on mobile, centered on desktop ideally */
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* On larger screens, center it */
|
||||
@media (min-width: 768px) {
|
||||
.modal-overlay {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--card-bg);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 24px;
|
||||
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
|
||||
animation: slide-up 0.3s ease-out;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.modal-content {
|
||||
border-radius: 16px;
|
||||
animation: zoom-in 0.2s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stop-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stop-address {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fav-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
transition: transform 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.fav-btn:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.fav-btn .material-icons.favorited {
|
||||
color: #ff4757;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.arrivals-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.arrival-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.route-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bus-icon {
|
||||
color: var(--header-bg);
|
||||
}
|
||||
|
||||
.route-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.arrival-time {
|
||||
font-weight: 700;
|
||||
color: var(--active-color, green);
|
||||
}
|
||||
|
||||
/* Amenities Styles */
|
||||
.amenities-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.amenity-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 4px 10px;
|
||||
border-radius: 16px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.md-16 { font-size: 16px; }
|
||||
.md-18 { font-size: 18px; }
|
||||
|
||||
/* Modal Footer */
|
||||
.modal-footer {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between; /* Spread buttons */
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background-color: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
flex: 1; /* Take remaining space */
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn.secondary {
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.arrival-item {
|
||||
animation: fade-in-slide 0.4s ease-out forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
@keyframes fade-in-slide {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes zoom-in {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.modal-fade-enter-active,
|
||||
.modal-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-fade-enter-from,
|
||||
.modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@keyframes spin { 100% { transform: rotate(360deg); } }
|
||||
</style>
|
||||
140
frontend/src/components/FavoriteButton.vue
Normal file
@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<button
|
||||
class="favorite-btn"
|
||||
:class="{ 'is-favorite': isFavorited, 'is-loading': isLoading }"
|
||||
@click.stop="handleToggle"
|
||||
:title="isFavorited ? 'Quitar de favoritos' : 'Agregar a favoritos'"
|
||||
>
|
||||
<span class="material-icons heart-icon">
|
||||
{{ isFavorited ? 'favorite' : 'favorite_border' }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useFavoritesStore } from '@/stores/favorites'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const props = defineProps<{
|
||||
itemType: 'coupon' | 'business' | 'taxi' | 'route'
|
||||
itemId: string
|
||||
itemName?: string
|
||||
itemImage?: string
|
||||
}>()
|
||||
|
||||
const favoritesStore = useFavoritesStore()
|
||||
const authStore = useAuthStore()
|
||||
const isLoading = ref(false)
|
||||
|
||||
const isFavorited = computed(() => {
|
||||
return favoritesStore.isFavorite(props.itemType, props.itemId)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Load favorites if authenticated and not loaded yet
|
||||
if (authStore.isAuthenticated && favoritesStore.favorites.length === 0) {
|
||||
favoritesStore.loadFavorites()
|
||||
}
|
||||
})
|
||||
|
||||
async function handleToggle() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
// Optionally redirect to login or show message
|
||||
alert('Debes iniciar sesión para agregar favoritos')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
await favoritesStore.toggleFavorite(
|
||||
props.itemType,
|
||||
props.itemId,
|
||||
props.itemName,
|
||||
props.itemImage
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error toggling favorite:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.favorite-btn {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.favorite-btn:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.favorite-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.heart-icon {
|
||||
color: #e91e63;
|
||||
font-size: 22px;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.favorite-btn.is-favorite .heart-icon {
|
||||
animation: heartBeat 0.5s ease;
|
||||
}
|
||||
|
||||
.favorite-btn.is-loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.favorite-btn.is-loading .heart-icon {
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes heartBeat {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
75% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
.dark .favorite-btn {
|
||||
background: rgba(30, 30, 30, 0.9);
|
||||
}
|
||||
</style>
|
||||
41
frontend/src/components/HelloWorld.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
285
frontend/src/components/ReportModal.vue
Normal file
@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<Transition name="modal-fade">
|
||||
<div v-if="isOpen" class="modal-overlay" @click.self="close">
|
||||
<div class="modal-container glass-effect">
|
||||
<div class="modal-header">
|
||||
<div class="title-with-icon">
|
||||
<span class="material-icons report-icon">report_problem</span>
|
||||
<h2>Enviar Reporte</h2>
|
||||
</div>
|
||||
<button class="close-btn" @click="close">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p class="instruction">Cuéntanos qué sucede o envíanos una sugerencia. El equipo administrativo revisará tu mensaje.</p>
|
||||
|
||||
<textarea
|
||||
v-model="message"
|
||||
placeholder="Escribe tu mensaje aquí..."
|
||||
class="report-textarea"
|
||||
:disabled="isSending"
|
||||
></textarea>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="success" class="success-message">
|
||||
¡Reporte enviado con éxito! Gracias por tu colaboración.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="cancel-btn" @click="close" :disabled="isSending">Cancelar</button>
|
||||
<button
|
||||
class="send-btn"
|
||||
@click="handleSend"
|
||||
:disabled="isSending || !message.trim() || success"
|
||||
>
|
||||
<span v-if="isSending" class="spinner-small"></span>
|
||||
<span v-else>Enviar Reporte</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { reportsService } from '@/services/reportsService'
|
||||
|
||||
defineProps<{
|
||||
isOpen: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const message = ref('')
|
||||
const isSending = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref(false)
|
||||
|
||||
function close() {
|
||||
if (isSending.value) return
|
||||
emit('close')
|
||||
// Reset state after transition
|
||||
setTimeout(() => {
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
success.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!message.value.trim()) return
|
||||
|
||||
isSending.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await reportsService.sendReport(message.value)
|
||||
success.value = true
|
||||
setTimeout(() => {
|
||||
close()
|
||||
}, 2000)
|
||||
} catch (e) {
|
||||
error.value = 'Hubo un error al enviar el reporte. Por favor, intenta de nuevo.'
|
||||
} finally {
|
||||
isSending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 28px;
|
||||
padding: 32px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title-with-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.report-icon {
|
||||
color: var(--active-color);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #ef4444;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.instruction {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.report-textarea {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
resize: none;
|
||||
transition: all 0.3s;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.report-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--active-color);
|
||||
background: rgba(254, 231, 21, 0.05);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ef4444;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: #22c55e;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
padding: 12px 24px;
|
||||
border-radius: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
background: var(--active-color);
|
||||
color: #101820;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 14px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.send-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(254, 231, 21, 0.3);
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(16, 24, 32, 0.2);
|
||||
border-top-color: #101820;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.modal-fade-enter-active,
|
||||
.modal-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-fade-enter-from,
|
||||
.modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-fade-enter-active .modal-container {
|
||||
animation: modal-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes modal-in {
|
||||
from { transform: scale(0.9) translateY(20px); opacity: 0; }
|
||||
to { transform: scale(1) translateY(0); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
207
frontend/src/components/auth/LoginForm.vue
Normal file
@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authService } from '@/services/authService'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
defineProps<{
|
||||
onToggle: () => void
|
||||
}>()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const keepSession = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const handleLogin = async () => {
|
||||
isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await authService.login({
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
keep_session: keepSession.value
|
||||
})
|
||||
|
||||
authStore.login(response.access_token, response.role, response.full_name)
|
||||
|
||||
// Redirect based on role or home
|
||||
const role = response.role.toUpperCase()
|
||||
if (role === 'ADMIN') {
|
||||
router.push('/admin')
|
||||
} else if (role === 'DRIVER') {
|
||||
router.push('/driver')
|
||||
} else if (role === 'PROMOTER') {
|
||||
router.push('/promoter')
|
||||
} else {
|
||||
router.push('/map')
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!error.response) {
|
||||
errorMessage.value = `Error de conexión: No se pudo contactar con el servidor en ${authService.getApiUrl()}. Revisa si el backend está encendido y accesible desde este dispositivo.`
|
||||
} else if (error.response.status === 401) {
|
||||
errorMessage.value = 'Correo o contraseña incorrectos.'
|
||||
} else {
|
||||
errorMessage.value = error.response?.data?.detail || 'Error interno del servidor. Inténtalo más tarde.'
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-form">
|
||||
<h2 class="auth-title">Iniciar Sesión</h2>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="form-container">
|
||||
<div class="form-group">
|
||||
<label for="email">Correo Electrónico</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
v-model="email"
|
||||
placeholder="ejemplo@correo.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
v-model="password"
|
||||
placeholder="********"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-options">
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" v-model="keepSession" />
|
||||
<span class="checkmark"></span>
|
||||
Mantener sesión
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="error-text">{{ errorMessage }}</p>
|
||||
|
||||
<button type="submit" class="auth-button" :disabled="isLoading">
|
||||
<span v-if="isLoading">Cargando...</span>
|
||||
<span v-else>Entrar</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>¿No tienes cuenta? <a @click.prevent="onToggle" href="#">Regístrate aquí</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 24px;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
input[type="email"],
|
||||
input[type="password"] {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.form-options {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
margin-top: 12px;
|
||||
padding: 14px;
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.auth-button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.auth-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ef5350;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.auth-footer a {
|
||||
color: var(--accent-color);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
255
frontend/src/components/auth/RegisterForm.vue
Normal file
@ -0,0 +1,255 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { authService } from '@/services/authService'
|
||||
|
||||
const { onToggle, onSuccess } = defineProps<{
|
||||
onToggle: () => void,
|
||||
onSuccess: () => void
|
||||
}>()
|
||||
|
||||
// Form data
|
||||
const fullName = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
|
||||
const isLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
const handleRegister = async () => {
|
||||
isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await authService.registerPassenger({
|
||||
full_name: fullName.value,
|
||||
email: email.value,
|
||||
password: password.value
|
||||
})
|
||||
|
||||
successMessage.value = 'Registro exitoso. Ya puedes iniciar sesión.'
|
||||
setTimeout(() => {
|
||||
onSuccess() // Back to login
|
||||
}, 2000)
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error.response?.data?.detail || 'Error al registrarse'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="register-form">
|
||||
<h2 class="auth-title">Registrarse</h2>
|
||||
|
||||
<div class="form-scroll-container">
|
||||
<form @submit.prevent="handleRegister" class="form-container">
|
||||
<!-- Common Fields -->
|
||||
<div class="form-group">
|
||||
<label>Nombre Completo</label>
|
||||
<input type="text" v-model="fullName" placeholder="Juan Pérez" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Correo Electrónico</label>
|
||||
<input type="email" v-model="email" placeholder="juan@correo.com" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Contraseña</label>
|
||||
<input type="password" v-model="password" placeholder="********" required />
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="error-text">{{ errorMessage }}</p>
|
||||
<p v-if="successMessage" class="success-text">{{ successMessage }}</p>
|
||||
|
||||
<button type="submit" class="auth-button" :disabled="isLoading">
|
||||
<span v-if="isLoading">Cargando...</span>
|
||||
<span v-else>Registrarse</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>¿Ya tienes cuenta? <a @click.prevent="onToggle" href="#">Inicia sesión</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.register-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 24px;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.role-selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.selection-detail {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.role-cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.role-card:hover {
|
||||
border-color: var(--accent-color);
|
||||
transform: translateY(-4px);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.role-card .material-icons {
|
||||
font-size: 32px;
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.role-card h3 {
|
||||
font-size: 16px;
|
||||
margin: 4px 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.role-card p {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-scroll-container {
|
||||
max-height: 450px;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
.form-scroll-container::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.form-scroll-container::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vehicle-tabs {
|
||||
display: flex;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.vehicle-tabs button {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.vehicle-tabs button.active {
|
||||
background: var(--card-bg);
|
||||
color: var(--accent-color);
|
||||
box-shadow: 0 2px 4px var(--shadow);
|
||||
}
|
||||
|
||||
.form-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
margin-top: 12px;
|
||||
padding: 14px;
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.error-text { color: #ef5350; font-size: 14px; }
|
||||
.success-text { color: #4caf50; font-size: 14px; font-weight: 600; }
|
||||
|
||||
.auth-footer {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.auth-footer a {
|
||||
color: var(--accent-color);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
156
frontend/src/components/common/OffersBadge.vue
Normal file
@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
isClose?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="offers-container" :class="{ 'is-close': isClose }">
|
||||
<div class="loader">
|
||||
<svg width="100" height="100" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<mask id="clipping">
|
||||
<polygon points="0,0 100,0 100,100 0,100" fill="black"></polygon>
|
||||
<polygon points="25,25 75,25 50,75" fill="white"></polygon>
|
||||
<polygon points="50,25 75,75 25,75" fill="white"></polygon>
|
||||
<polygon points="35,35 65,35 50,65" fill="white"></polygon>
|
||||
<polygon points="35,35 65,35 50,65" fill="white"></polygon>
|
||||
</mask>
|
||||
</defs>
|
||||
</svg>
|
||||
<div class="box">
|
||||
<span class="material-icons offer-icon">{{ isClose ? 'close' : 'local_offer' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.offers-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 65px;
|
||||
height: 65px;
|
||||
transform: scale(0.65);
|
||||
}
|
||||
|
||||
.loader {
|
||||
/* Default: SIBU GOLD */
|
||||
--color-one: #fee715;
|
||||
--color-two: #facc15;
|
||||
--color-three: rgba(254, 231, 21, 0.5);
|
||||
--color-four: rgba(250, 204, 21, 0.3);
|
||||
--color-five: rgba(254, 231, 21, 0.1);
|
||||
--time-animation: 2s;
|
||||
--size: 1;
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
transform: scale(var(--size));
|
||||
box-shadow: 0 0 25px 0 var(--color-three),
|
||||
0 10px 30px 0 var(--color-four);
|
||||
animation: colorize calc(var(--time-animation) * 3) ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* RED PHASE: CLOSE MODE */
|
||||
.is-close .loader {
|
||||
--color-one: #ef4444;
|
||||
--color-two: #dc2626;
|
||||
--color-three: rgba(239, 68, 68, 0.5);
|
||||
--color-four: rgba(220, 38, 38, 0.3);
|
||||
--color-five: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.loader::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
border-top: solid 2px var(--color-one);
|
||||
border-bottom: solid 2px var(--color-two);
|
||||
background: radial-gradient(circle, var(--color-five), transparent);
|
||||
box-shadow: inset 0 10px 20px 0 var(--color-three),
|
||||
inset 0 -10px 20px 0 var(--color-four);
|
||||
}
|
||||
|
||||
.loader .box {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: var(--color-one);
|
||||
mask: url(#clipping);
|
||||
-webkit-mask: url(#clipping);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.offer-icon {
|
||||
color: #101820;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
z-index: 5;
|
||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
|
||||
}
|
||||
|
||||
.is-close .offer-icon {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.loader svg {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.loader svg #clipping {
|
||||
filter: contrast(15);
|
||||
animation: roundness calc(var(--time-animation) / 2) linear infinite;
|
||||
}
|
||||
|
||||
.loader svg #clipping polygon {
|
||||
filter: blur(7px);
|
||||
}
|
||||
|
||||
.loader svg #clipping polygon:nth-child(1) {
|
||||
transform-origin: 75% 25%;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.loader svg #clipping polygon:nth-child(2) {
|
||||
transform-origin: 50% 50%;
|
||||
animation: rotation var(--time-animation) linear infinite reverse;
|
||||
}
|
||||
|
||||
.loader svg #clipping polygon:nth-child(3) {
|
||||
transform-origin: 50% 60%;
|
||||
animation: rotation var(--time-animation) linear infinite;
|
||||
animation-delay: calc(var(--time-animation) / -3);
|
||||
}
|
||||
|
||||
.loader svg #clipping polygon:nth-child(4) {
|
||||
transform-origin: 40% 40%;
|
||||
animation: rotation var(--time-animation) linear infinite reverse;
|
||||
}
|
||||
|
||||
.loader svg #clipping polygon:nth-child(5) {
|
||||
transform-origin: 60% 40%;
|
||||
animation: rotation var(--time-animation) linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes roundness {
|
||||
0%, 60%, 100% { filter: contrast(12); }
|
||||
20%, 40% { filter: contrast(2); }
|
||||
}
|
||||
|
||||
@keyframes colorize {
|
||||
0%, 100% { filter: saturate(1) brightness(1); }
|
||||
50% { filter: saturate(1.5) brightness(1.2); }
|
||||
}
|
||||
</style>
|
||||
233
frontend/src/components/common/ThemeToggle.vue
Normal file
@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="theme-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="theme-switch__checkbox"
|
||||
:checked="themeStore.isDarkMode"
|
||||
@change="themeStore.toggleDarkMode()"
|
||||
>
|
||||
<div class="theme-switch__container">
|
||||
<div class="theme-switch__clouds"></div>
|
||||
<div class="theme-switch__stars-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 55" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M135.831 3.00688C135.055 3.85027 134.111 4.29946 133 4.35447C134.111 4.40947 135.055 4.85867 135.831 5.71123C136.607 6.55462 136.996 7.56303 136.996 8.72727C136.996 7.95722 137.172 7.25134 137.525 6.59129C137.886 5.93124 138.372 5.39954 138.98 5.00535C139.598 4.60199 140.268 4.39114 141 4.35447C139.88 4.2903 138.936 3.85027 138.16 3.00688C137.384 2.16348 136.996 1.16425 136.996 0C136.996 1.16425 136.607 2.16348 135.831 3.00688ZM31 23.3545C32.1114 23.2995 33.0551 22.8503 33.8313 22.0069C34.6075 21.1635 34.9956 20.1642 34.9956 19C34.9956 20.1642 35.3837 21.1635 36.1599 22.0069C36.9361 22.8503 37.8798 23.2903 39 23.3545C38.2679 23.3911 37.5976 23.602 36.9802 24.0053C36.3716 24.3995 35.8864 24.9312 35.5248 25.5913C35.172 26.2513 34.9956 26.9572 34.9956 27.7273C34.9956 26.563 34.6075 25.5546 33.8313 24.7112C33.0551 23.8587 32.1114 23.4095 31 23.3545ZM0 36.3545C1.11136 36.2995 2.05513 35.8503 2.83131 35.0069C3.6075 34.1635 3.99559 33.1642 3.99559 32C3.99559 33.1642 4.38368 34.1635 5.15987 35.0069C5.93605 35.8503 6.87982 36.2903 8 36.3545C7.26792 36.3911 6.59757 36.602 5.98015 37.0053C5.37155 37.3995 4.88644 37.9312 4.52481 38.5913C4.172 39.2513 3.99559 39.9572 3.99559 40.7273C3.99559 39.563 3.6075 38.5546 2.83131 37.7112C2.05513 36.8587 1.11136 36.4095 0 36.3545ZM56.8313 24.0069C56.0551 24.8503 55.1114 25.2995 54 25.3545C55.1114 25.4095 56.0551 25.8587 56.8313 26.7112C57.6075 27.5546 57.9956 28.563 57.9956 29.7273C57.9956 28.9572 58.172 28.2513 58.5248 27.5913C58.8864 26.9312 59.3716 26.3995 59.9802 26.0053C60.5976 25.602 61.2679 25.3911 62 25.3545C60.8798 25.2903 59.9361 24.8503 59.1599 24.0069C58.3837 23.1635 57.9956 22.1642 57.9956 21C57.9956 22.1642 57.6075 23.1635 56.8313 24.0069ZM81 25.3545C82.1114 25.2995 83.0551 24.8503 83.8313 24.0069C84.6075 23.1635 84.9956 22.1642 84.9956 21C84.9956 22.1642 85.3837 23.1635 86.1599 24.0069C86.9361 24.8503 87.8798 25.2903 89 25.3545C88.2679 25.3911 87.5976 25.602 86.9802 26.0053C86.3716 26.3995 85.8864 26.9312 85.5248 27.5913C85.172 28.2513 84.9956 28.9572 84.9956 29.7273C84.9956 28.563 84.6075 27.5546 83.8313 26.7112C83.0551 25.8587 82.1114 25.4095 81 25.3545ZM136 36.3545C137.111 36.2995 138.055 35.8503 138.831 35.0069C139.607 34.1635 139.996 33.1642 139.996 32C139.996 33.1642 140.384 34.1635 141.16 35.0069C141.936 35.8503 142.88 36.2903 144 36.3545C143.268 36.3911 142.598 36.602 141.98 37.0053C141.372 37.3995 140.886 37.9312 140.525 38.5913C140.172 39.2513 139.996 39.9572 139.996 40.7273C139.996 39.563 139.607 38.5546 138.831 37.7112C138.055 36.8587 137.111 36.4095 136 36.3545ZM101.831 49.0069C101.055 49.8503 100.111 50.2995 99 50.3545C100.111 50.4095 101.055 50.8587 101.831 51.7112C102.607 52.5546 102.996 53.563 102.996 54.7273C102.996 53.9572 103.172 53.2513 103.525 52.5913C103.886 51.9312 104.372 51.3995 104.98 51.0053C105.598 50.602 106.268 50.3911 107 50.3545C105.88 50.2903 104.936 49.8503 104.16 49.0069C103.384 48.1635 102.996 47.1642 102.996 46C102.996 47.1642 102.607 48.1635 101.831 49.0069Z" fill="currentColor"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="theme-switch__circle-container">
|
||||
<div class="theme-switch__sun-moon-container">
|
||||
<div class="theme-switch__moon">
|
||||
<div class="theme-switch__spot"></div>
|
||||
<div class="theme-switch__spot"></div>
|
||||
<div class="theme-switch__spot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-switch {
|
||||
--toggle-size: 10px; /* Ajustado para caber mejor en la UI */
|
||||
--container-width: 5.625em;
|
||||
--container-height: 2.5em;
|
||||
--container-radius: 6.25em;
|
||||
--container-light-bg: #3D7EAE;
|
||||
--container-night-bg: #1D1F2C;
|
||||
--circle-container-diameter: 3.375em;
|
||||
--sun-moon-diameter: 2.125em;
|
||||
--sun-bg: #ECCA2F;
|
||||
--moon-bg: #C4C9D1;
|
||||
--spot-color: #959DB1;
|
||||
--circle-container-offset: calc((var(--circle-container-diameter) - var(--container-height)) / 2 * -1);
|
||||
--stars-color: #fff;
|
||||
--clouds-color: #F3FDFF;
|
||||
--back-clouds-color: #AACADF;
|
||||
--transition: .5s cubic-bezier(0, -0.02, 0.4, 1.25);
|
||||
--circle-transition: .3s cubic-bezier(0, -0.02, 0.35, 1.17);
|
||||
}
|
||||
|
||||
.theme-switch, .theme-switch *, .theme-switch *::before, .theme-switch *::after {
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: var(--toggle-size);
|
||||
}
|
||||
|
||||
.theme-switch__container {
|
||||
width: var(--container-width);
|
||||
height: var(--container-height);
|
||||
background-color: var(--container-light-bg);
|
||||
border-radius: var(--container-radius);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
-webkit-box-shadow: 0em -0.062em 0.062em rgba(0, 0, 0, 0.25), 0em 0.062em 0.125em rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0em -0.062em 0.062em rgba(0, 0, 0, 0.25), 0em 0.062em 0.125em rgba(255, 255, 255, 0.94);
|
||||
-webkit-transition: var(--transition);
|
||||
-o-transition: var(--transition);
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-switch__container::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
-webkit-box-shadow: 0em 0.05em 0.187em rgba(0, 0, 0, 0.25) inset, 0em 0.05em 0.187em rgba(0, 0, 0, 0.25) inset;
|
||||
box-shadow: 0em 0.05em 0.187em rgba(0, 0, 0, 0.25) inset, 0em 0.05em 0.187em rgba(0, 0, 0, 0.25) inset;
|
||||
border-radius: var(--container-radius)
|
||||
}
|
||||
|
||||
.theme-switch__checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-switch__circle-container {
|
||||
width: var(--circle-container-diameter);
|
||||
height: var(--circle-container-diameter);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
position: absolute;
|
||||
left: var(--circle-container-offset);
|
||||
top: var(--circle-container-offset);
|
||||
border-radius: var(--container-radius);
|
||||
-webkit-box-shadow: inset 0 0 0 3.375em rgba(255, 255, 255, 0.1), inset 0 0 0 3.375em rgba(255, 255, 255, 0.1), 0 0 0 0.625em rgba(255, 255, 255, 0.1), 0 0 0 1.25em rgba(255, 255, 255, 0.1);
|
||||
box-shadow: inset 0 0 0 3.375em rgba(255, 255, 255, 0.1), inset 0 0 0 3.375em rgba(255, 255, 255, 0.1), 0 0 0 0.625em rgba(255, 255, 255, 0.1), 0 0 0 1.25em rgba(255, 255, 255, 0.1);
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-transition: var(--circle-transition);
|
||||
-o-transition: var(--circle-transition);
|
||||
transition: var(--circle-transition);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.theme-switch__sun-moon-container {
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: var(--sun-moon-diameter);
|
||||
height: var(--sun-moon-diameter);
|
||||
margin: auto;
|
||||
border-radius: var(--container-radius);
|
||||
background-color: var(--sun-bg);
|
||||
-webkit-box-shadow: 0.062em 0.062em 0.062em 0em rgba(254, 255, 239, 0.61) inset, 0em -0.062em 0.062em 0em #a1872a inset;
|
||||
box-shadow: 0.062em 0.062em 0.062em 0em rgba(254, 255, 239, 0.61) inset, 0em -0.062em 0.062em 0em #a1872a inset;
|
||||
-webkit-filter: drop-shadow(0.062em 0.125em 0.125em rgba(0, 0, 0, 0.25)) drop-shadow(0em 0.062em 0.125em rgba(0, 0, 0, 0.25));
|
||||
filter: drop-shadow(0.062em 0.125em 0.125em rgba(0, 0, 0, 0.25)) drop-shadow(0em 0.062em 0.125em rgba(0, 0, 0, 0.25));
|
||||
overflow: hidden;
|
||||
-webkit-transition: var(--transition);
|
||||
-o-transition: var(--transition);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.theme-switch__moon {
|
||||
-webkit-transform: translateX(100%);
|
||||
-ms-transform: translateX(100%);
|
||||
transform: translateX(100%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--moon-bg);
|
||||
border-radius: inherit;
|
||||
-webkit-box-shadow: 0.062em 0.062em 0.062em 0em rgba(254, 255, 239, 0.61) inset, 0em -0.062em 0.062em 0em #969696 inset;
|
||||
box-shadow: 0.062em 0.062em 0.062em 0em rgba(254, 255, 239, 0.61) inset, 0em -0.062em 0.062em 0em #969696 inset;
|
||||
-webkit-transition: var(--transition);
|
||||
-o-transition: var(--transition);
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-switch__spot {
|
||||
position: absolute;
|
||||
top: 0.75em;
|
||||
left: 0.312em;
|
||||
width: 0.75em;
|
||||
height: 0.75em;
|
||||
border-radius: var(--container-radius);
|
||||
background-color: var(--spot-color);
|
||||
-webkit-box-shadow: 0em 0.0312em 0.062em rgba(0, 0, 0, 0.25) inset;
|
||||
box-shadow: 0em 0.0312em 0.062em rgba(0, 0, 0, 0.25) inset;
|
||||
}
|
||||
|
||||
.theme-switch__spot:nth-of-type(2) {
|
||||
width: 0.375em;
|
||||
height: 0.375em;
|
||||
top: 0.937em;
|
||||
left: 1.375em;
|
||||
}
|
||||
|
||||
.theme-switch__spot:nth-last-of-type(3) {
|
||||
width: 0.25em;
|
||||
height: 0.25em;
|
||||
top: 0.312em;
|
||||
left: 0.812em;
|
||||
}
|
||||
|
||||
.theme-switch__clouds {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
background-color: var(--clouds-color);
|
||||
border-radius: var(--container-radius);
|
||||
position: absolute;
|
||||
bottom: -0.625em;
|
||||
left: 0.312em;
|
||||
-webkit-box-shadow: 0.937em 0.312em var(--clouds-color), -0.312em -0.312em var(--back-clouds-color), 1.437em 0.375em var(--clouds-color), 0.5em -0.125em var(--back-clouds-color), 2.187em 0 var(--clouds-color), 1.25em -0.062em var(--back-clouds-color), 2.937em 0.312em var(--clouds-color), 2em -0.312em var(--back-clouds-color), 3.625em -0.062em var(--clouds-color), 2.625em 0em var(--back-clouds-color), 4.5em -0.312em var(--clouds-color), 3.375em -0.437em var(--back-clouds-color), 4.625em -1.75em 0 0.437em var(--clouds-color), 4em -0.625em var(--back-clouds-color), 4.125em -2.125em 0 0.437em var(--clouds-color);
|
||||
box-shadow: 0.937em 0.312em var(--clouds-color), -0.312em -0.312em var(--back-clouds-color), 1.437em 0.375em var(--clouds-color), 0.5em -0.125em var(--back-clouds-color), 2.187em 0 var(--clouds-color), 1.25em -0.062em var(--back-clouds-color), 2.937em 0.312em var(--clouds-color), 2em -0.312em var(--back-clouds-color), 3.625em -0.062em var(--clouds-color), 2.625em 0em var(--back-clouds-color), 4.5em -0.312em var(--clouds-color), 3.375em -0.437em var(--back-clouds-color), 4.625em -1.75em 0 0.437em var(--clouds-color), 4em -0.625em var(--back-clouds-color), 4.125em -2.125em 0 0.437em var(--clouds-color);
|
||||
-webkit-transition: 0.5s cubic-bezier(0, -0.02, 0.4, 1.25);
|
||||
-o-transition: 0.5s cubic-bezier(0, -0.02, 0.4, 1.25);
|
||||
transition: 0.5s cubic-bezier(0, -0.02, 0.4, 1.25);
|
||||
}
|
||||
|
||||
.theme-switch__stars-container {
|
||||
position: absolute;
|
||||
color: var(--stars-color);
|
||||
top: -100%;
|
||||
left: 0.312em;
|
||||
width: 2.75em;
|
||||
height: auto;
|
||||
-webkit-transition: var(--transition);
|
||||
-o-transition: var(--transition);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
/* Acciones al marcar el checkbox */
|
||||
.theme-switch__checkbox:checked + .theme-switch__container {
|
||||
background-color: var(--container-night-bg);
|
||||
}
|
||||
|
||||
.theme-switch__checkbox:checked + .theme-switch__container .theme-switch__circle-container {
|
||||
left: calc(100% - var(--circle-container-offset) - var(--circle-container-diameter));
|
||||
}
|
||||
|
||||
.theme-switch__checkbox:checked + .theme-switch__container .theme-switch__circle-container:hover {
|
||||
left: calc(100% - var(--circle-container-offset) - var(--circle-container-diameter) - 0.187em)
|
||||
}
|
||||
|
||||
.theme-switch__circle-container:hover {
|
||||
left: calc(var(--circle-container-offset) + 0.187em);
|
||||
}
|
||||
|
||||
.theme-switch__checkbox:checked + .theme-switch__container .theme-switch__moon {
|
||||
-webkit-transform: translate(0);
|
||||
-ms-transform: translate(0);
|
||||
transform: translate(0);
|
||||
}
|
||||
|
||||
.theme-switch__checkbox:checked + .theme-switch__container .theme-switch__clouds {
|
||||
bottom: -4.062em;
|
||||
}
|
||||
|
||||
.theme-switch__checkbox:checked + .theme-switch__container .theme-switch__stars-container {
|
||||
top: 50%;
|
||||
-webkit-transform: translateY(-50%);
|
||||
-ms-transform: translateY(-50%);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
</style>
|
||||
67
frontend/src/components/common/UserSonar.vue
Normal file
@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="sonar-wrapper">
|
||||
<div class="sonar-ring pointer"></div>
|
||||
<div class="sonar-ring ring-1"></div>
|
||||
<div class="sonar-ring ring-2"></div>
|
||||
<div class="sonar-core"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sonar-wrapper {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sonar-core {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #00d4ff; /* Celeste Cian */
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px #00d4ff, 0 0 20px #00d4ff;
|
||||
z-index: 2;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.sonar-ring {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 212, 255, 0.4);
|
||||
animation: pulse 2s infinite ease-out;
|
||||
}
|
||||
|
||||
.ring-1 {
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
.ring-2 {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
.pointer {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
box-shadow: 0 0 15px #00d4ff;
|
||||
animation: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(0.1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(3);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
47
frontend/src/components/layouts/MainLayout.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import AppHeader from "../AppHeader.vue";
|
||||
import BottomNav from "../BottomNav.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="main-layout">
|
||||
<AppHeader />
|
||||
<main class="main-content" :class="{ 'has-bottom-nav': authStore.isPassenger }">
|
||||
<slot />
|
||||
</main>
|
||||
<BottomNav v-if="authStore.isPassenger" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent; /* Permitir ver fondos de páginas */
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: transparent; /* Permitir ver fondos de páginas */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.has-bottom-nav {
|
||||
padding-bottom: 70px;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.has-bottom-nav {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
387
frontend/src/composables/useGoogleMaps.ts
Normal file
@ -0,0 +1,387 @@
|
||||
/** Composable for Google Maps integration */
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { setOptions, importLibrary } from '@googlemaps/js-api-loader'
|
||||
|
||||
const getApiKey = () => import.meta.env.VITE_GOOGLE_MAPS_API_KEY || ''
|
||||
|
||||
let mapsLoaded = false
|
||||
|
||||
// Global overlay tracker - persists across all composable instances
|
||||
const globalOverlays = new Map<google.maps.Map, Set<google.maps.Marker | google.maps.Polyline>>()
|
||||
|
||||
export function useGoogleMaps() {
|
||||
const map = ref<google.maps.Map | null>(null)
|
||||
const isLoaded = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Escuchar errores globales de autenticación de Google
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).gm_auth_failure = () => {
|
||||
error.value = '⚠️ Error de Autenticación de Google: Revisa que la API de Mapas esté activada y que la facturación de Google Cloud sea válida.';
|
||||
console.error('❌ Google Maps Auth Failure detected');
|
||||
};
|
||||
}
|
||||
|
||||
async function loadMaps() {
|
||||
if (mapsLoaded) {
|
||||
isLoaded.value = true
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const apiKey = getApiKey()
|
||||
if (!apiKey || apiKey.length < 10) {
|
||||
error.value = '❌ Error: VITE_GOOGLE_MAPS_API_KEY no detectada o es inválida.'
|
||||
console.error(error.value)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('🌐 Usando Nueva API Funcional de Google Maps...');
|
||||
|
||||
try {
|
||||
// Configuramos las opciones globales como pide el error
|
||||
setOptions({
|
||||
key: apiKey,
|
||||
v: 'weekly'
|
||||
});
|
||||
|
||||
// Cargamos las librerías necesarias una por una
|
||||
console.log('🛰️ Cargando librerías...');
|
||||
await importLibrary('maps');
|
||||
await importLibrary('places');
|
||||
await importLibrary('geometry');
|
||||
|
||||
if (typeof google === 'undefined' || !google.maps) {
|
||||
throw new Error('Google Maps se cargó pero el espacio de nombres "google.maps" no está disponible.');
|
||||
}
|
||||
|
||||
mapsLoaded = true
|
||||
isLoaded.value = true
|
||||
error.value = null
|
||||
console.log('✅ Google Maps (New API) cargado con éxito');
|
||||
} catch (e: any) {
|
||||
console.error('❌ Error crítico en Nueva API:', e)
|
||||
|
||||
let msg = 'Error de carga.'
|
||||
const errStr = String(e).toLowerCase()
|
||||
|
||||
if (errStr.includes('apiprojectmaperror')) {
|
||||
msg = 'Error de Proyecto: API no habilitada o llave incorrecta.'
|
||||
} else if (errStr.includes('billing')) {
|
||||
msg = 'Facturación: Revisa tu cuenta en Google Cloud Console.'
|
||||
} else if (errStr.includes('referer') || errStr.includes('origin')) {
|
||||
msg = 'Restricción de Origen: La llave no permite peticiones desde esta App.'
|
||||
} else {
|
||||
msg = `Detalle: ${e.message || e}`
|
||||
}
|
||||
|
||||
error.value = `⚠️ Google Maps: ${msg}`
|
||||
}
|
||||
}
|
||||
|
||||
function initMap(
|
||||
containerId: string,
|
||||
center: { lat: number; lng: number },
|
||||
zoom: number = 12
|
||||
) {
|
||||
if (!isLoaded.value) {
|
||||
console.error('Google Maps not loaded yet')
|
||||
return
|
||||
}
|
||||
|
||||
const container = document.getElementById(containerId)
|
||||
if (!container) {
|
||||
console.error(`Map container with id "${containerId}" not found`)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear any existing overlays for this map before creating a new one
|
||||
if (map.value && globalOverlays.has(map.value)) {
|
||||
clearAllOverlaysForMap(map.value)
|
||||
}
|
||||
|
||||
try {
|
||||
map.value = new google.maps.Map(container, {
|
||||
center,
|
||||
zoom,
|
||||
disableDefaultUI: true,
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error('❌ Error inicializando el objeto Map:', e);
|
||||
error.value = `Error de inicialización: ${e.message || e}`;
|
||||
}
|
||||
|
||||
// Initialize overlay tracking for this map
|
||||
if (map.value && !globalOverlays.has(map.value)) {
|
||||
globalOverlays.set(map.value, new Set())
|
||||
}
|
||||
}
|
||||
|
||||
function addMarker(
|
||||
position: { lat: number; lng: number },
|
||||
options?: {
|
||||
title?: string
|
||||
draggable?: boolean
|
||||
icon?: google.maps.Icon | google.maps.Symbol | string
|
||||
onDragEnd?: (pos: { lat: number; lng: number }) => void
|
||||
}
|
||||
): google.maps.Marker | null {
|
||||
if (!map.value) {
|
||||
console.error('Map not initialized')
|
||||
return null
|
||||
}
|
||||
|
||||
const marker = new google.maps.Marker({
|
||||
position,
|
||||
map: map.value,
|
||||
title: options?.title,
|
||||
draggable: options?.draggable,
|
||||
icon: options?.icon,
|
||||
})
|
||||
|
||||
if (options?.onDragEnd) {
|
||||
marker.addListener('dragend', () => {
|
||||
const pos = marker.getPosition()
|
||||
if (pos) {
|
||||
options.onDragEnd!({ lat: pos.lat(), lng: pos.lng() })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Track in global overlay tracker
|
||||
if (map.value) {
|
||||
if (!globalOverlays.has(map.value)) {
|
||||
globalOverlays.set(map.value, new Set())
|
||||
}
|
||||
globalOverlays.get(map.value)!.add(marker)
|
||||
}
|
||||
|
||||
return marker
|
||||
}
|
||||
|
||||
function addNumberedMarker(
|
||||
position: { lat: number; lng: number },
|
||||
number: number,
|
||||
title?: string,
|
||||
onClick?: () => void
|
||||
): google.maps.Marker | null {
|
||||
if (!map.value) {
|
||||
console.error('Map not initialized')
|
||||
return null
|
||||
}
|
||||
|
||||
// Note: google.maps.Marker is deprecated but still works
|
||||
// We'll keep using it for now as AdvancedMarkerElement requires additional setup
|
||||
// TODO: Migrate to google.maps.marker.AdvancedMarkerElement in the future
|
||||
const marker = new google.maps.Marker({
|
||||
position,
|
||||
map: map.value,
|
||||
title,
|
||||
icon: {
|
||||
path: google.maps.SymbolPath.CIRCLE,
|
||||
fillColor: '#FEE715', // Amarillo marca
|
||||
fillOpacity: 1,
|
||||
strokeColor: '#101820', // Negro marca
|
||||
strokeWeight: 2,
|
||||
scale: 14,
|
||||
},
|
||||
label: {
|
||||
text: number.toString(),
|
||||
color: '#101820',
|
||||
fontSize: '13px',
|
||||
fontWeight: '900',
|
||||
},
|
||||
})
|
||||
|
||||
if (onClick) {
|
||||
marker.addListener('click', onClick)
|
||||
}
|
||||
|
||||
// Track in global overlay tracker
|
||||
if (map.value) {
|
||||
if (!globalOverlays.has(map.value)) {
|
||||
globalOverlays.set(map.value, new Set())
|
||||
}
|
||||
globalOverlays.get(map.value)!.add(marker)
|
||||
}
|
||||
|
||||
return marker
|
||||
}
|
||||
|
||||
function addPolyline(path: Array<{ lat: number; lng: number }>): google.maps.Polyline | null {
|
||||
if (!map.value) {
|
||||
console.error('Map not initialized')
|
||||
return null
|
||||
}
|
||||
|
||||
const polyline = new google.maps.Polyline({
|
||||
path,
|
||||
geodesic: true,
|
||||
strokeColor: '#101820', // Negro premium
|
||||
strokeOpacity: 0.8,
|
||||
strokeWeight: 5,
|
||||
map: map.value,
|
||||
})
|
||||
|
||||
// Track in global overlay tracker
|
||||
if (map.value) {
|
||||
if (!globalOverlays.has(map.value)) {
|
||||
globalOverlays.set(map.value, new Set())
|
||||
}
|
||||
globalOverlays.get(map.value)!.add(polyline)
|
||||
}
|
||||
|
||||
return polyline
|
||||
}
|
||||
|
||||
function fitBounds(path: Array<{ lat: number; lng: number }>) {
|
||||
if (!map.value || path.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const bounds = new google.maps.LatLngBounds()
|
||||
path.forEach((point) => {
|
||||
bounds.extend(new google.maps.LatLng(point.lat, point.lng))
|
||||
})
|
||||
map.value.fitBounds(bounds)
|
||||
}
|
||||
|
||||
function setCenter(lat: number, lng: number) {
|
||||
if (map.value) {
|
||||
map.value.setCenter({ lat, lng })
|
||||
}
|
||||
}
|
||||
|
||||
function setZoom(zoom: number) {
|
||||
if (map.value) {
|
||||
map.value.setZoom(zoom)
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllOverlays() {
|
||||
if (!map.value) {
|
||||
return
|
||||
}
|
||||
clearAllOverlaysForMap(map.value)
|
||||
}
|
||||
|
||||
function clearAllOverlaysForMap(targetMap: google.maps.Map) {
|
||||
const overlays = globalOverlays.get(targetMap)
|
||||
|
||||
// Remove all tracked overlays from the map
|
||||
if (overlays) {
|
||||
const overlayCount = overlays.size
|
||||
overlays.forEach((overlay) => {
|
||||
if (overlay) {
|
||||
try {
|
||||
if ('setMap' in overlay && typeof overlay.setMap === 'function') {
|
||||
overlay.setMap(null)
|
||||
}
|
||||
if ('remove' in overlay && typeof overlay.remove === 'function') {
|
||||
overlay.remove()
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors when removing overlays
|
||||
console.warn('Error removing overlay:', e)
|
||||
}
|
||||
}
|
||||
})
|
||||
// Clear the set
|
||||
overlays.clear()
|
||||
console.log(`Cleared ${overlayCount} tracked overlays`)
|
||||
}
|
||||
|
||||
// Manual DOM scraping fallback removed as it causes "removeChild" errors
|
||||
// with Google Maps' native OverlayView management.
|
||||
}
|
||||
|
||||
function addHtmlMarker(
|
||||
position: { lat: number; lng: number },
|
||||
htmlContent: string,
|
||||
offset: { x: number; y: number } = { x: 0, y: 0 }
|
||||
) {
|
||||
if (!map.value) return null;
|
||||
|
||||
class CustomOverlay extends google.maps.OverlayView {
|
||||
private div: HTMLElement | null = null;
|
||||
private pos: google.maps.LatLng;
|
||||
|
||||
constructor(pos: google.maps.LatLng) {
|
||||
super();
|
||||
this.pos = pos;
|
||||
}
|
||||
|
||||
onAdd() {
|
||||
const div = document.createElement('div');
|
||||
div.style.position = 'absolute';
|
||||
div.style.cursor = 'pointer';
|
||||
div.innerHTML = htmlContent;
|
||||
this.div = div;
|
||||
const panes = this.getPanes();
|
||||
panes?.overlayMouseTarget.appendChild(div);
|
||||
}
|
||||
|
||||
draw() {
|
||||
const overlayProjection = this.getProjection();
|
||||
const point = overlayProjection.fromLatLngToDivPixel(this.pos);
|
||||
if (point && this.div) {
|
||||
this.div.style.left = (point.x + offset.x) + 'px';
|
||||
this.div.style.top = (point.y + offset.y) + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
onRemove() {
|
||||
if (this.div) {
|
||||
try {
|
||||
// Safer element removal
|
||||
if (this.div.parentNode) {
|
||||
this.div.parentNode.removeChild(this.div);
|
||||
} else {
|
||||
this.div.remove();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('CustomOverlay: element already removed or parent mismatch', e);
|
||||
}
|
||||
this.div = null;
|
||||
}
|
||||
}
|
||||
|
||||
setPosition(newPos: { lat: number; lng: number }) {
|
||||
this.pos = new google.maps.LatLng(newPos.lat, newPos.lng);
|
||||
this.draw();
|
||||
}
|
||||
}
|
||||
|
||||
const overlay = new CustomOverlay(new google.maps.LatLng(position.lat, position.lng));
|
||||
overlay.setMap(map.value);
|
||||
|
||||
// Track for cleanup
|
||||
if (!globalOverlays.has(map.value)) {
|
||||
globalOverlays.set(map.value, new Set());
|
||||
}
|
||||
globalOverlays.get(map.value)!.add(overlay as any);
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMaps()
|
||||
})
|
||||
|
||||
return {
|
||||
map,
|
||||
isLoaded,
|
||||
error,
|
||||
loadMaps,
|
||||
initMap,
|
||||
addMarker,
|
||||
addHtmlMarker,
|
||||
addNumberedMarker,
|
||||
addPolyline,
|
||||
fitBounds,
|
||||
setCenter,
|
||||
setZoom,
|
||||
clearAllOverlays,
|
||||
}
|
||||
}
|
||||
|
||||
16
frontend/src/i18n/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import es from './locales/es.json'
|
||||
import en from './locales/en.json'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'es', // Spanish as default
|
||||
fallbackLocale: 'es',
|
||||
messages: {
|
||||
es,
|
||||
en,
|
||||
},
|
||||
})
|
||||
|
||||
export default i18n
|
||||
|
||||
144
frontend/src/i18n/locales/en.json
Normal file
@ -0,0 +1,144 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"noData": "No data available",
|
||||
"select": "Select",
|
||||
"clear": "Clear",
|
||||
"clearSelection": "Clear selection"
|
||||
},
|
||||
"navigation": {
|
||||
"map": "Map",
|
||||
"schedules": "Schedules",
|
||||
"routes": "Routes",
|
||||
"favorites": "Favorites",
|
||||
"taxi": "Taxi",
|
||||
"coupons": "Offers",
|
||||
"discover": "Discover",
|
||||
"profile": "Profile"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "My Favorites",
|
||||
"subtitle": "Save your favorite routes, taxis, and businesses for quick access.",
|
||||
"removeConfirm": "Are you sure you want to remove this favorite?",
|
||||
"saved": "Saved in favorites",
|
||||
"contact": "Tap to contact",
|
||||
"viewDetails": "View details",
|
||||
"viewSchedules": "Tap to view schedules",
|
||||
"tabs": {
|
||||
"routes": "Routes",
|
||||
"taxis": "Taxis",
|
||||
"businesses": "Businesses",
|
||||
"coupons": "Offers"
|
||||
},
|
||||
"empty": {
|
||||
"routes": "You don't have any saved favorite routes.",
|
||||
"taxis": "You don't have any saved favorite taxis.",
|
||||
"businesses": "You don't have any saved favorite businesses.",
|
||||
"coupons": "You don't have any saved favorite offers."
|
||||
},
|
||||
"cta": {
|
||||
"routes": "Explore Routes",
|
||||
"taxis": "View Directory",
|
||||
"businesses": "Discover Businesses",
|
||||
"coupons": "View Offers"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"title": "SIBU",
|
||||
"switchToLightMode": "Switch to light mode",
|
||||
"switchToDarkMode": "Switch to dark mode"
|
||||
},
|
||||
"map": {
|
||||
"title": "Map",
|
||||
"loadingMap": "Loading map...",
|
||||
"mapLoadingError": "Map Loading Error",
|
||||
"commonFixes": "Common fixes:",
|
||||
"goToConsole": "Go to Google Cloud Console",
|
||||
"enableMapsApi": "Enable Maps JavaScript API for your project",
|
||||
"verifyApiKey": "Verify your API key is associated with the project",
|
||||
"enableBilling": "Ensure billing is enabled (required even for free tier)",
|
||||
"checkApiRestrictions": "Check API key restrictions allow localhost:5173",
|
||||
"restartServer": "Restart the dev server after changing .env.development",
|
||||
"selectRoute": "Select a route",
|
||||
"route": "Route",
|
||||
"stops": "stops",
|
||||
"stop": "stop"
|
||||
},
|
||||
"schedules": {
|
||||
"title": "Schedules",
|
||||
"loadingRoutes": "Loading routes...",
|
||||
"noRoutesAvailable": "No routes available",
|
||||
"selectRoute": "Select a route",
|
||||
"route": "Route",
|
||||
"schedules": "schedules",
|
||||
"schedule": "schedule",
|
||||
"departureTime": "Departure time"
|
||||
},
|
||||
"coupons": {
|
||||
"title": "Offers",
|
||||
"loadingCoupons": "Loading offers...",
|
||||
"noCouponsAvailable": "No offers available",
|
||||
"off": "OFF",
|
||||
"searchPlaceholder": "Search offers...",
|
||||
"filterByCategory": "Filter by category",
|
||||
"apply": "Apply",
|
||||
"offerDetails": "Offer Details",
|
||||
"description": "Description",
|
||||
"validity": "Validity",
|
||||
"category": "Category",
|
||||
"viewLocation": "View location",
|
||||
"validUntil": "Valid until",
|
||||
"tomorrow": "Tomorrow",
|
||||
"active": "Active",
|
||||
"offersCount": "{count} offer | {count} offers"
|
||||
},
|
||||
"taxi": {
|
||||
"title": "Transport Hub",
|
||||
"tabLocal": "Local Taxis",
|
||||
"tabIntercity": "Tourist Trips",
|
||||
"loadingTaxis": "Loading directory...",
|
||||
"noTaxisAvailable": "No taxis registered in this area.",
|
||||
"area": "Zone",
|
||||
"shift": "Schedule",
|
||||
"englishSpeakers": "Bilingual drivers",
|
||||
"callNow": "Call now",
|
||||
"englishLabel": "ENGLISH",
|
||||
"allZones": "All zones",
|
||||
"dayShift": "Day",
|
||||
"afternoonShift": "Afternoon",
|
||||
"nightShift": "Night"
|
||||
},
|
||||
"shuttle": {
|
||||
"title": "Tourist Trips & Shuttles",
|
||||
"reserve": "Book via WhatsApp",
|
||||
"perPerson": "per person",
|
||||
"privateTrip": "private trip",
|
||||
"duration": "Est. Duration",
|
||||
"departure": "Departures",
|
||||
"noShuttles": "No tourist routes available at the moment.",
|
||||
"filterRoute": "Filter by route",
|
||||
"allRoutes": "All routes",
|
||||
"tripType": "Trip type",
|
||||
"oneWay": "Outbound",
|
||||
"roundTrip": "Return",
|
||||
"both": "Both"
|
||||
},
|
||||
"busStop": {
|
||||
"loadingDetails": "Loading bus stop details...",
|
||||
"amenities": "Amenities",
|
||||
"shelter": "Shelter",
|
||||
"seating": "Seating",
|
||||
"accessible": "Accessible"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Discover",
|
||||
"subtitle": "Explore the best places in Chiriqui",
|
||||
"filterLabel": "Filter by area:",
|
||||
"allAreas": "All",
|
||||
"loading": "Searching for treasures...",
|
||||
"empty": "No places found in this area yet.",
|
||||
"exploreMore": "Explore Place",
|
||||
"tourism": "Tourism"
|
||||
}
|
||||
}
|
||||
145
frontend/src/i18n/locales/es.json
Normal file
@ -0,0 +1,145 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "Cargando...",
|
||||
"error": "Error",
|
||||
"noData": "No hay datos disponibles",
|
||||
"select": "Seleccionar",
|
||||
"clear": "Limpiar",
|
||||
"clearSelection": "Limpiar selección"
|
||||
},
|
||||
"navigation": {
|
||||
"map": "Mapa",
|
||||
"schedules": "Horarios",
|
||||
"routes": "Rutas",
|
||||
"favorites": "Favoritos",
|
||||
"taxi": "Transporte",
|
||||
"coupons": "Ofertas",
|
||||
"discover": "Descubrir",
|
||||
"profile": "Perfil"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Mis Favoritos",
|
||||
"subtitle": "Guarda tus rutas, taxis y negocios preferidos para acceder rápido.",
|
||||
"removeConfirm": "¿Estás seguro de que quieres eliminar este favorito?",
|
||||
"saved": "Guardado en favoritos",
|
||||
"contact": "Toca para contactar",
|
||||
"viewDetails": "Ver detalles",
|
||||
"viewSchedules": "Toque para ver horarios",
|
||||
"tabs": {
|
||||
"routes": "Rutas",
|
||||
"taxis": "Transporte",
|
||||
"businesses": "Negocios",
|
||||
"coupons": "Eventos"
|
||||
},
|
||||
"empty": {
|
||||
"subtitle": "Aún no tienes favoritos",
|
||||
"routes": "No tienes rutas favoritas guardadas.",
|
||||
"taxis": "No tienes taxis favoritos guardados.",
|
||||
"businesses": "No tienes negocios favoritos guardados.",
|
||||
"coupons": "No tienes eventos favoritos guardados."
|
||||
},
|
||||
"cta": {
|
||||
"routes": "Explorar Rutas",
|
||||
"taxis": "Ver Directorio",
|
||||
"businesses": "Descubrir Negocios",
|
||||
"coupons": "Ver Eventos"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"title": "SIBU",
|
||||
"switchToLightMode": "Cambiar a modo claro",
|
||||
"switchToDarkMode": "Cambiar a modo oscuro"
|
||||
},
|
||||
"map": {
|
||||
"title": "Mapa",
|
||||
"loadingMap": "Cargando mapa...",
|
||||
"mapLoadingError": "Error al cargar el mapa",
|
||||
"commonFixes": "Soluciones comunes:",
|
||||
"goToConsole": "Ir a Google Cloud Console",
|
||||
"enableMapsApi": "Habilitar Maps JavaScript API para tu proyecto",
|
||||
"verifyApiKey": "Verificar que tu clave API esté asociada con el proyecto",
|
||||
"enableBilling": "Asegurar que la facturación esté habilitada (requerido incluso para el nivel gratuito)",
|
||||
"checkApiRestrictions": "Verificar que las restricciones de la clave API permitan localhost:5173",
|
||||
"restartServer": "Reiniciar el servidor de desarrollo después de cambiar .env.development",
|
||||
"selectRoute": "Seleccionar una ruta",
|
||||
"route": "Ruta",
|
||||
"stops": "paradas",
|
||||
"stop": "parada"
|
||||
},
|
||||
"schedules": {
|
||||
"title": "Horarios",
|
||||
"loadingRoutes": "Cargando rutas...",
|
||||
"noRoutesAvailable": "No hay rutas disponibles",
|
||||
"selectRoute": "Seleccionar una ruta",
|
||||
"route": "Ruta",
|
||||
"schedules": "horarios",
|
||||
"schedule": "horario",
|
||||
"departureTime": "Hora de salida"
|
||||
},
|
||||
"coupons": {
|
||||
"title": "Ofertas",
|
||||
"loadingCoupons": "Cargando ofertas...",
|
||||
"noCouponsAvailable": "No hay ofertas disponibles",
|
||||
"off": "DESCUENTO",
|
||||
"searchPlaceholder": "Buscar ofertas...",
|
||||
"filterByCategory": "Filtrar por categoría",
|
||||
"apply": "Aplicar",
|
||||
"offerDetails": "Detalles de la Oferta",
|
||||
"description": "Descripción",
|
||||
"validity": "Validez",
|
||||
"category": "Categoría",
|
||||
"viewLocation": "Ver ubicación",
|
||||
"validUntil": "Válido hasta",
|
||||
"tomorrow": "Mañana",
|
||||
"active": "Activo",
|
||||
"offersCount": "{count} oferta | {count} ofertas"
|
||||
},
|
||||
"taxi": {
|
||||
"title": "Centro de Transporte",
|
||||
"tabLocal": "Taxis Locales",
|
||||
"tabIntercity": "Viajes Turísticos",
|
||||
"loadingTaxis": "Cargando directorio...",
|
||||
"noTaxisAvailable": "No hay taxis registrados en esta zona.",
|
||||
"area": "Zona",
|
||||
"shift": "Horario",
|
||||
"englishSpeakers": "Conductores bilingües",
|
||||
"callNow": "Llamar ahora",
|
||||
"englishLabel": "INGLÉS",
|
||||
"allZones": "Todas las zonas",
|
||||
"dayShift": "Día",
|
||||
"afternoonShift": "Tarde",
|
||||
"nightShift": "Noche"
|
||||
},
|
||||
"shuttle": {
|
||||
"title": "Viajes Turísticos & Shuttles",
|
||||
"reserve": "Reservar vía WhatsApp",
|
||||
"perPerson": "por persona",
|
||||
"privateTrip": "viaje privado",
|
||||
"duration": "Duración estimada",
|
||||
"departure": "Salidas",
|
||||
"noShuttles": "No hay rutas turísticas disponibles en este momento.",
|
||||
"filterRoute": "Filtrar por ruta",
|
||||
"allRoutes": "Todas las rutas",
|
||||
"tripType": "Tipo de viaje",
|
||||
"oneWay": "Ida",
|
||||
"roundTrip": "Vuelta",
|
||||
"both": "Ambos"
|
||||
},
|
||||
"busStop": {
|
||||
"loadingDetails": "Cargando detalles de la parada...",
|
||||
"amenities": "Servicios",
|
||||
"shelter": "Refugio",
|
||||
"seating": "Asientos",
|
||||
"accessible": "Accesible"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Descubrir",
|
||||
"subtitle": "Explora los mejores lugares de Chiriquí",
|
||||
"filterLabel": "Filtrar por área:",
|
||||
"allAreas": "Todas",
|
||||
"loading": "Buscando tesoros...",
|
||||
"empty": "No se encontraron lugares en esta área todavía.",
|
||||
"exploreMore": "Explorar Lugar",
|
||||
"tourism": "Turismo"
|
||||
}
|
||||
}
|
||||
23
frontend/src/main.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import i18n from './i18n'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
app.config.errorHandler = (err, _vm, info) => {
|
||||
console.error('Global Error Handler:', err, info)
|
||||
// Display error on screen if possible or alert for dev
|
||||
if (import.meta.env.DEV) {
|
||||
alert('Frontend Error: ' + err)
|
||||
}
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
161
frontend/src/router/index.ts
Normal file
@ -0,0 +1,161 @@
|
||||
/** Vue Router configuration */
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'splash',
|
||||
component: () => import('@/views/SplashScreen.vue'),
|
||||
},
|
||||
{
|
||||
path: '/map',
|
||||
name: 'map',
|
||||
component: () => import('@/views/MapView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/discover',
|
||||
name: 'discover',
|
||||
component: () => import('@/views/DiscoverView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/business/:id',
|
||||
name: 'business-details',
|
||||
component: () => import('@/views/BusinessDetailsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/routes',
|
||||
name: 'routes',
|
||||
component: () => import('@/views/RoutesView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/schedules',
|
||||
name: 'schedules',
|
||||
component: () => import('@/views/SchedulesView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/coupons',
|
||||
name: 'coupons',
|
||||
component: () => import('@/views/CouponsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/favorites',
|
||||
name: 'favorites',
|
||||
component: () => import('@/views/FavoritesView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: () => import('@/views/ProfileView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/taxi',
|
||||
name: 'taxi',
|
||||
component: () => import('@/views/TaxiView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/bus-stop/:id',
|
||||
name: 'bus-stop-details',
|
||||
component: () => import('@/views/BusStopDetailsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'auth',
|
||||
component: () => import('@/views/AuthView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin-panel',
|
||||
component: () => import('@/views/AdminPanel.vue'),
|
||||
meta: { requiresAuth: true, role: 'admin' }
|
||||
},
|
||||
{
|
||||
path: '/admin/bus-stops',
|
||||
name: 'admin-bus-stops',
|
||||
component: () => import('@/views/AdminBusStops.vue'),
|
||||
meta: { requiresAuth: true, role: 'admin' }
|
||||
},
|
||||
{
|
||||
path: '/admin/routes',
|
||||
name: 'admin-routes',
|
||||
component: () => import('@/views/AdminRoutes.vue'),
|
||||
meta: { requiresAuth: true, role: 'admin' }
|
||||
},
|
||||
{
|
||||
path: '/admin/reports',
|
||||
name: 'admin-reports',
|
||||
component: () => import('@/views/AdminReports.vue'),
|
||||
meta: { requiresAuth: true, role: 'admin' }
|
||||
},
|
||||
{
|
||||
path: '/admin/schedules',
|
||||
name: 'admin-schedules',
|
||||
component: () => import('@/views/AdminSchedules.vue'),
|
||||
meta: { requiresAuth: true, role: 'admin' }
|
||||
},
|
||||
{
|
||||
path: '/admin/drivers',
|
||||
name: 'admin-drivers',
|
||||
component: () => import('@/views/AdminDrivers.vue'),
|
||||
meta: { requiresAuth: true, role: 'admin' }
|
||||
},
|
||||
{
|
||||
path: '/admin/analytics',
|
||||
name: 'admin-analytics',
|
||||
component: () => import('@/views/StrategicAnalytics.vue'),
|
||||
meta: { requiresAuth: true, role: 'admin' }
|
||||
},
|
||||
{
|
||||
path: '/admin/taxis',
|
||||
name: 'admin-taxis',
|
||||
component: () => import('@/views/AdminTaxis.vue'),
|
||||
meta: { requiresAuth: true, role: 'admin' }
|
||||
},
|
||||
{
|
||||
path: '/admin/shuttles',
|
||||
name: 'admin-shuttles',
|
||||
component: () => import('@/views/AdminShuttles.vue'),
|
||||
meta: { requiresAuth: true, role: 'admin' }
|
||||
},
|
||||
{
|
||||
path: '/promoter',
|
||||
name: 'promoter-dashboard',
|
||||
component: () => import('@/views/PromoterDashboard.vue'),
|
||||
meta: { requiresAuth: true, role: ['PROMOTER', 'ADMIN'] }
|
||||
},
|
||||
{
|
||||
path: '/driver',
|
||||
name: 'driver-dashboard',
|
||||
component: () => import('@/views/DriverDashboard.vue'),
|
||||
meta: { requiresAuth: true, role: ['DRIVER', 'ADMIN'] }
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
const role = localStorage.getItem('user_role')?.toUpperCase()
|
||||
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
next('/login')
|
||||
} else if (to.meta.role) {
|
||||
const allowedRoles = Array.isArray(to.meta.role) ? to.meta.role : [to.meta.role]
|
||||
const hasAccess = allowedRoles.some(r => r.toUpperCase() === role)
|
||||
|
||||
if (!hasAccess) {
|
||||
if (role === 'ADMIN') next('/admin')
|
||||
else if (role === 'DRIVER') next('/driver')
|
||||
else if (role === 'PROMOTER') next('/promoter')
|
||||
else next('/map')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
22
frontend/src/services/analyticsService.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { apiClient } from './apiClient'
|
||||
|
||||
export interface AnalyticsEvent {
|
||||
event_name: 'app_open' | 'screen_view' | 'route_selected' | 'stop_selected' | 'schedule_viewed' | 'reminder_created' | 'promo_view' | 'promo_click' | 'taxi_view' | 'taxi_click' | 'shuttle_view' | 'shuttle_contact' | 'business_view' | 'business_contact'
|
||||
screen_name?: string
|
||||
item_id?: string
|
||||
properties?: Record<string, any>
|
||||
}
|
||||
|
||||
export const analyticsService = {
|
||||
logEvent(event: AnalyticsEvent) {
|
||||
// Log asynchronously without awaiting to avoid blocking UI
|
||||
apiClient.post('/api/analytics/event', event).catch(error => {
|
||||
console.warn('Analytics capture failed:', error)
|
||||
})
|
||||
},
|
||||
|
||||
async getStats() {
|
||||
const response = await apiClient.get('/api/analytics/dashboard/stats')
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
59
frontend/src/services/apiClient.ts
Normal file
@ -0,0 +1,59 @@
|
||||
/** Base API client for making HTTP requests to the backend */
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosError } from 'axios'
|
||||
|
||||
export const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
// Request interceptor
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
// Handle common errors
|
||||
if (error.response) {
|
||||
// Server responded with error status
|
||||
console.error('API Error:', error.response.status, error.response.data)
|
||||
} else if (error.request) {
|
||||
// Request made but no response
|
||||
console.error('Network Error:', error.request)
|
||||
} else {
|
||||
// Something else happened
|
||||
console.error('Error:', error.message)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
get instance(): AxiosInstance {
|
||||
return this.client
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient().instance
|
||||
|
||||