Initial commit: SIBU 2.0 MISSION

This commit is contained in:
2026-02-21 09:53:31 -05:00
commit 0c7aa53c8b
400 changed files with 67708 additions and 0 deletions

View 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
View 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
View 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
View 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?

View File

@ -0,0 +1,8 @@
{
"hash": "69e445e7",
"configHash": "f38005ec",
"lockfileHash": "e3b0c442",
"browserHash": "7f4b7699",
"optimized": {},
"chunks": {}
}

View File

@ -0,0 +1,3 @@
{
"type": "module"
}

5
frontend/README.md Normal file
View 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
View 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
View File

@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep

View 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")
}

View 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
View 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

View File

@ -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());
}
}

View 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>

View File

@ -0,0 +1,5 @@
package com.sibu.app;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -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>

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View 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>

View File

@ -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>

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View 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>

View 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>

View 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>

View File

@ -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>

View File

@ -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);
}
}

View 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
}

View 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')

View 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

Binary file not shown.

View 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
View 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
View 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

View 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'

View 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

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

38
frontend/package.json Normal file
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 898 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

14
frontend/public/icon.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

1
frontend/public/vite.svg Normal file
View 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
View 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>

View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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,
}
}

View 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

View 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"
}
}

View 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
View 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')

View 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

View 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
}
}

View 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

Some files were not shown because too many files have changed in this diff Show More