init
BIN
frontend/.tmp/launcher-source.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
15
frontend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/.output ./.output
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
101
frontend/android/.gitignore
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
3
frontend/android/.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
6
frontend/android/.idea/AndroidProjectSystem.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
6
frontend/android/.idea/compiler.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
10
frontend/android/.idea/deploymentTargetSelector.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
</project>
|
||||
10
frontend/android/.idea/migrations.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectMigrations">
|
||||
<option name="MigrateToGradleLocalJavaHome">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
9
frontend/android/.idea/misc.xml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
</project>
|
||||
17
frontend/android/.idea/runConfigurations.xml
generated
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RunConfigurationProducerService">
|
||||
<option name="ignoredProducers">
|
||||
<set>
|
||||
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
2
frontend/android/app/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
63
frontend/android/app/build.gradle
Normal file
@@ -0,0 +1,63 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
namespace "com.alpinbet.app"
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
signingConfigs {
|
||||
release {
|
||||
storeFile file(ALPINBET_UPLOAD_STORE_FILE)
|
||||
storePassword ALPINBET_UPLOAD_STORE_PASSWORD
|
||||
keyAlias ALPINBET_UPLOAD_KEY_ALIAS
|
||||
keyPassword ALPINBET_UPLOAD_KEY_PASSWORD
|
||||
}
|
||||
}
|
||||
defaultConfig {
|
||||
applicationId "com.alpinbet.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 5
|
||||
versionName "1.0.5"
|
||||
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
|
||||
signingConfig signingConfigs.release
|
||||
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")
|
||||
}
|
||||
23
frontend/android/app/capacitor.build.gradle
Normal file
@@ -0,0 +1,23 @@
|
||||
// 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-app')
|
||||
implementation project(':capacitor-device')
|
||||
implementation project(':capacitor-preferences')
|
||||
implementation project(':capacitor-push-notifications')
|
||||
implementation project(':capacitor-splash-screen')
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
21
frontend/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
46
frontend/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,46 @@
|
||||
<?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">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||
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>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="OPEN_SIGNAL" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<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" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.alpinbet.app;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
BIN
frontend/android/app/src/main/res/drawable-land-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
frontend/android/app/src/main/res/drawable-land-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
frontend/android/app/src/main/res/drawable-land-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/android/app/src/main/res/drawable-port-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
frontend/android/app/src/main/res/drawable-port-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
frontend/android/app/src/main/res/drawable-port-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,34 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?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="#FFFFFF"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
BIN
frontend/android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
12
frontend/android/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
BIN
frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
BIN
frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
BIN
frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
7
frontend/android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Антигол</string>
|
||||
<string name="title_activity_main">Антигол</string>
|
||||
<string name="package_name">com.alpinbet.app</string>
|
||||
<string name="custom_url_scheme">com.alpinbet.app</string>
|
||||
</resources>
|
||||
26
frontend/android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</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:windowSplashScreenBackground">@android:color/black</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@null</item>
|
||||
<item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
|
||||
</style>
|
||||
</resources>
|
||||
5
frontend/android/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||
29
frontend/android/build.gradle
Normal file
@@ -0,0 +1,29 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.7.2'
|
||||
classpath 'com.google.gms:google-services:4.4.2'
|
||||
|
||||
// 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
|
||||
}
|
||||
18
frontend/android/capacitor.settings.gradle
Normal file
@@ -0,0 +1,18 @@
|
||||
// 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-app'
|
||||
project(':capacitor-app').projectDir = new File('../../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-device'
|
||||
project(':capacitor-device').projectDir = new File('../../node_modules/@capacitor/device/android')
|
||||
|
||||
include ':capacitor-preferences'
|
||||
project(':capacitor-preferences').projectDir = new File('../../node_modules/@capacitor/preferences/android')
|
||||
|
||||
include ':capacitor-push-notifications'
|
||||
project(':capacitor-push-notifications').projectDir = new File('../../node_modules/@capacitor/push-notifications/android')
|
||||
|
||||
include ':capacitor-splash-screen'
|
||||
project(':capacitor-splash-screen').projectDir = new File('../../node_modules/@capacitor/splash-screen/android')
|
||||
27
frontend/android/gradle.properties
Normal file
@@ -0,0 +1,27 @@
|
||||
# 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
|
||||
|
||||
ALPINBET_UPLOAD_STORE_FILE=../keystore/alpinbet-release.jks
|
||||
ALPINBET_UPLOAD_KEY_ALIAS=alpinbet
|
||||
ALPINBET_UPLOAD_STORE_PASSWORD=talorr31
|
||||
ALPINBET_UPLOAD_KEY_PASSWORD=talorr31
|
||||
BIN
frontend/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
frontend/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
252
frontend/android/gradlew
vendored
Normal file
@@ -0,0 +1,252 @@
|
||||
#!/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
|
||||
' "$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=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# 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, 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" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
frontend/android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
5
frontend/android/settings.gradle
Normal file
@@ -0,0 +1,5 @@
|
||||
include ':app'
|
||||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
16
frontend/android/variables.gradle
Normal file
@@ -0,0 +1,16 @@
|
||||
ext {
|
||||
minSdkVersion = 23
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
androidxActivityVersion = '1.9.2'
|
||||
androidxAppCompatVersion = '1.7.0'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.15.0'
|
||||
androidxFragmentVersion = '1.8.4'
|
||||
coreSplashScreenVersion = '1.0.1'
|
||||
androidxWebkitVersion = '1.12.1'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.2.1'
|
||||
androidxEspressoCoreVersion = '3.6.1'
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
}
|
||||
382
frontend/app.vue
Normal file
@@ -0,0 +1,382 @@
|
||||
<script setup lang="ts">
|
||||
import { App as CapacitorApp, type AppInfo } from "@capacitor/app";
|
||||
|
||||
const { user, loading } = useAuth();
|
||||
const { initializeTheme } = useTheme();
|
||||
const {
|
||||
ensurePushSubscription,
|
||||
getPushStatus,
|
||||
triggerInstallPrompt,
|
||||
canTriggerInstallPrompt,
|
||||
isNativeApp,
|
||||
initializeWebPushRouting,
|
||||
consumePendingPushRoute
|
||||
} = usePush();
|
||||
|
||||
type AppVersionPayload = {
|
||||
latestVersion: string | null;
|
||||
minSupportedVersion: string | null;
|
||||
updateUrl: string | null;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
const promptDismissed = ref(false);
|
||||
const installPromptDismissed = ref(false);
|
||||
const promptLoading = ref(false);
|
||||
const installLoading = ref(false);
|
||||
const promptMessage = ref("");
|
||||
const showPushPrompt = ref(false);
|
||||
const showInstallPrompt = ref(false);
|
||||
const installHelpMessage = ref("");
|
||||
const showUpdatePrompt = ref(false);
|
||||
const isUpdateRequired = ref(false);
|
||||
const updateLoading = ref(false);
|
||||
const updateMessage = ref("");
|
||||
const updateUrl = ref("");
|
||||
let updateCheckIntervalId: ReturnType<typeof window.setInterval> | null = null;
|
||||
let lastUpdateCheckAt = 0;
|
||||
let appStateChangeListener: Awaited<ReturnType<typeof CapacitorApp.addListener>> | null = null;
|
||||
|
||||
const UPDATE_CHECK_INTERVAL_MS = 60_000;
|
||||
|
||||
const parseVersion = (value: string) =>
|
||||
value
|
||||
.split(".")
|
||||
.map((part) => Number.parseInt(part.replace(/\D+/g, ""), 10))
|
||||
.map((part) => (Number.isFinite(part) ? part : 0));
|
||||
|
||||
const compareVersions = (left: string, right: string) => {
|
||||
const leftParts = parseVersion(left);
|
||||
const rightParts = parseVersion(right);
|
||||
const maxLength = Math.max(leftParts.length, rightParts.length);
|
||||
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const leftPart = leftParts[index] ?? 0;
|
||||
const rightPart = rightParts[index] ?? 0;
|
||||
|
||||
if (leftPart > rightPart) return 1;
|
||||
if (leftPart < rightPart) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const formatPushError = (error: unknown) => {
|
||||
if (!(error instanceof Error)) {
|
||||
return "Не удалось подключить уведомления";
|
||||
}
|
||||
|
||||
if (error.message.includes("/public/push-subscriptions") && error.message.includes("404")) {
|
||||
return "На сервере пока нет публичного push-endpoint. Нужно обновить backend до последней версии.";
|
||||
}
|
||||
|
||||
if (error.message.includes("/public/push-subscriptions") && error.message.includes("400")) {
|
||||
return "Сервер отклонил push-подписку. Обычно это значит, что backend на сервере ещё не обновлён или работает старая версия API.";
|
||||
}
|
||||
|
||||
return error.message;
|
||||
};
|
||||
|
||||
const checkAppVersion = async () => {
|
||||
if (!process.client || !isNativeApp()) {
|
||||
showUpdatePrompt.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [appInfo, versionInfo] = await Promise.all([
|
||||
CapacitorApp.getInfo(),
|
||||
useApi<AppVersionPayload>("/app-version")
|
||||
]);
|
||||
|
||||
const currentVersion = (appInfo as AppInfo).version?.trim();
|
||||
const latestVersion = versionInfo.latestVersion?.trim();
|
||||
const minSupportedVersion = versionInfo.minSupportedVersion?.trim();
|
||||
|
||||
if (!currentVersion || (!latestVersion && !minSupportedVersion)) {
|
||||
showUpdatePrompt.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isUpdateRequired.value = Boolean(
|
||||
minSupportedVersion && compareVersions(currentVersion, minSupportedVersion) < 0
|
||||
);
|
||||
|
||||
const hasOptionalUpdate = Boolean(
|
||||
latestVersion && compareVersions(currentVersion, latestVersion) < 0
|
||||
);
|
||||
|
||||
showUpdatePrompt.value = isUpdateRequired.value || hasOptionalUpdate;
|
||||
updateUrl.value = versionInfo.updateUrl?.trim() || "";
|
||||
updateMessage.value = versionInfo.message?.trim() || "Доступна новая версия приложения";
|
||||
} catch {
|
||||
showUpdatePrompt.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const checkAppVersionThrottled = async (force = false) => {
|
||||
if (!process.client || !isNativeApp()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (!force && now - lastUpdateCheckAt < UPDATE_CHECK_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastUpdateCheckAt = now;
|
||||
await checkAppVersion();
|
||||
};
|
||||
|
||||
const refreshPromptState = async () => {
|
||||
if (!process.client || loading.value) {
|
||||
showPushPrompt.value = false;
|
||||
showInstallPrompt.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.value) {
|
||||
showPushPrompt.value = false;
|
||||
showInstallPrompt.value = false;
|
||||
promptMessage.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await getPushStatus();
|
||||
|
||||
showInstallPrompt.value = false;
|
||||
showPushPrompt.value = false;
|
||||
installHelpMessage.value = "";
|
||||
|
||||
if (isNativeApp()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.installRequired && !installPromptDismissed.value) {
|
||||
showInstallPrompt.value = true;
|
||||
|
||||
if (status.isIos) {
|
||||
installHelpMessage.value =
|
||||
"На iPhone и iPad push-уведомления работают только после установки приложения на экран «Домой». Откройте меню Safari и выберите «На экран Домой».";
|
||||
} else if (!status.canInstall) {
|
||||
installHelpMessage.value =
|
||||
"Если кнопки установки нет, откройте меню браузера на телефоне и выберите «Установить приложение» или «Добавить на главный экран».";
|
||||
}
|
||||
}
|
||||
|
||||
if (!status.supported) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.permission === "denied") {
|
||||
promptMessage.value = "Уведомления запрещены в браузере. Их можно включить в настройках сайта.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showInstallPrompt.value) {
|
||||
showPushPrompt.value = !promptDismissed.value && (!status.hasBrowserSubscription || !status.hasServerSubscription);
|
||||
}
|
||||
};
|
||||
|
||||
const enableNotifications = async () => {
|
||||
promptLoading.value = true;
|
||||
promptMessage.value = "";
|
||||
|
||||
try {
|
||||
await ensurePushSubscription();
|
||||
showPushPrompt.value = false;
|
||||
promptDismissed.value = true;
|
||||
promptMessage.value = "Уведомления подключены";
|
||||
} catch (error) {
|
||||
promptMessage.value = formatPushError(error);
|
||||
} finally {
|
||||
promptLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const installApplication = async () => {
|
||||
installLoading.value = true;
|
||||
installHelpMessage.value = "";
|
||||
|
||||
try {
|
||||
const accepted = await triggerInstallPrompt();
|
||||
if (!accepted) {
|
||||
installHelpMessage.value = "Установка была отменена. Можно вернуться к этому позже.";
|
||||
return;
|
||||
}
|
||||
|
||||
installPromptDismissed.value = true;
|
||||
showInstallPrompt.value = false;
|
||||
promptMessage.value = "Приложение установлено. Теперь можно включить push-уведомления.";
|
||||
await refreshPromptState();
|
||||
} catch (error) {
|
||||
installHelpMessage.value = error instanceof Error ? error.message : "Не удалось открыть окно установки";
|
||||
} finally {
|
||||
installLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openUpdateUrl = async () => {
|
||||
if (!process.client || !updateUrl.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateLoading.value = true;
|
||||
|
||||
try {
|
||||
window.location.assign(updateUrl.value);
|
||||
} finally {
|
||||
updateLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const dismissPrompt = () => {
|
||||
promptDismissed.value = true;
|
||||
showPushPrompt.value = false;
|
||||
};
|
||||
|
||||
const dismissInstallPrompt = () => {
|
||||
installPromptDismissed.value = true;
|
||||
showInstallPrompt.value = false;
|
||||
};
|
||||
|
||||
const dismissUpdatePrompt = () => {
|
||||
if (isUpdateRequired.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
showUpdatePrompt.value = false;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
initializeTheme();
|
||||
initializeWebPushRouting();
|
||||
void refreshPromptState();
|
||||
void consumePendingPushRoute();
|
||||
void checkAppVersionThrottled(true);
|
||||
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.addEventListener("focus", handleWindowFocus);
|
||||
|
||||
if (isNativeApp()) {
|
||||
appStateChangeListener = await CapacitorApp.addListener("appStateChange", handleAppStateChange);
|
||||
}
|
||||
|
||||
updateCheckIntervalId = window.setInterval(() => {
|
||||
void checkAppVersionThrottled();
|
||||
}, UPDATE_CHECK_INTERVAL_MS);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.removeEventListener("focus", handleWindowFocus);
|
||||
|
||||
if (updateCheckIntervalId !== null) {
|
||||
window.clearInterval(updateCheckIntervalId);
|
||||
updateCheckIntervalId = null;
|
||||
}
|
||||
|
||||
void appStateChangeListener?.remove();
|
||||
appStateChangeListener = null;
|
||||
});
|
||||
|
||||
const handleAppStateChange = ({ isActive }: { isActive: boolean }) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
void checkAppVersionThrottled(true);
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
|
||||
void checkAppVersionThrottled();
|
||||
};
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
void checkAppVersionThrottled();
|
||||
};
|
||||
|
||||
watch([user, loading], () => {
|
||||
promptMessage.value = "";
|
||||
void refreshPromptState();
|
||||
void consumePendingPushRoute();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<div v-if="showUpdatePrompt" :class="isUpdateRequired ? 'app-update app-update--blocking' : 'push-consent app-update'">
|
||||
<div class="push-consent__content">
|
||||
<strong>{{ isUpdateRequired ? "Нужно обновить приложение" : "Доступно обновление" }}</strong>
|
||||
<p>{{ updateMessage }}</p>
|
||||
<p v-if="isUpdateRequired">Текущая версия больше не поддерживается. Обновите приложение, чтобы продолжить работу.</p>
|
||||
</div>
|
||||
<div class="push-consent__actions">
|
||||
<button :disabled="updateLoading || !updateUrl" @click="openUpdateUrl">
|
||||
{{ updateLoading ? "Открываем..." : "Обновить приложение" }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!isUpdateRequired"
|
||||
class="secondary"
|
||||
:disabled="updateLoading"
|
||||
@click="dismissUpdatePrompt"
|
||||
>
|
||||
Позже
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showInstallPrompt" class="push-consent push-consent--install">
|
||||
<div class="push-consent__content">
|
||||
<strong>Установить приложение на телефон?</strong>
|
||||
<p>
|
||||
Чтобы уведомления приходили на мобильном устройстве, откройте сервис как установленное PWA-приложение.
|
||||
</p>
|
||||
<p v-if="installHelpMessage" class="push-consent__hint">{{ installHelpMessage }}</p>
|
||||
</div>
|
||||
<div class="push-consent__actions">
|
||||
<button v-if="canTriggerInstallPrompt()" :disabled="installLoading" @click="installApplication">
|
||||
{{ installLoading ? "Открываем..." : "Установить" }}
|
||||
</button>
|
||||
<button class="secondary" :disabled="installLoading" @click="dismissInstallPrompt">Не сейчас</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showPushPrompt" class="push-consent">
|
||||
<div class="push-consent__content">
|
||||
<strong>Получать уведомления о новых матчах и сигналах?</strong>
|
||||
<p>
|
||||
При появлении новых матчей в списке мы будем отправлять push-уведомления в браузер и в установленное приложение.
|
||||
</p>
|
||||
</div>
|
||||
<div class="push-consent__actions">
|
||||
<button :disabled="promptLoading" @click="enableNotifications">
|
||||
{{ promptLoading ? "Подключаем..." : "Да, включить" }}
|
||||
</button>
|
||||
<button class="secondary" :disabled="promptLoading" @click="dismissPrompt">Не сейчас</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="promptMessage && !isUpdateRequired"
|
||||
class="push-consent__message"
|
||||
:style="{ marginTop: 'calc(0.5rem + env(safe-area-inset-top, 0px))' }"
|
||||
>
|
||||
{{ promptMessage }}
|
||||
</p>
|
||||
|
||||
<NuxtPage v-if="!isUpdateRequired" />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
1792
frontend/assets/css/main.css
Normal file
22
frontend/capacitor.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { CapacitorConfig } from "@capacitor/cli";
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: "com.alpinbet.app",
|
||||
appName: "Антигол",
|
||||
webDir: ".output/public",
|
||||
bundledWebRuntime: false,
|
||||
server: {
|
||||
androidScheme: "https"
|
||||
},
|
||||
plugins: {
|
||||
SplashScreen: {
|
||||
launchAutoHide: true,
|
||||
launchShowDuration: 0
|
||||
},
|
||||
PushNotifications: {
|
||||
presentationOptions: ["badge", "sound", "alert"]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
26
frontend/components/AppLogo.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
src?: string;
|
||||
alt?: string;
|
||||
size?: number | string;
|
||||
}>(),
|
||||
{
|
||||
src: "/icons/app-icon-192.png",
|
||||
alt: "Антигол",
|
||||
size: 40
|
||||
}
|
||||
);
|
||||
|
||||
const resolvedSize = computed(() => (typeof props.size === "number" ? `${props.size}px` : props.size));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
:style="{ width: resolvedSize, height: resolvedSize }"
|
||||
class="block shrink-0 object-contain"
|
||||
draggable="false"
|
||||
/>
|
||||
</template>
|
||||
153
frontend/components/SignalCard.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import Tag from "primevue/tag";
|
||||
import type { Signal } from "~/types";
|
||||
|
||||
const props = defineProps<{
|
||||
signal: Signal;
|
||||
}>();
|
||||
|
||||
const { formatDateTime } = useBrowserDateTime();
|
||||
const { copyText } = useClipboard();
|
||||
|
||||
const botName = computed(() => props.signal.rawPayload?.botName || null);
|
||||
const isInactiveForecast = computed(() => props.signal.rawPayload?.forecastInactive === true);
|
||||
const forecast = computed(() => props.signal.forecast || props.signal.rawPayload?.forecast || null);
|
||||
const forecastImageUrl = computed(() => props.signal.rawPayload?.forecastImageUrl || null);
|
||||
const forecastImageFailed = ref(false);
|
||||
const copiedMatch = ref(false);
|
||||
let copiedMatchResetTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const statusLabels: Record<Signal["status"], string> = {
|
||||
pending: "LIVE",
|
||||
win: "WIN",
|
||||
lose: "LOSE",
|
||||
void: "VOID",
|
||||
manual_review: "CHECK",
|
||||
unpublished: "OFF"
|
||||
};
|
||||
|
||||
const displayStatusLabel = computed(() => {
|
||||
if (props.signal.status === "pending" && isInactiveForecast.value) return "OFF";
|
||||
return statusLabels[props.signal.status];
|
||||
});
|
||||
|
||||
const statusSeverity = computed(() => {
|
||||
if (props.signal.status === "pending" && isInactiveForecast.value) return "secondary";
|
||||
|
||||
switch (props.signal.status) {
|
||||
case "win":
|
||||
return "success";
|
||||
case "lose":
|
||||
return "danger";
|
||||
case "manual_review":
|
||||
return "warn";
|
||||
case "pending":
|
||||
return "info";
|
||||
default:
|
||||
return "secondary";
|
||||
}
|
||||
});
|
||||
|
||||
const formattedDate = computed(() =>
|
||||
formatDateTime(props.signal.signalTime, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
})
|
||||
);
|
||||
|
||||
const shouldShowForecastImage = computed(() => Boolean(forecastImageUrl.value) && !forecastImageFailed.value);
|
||||
|
||||
const copyMatchName = async (event?: Event) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
const copied = await copyText(`${props.signal.homeTeam} - ${props.signal.awayTeam}`);
|
||||
if (!copied) {
|
||||
return;
|
||||
}
|
||||
copiedMatch.value = true;
|
||||
|
||||
if (copiedMatchResetTimeout) {
|
||||
clearTimeout(copiedMatchResetTimeout);
|
||||
}
|
||||
|
||||
copiedMatchResetTimeout = setTimeout(() => {
|
||||
copiedMatch.value = false;
|
||||
}, 1600);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.signal.rawPayload?.forecastImageUrl,
|
||||
() => {
|
||||
forecastImageFailed.value = false;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="sakai-signal-card">
|
||||
<div class="sakai-signal-card__main">
|
||||
<div class="sakai-signal-card__meta">
|
||||
<span>
|
||||
<i class="pi pi-clock" />
|
||||
{{ formattedDate }}
|
||||
</span>
|
||||
<span>
|
||||
<i class="pi pi-flag" />
|
||||
{{ signal.leagueName }}
|
||||
</span>
|
||||
<span v-if="botName">
|
||||
<AppLogo size="14px" />
|
||||
{{ botName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="sakai-signal-card__teams">
|
||||
<strong>{{ signal.homeTeam }}</strong>
|
||||
<strong>{{ signal.awayTeam }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="sakai-signal-card__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="sakai-copy-button sakai-copy-button--wide"
|
||||
:aria-label="`Скопировать матч ${signal.homeTeam} - ${signal.awayTeam}`"
|
||||
@click="copyMatchName($event)"
|
||||
>
|
||||
<i class="pi" :class="copiedMatch ? 'pi-check' : 'pi-copy'" aria-hidden="true" />
|
||||
<span>{{ copiedMatch ? "Скопировано" : "Скопировать матч" }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sakai-signal-card__market">
|
||||
<span>{{ signal.marketType }}</span>
|
||||
<span>{{ signal.selection }}</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="sakai-signal-card__side">
|
||||
<div v-if="forecastImageUrl || forecast" class="sakai-signal-card__forecast">
|
||||
<img
|
||||
v-if="shouldShowForecastImage"
|
||||
:src="forecastImageUrl"
|
||||
:alt="forecast || `${signal.homeTeam} - ${signal.awayTeam}`"
|
||||
class="sakai-signal-card__forecast-image"
|
||||
loading="lazy"
|
||||
@error="forecastImageFailed = true"
|
||||
>
|
||||
<p v-else-if="forecast" class="sakai-signal-card__forecast-text">
|
||||
{{ forecast }}
|
||||
</p>
|
||||
<p v-else class="sakai-signal-card__forecast-text sakai-signal-card__forecast-text--empty">
|
||||
Прогноз недоступен
|
||||
</p>
|
||||
</div>
|
||||
<Tag class="rounded" :value="displayStatusLabel" :severity="statusSeverity" />
|
||||
<div class="sakai-signal-card__odds">{{ signal.odds.toFixed(2) }}</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
91
frontend/composables/useApi.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
type ApiErrorPayload = {
|
||||
message?: string;
|
||||
issues?: {
|
||||
formErrors?: string[];
|
||||
fieldErrors?: Record<string, string[] | undefined>;
|
||||
};
|
||||
};
|
||||
|
||||
type ApiLikeError = Error & {
|
||||
statusCode?: number;
|
||||
status?: number;
|
||||
data?: ApiErrorPayload;
|
||||
};
|
||||
|
||||
const apiFieldLabels: Record<string, string> = {
|
||||
email: "Email",
|
||||
password: "Пароль",
|
||||
confirmPassword: "Повтор пароля",
|
||||
title: "Заголовок",
|
||||
body: "Текст",
|
||||
status: "Статус",
|
||||
startsAt: "Начало",
|
||||
expiresAt: "Окончание"
|
||||
};
|
||||
|
||||
function getApiFieldLabel(field: string) {
|
||||
return apiFieldLabels[field] ?? field;
|
||||
}
|
||||
|
||||
function getApiErrorMessage(error: ApiLikeError) {
|
||||
const formErrors = Array.isArray(error.data?.issues?.formErrors)
|
||||
? error.data!.issues!.formErrors.filter((entry) => typeof entry === "string" && entry.trim())
|
||||
: [];
|
||||
|
||||
if (formErrors.length > 0) {
|
||||
return formErrors.join("\n");
|
||||
}
|
||||
|
||||
const fieldErrors = error.data?.issues?.fieldErrors;
|
||||
if (fieldErrors && typeof fieldErrors === "object") {
|
||||
const messages = Object.entries(fieldErrors)
|
||||
.flatMap(([field, value]) =>
|
||||
(Array.isArray(value) ? value : [])
|
||||
.filter((entry) => typeof entry === "string" && entry.trim())
|
||||
.map((entry) => `${getApiFieldLabel(field)}: ${entry}`)
|
||||
);
|
||||
|
||||
if (messages.length > 0) {
|
||||
return messages.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
const responseMessage = typeof error.data?.message === "string" ? error.data.message.trim() : "";
|
||||
if (responseMessage) {
|
||||
return responseMessage;
|
||||
}
|
||||
|
||||
const statusCode = error.statusCode ?? error.status;
|
||||
|
||||
if (statusCode === 400) return "Проверьте правильность заполнения формы";
|
||||
if (statusCode === 401) return "Неверный логин или пароль";
|
||||
if (statusCode === 403) return "Недостаточно прав для выполнения действия";
|
||||
if (statusCode === 404) return "Запрашиваемые данные не найдены";
|
||||
if (statusCode === 409) return "Такая запись уже существует";
|
||||
if (statusCode === 422) return "Не удалось обработать введённые данные";
|
||||
if (statusCode && statusCode >= 500) return "Ошибка сервера. Попробуйте ещё раз позже";
|
||||
|
||||
return error.message || "Не удалось выполнить запрос";
|
||||
}
|
||||
|
||||
export function useApi<T>(path: string, options: Parameters<typeof $fetch<T>>[1] = {}) {
|
||||
const config = useRuntimeConfig();
|
||||
const token = useState<string | null>("auth-token", () => null);
|
||||
const baseUrl = process.server ? config.apiBaseInternal : config.public.apiBase;
|
||||
|
||||
const headers = new Headers(options.headers as HeadersInit | undefined);
|
||||
if (!headers.has("Content-Type") && options.method && options.method !== "GET") {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (token.value) {
|
||||
headers.set("Authorization", `Bearer ${token.value}`);
|
||||
}
|
||||
|
||||
return $fetch<T>(`${baseUrl}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: "include"
|
||||
}).catch((error: ApiLikeError) => {
|
||||
throw new Error(getApiErrorMessage(error));
|
||||
});
|
||||
}
|
||||
92
frontend/composables/useAuth.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { Preferences } from "@capacitor/preferences";
|
||||
import type { User } from "~/types";
|
||||
|
||||
export function useAuth() {
|
||||
const user = useState<User | null>("auth-user", () => null);
|
||||
const token = useState<string | null>("auth-token", () => null);
|
||||
const loading = useState<boolean>("auth-loading", () => true);
|
||||
|
||||
const isNativeApp = () => process.client && Capacitor.isNativePlatform();
|
||||
const webTokenStorageKey = "auth-token";
|
||||
|
||||
const persistToken = async (nextToken: string | null) => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
if (nextToken) {
|
||||
await Preferences.set({ key: webTokenStorageKey, value: nextToken });
|
||||
return;
|
||||
}
|
||||
|
||||
await Preferences.remove({ key: webTokenStorageKey });
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextToken) {
|
||||
window.localStorage.setItem(webTokenStorageKey, nextToken);
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(webTokenStorageKey);
|
||||
};
|
||||
|
||||
const restoreToken = async () => {
|
||||
if (!process.client || token.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
const storedToken = await Preferences.get({ key: webTokenStorageKey });
|
||||
token.value = storedToken.value;
|
||||
return;
|
||||
}
|
||||
|
||||
token.value = window.localStorage.getItem(webTokenStorageKey);
|
||||
};
|
||||
|
||||
const setSession = async (nextToken: string | null, nextUser: User | null) => {
|
||||
token.value = nextToken;
|
||||
user.value = nextUser;
|
||||
await persistToken(nextToken);
|
||||
};
|
||||
|
||||
const refreshMe = async () => {
|
||||
await restoreToken();
|
||||
|
||||
try {
|
||||
const me = await useApi<User>("/auth/me");
|
||||
user.value = me;
|
||||
} catch {
|
||||
await setSession(null, null);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (nextToken: string | null, nextUser: User) => {
|
||||
loading.value = false;
|
||||
await setSession(nextToken, nextUser);
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
const { deactivateCurrentPushSubscription, syncServiceWorkerContext } = usePush();
|
||||
|
||||
await deactivateCurrentPushSubscription().catch(() => undefined);
|
||||
await useApi("/auth/logout", { method: "POST" }).catch(() => undefined);
|
||||
await setSession(null, null);
|
||||
await syncServiceWorkerContext().catch(() => undefined);
|
||||
await navigateTo("/login");
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
refreshMe
|
||||
};
|
||||
}
|
||||
34
frontend/composables/useBrowserDateTime.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
type BrowserDateTimeOptions = Intl.DateTimeFormatOptions;
|
||||
|
||||
const DEFAULT_DATE_TIME_OPTIONS: BrowserDateTimeOptions = {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
};
|
||||
|
||||
export function useBrowserDateTime() {
|
||||
const browserTimeZone = useState<string | null>("browser-time-zone", () => null);
|
||||
|
||||
onMounted(() => {
|
||||
browserTimeZone.value = Intl.DateTimeFormat().resolvedOptions().timeZone || null;
|
||||
});
|
||||
|
||||
const formatDateTime = (value: string | Date | null | undefined, options: BrowserDateTimeOptions = DEFAULT_DATE_TIME_OPTIONS) => {
|
||||
if (!value) return "—";
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
...options,
|
||||
timeZone: browserTimeZone.value ?? "UTC"
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return {
|
||||
browserTimeZone,
|
||||
formatDateTime
|
||||
};
|
||||
}
|
||||
25
frontend/composables/useChatApi.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export function useChatApi<T>(path: string, options: Parameters<typeof $fetch<T>>[1] = {}) {
|
||||
const config = useRuntimeConfig();
|
||||
const { token, user } = useAuth();
|
||||
const headers = new Headers(options.headers as HeadersInit | undefined);
|
||||
|
||||
if (!headers.has("Content-Type") && options.method && options.method !== "GET") {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
if (token.value) {
|
||||
headers.set("Authorization", `Bearer ${token.value}`);
|
||||
}
|
||||
|
||||
if (user.value?.email) {
|
||||
headers.set("x-user-email", user.value.email);
|
||||
}
|
||||
|
||||
return $fetch<T>(`${config.public.chatApiBase}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: "include"
|
||||
}).catch((error: Error) => {
|
||||
throw new Error(error.message || "Не удалось выполнить запрос к чату");
|
||||
});
|
||||
}
|
||||
45
frontend/composables/useClipboard.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export const useClipboard = () => {
|
||||
const fallbackCopy = (value: string) => {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = value;
|
||||
textarea.setAttribute("readonly", "true");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "-9999px";
|
||||
textarea.style.left = "-9999px";
|
||||
textarea.style.opacity = "0";
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
return document.execCommand("copy");
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
};
|
||||
|
||||
const copyText = async (value: string) => {
|
||||
if (!process.client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(normalized);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to legacy copy API for embedded webviews.
|
||||
}
|
||||
|
||||
return fallbackCopy(normalized);
|
||||
};
|
||||
|
||||
return { copyText };
|
||||
};
|
||||
784
frontend/composables/usePush.ts
Normal file
@@ -0,0 +1,784 @@
|
||||
import { App as CapacitorApp } from "@capacitor/app";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { Device } from "@capacitor/device";
|
||||
import { Preferences } from "@capacitor/preferences";
|
||||
import { PushNotifications, type Token } from "@capacitor/push-notifications";
|
||||
|
||||
type PushSubscriptionsResponse = {
|
||||
items: Array<{
|
||||
id: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
endpoint?: string;
|
||||
platform?: string;
|
||||
}>;
|
||||
hasActiveSubscription: boolean;
|
||||
};
|
||||
|
||||
type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
|
||||
};
|
||||
|
||||
type NotificationPermissionState = "default" | "denied" | "granted" | "prompt" | "unsupported";
|
||||
|
||||
const ANON_PUSH_CLIENT_ID_KEY = "anonymous-push-client-id";
|
||||
const PENDING_PUSH_ROUTE_KEY = "pending-push-route";
|
||||
|
||||
export function usePush() {
|
||||
const { user, token } = useAuth();
|
||||
const config = useRuntimeConfig();
|
||||
const installPromptEvent = useState<BeforeInstallPromptEvent | null>("pwa-install-prompt-event", () => null);
|
||||
const anonymousClientId = useState<string | null>("anonymous-push-client-id", () => null);
|
||||
const nativePushToken = useState<string | null>("native-push-token", () => null);
|
||||
const nativePushDeviceId = useState<string | null>("native-push-device-id", () => null);
|
||||
const nativePushInitialized = useState<boolean>("native-push-initialized", () => false);
|
||||
const webPushClickInitialized = useState<boolean>("web-push-click-initialized", () => false);
|
||||
const pendingPushRoute = useState<string | null>("pending-push-route", () => null);
|
||||
|
||||
const isNativeApp = () => process.client && Capacitor.isNativePlatform();
|
||||
|
||||
const getNativePlatform = () => {
|
||||
if (!isNativeApp()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const platform = Capacitor.getPlatform();
|
||||
if (platform === "android" || platform === "ios") {
|
||||
return platform;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const readStoredAnonymousClientId = async () => {
|
||||
if (!process.client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
const result = await Preferences.get({ key: ANON_PUSH_CLIENT_ID_KEY });
|
||||
return result.value;
|
||||
}
|
||||
|
||||
return localStorage.getItem(ANON_PUSH_CLIENT_ID_KEY);
|
||||
};
|
||||
|
||||
const persistAnonymousClientId = async (value: string) => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
await Preferences.set({ key: ANON_PUSH_CLIENT_ID_KEY, value });
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(ANON_PUSH_CLIENT_ID_KEY, value);
|
||||
};
|
||||
|
||||
const clearAnonymousClientId = async () => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
anonymousClientId.value = null;
|
||||
|
||||
if (isNativeApp()) {
|
||||
await Preferences.remove({ key: ANON_PUSH_CLIENT_ID_KEY });
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(ANON_PUSH_CLIENT_ID_KEY);
|
||||
};
|
||||
|
||||
const readStoredPendingPushRoute = async () => {
|
||||
if (!process.client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
const result = await Preferences.get({ key: PENDING_PUSH_ROUTE_KEY });
|
||||
return result.value;
|
||||
}
|
||||
|
||||
return localStorage.getItem(PENDING_PUSH_ROUTE_KEY);
|
||||
};
|
||||
|
||||
const persistPendingPushRoute = async (value: string | null) => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingPushRoute.value = value;
|
||||
|
||||
if (isNativeApp()) {
|
||||
if (value) {
|
||||
await Preferences.set({ key: PENDING_PUSH_ROUTE_KEY, value });
|
||||
} else {
|
||||
await Preferences.remove({ key: PENDING_PUSH_ROUTE_KEY });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
localStorage.setItem(PENDING_PUSH_ROUTE_KEY, value);
|
||||
} else {
|
||||
localStorage.removeItem(PENDING_PUSH_ROUTE_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeTargetRoute = (targetUrl: unknown) => {
|
||||
if (typeof targetUrl !== "string" || targetUrl.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (targetUrl.startsWith("/")) {
|
||||
return targetUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(targetUrl);
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}` || "/";
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const openPushTarget = async (targetUrl: unknown) => {
|
||||
const route = normalizeTargetRoute(targetUrl);
|
||||
if (!route) {
|
||||
return;
|
||||
}
|
||||
|
||||
await persistPendingPushRoute(route);
|
||||
|
||||
if (user.value) {
|
||||
await navigateTo(route);
|
||||
await persistPendingPushRoute(null);
|
||||
return;
|
||||
}
|
||||
|
||||
await navigateTo(`/login?redirect=${encodeURIComponent(route)}`);
|
||||
};
|
||||
|
||||
const consumePendingPushRoute = async () => {
|
||||
if (!process.client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!pendingPushRoute.value) {
|
||||
pendingPushRoute.value = await readStoredPendingPushRoute();
|
||||
}
|
||||
|
||||
const route = pendingPushRoute.value;
|
||||
if (!route || !user.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await navigateTo(route);
|
||||
await persistPendingPushRoute(null);
|
||||
return route;
|
||||
};
|
||||
|
||||
const ensureAnonymousClientId = async () => {
|
||||
if (!process.client) {
|
||||
return "server-client";
|
||||
}
|
||||
|
||||
if (!anonymousClientId.value) {
|
||||
anonymousClientId.value = await readStoredAnonymousClientId();
|
||||
}
|
||||
|
||||
if (!anonymousClientId.value) {
|
||||
anonymousClientId.value = crypto.randomUUID().replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
await persistAnonymousClientId(anonymousClientId.value);
|
||||
}
|
||||
|
||||
return anonymousClientId.value;
|
||||
};
|
||||
|
||||
const registerServiceWorker = async () => {
|
||||
if (!process.client || isNativeApp() || !("serviceWorker" in navigator)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return navigator.serviceWorker.register("/sw.js");
|
||||
};
|
||||
|
||||
const syncServiceWorkerContext = async () => {
|
||||
if (!process.client || isNativeApp() || !("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = await registerServiceWorker();
|
||||
const readyRegistration = registration ?? (await navigator.serviceWorker.ready);
|
||||
const worker = readyRegistration.active ?? navigator.serviceWorker.controller;
|
||||
if (!worker) {
|
||||
return;
|
||||
}
|
||||
|
||||
worker.postMessage({
|
||||
type: "push-context-sync",
|
||||
payload: {
|
||||
apiBase: config.public.apiBase,
|
||||
isAuthenticated: Boolean(user.value),
|
||||
anonymousClientId: await ensureAnonymousClientId()
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const urlBase64ToUint8Array = (base64String: string) => {
|
||||
const normalizedInput = base64String.trim();
|
||||
if (!normalizedInput || normalizedInput.startsWith("replace_")) {
|
||||
throw new Error("Push-уведомления еще не настроены на сервере");
|
||||
}
|
||||
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (normalizedInput + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
try {
|
||||
const rawData = window.atob(base64);
|
||||
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
|
||||
} catch {
|
||||
throw new Error("На сервере задан некорректный VAPID public key");
|
||||
}
|
||||
};
|
||||
|
||||
const isMobileDevice = () => {
|
||||
if (!process.client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
};
|
||||
|
||||
const isIosDevice = () => {
|
||||
if (!process.client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
return Capacitor.getPlatform() === "ios";
|
||||
}
|
||||
|
||||
return /iPhone|iPad|iPod/i.test(navigator.userAgent);
|
||||
};
|
||||
|
||||
const isStandaloneMode = () => {
|
||||
if (!process.client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const standaloneMedia = window.matchMedia?.("(display-mode: standalone)")?.matches ?? false;
|
||||
const standaloneNavigator =
|
||||
"standalone" in navigator ? Boolean((navigator as Navigator & { standalone?: boolean }).standalone) : false;
|
||||
return standaloneMedia || standaloneNavigator;
|
||||
};
|
||||
|
||||
const setInstallPromptEvent = (event: BeforeInstallPromptEvent | null) => {
|
||||
installPromptEvent.value = event;
|
||||
};
|
||||
|
||||
const canTriggerInstallPrompt = () => !isNativeApp() && Boolean(installPromptEvent.value);
|
||||
|
||||
const triggerInstallPrompt = async () => {
|
||||
if (!installPromptEvent.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await installPromptEvent.value.prompt();
|
||||
const choice = await installPromptEvent.value.userChoice;
|
||||
installPromptEvent.value = null;
|
||||
return choice.outcome === "accepted";
|
||||
};
|
||||
|
||||
const getBrowserPermissionState = (): NotificationPermissionState => {
|
||||
if (!process.client || !("Notification" in window)) {
|
||||
return "unsupported";
|
||||
}
|
||||
|
||||
return Notification.permission;
|
||||
};
|
||||
|
||||
const getNativePermissionState = async (): Promise<NotificationPermissionState> => {
|
||||
if (!isNativeApp()) {
|
||||
return "unsupported";
|
||||
}
|
||||
|
||||
const permissions = await PushNotifications.checkPermissions();
|
||||
if (permissions.receive === "prompt") {
|
||||
return "default";
|
||||
}
|
||||
|
||||
return permissions.receive;
|
||||
};
|
||||
|
||||
const getPermissionState = () => {
|
||||
if (isNativeApp()) {
|
||||
return getNativePermissionState();
|
||||
}
|
||||
|
||||
return Promise.resolve(getBrowserPermissionState());
|
||||
};
|
||||
|
||||
const requestBrowserPermission = async (): Promise<NotificationPermissionState> => {
|
||||
if (!process.client || !("Notification" in window)) {
|
||||
return "unsupported";
|
||||
}
|
||||
|
||||
return Notification.requestPermission();
|
||||
};
|
||||
|
||||
const requestNativePermission = async (): Promise<NotificationPermissionState> => {
|
||||
if (!isNativeApp()) {
|
||||
return "unsupported";
|
||||
}
|
||||
|
||||
const permissions = await PushNotifications.requestPermissions();
|
||||
if (permissions.receive === "prompt") {
|
||||
return "default";
|
||||
}
|
||||
|
||||
return permissions.receive;
|
||||
};
|
||||
|
||||
const requestPermission = () => {
|
||||
if (isNativeApp()) {
|
||||
return requestNativePermission();
|
||||
}
|
||||
|
||||
return requestBrowserPermission();
|
||||
};
|
||||
|
||||
const syncWebSubscription = async (subscription: PushSubscription) => {
|
||||
const serializedSubscription = subscription.toJSON();
|
||||
if (!serializedSubscription.endpoint || !serializedSubscription.keys?.p256dh || !serializedSubscription.keys?.auth) {
|
||||
throw new Error("Браузер вернул неполную push-подписку");
|
||||
}
|
||||
|
||||
if (user.value) {
|
||||
await useApi("/me/push-subscriptions", {
|
||||
method: "POST",
|
||||
body: serializedSubscription
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await useApi("/public/push-subscriptions", {
|
||||
method: "POST",
|
||||
body: {
|
||||
clientId: await ensureAnonymousClientId(),
|
||||
...serializedSubscription
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const syncNativeSubscription = async (tokenValue = nativePushToken.value) => {
|
||||
const platform = getNativePlatform();
|
||||
if (!platform || !tokenValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nativePushDeviceId.value) {
|
||||
const device = await Device.getId();
|
||||
nativePushDeviceId.value = device.identifier;
|
||||
}
|
||||
|
||||
const body = {
|
||||
token: tokenValue,
|
||||
platform,
|
||||
deviceId: nativePushDeviceId.value ?? undefined
|
||||
};
|
||||
|
||||
if (user.value) {
|
||||
await useApi("/me/native-push-subscriptions", {
|
||||
method: "POST",
|
||||
body
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await useApi("/public/native-push-subscriptions", {
|
||||
method: "POST",
|
||||
body: {
|
||||
clientId: await ensureAnonymousClientId(),
|
||||
...body
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const initializeNativePush = async () => {
|
||||
if (!isNativeApp() || nativePushInitialized.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
nativePushInitialized.value = true;
|
||||
|
||||
await PushNotifications.createChannel({
|
||||
id: "signals",
|
||||
name: "Signals",
|
||||
description: "Уведомления о новых сигналах",
|
||||
importance: 5,
|
||||
visibility: 1,
|
||||
sound: "default"
|
||||
}).catch(() => undefined);
|
||||
|
||||
await PushNotifications.addListener("registration", async (registrationToken: Token) => {
|
||||
nativePushToken.value = registrationToken.value;
|
||||
await syncNativeSubscription(registrationToken.value);
|
||||
});
|
||||
|
||||
await PushNotifications.addListener("registrationError", (error) => {
|
||||
console.error("Native push registration failed", error);
|
||||
});
|
||||
|
||||
await PushNotifications.addListener("pushNotificationActionPerformed", (notification) => {
|
||||
const targetUrl =
|
||||
notification.notification.data?.url ||
|
||||
notification.notification.data?.link ||
|
||||
notification.notification.data?.path;
|
||||
|
||||
void openPushTarget(targetUrl);
|
||||
});
|
||||
|
||||
await CapacitorApp.addListener("appUrlOpen", ({ url }) => {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const route = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
void openPushTarget(route || "/");
|
||||
} catch {
|
||||
// Ignore invalid deep links.
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const initializeWebPushRouting = () => {
|
||||
if (!process.client || isNativeApp() || webPushClickInitialized.value || !("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
webPushClickInitialized.value = true;
|
||||
|
||||
navigator.serviceWorker.addEventListener("message", (event) => {
|
||||
if (event.data?.type !== "push-notification-click") {
|
||||
return;
|
||||
}
|
||||
|
||||
void openPushTarget(event.data.url);
|
||||
});
|
||||
};
|
||||
|
||||
const getCurrentBrowserSubscription = async () => {
|
||||
const registration = await registerServiceWorker();
|
||||
if (!registration || !("PushManager" in window)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return registration.pushManager.getSubscription();
|
||||
};
|
||||
|
||||
const claimAnonymousPushSubscriptions = async () => {
|
||||
if (!process.client || !user.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingClientId = anonymousClientId.value ?? (await readStoredAnonymousClientId());
|
||||
if (!existingClientId) {
|
||||
await syncServiceWorkerContext();
|
||||
return;
|
||||
}
|
||||
|
||||
anonymousClientId.value = existingClientId;
|
||||
|
||||
if (isNativeApp()) {
|
||||
if (nativePushToken.value) {
|
||||
await syncNativeSubscription(nativePushToken.value);
|
||||
}
|
||||
|
||||
const anonymousSubscriptions = await useApi<PushSubscriptionsResponse>(
|
||||
`/public/native-push-subscriptions/${existingClientId}`
|
||||
).catch(() => null);
|
||||
|
||||
for (const subscription of anonymousSubscriptions?.items ?? []) {
|
||||
await useApi(`/public/native-push-subscriptions/${existingClientId}/${subscription.id}`, {
|
||||
method: "DELETE"
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
} else {
|
||||
const currentSubscription = await getCurrentBrowserSubscription();
|
||||
if (currentSubscription) {
|
||||
await syncWebSubscription(currentSubscription);
|
||||
}
|
||||
|
||||
const anonymousSubscriptions = await useApi<PushSubscriptionsResponse>(
|
||||
`/public/push-subscriptions/${existingClientId}`
|
||||
).catch(() => null);
|
||||
|
||||
for (const subscription of anonymousSubscriptions?.items ?? []) {
|
||||
await useApi(`/public/push-subscriptions/${existingClientId}/${subscription.id}`, {
|
||||
method: "DELETE"
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
await clearAnonymousClientId();
|
||||
await syncServiceWorkerContext();
|
||||
};
|
||||
|
||||
const getCurrentSubscription = async () => {
|
||||
if (isNativeApp()) {
|
||||
return nativePushToken.value;
|
||||
}
|
||||
|
||||
return getCurrentBrowserSubscription();
|
||||
};
|
||||
|
||||
const deactivateCurrentPushSubscription = async () => {
|
||||
if (!process.client || !user.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
if (!nativePushToken.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await useApi("/me/native-push-subscriptions/deactivate", {
|
||||
method: "POST",
|
||||
body: {
|
||||
token: nativePushToken.value
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSubscription = await getCurrentBrowserSubscription();
|
||||
if (!currentSubscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serializedSubscription = currentSubscription.toJSON();
|
||||
if (!serializedSubscription.endpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
await useApi("/me/push-subscriptions/deactivate", {
|
||||
method: "POST",
|
||||
body: {
|
||||
endpoint: serializedSubscription.endpoint
|
||||
}
|
||||
});
|
||||
|
||||
await currentSubscription.unsubscribe().catch(() => undefined);
|
||||
};
|
||||
|
||||
const subscribeToBrowserPush = async () => {
|
||||
const registration = await registerServiceWorker();
|
||||
if (!registration || !("PushManager" in window)) {
|
||||
throw new Error("Push не поддерживается в этом браузере");
|
||||
}
|
||||
|
||||
const vapid = await useApi<{ publicKey: string }>("/vapid-public-key");
|
||||
if (!vapid.publicKey || vapid.publicKey.startsWith("replace_")) {
|
||||
throw new Error("Push-уведомления еще не настроены на сервере");
|
||||
}
|
||||
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapid.publicKey)
|
||||
});
|
||||
|
||||
await syncWebSubscription(subscription);
|
||||
return subscription;
|
||||
};
|
||||
|
||||
const subscribeToNativePush = async () => {
|
||||
await initializeNativePush();
|
||||
|
||||
let permission = await getNativePermissionState();
|
||||
if (permission === "default") {
|
||||
permission = await requestNativePermission();
|
||||
}
|
||||
|
||||
if (permission !== "granted") {
|
||||
throw new Error("Разрешение на уведомления не выдано");
|
||||
}
|
||||
|
||||
const device = await Device.getId();
|
||||
nativePushDeviceId.value = device.identifier;
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let finished = false;
|
||||
|
||||
const finalize = (callback: () => void) => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
callback();
|
||||
};
|
||||
|
||||
PushNotifications.addListener("registration", async (registrationToken: Token) => {
|
||||
finalize(() => {
|
||||
nativePushToken.value = registrationToken.value;
|
||||
void syncNativeSubscription(registrationToken.value);
|
||||
resolve(registrationToken.value);
|
||||
});
|
||||
}).catch(reject);
|
||||
|
||||
PushNotifications.addListener("registrationError", (error) => {
|
||||
finalize(() => {
|
||||
reject(new Error(error.error || "Не удалось зарегистрировать устройство для push"));
|
||||
});
|
||||
}).catch(reject);
|
||||
|
||||
PushNotifications.register().catch((error) => {
|
||||
finalize(() => {
|
||||
reject(error instanceof Error ? error : new Error("Не удалось зарегистрировать устройство для push"));
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const subscribeToPush = () => {
|
||||
if (isNativeApp()) {
|
||||
return subscribeToNativePush();
|
||||
}
|
||||
|
||||
return subscribeToBrowserPush();
|
||||
};
|
||||
|
||||
const ensurePushSubscription = async () => {
|
||||
const permission = await getPermissionState();
|
||||
if (permission === "unsupported") {
|
||||
throw new Error("Push не поддерживается на этом устройстве");
|
||||
}
|
||||
|
||||
let finalPermission = permission;
|
||||
if (finalPermission === "default") {
|
||||
finalPermission = await requestPermission();
|
||||
}
|
||||
|
||||
if (finalPermission !== "granted") {
|
||||
throw new Error("Разрешение на уведомления не выдано");
|
||||
}
|
||||
|
||||
if (isNativeApp()) {
|
||||
if (nativePushToken.value) {
|
||||
await syncNativeSubscription(nativePushToken.value);
|
||||
return nativePushToken.value;
|
||||
}
|
||||
|
||||
return subscribeToNativePush();
|
||||
}
|
||||
|
||||
const currentSubscription = await getCurrentBrowserSubscription();
|
||||
if (currentSubscription) {
|
||||
await syncWebSubscription(currentSubscription);
|
||||
return currentSubscription;
|
||||
}
|
||||
|
||||
return subscribeToBrowserPush();
|
||||
};
|
||||
|
||||
const getPushStatus = async () => {
|
||||
const permission = await getPermissionState();
|
||||
const isMobile = isMobileDevice();
|
||||
const isIos = isIosDevice();
|
||||
const isStandalone = isStandaloneMode();
|
||||
const installRequired = !isNativeApp() && isMobile && !isStandalone;
|
||||
|
||||
if (permission === "unsupported") {
|
||||
return {
|
||||
supported: false,
|
||||
permission,
|
||||
isMobile,
|
||||
isIos,
|
||||
isStandalone,
|
||||
installRequired,
|
||||
canInstall: canTriggerInstallPrompt(),
|
||||
hasBrowserSubscription: false,
|
||||
hasServerSubscription: false
|
||||
};
|
||||
}
|
||||
|
||||
let hasServerSubscription = false;
|
||||
|
||||
try {
|
||||
if (isNativeApp()) {
|
||||
if (user.value) {
|
||||
const serverSubscriptions = await useApi<PushSubscriptionsResponse>("/me/native-push-subscriptions");
|
||||
hasServerSubscription = serverSubscriptions.hasActiveSubscription;
|
||||
} else {
|
||||
const clientId = await ensureAnonymousClientId();
|
||||
const serverSubscriptions = await useApi<PushSubscriptionsResponse>(`/public/native-push-subscriptions/${clientId}`);
|
||||
hasServerSubscription = serverSubscriptions.hasActiveSubscription;
|
||||
}
|
||||
} else if (user.value) {
|
||||
const serverSubscriptions = await useApi<PushSubscriptionsResponse>("/me/push-subscriptions");
|
||||
hasServerSubscription = serverSubscriptions.hasActiveSubscription;
|
||||
} else {
|
||||
const clientId = await ensureAnonymousClientId();
|
||||
const serverSubscriptions = await useApi<PushSubscriptionsResponse>(`/public/push-subscriptions/${clientId}`);
|
||||
hasServerSubscription = serverSubscriptions.hasActiveSubscription;
|
||||
}
|
||||
} catch {
|
||||
hasServerSubscription = false;
|
||||
}
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
permission,
|
||||
isMobile,
|
||||
isIos,
|
||||
isStandalone,
|
||||
installRequired,
|
||||
canInstall: canTriggerInstallPrompt(),
|
||||
hasBrowserSubscription: isNativeApp()
|
||||
? Boolean(nativePushToken.value)
|
||||
: Boolean(await getCurrentBrowserSubscription()),
|
||||
hasServerSubscription
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
registerServiceWorker,
|
||||
syncServiceWorkerContext,
|
||||
syncNativeSubscription,
|
||||
initializeNativePush,
|
||||
initializeWebPushRouting,
|
||||
isNativeApp,
|
||||
isMobileDevice,
|
||||
isIosDevice,
|
||||
isStandaloneMode,
|
||||
getPermissionState,
|
||||
requestPermission,
|
||||
getCurrentSubscription,
|
||||
deactivateCurrentPushSubscription,
|
||||
getPushStatus,
|
||||
subscribeToPush,
|
||||
ensurePushSubscription,
|
||||
claimAnonymousPushSubscriptions,
|
||||
consumePendingPushRoute,
|
||||
setInstallPromptEvent,
|
||||
canTriggerInstallPrompt,
|
||||
triggerInstallPrompt,
|
||||
ensureAnonymousClientId
|
||||
};
|
||||
}
|
||||
70
frontend/composables/useSupportRealtime.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { io, type Socket } from "socket.io-client";
|
||||
import type { SupportConversation, SupportMessage } from "~/types";
|
||||
|
||||
type SupportRealtimePayload = {
|
||||
conversation: SupportConversation;
|
||||
message?: SupportMessage;
|
||||
};
|
||||
|
||||
export function useSupportRealtime() {
|
||||
const socket = useState<Socket | null>("support-realtime-socket", () => null);
|
||||
const { token } = useAuth();
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const connect = () => {
|
||||
if (!process.client || !token.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (socket.value?.connected) {
|
||||
return socket.value;
|
||||
}
|
||||
|
||||
if (socket.value) {
|
||||
socket.value.auth = { token: token.value };
|
||||
socket.value.connect();
|
||||
return socket.value;
|
||||
}
|
||||
|
||||
const nextSocket = io(config.public.chatApiBase, {
|
||||
transports: ["websocket", "polling"],
|
||||
withCredentials: true,
|
||||
auth: {
|
||||
token: token.value
|
||||
}
|
||||
});
|
||||
|
||||
socket.value = nextSocket;
|
||||
return nextSocket;
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
socket.value?.disconnect();
|
||||
};
|
||||
|
||||
const onConversationUpdated = (handler: (payload: SupportRealtimePayload) => void) => {
|
||||
const activeSocket = connect();
|
||||
activeSocket?.on("support:conversation.updated", handler);
|
||||
|
||||
return () => {
|
||||
activeSocket?.off("support:conversation.updated", handler);
|
||||
};
|
||||
};
|
||||
|
||||
const onMessageCreated = (handler: (payload: SupportRealtimePayload) => void) => {
|
||||
const activeSocket = connect();
|
||||
activeSocket?.on("support:message.created", handler);
|
||||
|
||||
return () => {
|
||||
activeSocket?.off("support:message.created", handler);
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
socket,
|
||||
connect,
|
||||
disconnect,
|
||||
onConversationUpdated,
|
||||
onMessageCreated
|
||||
};
|
||||
}
|
||||
106
frontend/composables/useSupportUnread.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { SupportConversation } from "~/types";
|
||||
|
||||
const toConversationMap = (items: SupportConversation[]) =>
|
||||
Object.fromEntries(items.map((item) => [item.id, item])) as Record<string, SupportConversation>;
|
||||
|
||||
export function useSupportUnread() {
|
||||
const { user, token } = useAuth();
|
||||
const { connect, onConversationUpdated, onMessageCreated } = useSupportRealtime();
|
||||
|
||||
const conversations = useState<Record<string, SupportConversation>>("support-unread-conversations", () => ({}));
|
||||
const unreadCount = useState<number>("support-unread-count", () => 0);
|
||||
const initialized = useState<boolean>("support-unread-initialized", () => false);
|
||||
const loading = useState<boolean>("support-unread-loading", () => false);
|
||||
const offConversationUpdated = useState<(() => void) | null>("support-unread-off-conversation", () => null);
|
||||
const offMessageCreated = useState<(() => void) | null>("support-unread-off-message", () => null);
|
||||
|
||||
const recomputeUnreadCount = () => {
|
||||
const items = Object.values(conversations.value);
|
||||
|
||||
unreadCount.value = user.value?.role === "admin"
|
||||
? items.filter((item) => item.unreadForAdmin).length
|
||||
: items.some((item) => item.unreadForUser) ? 1 : 0;
|
||||
};
|
||||
|
||||
const replaceConversations = (items: SupportConversation[]) => {
|
||||
conversations.value = toConversationMap(items);
|
||||
recomputeUnreadCount();
|
||||
};
|
||||
|
||||
const upsertConversation = (item: SupportConversation) => {
|
||||
conversations.value = {
|
||||
...conversations.value,
|
||||
[item.id]: item
|
||||
};
|
||||
recomputeUnreadCount();
|
||||
};
|
||||
|
||||
const clearState = () => {
|
||||
conversations.value = {};
|
||||
unreadCount.value = 0;
|
||||
initialized.value = false;
|
||||
offConversationUpdated.value?.();
|
||||
offConversationUpdated.value = null;
|
||||
offMessageCreated.value?.();
|
||||
offMessageCreated.value = null;
|
||||
};
|
||||
|
||||
const refreshUnread = async () => {
|
||||
if (!user.value || !token.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
if (user.value.role === "admin") {
|
||||
const response = await useChatApi<SupportConversation[]>("/admin/support/conversations");
|
||||
replaceConversations(response);
|
||||
} else {
|
||||
const response = await useChatApi<SupportConversation>("/support/conversation");
|
||||
replaceConversations([response]);
|
||||
}
|
||||
} catch {
|
||||
// Ignore unread refresh failures in shell navigation.
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureRealtime = () => {
|
||||
if (!process.client || !token.value || offConversationUpdated.value || offMessageCreated.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
connect();
|
||||
offConversationUpdated.value = onConversationUpdated(({ conversation }) => {
|
||||
if (conversation) {
|
||||
upsertConversation(conversation);
|
||||
}
|
||||
});
|
||||
offMessageCreated.value = onMessageCreated(({ conversation }) => {
|
||||
if (conversation) {
|
||||
upsertConversation(conversation);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const initializeUnread = async () => {
|
||||
if (initialized.value || !user.value || !token.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshUnread();
|
||||
ensureRealtime();
|
||||
initialized.value = true;
|
||||
};
|
||||
|
||||
return {
|
||||
unreadCount,
|
||||
initializeUnread,
|
||||
refreshUnread,
|
||||
replaceConversations,
|
||||
upsertConversation,
|
||||
clearState
|
||||
};
|
||||
}
|
||||
66
frontend/composables/useTheme.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
type ThemeMode = "light" | "dark";
|
||||
|
||||
const STORAGE_KEY = "signals-theme";
|
||||
|
||||
const themes: Record<ThemeMode, { background: string; surface: string }> = {
|
||||
light: {
|
||||
background: "#f3f6fb",
|
||||
surface: "#ffffff"
|
||||
},
|
||||
dark: {
|
||||
background: "#0f172a",
|
||||
surface: "#162033"
|
||||
}
|
||||
};
|
||||
|
||||
export const useTheme = () => {
|
||||
const theme = useState<ThemeMode>("theme-mode", () => "light");
|
||||
const initialized = useState("theme-initialized", () => false);
|
||||
|
||||
const applyTheme = (nextTheme: ThemeMode) => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
theme.value = nextTheme;
|
||||
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = nextTheme;
|
||||
root.style.colorScheme = nextTheme;
|
||||
root.classList.toggle("app-dark", nextTheme === "dark");
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, nextTheme);
|
||||
|
||||
const themeMeta = document.querySelector('meta[name="theme-color"]');
|
||||
const themeColor = themes[nextTheme].surface;
|
||||
|
||||
if (themeMeta) {
|
||||
themeMeta.setAttribute("content", themeColor);
|
||||
}
|
||||
|
||||
document.body.style.backgroundColor = themes[nextTheme].background;
|
||||
};
|
||||
|
||||
const initializeTheme = () => {
|
||||
if (!process.client || initialized.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storedTheme = localStorage.getItem(STORAGE_KEY);
|
||||
const nextTheme = storedTheme === "light" || storedTheme === "dark" ? storedTheme : "light";
|
||||
|
||||
applyTheme(nextTheme);
|
||||
initialized.value = true;
|
||||
};
|
||||
|
||||
const toggleTheme = () => {
|
||||
applyTheme(theme.value === "dark" ? "light" : "dark");
|
||||
};
|
||||
|
||||
return {
|
||||
theme: readonly(theme),
|
||||
initializeTheme,
|
||||
applyTheme,
|
||||
toggleTheme
|
||||
};
|
||||
};
|
||||
221
frontend/layouts/default.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import Avatar from "primevue/avatar";
|
||||
import Button from "primevue/button";
|
||||
|
||||
const route = useRoute();
|
||||
const { user, logout } = useAuth();
|
||||
const { theme, toggleTheme, initializeTheme } = useTheme();
|
||||
const { unreadCount, initializeUnread, clearState } = useSupportUnread();
|
||||
|
||||
const isGuest = computed(() => !user.value);
|
||||
|
||||
const navigationItems = computed(() => {
|
||||
if (!user.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items = [
|
||||
{ label: "Боты", to: "/", icon: "pi pi-th-large", badge: null },
|
||||
{
|
||||
label: user.value.role === "admin" ? "Чаты" : "Чат",
|
||||
to: "/chat",
|
||||
icon: "pi pi-comments",
|
||||
badge: unreadCount.value > 0 ? unreadCount.value : null
|
||||
},
|
||||
{ label: "Настройки", to: "/settings", icon: "pi pi-sliders-h", badge: null }
|
||||
];
|
||||
|
||||
if (user.value.role === "admin") {
|
||||
items.push({ label: "Админка", to: "/admin", icon: "pi pi-shield", badge: null });
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const userRoleLabel = computed(() => (user.value?.role === "admin" ? "Администратор" : "Пользователь"));
|
||||
const userInitial = computed(() => user.value?.email?.charAt(0)?.toUpperCase() ?? "U");
|
||||
|
||||
const isRouteActive = (path: string) => {
|
||||
if (path === "/") return route.path === "/";
|
||||
return route.path.startsWith(path);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeTheme();
|
||||
void initializeUnread();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => user.value?.id,
|
||||
() => {
|
||||
if (user.value) {
|
||||
void initializeUnread();
|
||||
return;
|
||||
}
|
||||
|
||||
clearState();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-h-screen"
|
||||
:class="[
|
||||
isGuest ? 'mx-auto w-full max-w-7xl' : 'md:grid md:grid-cols-[18rem_minmax(0,1fr)]',
|
||||
'bg-(--bg)'
|
||||
]"
|
||||
:style="{ background: 'linear-gradient(180deg, color-mix(in srgb, var(--accent) 6%, transparent), transparent 24%), var(--bg)' }"
|
||||
>
|
||||
<aside
|
||||
v-if="user"
|
||||
class="hidden h-screen border-r px-5 py-6 md:sticky md:top-0 md:flex md:flex-col md:gap-6"
|
||||
:style="{
|
||||
borderColor: 'var(--border)',
|
||||
backgroundColor: 'color-mix(in srgb, var(--surface) 90%, white 10%)'
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="grid h-12 w-12 place-items-center overflow-hidden rounded-2xl"
|
||||
:style="{
|
||||
backgroundColor: 'color-mix(in srgb, var(--accent) 12%, var(--surface-soft))',
|
||||
color: 'var(--accent-strong)'
|
||||
}"
|
||||
>
|
||||
<AppLogo size="32px" />
|
||||
</div>
|
||||
<div>
|
||||
<strong class="block text-lg">Антигол</strong>
|
||||
<p class="m-0 text-sm text-(--muted)">Рабочее пространство сигналов</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="grid gap-2">
|
||||
<NuxtLink
|
||||
v-for="item in navigationItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="inline-flex items-center gap-3 rounded-2xl px-3 py-3 text-sm font-medium transition"
|
||||
:class="isRouteActive(item.to) ? 'text-(--text)' : 'text-(--muted)'"
|
||||
:style="isRouteActive(item.to) ? { backgroundColor: 'color-mix(in srgb, var(--accent) 10%, var(--surface-soft))' } : {}"
|
||||
>
|
||||
<i :class="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.badge"
|
||||
class="signal-row__badge signal-row__badge--win"
|
||||
style=" margin: auto; margin-right: 0; min-height: 1.5rem; padding: 0.2rem 0.55rem;"
|
||||
>
|
||||
{{ item.badge }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto">
|
||||
<div class="grid gap-1 rounded-2xl border p-4" :style="{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }">
|
||||
<span class="text-sm text-(--muted)">Доступы</span>
|
||||
<strong>{{ user.botAccesses?.length ?? 0 }} ботов</strong>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="min-h-screen">
|
||||
<header
|
||||
class="flex flex-col gap-4 px-4 py-4 md:flex-row md:justify-between md:px-6 md:py-6"
|
||||
:class="isGuest ? 'md:mx-auto md:w-full md:max-w-7xl md:items-start' : 'md:items-center'"
|
||||
:style="{
|
||||
paddingTop: 'calc(1rem + env(safe-area-inset-top, 0px))'
|
||||
}"
|
||||
>
|
||||
<div class="grid gap-1">
|
||||
<h1 class="m-0 text-2xl font-semibold">{{ user ? "Панель сигналов" : "Антигол" }}</h1>
|
||||
<p class="m-0 text-sm text-(--muted)">
|
||||
{{ user ? "Рабочее пространство по ботам, сигналам и поддержке" : "Авторизуйтесь для доступа к системе" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
class="rounded"
|
||||
text
|
||||
severity="secondary"
|
||||
:icon="theme === 'dark' ? 'pi pi-sun' : 'pi pi-moon'"
|
||||
:aria-label="theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'"
|
||||
@click="toggleTheme"
|
||||
/>
|
||||
|
||||
<template v-if="user">
|
||||
<div class="inline-flex items-center gap-3 rounded-2xl border px-3 py-2" :style="{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }">
|
||||
<Avatar :label="userInitial" shape="circle" />
|
||||
<div>
|
||||
<strong class="block">{{ user.email }}</strong>
|
||||
<span class="block text-xs text-(--muted)">{{ userRoleLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button label="Выйти" icon="pi pi-sign-out" severity="secondary" outlined @click="logout" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<NuxtLink to="/login">
|
||||
<Button label="Войти" severity="secondary" outlined />
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/register">
|
||||
<Button label="Регистрация" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main
|
||||
class="px-4 pb-24 md:px-6 md:pb-8"
|
||||
:class="isGuest ? 'md:mx-auto md:w-full md:max-w-7xl' : ''"
|
||||
:style="{
|
||||
paddingBottom: 'calc(6rem + env(safe-area-inset-bottom, 0px))'
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<nav
|
||||
v-if="user"
|
||||
aria-label="Нижняя навигация"
|
||||
class="fixed inset-x-4 bottom-4 z-40 flex items-center justify-around rounded-[22px] border px-2 py-2 backdrop-blur-[18px] md:hidden"
|
||||
:style="{
|
||||
borderColor: 'color-mix(in srgb, var(--border) 85%, transparent)',
|
||||
backgroundColor: 'color-mix(in srgb, var(--surface) 88%, transparent)',
|
||||
boxShadow: '0 12px 32px color-mix(in srgb, var(--text) 12%, transparent)',
|
||||
bottom: 'calc(1rem + env(safe-area-inset-bottom, 0px))'
|
||||
}"
|
||||
>
|
||||
<NuxtLink
|
||||
v-for="item in navigationItems"
|
||||
:key="`bottom-${item.to}`"
|
||||
:to="item.to"
|
||||
class="flex min-w-0 flex-1 flex-col items-center gap-1 rounded-2xl px-2 py-2 text-center text-[0.7rem] font-medium transition"
|
||||
:class="isRouteActive(item.to) ? 'text-(--text)' : 'text-(--muted)'"
|
||||
:style="isRouteActive(item.to) ? { backgroundColor: 'color-mix(in srgb, var(--accent) 12%, var(--surface-soft))' } : {}"
|
||||
>
|
||||
<span class="mobile-nav__icon-wrap">
|
||||
<i :class="[item.icon, 'text-base']" />
|
||||
<span
|
||||
v-if="item.badge"
|
||||
class="mobile-nav__badge"
|
||||
>
|
||||
{{ item.badge }}
|
||||
</span>
|
||||
</span>
|
||||
<span>{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.badge"
|
||||
class="signal-row__badge signal-row__badge--win"
|
||||
style="display: none;"
|
||||
>
|
||||
{{ item.badge }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
19
frontend/middleware/admin.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export default defineNuxtRouteMiddleware(async () => {
|
||||
if (process.server) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { user, loading, refreshMe } = useAuth();
|
||||
|
||||
if (loading.value) {
|
||||
await refreshMe();
|
||||
}
|
||||
|
||||
if (!user.value) {
|
||||
return navigateTo("/login");
|
||||
}
|
||||
|
||||
if (user.value.role !== "admin") {
|
||||
return navigateTo("/");
|
||||
}
|
||||
});
|
||||
16
frontend/middleware/auth.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export default defineNuxtRouteMiddleware(async () => {
|
||||
if (process.server) {
|
||||
return;
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const { user, loading, refreshMe } = useAuth();
|
||||
|
||||
if (loading.value) {
|
||||
await refreshMe();
|
||||
}
|
||||
|
||||
if (!user.value) {
|
||||
return navigateTo(`/login?redirect=${encodeURIComponent(route.fullPath || "/")}`);
|
||||
}
|
||||
});
|
||||
48
frontend/nuxt.config.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import dotenv from "dotenv";
|
||||
import { resolve } from "node:path";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
dotenv.config({ path: resolve(__dirname, ".env") });
|
||||
dotenv.config({ path: resolve(__dirname, "../.env"), override: false });
|
||||
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const defaultPublicApiBase = isDev ? "http://localhost:4000" : "https://api.antigol.ru";
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: "2026-03-18",
|
||||
ssr: false,
|
||||
devtools: { enabled: true },
|
||||
css: ["primeicons/primeicons.css", "~/assets/css/main.css"],
|
||||
vite: {
|
||||
plugins: [tailwindcss()]
|
||||
},
|
||||
runtimeConfig: {
|
||||
apiBaseInternal: process.env.NUXT_API_BASE_INTERNAL || "http://backend:4000",
|
||||
public: {
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE || defaultPublicApiBase,
|
||||
chatApiBase: process.env.NUXT_PUBLIC_CHAT_API_BASE || (isDev ? "http://localhost:4050" : "https://chat.antigol.ru")
|
||||
}
|
||||
},
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: {
|
||||
lang: "ru"
|
||||
},
|
||||
title: "Антигол",
|
||||
meta: [
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||
{ name: "theme-color", content: "#0f172a" },
|
||||
{ name: "mobile-web-app-capable", content: "yes" },
|
||||
{ name: "apple-mobile-web-app-capable", content: "yes" },
|
||||
{ name: "apple-mobile-web-app-status-bar-style", content: "black-translucent" },
|
||||
{ name: "apple-mobile-web-app-title", content: "Антигол" }
|
||||
],
|
||||
link: [
|
||||
{ rel: "manifest", href: "/manifest.webmanifest" },
|
||||
{ rel: "icon", href: "/icons/favicon-32.png", type: "image/png", sizes: "32x32" },
|
||||
{ rel: "icon", href: "/icons/favicon-16.png", type: "image/png", sizes: "16x16" },
|
||||
{ rel: "apple-touch-icon", href: "/icons/apple-touch-icon.png", sizes: "180x180" }
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
38
frontend/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "betting-signals-frontend",
|
||||
"version": "1.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"generate": "nuxi generate",
|
||||
"preview": "nuxt preview",
|
||||
"cap:sync": "npx cap sync",
|
||||
"cap:open:android": "npx cap open android",
|
||||
"cap:open:ios": "npx cap open ios",
|
||||
"build:mobile": "npm run generate && npm run cap:sync"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^7.4.3",
|
||||
"@capacitor/app": "^7.0.2",
|
||||
"@capacitor/core": "^7.4.3",
|
||||
"@capacitor/device": "^7.0.2",
|
||||
"@capacitor/ios": "^7.4.3",
|
||||
"@capacitor/preferences": "^7.0.2",
|
||||
"@capacitor/push-notifications": "^7.0.2",
|
||||
"@capacitor/splash-screen": "^7.0.5",
|
||||
"@primeuix/themes": "^2.0.3",
|
||||
"nuxt": "4.4.2",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.5.4",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^7.4.3",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"tailwindcss": "^4.2.2"
|
||||
}
|
||||
}
|
||||
687
frontend/pages/admin.vue
Normal file
@@ -0,0 +1,687 @@
|
||||
<script setup lang="ts">
|
||||
import type { Bot, PaginatedResponse, Signal, SubscriptionStatus, User, UserBotAccess } from "~/types";
|
||||
|
||||
definePageMeta({
|
||||
middleware: "admin"
|
||||
});
|
||||
|
||||
type AdminUser = User & {
|
||||
pushSubscriptions: { id: string; endpoint: string; active: boolean }[];
|
||||
};
|
||||
|
||||
type AdminPushDashboard = {
|
||||
items: Array<{
|
||||
id: string;
|
||||
status: "ok" | "ready" | "error" | "inactive";
|
||||
endpointHost: string;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
latestEvent: {
|
||||
statusCode: number | null;
|
||||
reason: string | null;
|
||||
} | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
type SubscriptionDraft = {
|
||||
status: SubscriptionStatus;
|
||||
startsAt: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
const { formatDateTime } = useBrowserDateTime();
|
||||
|
||||
const initialForm = () => ({
|
||||
eventId: "",
|
||||
sportType: "football",
|
||||
leagueName: "",
|
||||
homeTeam: "",
|
||||
awayTeam: "",
|
||||
eventStartTime: "",
|
||||
marketType: "1X2",
|
||||
selection: "",
|
||||
lineValue: "",
|
||||
odds: "1.90",
|
||||
signalTime: "",
|
||||
comment: ""
|
||||
});
|
||||
|
||||
const fieldLabels: Record<keyof ReturnType<typeof initialForm>, string> = {
|
||||
eventId: "ID события",
|
||||
sportType: "Вид спорта",
|
||||
leagueName: "Лига",
|
||||
homeTeam: "Домашняя команда",
|
||||
awayTeam: "Гостевая команда",
|
||||
eventStartTime: "Время начала события",
|
||||
marketType: "Тип рынка",
|
||||
selection: "Выбор",
|
||||
lineValue: "Значение линии",
|
||||
odds: "Коэффициент",
|
||||
signalTime: "Время сигнала",
|
||||
comment: "Комментарий"
|
||||
};
|
||||
|
||||
const getFieldLabel = (key: string) => fieldLabels[key as keyof typeof fieldLabels];
|
||||
const getFormValue = (key: string) => form.value[key as keyof typeof form.value];
|
||||
const handleFormInput = (event: Event, key: string) => {
|
||||
const target = event.target as HTMLInputElement | null;
|
||||
form.value[key as keyof typeof form.value] = target?.value ?? "";
|
||||
};
|
||||
|
||||
const form = ref(initialForm());
|
||||
const signals = ref<Signal[]>([]);
|
||||
const users = ref<AdminUser[]>([]);
|
||||
const bots = ref<Bot[]>([]);
|
||||
const pushDashboard = ref<AdminPushDashboard | null>(null);
|
||||
const pushDashboardLoading = ref(false);
|
||||
const pushDashboardError = ref("");
|
||||
const broadcastTitle = ref("");
|
||||
const broadcastBody = ref("");
|
||||
const userSearch = ref("");
|
||||
const usersLoading = ref(false);
|
||||
const userActionMessage = ref("");
|
||||
const createSignalOpen = ref(false);
|
||||
const subscriptionDrafts = ref<Record<string, SubscriptionDraft>>({});
|
||||
const savingSubscriptionKey = ref<string | null>(null);
|
||||
const togglingUserId = ref<string | null>(null);
|
||||
|
||||
const subscriptionStatusOptions: Array<{ value: SubscriptionStatus; label: string }> = [
|
||||
{ value: "active", label: "Активна" },
|
||||
{ value: "expired", label: "Истекла" },
|
||||
{ value: "canceled", label: "Отменена" }
|
||||
];
|
||||
|
||||
const statusLabel: Record<"ok" | "ready" | "error" | "inactive", string> = {
|
||||
ok: "OK",
|
||||
ready: "Готово",
|
||||
error: "Ошибка",
|
||||
inactive: "Неактивно"
|
||||
};
|
||||
|
||||
const subscriptionStatusLabel: Record<SubscriptionStatus, string> = {
|
||||
active: "Активна",
|
||||
expired: "Истекла",
|
||||
canceled: "Отменена"
|
||||
};
|
||||
|
||||
const userRoleLabel = (role: User["role"]) => (role === "admin" ? "Администратор" : "Пользователь");
|
||||
const userActiveLabel = (active?: boolean) => (active ? "активен" : "отключен");
|
||||
|
||||
const statusBadgeClass = (status: "ok" | "ready" | "error" | "inactive") => {
|
||||
if (status === "error") return "signal-row__badge--manual_review";
|
||||
if (status === "inactive") return "signal-row__badge--inactive";
|
||||
return "signal-row__badge--win";
|
||||
};
|
||||
|
||||
const subscriptionBadgeClass = (status: SubscriptionStatus, isActiveNow?: boolean) => {
|
||||
if (status === "canceled") return "signal-row__badge--inactive";
|
||||
if (status === "expired" || !isActiveNow) return "signal-row__badge--manual_review";
|
||||
return "signal-row__badge--win";
|
||||
};
|
||||
|
||||
const subscriptionSelectClass = (status: SubscriptionStatus) => {
|
||||
if (status === "canceled") return "admin-subscription-select admin-subscription-select--canceled";
|
||||
if (status === "expired") return "admin-subscription-select admin-subscription-select--expired";
|
||||
return "admin-subscription-select admin-subscription-select--active";
|
||||
};
|
||||
|
||||
const subscriptionsByUserId = computed(() => {
|
||||
const grouped = new Map<string, AdminPushDashboard["items"]>();
|
||||
|
||||
for (const item of pushDashboard.value?.items ?? []) {
|
||||
const userItems = grouped.get(item.user.id) ?? [];
|
||||
userItems.push(item);
|
||||
grouped.set(item.user.id, userItems);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
});
|
||||
|
||||
const toDateTimeLocal = (value?: string | null) => {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
|
||||
const pad = (part: number) => String(part).padStart(2, "0");
|
||||
const year = date.getFullYear();
|
||||
const month = pad(date.getMonth() + 1);
|
||||
const day = pad(date.getDate());
|
||||
const hours = pad(date.getHours());
|
||||
const minutes = pad(date.getMinutes());
|
||||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
const subscriptionDraftKey = (userId: string, botId: string) => `${userId}:${botId}`;
|
||||
|
||||
const createSubscriptionDraft = (access?: UserBotAccess): SubscriptionDraft => ({
|
||||
status: access?.status ?? "canceled",
|
||||
startsAt: toDateTimeLocal(access?.startsAt ?? new Date().toISOString()),
|
||||
expiresAt: toDateTimeLocal(access?.expiresAt ?? null)
|
||||
});
|
||||
|
||||
const assignDrafts = (items: AdminUser[]) => {
|
||||
const nextDrafts: Record<string, SubscriptionDraft> = {};
|
||||
|
||||
for (const user of items) {
|
||||
for (const bot of bots.value) {
|
||||
const access = (user.botAccesses ?? []).find((entry: UserBotAccess) => entry.bot.id === bot.id);
|
||||
nextDrafts[subscriptionDraftKey(user.id, bot.id)] = createSubscriptionDraft(access);
|
||||
}
|
||||
}
|
||||
|
||||
subscriptionDrafts.value = nextDrafts;
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
usersLoading.value = true;
|
||||
|
||||
try {
|
||||
const query = userSearch.value.trim();
|
||||
const suffix = query ? `?q=${encodeURIComponent(query)}` : "";
|
||||
const usersData = await useApi<AdminUser[]>(`/admin/users${suffix}`);
|
||||
users.value = usersData;
|
||||
assignDrafts(usersData);
|
||||
userActionMessage.value = "";
|
||||
} finally {
|
||||
usersLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadCore = async () => {
|
||||
const [signalsData, botsData] = await Promise.all([
|
||||
useApi<PaginatedResponse<Signal>>("/signals?page=1&perPage=200&published=true"),
|
||||
useApi<Bot[]>("/admin/bots")
|
||||
]);
|
||||
|
||||
signals.value = signalsData.items;
|
||||
bots.value = botsData;
|
||||
await loadUsers();
|
||||
};
|
||||
|
||||
const loadPushDashboard = async () => {
|
||||
pushDashboardLoading.value = true;
|
||||
|
||||
try {
|
||||
pushDashboard.value = await useApi<AdminPushDashboard>("/admin/push-subscriptions");
|
||||
pushDashboardError.value = "";
|
||||
} catch (error) {
|
||||
pushDashboard.value = null;
|
||||
pushDashboardError.value = error instanceof Error ? error.message : "Не удалось загрузить статусы подписок";
|
||||
} finally {
|
||||
pushDashboardLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
await Promise.all([loadCore(), loadPushDashboard()]);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await load();
|
||||
});
|
||||
|
||||
const createSignal = async () => {
|
||||
await useApi("/admin/signals", {
|
||||
method: "POST",
|
||||
body: {
|
||||
...form.value,
|
||||
eventStartTime: new Date(form.value.eventStartTime).toISOString(),
|
||||
signalTime: new Date(form.value.signalTime || form.value.eventStartTime).toISOString(),
|
||||
lineValue: form.value.lineValue ? Number(form.value.lineValue) : null,
|
||||
odds: Number(form.value.odds),
|
||||
sourceType: "manual",
|
||||
published: true
|
||||
}
|
||||
});
|
||||
|
||||
form.value = initialForm();
|
||||
createSignalOpen.value = false;
|
||||
await loadCore();
|
||||
};
|
||||
|
||||
const setStatus = async (signalId: string, status: Signal["status"]) => {
|
||||
await useApi(`/admin/signals/${signalId}/set-status`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
status,
|
||||
explanation: `Статус был установлен вручную: ${status}`
|
||||
}
|
||||
});
|
||||
|
||||
await loadCore();
|
||||
};
|
||||
|
||||
const sendPush = async (signalId: string) => {
|
||||
await useApi(`/admin/signals/${signalId}/send-push`, {
|
||||
method: "POST",
|
||||
body: {}
|
||||
});
|
||||
};
|
||||
|
||||
const sendBroadcast = async () => {
|
||||
await useApi("/admin/broadcast", {
|
||||
method: "POST",
|
||||
body: {
|
||||
title: broadcastTitle.value,
|
||||
body: broadcastBody.value
|
||||
}
|
||||
});
|
||||
|
||||
broadcastTitle.value = "";
|
||||
broadcastBody.value = "";
|
||||
};
|
||||
|
||||
const updateSubscriptionDraft = (userId: string, botId: string, patch: Partial<SubscriptionDraft>) => {
|
||||
const key = subscriptionDraftKey(userId, botId);
|
||||
subscriptionDrafts.value = {
|
||||
...subscriptionDrafts.value,
|
||||
[key]: {
|
||||
...createSubscriptionDraft(),
|
||||
...(subscriptionDrafts.value[key] ?? {}),
|
||||
...patch
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const handleSubscriptionStatusChange = (event: Event, userId: string, botId: string) => {
|
||||
const target = event.target as HTMLSelectElement | null;
|
||||
updateSubscriptionDraft(userId, botId, { status: (target?.value as SubscriptionStatus | undefined) ?? "canceled" });
|
||||
};
|
||||
|
||||
const handleSubscriptionStartsAtChange = (event: Event, userId: string, botId: string) => {
|
||||
const target = event.target as HTMLInputElement | null;
|
||||
updateSubscriptionDraft(userId, botId, { startsAt: target?.value ?? "" });
|
||||
};
|
||||
|
||||
const handleSubscriptionExpiresAtChange = (event: Event, userId: string, botId: string) => {
|
||||
const target = event.target as HTMLInputElement | null;
|
||||
updateSubscriptionDraft(userId, botId, { expiresAt: target?.value ?? "" });
|
||||
};
|
||||
|
||||
const findUserSubscription = (member: AdminUser, botId: string) =>
|
||||
(member.botAccesses ?? []).find((entry) => entry.bot.id === botId);
|
||||
|
||||
const addDays = (date: Date, days: number) => {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
};
|
||||
|
||||
const saveSubscription = async (userId: string, botId: string) => {
|
||||
const key = subscriptionDraftKey(userId, botId);
|
||||
const draft = subscriptionDrafts.value[key];
|
||||
if (!draft) return;
|
||||
|
||||
savingSubscriptionKey.value = key;
|
||||
|
||||
try {
|
||||
const savedSubscription = await useApi<UserBotAccess>(`/admin/users/${userId}/subscriptions/${botId}`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
status: draft.status,
|
||||
startsAt: draft.startsAt ? new Date(draft.startsAt).toISOString() : new Date().toISOString(),
|
||||
expiresAt: draft.expiresAt ? new Date(draft.expiresAt).toISOString() : null
|
||||
}
|
||||
});
|
||||
|
||||
users.value = users.value.map((user) => {
|
||||
if (user.id !== userId) return user;
|
||||
|
||||
const existing = (user.botAccesses ?? []).filter((entry: UserBotAccess) => entry.bot.id !== botId);
|
||||
return {
|
||||
...user,
|
||||
botAccesses: [...existing, savedSubscription].sort((left, right) => left.bot.name.localeCompare(right.bot.name, "ru"))
|
||||
};
|
||||
});
|
||||
|
||||
updateSubscriptionDraft(userId, botId, createSubscriptionDraft(savedSubscription));
|
||||
|
||||
const userEmail = users.value.find((entry) => entry.id === userId)?.email ?? "пользователя";
|
||||
const botName = bots.value.find((entry) => entry.id === botId)?.name ?? "бота";
|
||||
userActionMessage.value = `Подписка ${userEmail} на ${botName} обновлена`;
|
||||
} finally {
|
||||
savingSubscriptionKey.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const activateMonthlySubscription = async (userId: string, botId: string) => {
|
||||
const now = new Date();
|
||||
const expiresAt = addDays(now, 30);
|
||||
|
||||
updateSubscriptionDraft(userId, botId, {
|
||||
status: "active",
|
||||
startsAt: toDateTimeLocal(now.toISOString()),
|
||||
expiresAt: toDateTimeLocal(expiresAt.toISOString())
|
||||
});
|
||||
|
||||
await saveSubscription(userId, botId);
|
||||
};
|
||||
|
||||
const toggleUserActive = async (user: AdminUser) => {
|
||||
togglingUserId.value = user.id;
|
||||
|
||||
try {
|
||||
const updated = await useApi<AdminUser>(`/admin/users/${user.id}/active`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
active: !user.active
|
||||
}
|
||||
});
|
||||
|
||||
users.value = users.value.map((entry) => (entry.id === user.id ? { ...entry, active: updated.active } : entry));
|
||||
userActionMessage.value = `${updated.email}: ${updated.active ? "доступ включен" : "доступ отключен"}`;
|
||||
} finally {
|
||||
togglingUserId.value = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page admin-grid">
|
||||
<!-- <div class="panel">
|
||||
<div class="page-header page-header--admin-section">
|
||||
<div>
|
||||
<h1>Создание сигнала</h1>
|
||||
<p class="muted">Разверните блок, чтобы добавить сигнал вручную.</p>
|
||||
</div>
|
||||
<button class="secondary" type="button" @click="createSignalOpen = !createSignalOpen">
|
||||
{{ createSignalOpen ? "Свернуть" : "Развернуть" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form v-if="createSignalOpen" class="admin-list" @submit.prevent="createSignal">
|
||||
<label v-for="(value, key) in form" :key="key">
|
||||
{{ getFieldLabel(String(key)) }}
|
||||
<input
|
||||
:value="getFormValue(String(key))"
|
||||
:type="String(key).includes('Time') ? 'datetime-local' : 'text'"
|
||||
@input="handleFormInput($event, String(key))"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">Сохранить сигнал</button>
|
||||
</form>
|
||||
</div> -->
|
||||
|
||||
<!-- <div class="panel">
|
||||
<h2>Действия с сигналами</h2>
|
||||
<div class="admin-list">
|
||||
<div v-for="signal in signals" :key="signal.id" class="admin-row">
|
||||
<div>
|
||||
<strong>{{ signal.homeTeam }} - {{ signal.awayTeam }}</strong>
|
||||
<p>{{ signal.selection }} @ {{ signal.odds }}</p>
|
||||
<p v-if="signal.forecast" class="muted">{{ signal.forecast }}</p>
|
||||
<p v-if="signal.rawPayload?.botName" class="muted">{{ signal.rawPayload.botName }}</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="secondary" @click="sendPush(signal.id)">Отправить push</button>
|
||||
<button class="secondary" @click="setStatus(signal.id, 'win')">Выигрыш</button>
|
||||
<button class="secondary" @click="setStatus(signal.id, 'lose')">Проигрыш</button>
|
||||
<button class="secondary" @click="setStatus(signal.id, 'void')">Возврат</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="panel">
|
||||
<h2>Отправить уведомление со своим текстом</h2>
|
||||
<label>
|
||||
Заголовок
|
||||
<input v-model="broadcastTitle" />
|
||||
</label>
|
||||
<label>
|
||||
Текст
|
||||
<textarea v-model="broadcastBody" rows="4" />
|
||||
</label>
|
||||
<button @click="sendBroadcast">Отправить рассылку</button>
|
||||
</div>
|
||||
<!--
|
||||
<div class="panel">
|
||||
<div class="page-header page-header--admin-section">
|
||||
<h2>Push-подписки</h2>
|
||||
<NuxtLink class="topbar__link" to="/admin/pushes">Открыть монитор</NuxtLink>
|
||||
</div>
|
||||
<p class="muted">Отдельная страница со всеми подписками, статусами доставки и автообновлением.</p>
|
||||
<p v-if="pushDashboardLoading" class="muted">Загрузка статусов подписок...</p>
|
||||
<p v-else-if="pushDashboardError" class="error">Статусы подписок недоступны: {{ pushDashboardError }}</p>
|
||||
<p v-else-if="pushDashboard" class="muted">
|
||||
{{ pushDashboard.items.length }} подписок, {{ pushDashboard.items.filter((item) => item.status === "ok").length }} OK,
|
||||
{{ pushDashboard.items.filter((item) => item.status === "ready").length }} готово,
|
||||
{{ pushDashboard.items.filter((item) => item.status === "error").length }} ошибок.
|
||||
</p>
|
||||
</div> -->
|
||||
|
||||
<div class="panel">
|
||||
<div class="page-header page-header--admin-section">
|
||||
<div>
|
||||
<h2>Пользователи и подписки</h2>
|
||||
<p class="muted">Ищите пользователя по email и управляйте подпиской на каждого бота отдельно.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-users-toolbar">
|
||||
<input
|
||||
v-model="userSearch"
|
||||
class="admin-users-toolbar__search"
|
||||
placeholder="Поиск по email"
|
||||
@keyup.enter="loadUsers"
|
||||
/>
|
||||
<button class="secondary" :disabled="usersLoading" @click="loadUsers">
|
||||
{{ usersLoading ? "Поиск..." : "Найти пользователя" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="userActionMessage" class="success">{{ userActionMessage }}</p>
|
||||
|
||||
<div class="admin-list">
|
||||
<article v-for="member in users" :key="member.id" class="admin-user-card">
|
||||
<div class="admin-user-card__header">
|
||||
<div>
|
||||
<strong>{{ member.email }}</strong>
|
||||
<p class="muted">
|
||||
{{ userRoleLabel(member.role) }} · {{ userActiveLabel(member.active) }} · создан
|
||||
{{ formatDateTime(member.createdAt ?? "") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="secondary"
|
||||
:disabled="togglingUserId === member.id"
|
||||
@click="toggleUserActive(member)"
|
||||
>
|
||||
{{ togglingUserId === member.id ? "Сохранение..." : member.active ? "Отключить пользователя" : "Включить пользователя" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-user-card__body">
|
||||
<div class="p-4 mt-2 border rounded-2xl border-(--border)">
|
||||
<p class="muted">Текущие подписки</p>
|
||||
<div class="admin-bot-chip-list">
|
||||
<span
|
||||
v-for="access in member.botAccesses ?? []"
|
||||
:key="access.id"
|
||||
class="signal-row__badge"
|
||||
:class="subscriptionBadgeClass(access.status, access.isActiveNow)"
|
||||
>
|
||||
{{ access.bot.name }} · {{ subscriptionStatusLabel[access.status] }}
|
||||
</span>
|
||||
<span v-if="!(member.botAccesses ?? []).length" class="muted">Подписок пока нет</span>
|
||||
</div>
|
||||
|
||||
<div v-if="subscriptionsByUserId.get(member.id)?.length" class="admin-user-subscriptions">
|
||||
<div
|
||||
v-for="subscription in subscriptionsByUserId.get(member.id)"
|
||||
:key="subscription.id"
|
||||
class="admin-user-subscription"
|
||||
>
|
||||
<span class="signal-row__badge" :class="statusBadgeClass(subscription.status)">
|
||||
{{ statusLabel[subscription.status] }}
|
||||
</span>
|
||||
<span class="muted">{{ subscription.endpointHost }}</span>
|
||||
<span v-if="subscription.latestEvent?.statusCode" class="muted">
|
||||
{{ subscription.latestEvent.statusCode }}
|
||||
</span>
|
||||
<span v-if="subscription.latestEvent?.reason" class="muted">
|
||||
{{ subscription.latestEvent.reason }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="admin-subscription-manager">
|
||||
<summary class="admin-subscription-manager__toggle">
|
||||
<span>Управление подписками по ботам</span>
|
||||
<span class="admin-subscription-manager__toggle-icon" aria-hidden="true">+</span>
|
||||
</summary>
|
||||
<div class="admin-subscription-grid admin-subscription-grid--mobile">
|
||||
<article v-for="bot in bots" :key="bot.id" class="admin-subscription-card">
|
||||
<div class="admin-subscription-card__header">
|
||||
<div>
|
||||
<strong>{{ bot.name }}</strong>
|
||||
<small>{{ bot.key }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Статус
|
||||
<select
|
||||
:class="subscriptionSelectClass(
|
||||
subscriptionDrafts[subscriptionDraftKey(member.id, bot.id)]?.status ?? 'canceled'
|
||||
)"
|
||||
:value="subscriptionDrafts[subscriptionDraftKey(member.id, bot.id)]?.status ?? 'canceled'"
|
||||
@change="handleSubscriptionStatusChange($event, member.id, bot.id)"
|
||||
>
|
||||
<option
|
||||
v-for="option in subscriptionStatusOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Начало
|
||||
<input
|
||||
type="datetime-local"
|
||||
:value="subscriptionDrafts[subscriptionDraftKey(member.id, bot.id)]?.startsAt ?? ''"
|
||||
@input="handleSubscriptionStartsAtChange($event, member.id, bot.id)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Окончание
|
||||
<input
|
||||
type="datetime-local"
|
||||
:value="subscriptionDrafts[subscriptionDraftKey(member.id, bot.id)]?.expiresAt ?? ''"
|
||||
@input="handleSubscriptionExpiresAtChange($event, member.id, bot.id)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="button-row">
|
||||
<button
|
||||
class="secondary"
|
||||
:disabled="savingSubscriptionKey === subscriptionDraftKey(member.id, bot.id)"
|
||||
@click="activateMonthlySubscription(member.id, bot.id)"
|
||||
>
|
||||
Активировать на месяц
|
||||
</button>
|
||||
<button
|
||||
:disabled="savingSubscriptionKey === subscriptionDraftKey(member.id, bot.id)"
|
||||
@click="saveSubscription(member.id, bot.id)"
|
||||
>
|
||||
{{
|
||||
savingSubscriptionKey === subscriptionDraftKey(member.id, bot.id)
|
||||
? "Сохранение..."
|
||||
: "Сохранить подписку"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="admin-subscription-table-wrap">
|
||||
<table class="admin-subscription-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Бот</th>
|
||||
<th>Статус</th>
|
||||
<th>Начало</th>
|
||||
<th>Окончание</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="bot in bots" :key="`table-${bot.id}`">
|
||||
<td class="admin-subscription-table__bot">
|
||||
<strong>{{ bot.name }}</strong>
|
||||
<small>{{ bot.key }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<div class="admin-subscription-table__field">
|
||||
<select
|
||||
:class="subscriptionSelectClass(
|
||||
subscriptionDrafts[subscriptionDraftKey(member.id, bot.id)]?.status ?? 'canceled'
|
||||
)"
|
||||
:value="subscriptionDrafts[subscriptionDraftKey(member.id, bot.id)]?.status ?? 'canceled'"
|
||||
@change="handleSubscriptionStatusChange($event, member.id, bot.id)"
|
||||
>
|
||||
<option
|
||||
v-for="option in subscriptionStatusOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="datetime-local"
|
||||
:value="subscriptionDrafts[subscriptionDraftKey(member.id, bot.id)]?.startsAt ?? ''"
|
||||
@input="handleSubscriptionStartsAtChange($event, member.id, bot.id)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="datetime-local"
|
||||
:value="subscriptionDrafts[subscriptionDraftKey(member.id, bot.id)]?.expiresAt ?? ''"
|
||||
@input="handleSubscriptionExpiresAtChange($event, member.id, bot.id)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div class="admin-subscription-table__actions">
|
||||
<button
|
||||
class="secondary"
|
||||
:disabled="savingSubscriptionKey === subscriptionDraftKey(member.id, bot.id)"
|
||||
@click="activateMonthlySubscription(member.id, bot.id)"
|
||||
>
|
||||
Добавить еще месяц
|
||||
</button>
|
||||
<button
|
||||
:disabled="savingSubscriptionKey === subscriptionDraftKey(member.id, bot.id)"
|
||||
@click="saveSubscription(member.id, bot.id)"
|
||||
>
|
||||
{{
|
||||
savingSubscriptionKey === subscriptionDraftKey(member.id, bot.id)
|
||||
? "Сохранение..."
|
||||
: "Сохранить"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
278
frontend/pages/admin/pushes.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: "admin"
|
||||
});
|
||||
|
||||
type PushSubscriptionsDashboardResponse = {
|
||||
summary: {
|
||||
total: number;
|
||||
active: number;
|
||||
inactive: number;
|
||||
ok: number;
|
||||
ready: number;
|
||||
error: number;
|
||||
};
|
||||
items: Array<{
|
||||
id: string;
|
||||
endpoint: string;
|
||||
endpointHost: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
status: "ok" | "ready" | "error" | "inactive";
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
role: "admin" | "user";
|
||||
active: boolean;
|
||||
notificationSetting: {
|
||||
signalsPushEnabled: boolean;
|
||||
resultsPushEnabled: boolean;
|
||||
} | null;
|
||||
};
|
||||
latestEvent: {
|
||||
createdAt: string;
|
||||
level: string;
|
||||
message: string;
|
||||
ok: boolean | null;
|
||||
statusCode: number | null;
|
||||
reason: string | null;
|
||||
notificationType: string | null;
|
||||
} | null;
|
||||
}>;
|
||||
recentNotificationLogs: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
recipients: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
createdAt: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const { formatDateTime } = useBrowserDateTime();
|
||||
|
||||
const dashboard = ref<PushSubscriptionsDashboardResponse | null>(null);
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const lastUpdatedAt = ref<Date | null>(null);
|
||||
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const statusLabel: Record<PushSubscriptionsDashboardResponse["items"][number]["status"], string> = {
|
||||
ok: "OK",
|
||||
ready: "Ready",
|
||||
error: "Error",
|
||||
inactive: "Inactive"
|
||||
};
|
||||
|
||||
const statusBadgeClass = (status: PushSubscriptionsDashboardResponse["items"][number]["status"]) => {
|
||||
if (status === "error") {
|
||||
return "signal-row__badge--manual_review";
|
||||
}
|
||||
|
||||
if (status === "inactive") {
|
||||
return "signal-row__badge--inactive";
|
||||
}
|
||||
|
||||
return "signal-row__badge--win";
|
||||
};
|
||||
|
||||
const staleSubscriptions = computed(() => {
|
||||
if (!dashboard.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return dashboard.value.items.filter((item) => {
|
||||
const providerExpired =
|
||||
item.latestEvent?.statusCode === 404 ||
|
||||
item.latestEvent?.statusCode === 410;
|
||||
|
||||
return item.status === "inactive" || providerExpired;
|
||||
});
|
||||
});
|
||||
|
||||
const healthySubscriptions = computed(() => {
|
||||
if (!dashboard.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const staleIds = new Set(staleSubscriptions.value.map((item) => item.id));
|
||||
return dashboard.value.items.filter((item) => !staleIds.has(item.id));
|
||||
});
|
||||
|
||||
const loadDashboard = async () => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
dashboard.value = await useApi<PushSubscriptionsDashboardResponse>("/admin/push-subscriptions");
|
||||
lastUpdatedAt.value = new Date();
|
||||
errorMessage.value = "";
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "Failed to load push dashboard";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDashboard();
|
||||
|
||||
refreshTimer = setInterval(() => {
|
||||
void loadDashboard();
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page admin-grid">
|
||||
<div class="panel">
|
||||
<div class="page-header page-header--admin-section">
|
||||
<div>
|
||||
<p class="eyebrow">Admin</p>
|
||||
<h1>Push subscriptions</h1>
|
||||
<p class="muted">Auto-refresh every 5 seconds with the latest delivery state for each subscription.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<NuxtLink class="topbar__link" to="/admin">Back to admin</NuxtLink>
|
||||
<button :disabled="loading" @click="loadDashboard">{{ loading ? "Refreshing..." : "Refresh now" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="lastUpdatedAt" class="muted">Last updated: {{ formatDateTime(lastUpdatedAt) }}</p>
|
||||
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboard" class="admin-summary-grid">
|
||||
<div class="panel admin-summary-card">
|
||||
<span class="eyebrow">Total</span>
|
||||
<strong>{{ dashboard.summary.total }}</strong>
|
||||
<p class="muted">subscriptions in database</p>
|
||||
</div>
|
||||
<div class="panel admin-summary-card">
|
||||
<span class="eyebrow">Active</span>
|
||||
<strong>{{ dashboard.summary.active }}</strong>
|
||||
<p class="muted">eligible for delivery</p>
|
||||
</div>
|
||||
<div class="panel admin-summary-card">
|
||||
<span class="eyebrow">Ready</span>
|
||||
<strong>{{ dashboard.summary.ready }}</strong>
|
||||
<p class="muted">active without delivery history</p>
|
||||
</div>
|
||||
<div class="panel admin-summary-card">
|
||||
<span class="eyebrow">Errors</span>
|
||||
<strong>{{ dashboard.summary.error }}</strong>
|
||||
<p class="muted">last attempt failed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboard" class="panel">
|
||||
<div class="page-header page-header--admin-section">
|
||||
<h2>Stale or dropped subscriptions</h2>
|
||||
<p class="muted">This block highlights subscriptions that are already inactive or were rejected by the push provider with 404/410.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="staleSubscriptions.length" class="admin-list">
|
||||
<article v-for="item in staleSubscriptions" :key="item.id" class="push-row">
|
||||
<div class="push-row__main">
|
||||
<div class="push-row__heading">
|
||||
<strong>{{ item.user.email }}</strong>
|
||||
<span class="signal-row__badge" :class="statusBadgeClass(item.status)">
|
||||
{{ statusLabel[item.status] }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="muted">{{ item.endpointHost }}</p>
|
||||
<p class="push-row__endpoint">{{ item.endpoint }}</p>
|
||||
</div>
|
||||
|
||||
<div class="push-row__meta">
|
||||
<p><strong>Signals push:</strong> {{ item.user.notificationSetting?.signalsPushEnabled ? "on" : "off" }}</p>
|
||||
<p><strong>Results push:</strong> {{ item.user.notificationSetting?.resultsPushEnabled ? "on" : "off" }}</p>
|
||||
<p><strong>Created:</strong> {{ formatDateTime(item.createdAt) }}</p>
|
||||
<p><strong>Updated:</strong> {{ formatDateTime(item.updatedAt) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="push-row__event">
|
||||
<template v-if="item.latestEvent">
|
||||
<p><strong>Last event:</strong> {{ formatDateTime(item.latestEvent.createdAt) }}</p>
|
||||
<p><strong>Type:</strong> {{ item.latestEvent.notificationType ?? "unknown" }}</p>
|
||||
<p><strong>Status code:</strong> {{ item.latestEvent.statusCode ?? "n/a" }}</p>
|
||||
<p><strong>Reason:</strong> {{ item.latestEvent.reason ?? item.latestEvent.message }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="muted">No delivery attempts yet.</p>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="muted">No stale subscriptions right now.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboard" class="panel">
|
||||
<div class="page-header page-header--admin-section">
|
||||
<h2>All other subscriptions</h2>
|
||||
<p class="muted">OK means the last delivery succeeded. Ready means the subscription is active but has not been used yet.</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
<article v-for="item in healthySubscriptions" :key="item.id" class="push-row">
|
||||
<div class="push-row__main">
|
||||
<div class="push-row__heading">
|
||||
<strong>{{ item.user.email }}</strong>
|
||||
<span class="signal-row__badge" :class="statusBadgeClass(item.status)">
|
||||
{{ statusLabel[item.status] }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="muted">{{ item.endpointHost }}</p>
|
||||
<p class="push-row__endpoint">{{ item.endpoint }}</p>
|
||||
</div>
|
||||
|
||||
<div class="push-row__meta">
|
||||
<p><strong>Signals push:</strong> {{ item.user.notificationSetting?.signalsPushEnabled ? "on" : "off" }}</p>
|
||||
<p><strong>Results push:</strong> {{ item.user.notificationSetting?.resultsPushEnabled ? "on" : "off" }}</p>
|
||||
<p><strong>Created:</strong> {{ formatDateTime(item.createdAt) }}</p>
|
||||
<p><strong>Updated:</strong> {{ formatDateTime(item.updatedAt) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="push-row__event">
|
||||
<template v-if="item.latestEvent">
|
||||
<p><strong>Last event:</strong> {{ formatDateTime(item.latestEvent.createdAt) }}</p>
|
||||
<p><strong>Type:</strong> {{ item.latestEvent.notificationType ?? "unknown" }}</p>
|
||||
<p><strong>Status code:</strong> {{ item.latestEvent.statusCode ?? "n/a" }}</p>
|
||||
<p><strong>Reason:</strong> {{ item.latestEvent.reason ?? item.latestEvent.message }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="muted">No delivery attempts yet.</p>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboard" class="panel">
|
||||
<div class="page-header page-header--admin-section">
|
||||
<h2>Recent notification batches</h2>
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
<div v-for="log in dashboard.recentNotificationLogs" :key="log.id" class="admin-row">
|
||||
<div>
|
||||
<strong>{{ log.type }}</strong>
|
||||
<p class="muted">{{ formatDateTime(log.createdAt) }}</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<span class="muted">Recipients: {{ log.recipients }}</span>
|
||||
<span class="muted">Success: {{ log.successCount }}</span>
|
||||
<span class="muted">Failed: {{ log.failedCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
321
frontend/pages/bots/[key].vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<script setup lang="ts">
|
||||
import Button from "primevue/button";
|
||||
import Card from "primevue/card";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Message from "primevue/message";
|
||||
import Paginator from "primevue/paginator";
|
||||
import Select from "primevue/select";
|
||||
import SelectButton from "primevue/selectbutton";
|
||||
import Skeleton from "primevue/skeleton";
|
||||
import Tag from "primevue/tag";
|
||||
import type { Bot, PaginatedResponse, Signal } from "~/types";
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const { user } = useAuth();
|
||||
|
||||
const botKey = computed(() => String(route.params.key || ""));
|
||||
const availableBots = computed(() => user.value?.botAccesses?.map((access) => access.bot) ?? []);
|
||||
const bot = computed<Bot | null>(() => availableBots.value.find((entry) => entry.key === botKey.value) ?? null);
|
||||
|
||||
const signals = ref<Signal[]>([]);
|
||||
const status = ref<string | null>(null);
|
||||
const query = ref("");
|
||||
const activeTab = ref<"all" | "1" | "2">("1");
|
||||
const perPage = ref(20);
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
total: 0,
|
||||
totalPages: 1
|
||||
});
|
||||
|
||||
const sortBy = ref<"eventStartTime" | "signalTime" | "odds">("eventStartTime");
|
||||
const sortDirection = ref<"desc" | "asc">("desc");
|
||||
const loading = ref(true);
|
||||
const loadError = ref("");
|
||||
|
||||
const tabCounts = ref<{ all: number; "1": number; "2": number }>({ all: 0, "1": 0, "2": 0 });
|
||||
|
||||
const tabOptions = [
|
||||
{ label: "Все", value: "all" },
|
||||
{ label: "Активные", value: "1" },
|
||||
{ label: "Неактивные", value: "2" }
|
||||
] as const;
|
||||
|
||||
const statusOptions = [
|
||||
{ label: "Все статусы", value: null },
|
||||
{ label: "В ожидании", value: "pending" },
|
||||
{ label: "Выигрыш", value: "win" },
|
||||
{ label: "Проигрыш", value: "lose" },
|
||||
{ label: "Возврат", value: "void" },
|
||||
{ label: "Ручная проверка", value: "manual_review" }
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{ label: "По дате матча", value: "eventStartTime" },
|
||||
{ label: "По времени сигнала", value: "signalTime" },
|
||||
{ label: "По коэффициенту", value: "odds" }
|
||||
] as const;
|
||||
|
||||
const perPageOptions = [
|
||||
{ label: "20 на странице", value: 20 },
|
||||
{ label: "40 на странице", value: 40 },
|
||||
{ label: "100 на странице", value: 100 }
|
||||
];
|
||||
|
||||
const getSortValue = (signal: Signal) => {
|
||||
if (sortBy.value === "odds") {
|
||||
return signal.odds;
|
||||
}
|
||||
|
||||
const rawValue = sortBy.value === "eventStartTime" ? signal.eventStartTime || signal.signalTime : signal.signalTime;
|
||||
const parsedValue = Date.parse(rawValue);
|
||||
return Number.isNaN(parsedValue) ? 0 : parsedValue;
|
||||
};
|
||||
|
||||
const sortedSignals = computed(() =>
|
||||
[...signals.value].sort((left, right) => {
|
||||
const leftValue = getSortValue(left);
|
||||
const rightValue = getSortValue(right);
|
||||
|
||||
if (leftValue !== rightValue) {
|
||||
return sortDirection.value === "desc" ? rightValue - leftValue : leftValue - rightValue;
|
||||
}
|
||||
|
||||
const leftSignalTime = Date.parse(left.signalTime);
|
||||
const rightSignalTime = Date.parse(right.signalTime);
|
||||
return sortDirection.value === "desc" ? rightSignalTime - leftSignalTime : leftSignalTime - rightSignalTime;
|
||||
})
|
||||
);
|
||||
|
||||
const currentTabLabel = computed(() => tabOptions.find((option) => option.value === activeTab.value)?.label ?? "Все");
|
||||
|
||||
const buildParams = (tab: "all" | "1" | "2", targetPage: number, targetPerPage: number) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("published", "true");
|
||||
params.set("botKey", botKey.value);
|
||||
if (tab !== "all") params.set("activeTab", tab);
|
||||
params.set("page", String(targetPage));
|
||||
params.set("perPage", String(targetPerPage));
|
||||
if (status.value) params.set("status", status.value);
|
||||
if (query.value) params.set("q", query.value);
|
||||
return params;
|
||||
};
|
||||
|
||||
const loadSignals = async () => {
|
||||
if (!bot.value) {
|
||||
loadError.value = "Нет доступа к этому боту";
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const response = await useApi<PaginatedResponse<Signal>>(
|
||||
`/signals?${buildParams(activeTab.value, pagination.value.page, perPage.value).toString()}`
|
||||
);
|
||||
|
||||
signals.value = response.items;
|
||||
pagination.value = response.pagination;
|
||||
perPage.value = response.pagination.perPage;
|
||||
if (response.tabCounts) {
|
||||
tabCounts.value = response.tabCounts;
|
||||
}
|
||||
loadError.value = "";
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : "Не удалось загрузить сигналы";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const reloadFromFirstPage = async () => {
|
||||
pagination.value.page = 1;
|
||||
await loadSignals();
|
||||
};
|
||||
|
||||
const handlePageChange = async (event: { page: number; rows: number }) => {
|
||||
pagination.value.page = event.page + 1;
|
||||
perPage.value = event.rows;
|
||||
await loadSignals();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSignals();
|
||||
});
|
||||
|
||||
watch([status, query, sortBy], () => {
|
||||
void reloadFromFirstPage();
|
||||
});
|
||||
|
||||
watch(activeTab, () => {
|
||||
void reloadFromFirstPage();
|
||||
});
|
||||
|
||||
watch(perPage, () => {
|
||||
void reloadFromFirstPage();
|
||||
});
|
||||
|
||||
watch(botKey, () => {
|
||||
pagination.value.page = 1;
|
||||
void loadSignals();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="sakai-page">
|
||||
<div class="sakai-hero-card">
|
||||
<div>
|
||||
<div class="sakai-page-back">
|
||||
<NuxtLink to="/">
|
||||
<Button text severity="secondary" icon="pi pi-arrow-left" label="Все боты" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<span class="sakai-section-label">Лента бота</span>
|
||||
<h2>{{ bot?.name ?? "Бот" }}</h2>
|
||||
<p>Сигналы по выбранному боту с отдельными фильтрами, статусами и навигацией по страницам.</p>
|
||||
</div>
|
||||
<div class="sakai-hero-card__tags">
|
||||
<Tag class="rounded" :value="currentTabLabel" severity="contrast" />
|
||||
<Tag class="rounded" :value="`${pagination.total} сигналов`" severity="info" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card class="sakai-filter-card">
|
||||
<template #content>
|
||||
<div class="sakai-filter-grid">
|
||||
<div class="sakai-field">
|
||||
<label for="signal-search">Поиск</label>
|
||||
<InputText id="signal-search" v-model="query" placeholder="Лига, команда, рынок" />
|
||||
</div>
|
||||
|
||||
<div class="sakai-field">
|
||||
<label>Вкладка</label>
|
||||
<div class="sakai-tab-scroll">
|
||||
<SelectButton v-model="activeTab" :options="tabOptions" option-label="label" option-value="value" allow-empty="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sakai-field">
|
||||
<label for="signal-status">Статус</label>
|
||||
<Select
|
||||
id="signal-status"
|
||||
v-model="status"
|
||||
:options="statusOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="Все статусы"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sakai-field">
|
||||
<label for="signal-sort">Сортировка</label>
|
||||
<Select
|
||||
id="signal-sort"
|
||||
v-model="sortBy"
|
||||
:options="sortOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sakai-field">
|
||||
<label for="signal-limit">Лимит</label>
|
||||
<Select
|
||||
id="signal-limit"
|
||||
v-model="perPage"
|
||||
:options="perPageOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sakai-field">
|
||||
<label>Порядок</label>
|
||||
<Button
|
||||
fluid
|
||||
severity="secondary"
|
||||
outlined
|
||||
:label="sortDirection === 'desc' ? 'Сначала новые' : 'Сначала старые'"
|
||||
:icon="sortDirection === 'desc' ? 'pi pi-sort-amount-down' : 'pi pi-sort-amount-up'"
|
||||
@click="sortDirection = sortDirection === 'desc' ? 'asc' : 'desc'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Message v-if="loadError" severity="error" :closable="false">{{ loadError }}</Message>
|
||||
|
||||
<div class="sakai-signal-layout">
|
||||
<Card class="sakai-signal-panel">
|
||||
<template #title>Сигналы</template>
|
||||
<template #subtitle>
|
||||
Всего: {{ tabCounts.all }} · Активные: {{ tabCounts["1"] }} · Неактивные: {{ tabCounts["2"] }}
|
||||
</template>
|
||||
<template #content>
|
||||
<div v-if="loading" class="sakai-signal-list">
|
||||
<div v-for="index in 5" :key="index" class="sakai-signal-skeleton">
|
||||
<Skeleton width="35%" height="1rem" />
|
||||
<Skeleton width="70%" height="1.25rem" />
|
||||
<Skeleton width="55%" height="1rem" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Message v-else-if="sortedSignals.length === 0" severity="secondary" :closable="false">
|
||||
По текущему фильтру сигналов нет.
|
||||
</Message>
|
||||
|
||||
<div v-else class="sakai-signal-list">
|
||||
<NuxtLink
|
||||
v-for="signal in sortedSignals"
|
||||
:key="signal.id"
|
||||
:to="`/signals/${signal.id}`"
|
||||
class="sakai-signal-link"
|
||||
>
|
||||
<SignalCard :signal="signal" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="sakai-summary-panel">
|
||||
<template #title>Сводка</template>
|
||||
<template #content>
|
||||
<div class="sakai-summary-list">
|
||||
<div class="sakai-summary-item">
|
||||
<span>Все сигналы</span>
|
||||
<strong>{{ tabCounts.all }}</strong>
|
||||
</div>
|
||||
<div class="sakai-summary-item">
|
||||
<span>Активные</span>
|
||||
<strong>{{ tabCounts["1"] }}</strong>
|
||||
</div>
|
||||
<div class="sakai-summary-item">
|
||||
<span>Неактивные</span>
|
||||
<strong>{{ tabCounts["2"] }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<template #content>
|
||||
<Paginator
|
||||
:first="(pagination.page - 1) * perPage"
|
||||
:rows="perPage"
|
||||
:total-records="pagination.total"
|
||||
:rows-per-page-options="[20, 40, 100]"
|
||||
template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
|
||||
@page="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</Card>
|
||||
</section>
|
||||
</template>
|
||||
455
frontend/pages/chat.vue
Normal file
@@ -0,0 +1,455 @@
|
||||
<script setup lang="ts">
|
||||
import type { SupportConversation } from "~/types";
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
});
|
||||
|
||||
const { user, token } = useAuth();
|
||||
const { formatDateTime } = useBrowserDateTime();
|
||||
const { connect, disconnect, onConversationUpdated, onMessageCreated } = useSupportRealtime();
|
||||
const { replaceConversations, upsertConversation } = useSupportUnread();
|
||||
|
||||
const conversations = ref<SupportConversation[]>([]);
|
||||
const activeConversationId = ref<string | null>(null);
|
||||
const conversation = ref<SupportConversation | null>(null);
|
||||
const loading = ref(false);
|
||||
const sending = ref(false);
|
||||
const statusSaving = ref(false);
|
||||
const draft = ref("");
|
||||
const errorMessage = ref("");
|
||||
const messageListRef = ref<HTMLElement | null>(null);
|
||||
const isMobile = ref(false);
|
||||
|
||||
const isAdmin = computed(() => user.value?.role === "admin");
|
||||
const showAdminConversation = computed(() => !isAdmin.value || !isMobile.value || Boolean(activeConversationId.value));
|
||||
const showAdminList = computed(() => isAdmin.value && (!isMobile.value || !activeConversationId.value));
|
||||
|
||||
const activeConversation = computed(() => {
|
||||
if (!isAdmin.value) {
|
||||
return conversation.value;
|
||||
}
|
||||
|
||||
return conversations.value.find((item) => item.id === activeConversationId.value) ?? conversation.value;
|
||||
});
|
||||
|
||||
const canSend = computed(() => draft.value.trim().length > 0 && !sending.value);
|
||||
|
||||
const sortConversations = (items: SupportConversation[]) =>
|
||||
[...items].sort((left, right) => new Date(right.lastMessageAt).getTime() - new Date(left.lastMessageAt).getTime());
|
||||
|
||||
const updateConversationInList = (nextConversation: SupportConversation) => {
|
||||
const withoutCurrent = conversations.value.filter((item) => item.id !== nextConversation.id);
|
||||
conversations.value = sortConversations([nextConversation, ...withoutCurrent]);
|
||||
};
|
||||
|
||||
const replaceActiveConversation = (nextConversation: SupportConversation) => {
|
||||
conversation.value = nextConversation;
|
||||
updateConversationInList(nextConversation);
|
||||
|
||||
if (!activeConversationId.value) {
|
||||
activeConversationId.value = nextConversation.id;
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick();
|
||||
|
||||
if (!messageListRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
|
||||
};
|
||||
|
||||
const loadUserConversation = async () => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const response = await useChatApi<SupportConversation>("/support/conversation");
|
||||
conversation.value = response;
|
||||
activeConversationId.value = response.id;
|
||||
conversations.value = [response];
|
||||
replaceConversations([response]);
|
||||
errorMessage.value = "";
|
||||
await scrollToBottom();
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "Не удалось загрузить чат";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadAdminConversations = async () => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const response = await useChatApi<SupportConversation[]>("/admin/support/conversations");
|
||||
conversations.value = sortConversations(response);
|
||||
replaceConversations(response);
|
||||
|
||||
if (!activeConversationId.value && response.length > 0) {
|
||||
activeConversationId.value = response[0].id;
|
||||
}
|
||||
|
||||
if (activeConversationId.value) {
|
||||
await loadAdminConversation(activeConversationId.value);
|
||||
} else {
|
||||
conversation.value = null;
|
||||
}
|
||||
|
||||
errorMessage.value = "";
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "Не удалось загрузить обращения";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadAdminConversation = async (conversationId: string) => {
|
||||
activeConversationId.value = conversationId;
|
||||
|
||||
try {
|
||||
const response = await useChatApi<SupportConversation>(`/admin/support/conversations/${conversationId}`);
|
||||
replaceActiveConversation(response);
|
||||
upsertConversation(response);
|
||||
errorMessage.value = "";
|
||||
await scrollToBottom();
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "Не удалось открыть диалог";
|
||||
}
|
||||
};
|
||||
|
||||
const closeMobileConversation = () => {
|
||||
if (!isAdmin.value || !isMobile.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeConversationId.value = null;
|
||||
conversation.value = null;
|
||||
};
|
||||
|
||||
const loadPage = async () => {
|
||||
if (isAdmin.value) {
|
||||
await loadAdminConversations();
|
||||
return;
|
||||
}
|
||||
|
||||
await loadUserConversation();
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
const body = draft.value.trim();
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
|
||||
sending.value = true;
|
||||
|
||||
try {
|
||||
const response = isAdmin.value
|
||||
? await useChatApi<SupportConversation>(`/admin/support/conversations/${activeConversationId.value}/messages`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
body
|
||||
}
|
||||
})
|
||||
: await useChatApi<SupportConversation>("/support/conversation/messages", {
|
||||
method: "POST",
|
||||
body: {
|
||||
body
|
||||
}
|
||||
});
|
||||
|
||||
draft.value = "";
|
||||
replaceActiveConversation(response);
|
||||
upsertConversation(response);
|
||||
errorMessage.value = "";
|
||||
await scrollToBottom();
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "Не удалось отправить сообщение";
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setConversationStatus = async (status: "open" | "closed") => {
|
||||
if (!isAdmin.value || !activeConversationId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
statusSaving.value = true;
|
||||
|
||||
try {
|
||||
const response = await useChatApi<SupportConversation>(`/admin/support/conversations/${activeConversationId.value}`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
status
|
||||
}
|
||||
});
|
||||
|
||||
replaceActiveConversation(response);
|
||||
upsertConversation(response);
|
||||
errorMessage.value = "";
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "Не удалось обновить статус";
|
||||
} finally {
|
||||
statusSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRealtimeConversation = async (payload: { conversation: SupportConversation }) => {
|
||||
if (!payload?.conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateConversationInList(payload.conversation);
|
||||
upsertConversation(payload.conversation);
|
||||
|
||||
if (!isAdmin.value) {
|
||||
conversation.value = payload.conversation;
|
||||
await scrollToBottom();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeConversationId.value) {
|
||||
activeConversationId.value = payload.conversation.id;
|
||||
}
|
||||
|
||||
if (payload.conversation.id === activeConversationId.value) {
|
||||
conversation.value = payload.conversation;
|
||||
await scrollToBottom();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRealtimeMessage = async (payload: { conversation: SupportConversation }) => {
|
||||
await handleRealtimeConversation(payload);
|
||||
};
|
||||
|
||||
let offConversationUpdated: (() => void) | null = null;
|
||||
let offMessageCreated: (() => void) | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
if (process.client) {
|
||||
const mediaQuery = window.matchMedia("(max-width: 767px)");
|
||||
const syncIsMobile = () => {
|
||||
isMobile.value = mediaQuery.matches;
|
||||
};
|
||||
|
||||
syncIsMobile();
|
||||
mediaQuery.addEventListener("change", syncIsMobile);
|
||||
onBeforeUnmount(() => {
|
||||
mediaQuery.removeEventListener("change", syncIsMobile);
|
||||
});
|
||||
}
|
||||
|
||||
await loadPage();
|
||||
|
||||
if (!process.client || !token.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
connect();
|
||||
offConversationUpdated = onConversationUpdated((payload) => {
|
||||
void handleRealtimeConversation(payload);
|
||||
});
|
||||
offMessageCreated = onMessageCreated((payload) => {
|
||||
void handleRealtimeMessage(payload);
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
offConversationUpdated?.();
|
||||
offConversationUpdated = null;
|
||||
offMessageCreated?.();
|
||||
offMessageCreated = null;
|
||||
disconnect();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => activeConversation.value?.messages?.length,
|
||||
() => {
|
||||
void scrollToBottom();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page admin-grid">
|
||||
<div class="page-header page-header--admin-section">
|
||||
<div>
|
||||
<h2>{{ isAdmin ? "Обращения пользователей" : "Чат с поддержкой" }}</h2>
|
||||
<p class="muted">
|
||||
{{
|
||||
isAdmin
|
||||
? "Все администраторы видят единый поток обращений и могут отвечать пользователям прямо из приложения."
|
||||
: "Напишите админу прямо здесь. Когда поддержка ответит, сообщение появится в этом чате."
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||
|
||||
<div
|
||||
class="grid gap-4"
|
||||
:style="isAdmin && !isMobile ? { gridTemplateColumns: 'minmax(280px, 360px) minmax(0, 1fr)' } : undefined"
|
||||
>
|
||||
<div v-if="showAdminList" class="panel" style="padding: 0;">
|
||||
<div class="admin-list" style="padding: 1rem;">
|
||||
<div
|
||||
v-for="item in conversations"
|
||||
:key="item.id"
|
||||
class="rounded-2xl border p-4 transition cursor-pointer"
|
||||
:style="{
|
||||
borderColor: item.id === activeConversationId ? 'var(--accent)' : 'var(--border)',
|
||||
backgroundColor: item.id === activeConversationId ? 'color-mix(in srgb, var(--accent) 8%, var(--surface))' : 'var(--surface)'
|
||||
}"
|
||||
@click="loadAdminConversation(item.id)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<strong class="block truncate">{{ item.user.email }}</strong>
|
||||
<span class="text-sm text-(--muted)">
|
||||
{{ item.status === "closed" ? "Закрыт" : item.unreadForAdmin ? "Новое сообщение" : "Открыт" }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="item.unreadForAdmin"
|
||||
class="signal-row__badge signal-row__badge--win"
|
||||
>
|
||||
новое
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-3 mb-1 text-sm break-words">
|
||||
{{ item.latestMessage?.body || "Пока без сообщений" }}
|
||||
</p>
|
||||
<p class="m-0 text-xs text-(--muted)">
|
||||
{{ formatDateTime(item.lastMessageAt) }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="!conversations.length && !loading" class="muted">Обращений пока нет.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showAdminConversation" class="panel">
|
||||
<div
|
||||
v-if="activeConversation"
|
||||
class="grid gap-4"
|
||||
:style="{ minHeight: isAdmin ? '70vh' : '60vh', gridTemplateRows: 'auto minmax(0, 1fr) auto' }"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
v-if="isAdmin && isMobile"
|
||||
type="button"
|
||||
class="secondary"
|
||||
style="padding-inline: 0.85rem;"
|
||||
@click="closeMobileConversation"
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<strong class="block">
|
||||
{{ isAdmin ? activeConversation.user.email : "Поддержка" }}
|
||||
</strong>
|
||||
<span class="text-sm text-(--muted)">
|
||||
{{
|
||||
activeConversation.status === "closed"
|
||||
? "Диалог закрыт"
|
||||
: isAdmin
|
||||
? "Отвечайте пользователю от лица поддержки"
|
||||
: "Админы ответят сюда же"
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isAdmin" class="flex gap-2">
|
||||
<button
|
||||
class="secondary"
|
||||
:disabled="statusSaving || activeConversation.status === 'open'"
|
||||
@click="setConversationStatus('open')"
|
||||
>
|
||||
Открыть
|
||||
</button>
|
||||
<button
|
||||
:disabled="statusSaving || activeConversation.status === 'closed'"
|
||||
@click="setConversationStatus('closed')"
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="messageListRef"
|
||||
class="grid gap-3 overflow-y-auto rounded-2xl border p-3"
|
||||
:style="{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }"
|
||||
>
|
||||
<div
|
||||
v-for="message in activeConversation.messages ?? []"
|
||||
:key="message.id"
|
||||
class="flex"
|
||||
:style="{ justifyContent: message.author.id === user?.id ? 'flex-end' : 'flex-start' }"
|
||||
>
|
||||
<div
|
||||
class="max-w-[85%] rounded-2xl px-4 py-3"
|
||||
:style="message.author.id === user?.id
|
||||
? {
|
||||
backgroundColor: 'color-mix(in srgb, var(--accent) 18%, var(--surface))',
|
||||
border: '1px solid color-mix(in srgb, var(--accent) 35%, var(--border))'
|
||||
}
|
||||
: {
|
||||
backgroundColor: 'var(--surface-strong)',
|
||||
border: '1px solid var(--border)'
|
||||
}"
|
||||
>
|
||||
<div class="mb-2 text-xs text-(--muted)">
|
||||
{{ message.author.email }} · {{ formatDateTime(message.createdAt) }}
|
||||
</div>
|
||||
<div class="whitespace-pre-wrap break-words">{{ message.body }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!(activeConversation.messages ?? []).length" class="muted">
|
||||
{{ isAdmin ? "В этом диалоге ещё нет сообщений." : "Напишите первое сообщение админу." }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form class="grid gap-3" @submit.prevent="sendMessage">
|
||||
<textarea
|
||||
v-model="draft"
|
||||
rows="4"
|
||||
placeholder="Введите сообщение"
|
||||
:disabled="activeConversation.status === 'closed' && !isAdmin"
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-sm text-(--muted)">
|
||||
{{
|
||||
activeConversation.status === "closed"
|
||||
? isAdmin
|
||||
? "Диалог закрыт, но администратор может открыть его снова."
|
||||
: "Диалог закрыт. Дождитесь, пока администратор откроет его снова."
|
||||
: "Сообщения доставляются в реальном времени."
|
||||
}}
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!canSend || (!isAdmin && activeConversation.status === 'closed')"
|
||||
>
|
||||
{{ sending ? "Отправка..." : "Отправить" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="muted">Загрузка чата...</div>
|
||||
<div v-else class="muted">
|
||||
{{ isAdmin ? "Выберите диалог слева или дождитесь нового обращения." : "Чат пока недоступен." }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
75
frontend/pages/forgot-password.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
const email = ref("");
|
||||
const error = ref("");
|
||||
const success = ref("");
|
||||
const pending = ref(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = "";
|
||||
success.value = "";
|
||||
pending.value = true;
|
||||
|
||||
try {
|
||||
const result = await useApi<{ message: string }>("/auth/forgot-password", {
|
||||
method: "POST",
|
||||
body: { email: email.value }
|
||||
});
|
||||
|
||||
success.value = result.message;
|
||||
} catch (submitError) {
|
||||
error.value = submitError instanceof Error ? submitError.message : "Не удалось отправить письмо";
|
||||
} finally {
|
||||
pending.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto grid min-h-[calc(100vh-12rem)] w-full items-center gap-8 md:max-w-6xl md:grid-cols-[minmax(0,1.1fr)_minmax(22rem,28rem)] md:[min-height:calc(100vh-10rem)]">
|
||||
<div class="grid max-w-lg gap-3">
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.18em] text-(--muted)">Alpinbet</p>
|
||||
<h1 class="m-0 text-4xl font-semibold leading-tight">Восстановление пароля</h1>
|
||||
<p class="m-0 text-base leading-7 text-(--muted)">
|
||||
Укажите email аккаунта. Мы отправим ссылку, по которой можно задать новый пароль.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="grid w-full max-w-md gap-4 rounded-[28px] border p-4"
|
||||
:style="{ borderColor: 'var(--border)', backgroundColor: 'var(--surface-strong)' }"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.18em] text-(--muted)">Запрос письма</p>
|
||||
|
||||
<label class="grid gap-1">
|
||||
<span>Email</span>
|
||||
<input v-model="email" type="email" autocomplete="email" />
|
||||
</label>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
class="rounded-2xl p-4 text-sm whitespace-pre-line"
|
||||
:style="{ backgroundColor: 'var(--danger-bg)', color: 'var(--danger-text)' }"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="success"
|
||||
class="rounded-2xl p-4 text-sm"
|
||||
:style="{ backgroundColor: 'var(--success-bg)', color: 'var(--success-text)' }"
|
||||
>
|
||||
{{ success }}
|
||||
</p>
|
||||
|
||||
<button type="submit" :disabled="pending">
|
||||
{{ pending ? "Отправляем..." : "Отправить ссылку" }}
|
||||
</button>
|
||||
|
||||
<p class="m-0 text-(--muted)">
|
||||
Вспомнили пароль?
|
||||
<NuxtLink class="text-(--accent-strong)" to="/login">Вернуться ко входу</NuxtLink>
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
134
frontend/pages/index.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import Card from "primevue/card";
|
||||
import Message from "primevue/message";
|
||||
import Skeleton from "primevue/skeleton";
|
||||
import Tag from "primevue/tag";
|
||||
import type { ActiveSignalCountByBot, Bot } from "~/types";
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
});
|
||||
|
||||
type BotCard = {
|
||||
bot: Bot;
|
||||
totalSignals: number;
|
||||
};
|
||||
|
||||
const { user } = useAuth();
|
||||
|
||||
const loading = ref(true);
|
||||
const loadError = ref("");
|
||||
const botCards = ref<BotCard[]>([]);
|
||||
|
||||
const availableBots = computed(() => user.value?.botAccesses?.map((access) => access.bot) ?? []);
|
||||
|
||||
const formatSignalCount = (count: number) => {
|
||||
if (count === 0) return "Нет активных сигналов";
|
||||
if (count === 1) return "1 активный сигнал";
|
||||
if (count < 5) return `${count} активных сигнала`;
|
||||
return `${count} активных сигналов`;
|
||||
};
|
||||
|
||||
const loadBotCards = async () => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const response = await useApi<{ items: ActiveSignalCountByBot[] }>("/signals/active-counts");
|
||||
const countsByBotKey = new Map(response.items.map((item) => [item.botKey, item.activeSignals]));
|
||||
const cards = availableBots.value.map((bot) => ({
|
||||
bot,
|
||||
totalSignals: countsByBotKey.get(bot.key) ?? 0
|
||||
}));
|
||||
|
||||
botCards.value = cards;
|
||||
loadError.value = "";
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : "Не удалось загрузить список ботов";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadBotCards();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => availableBots.value.map((bot) => bot.key).join("|"),
|
||||
(nextKeys, previousKeys) => {
|
||||
if (nextKeys === previousKeys) return;
|
||||
void loadBotCards();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="grid gap-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 rounded-[24px] border p-5 md:flex-row md:items-start md:justify-between"
|
||||
:style="{
|
||||
borderColor: 'var(--border)',
|
||||
background: 'radial-gradient(circle at top right, color-mix(in srgb, var(--accent) 10%, transparent), transparent 28%), var(--surface)',
|
||||
boxShadow: '0 10px 30px color-mix(in srgb, var(--text) 4%, transparent)'
|
||||
}"
|
||||
>
|
||||
<div>
|
||||
<span class="mb-2 inline-block text-xs font-semibold uppercase tracking-[0.18em] text-(--muted)">Доступные боты</span>
|
||||
<h2 class="m-0 text-3xl font-semibold">Выберите бота</h2>
|
||||
<p class="m-0 text-sm leading-6 text-(--muted)">
|
||||
Каждая карточка открывает отдельную ленту сигналов без общего шума и лишних фильтров на старте.
|
||||
</p>
|
||||
</div>
|
||||
<Tag class="rounded" :value="`${availableBots.length} в доступе`" severity="contrast" />
|
||||
</div>
|
||||
|
||||
<Message v-if="loadError" severity="error" :closable="false">{{ loadError }}</Message>
|
||||
<Message v-else-if="!loading && botCards.length === 0" severity="info" :closable="false">
|
||||
У пользователя пока нет доступа ни к одному боту.
|
||||
</Message>
|
||||
|
||||
<div v-if="loading" class="bot-grid">
|
||||
<Card v-for="index in 6" :key="index" class="overflow-hidden rounded-[24px] border shadow-sm">
|
||||
<template #content>
|
||||
<div class="grid gap-3 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Skeleton width="2.75rem" height="2.75rem" borderRadius="16px" />
|
||||
<Skeleton width="2.25rem" height="1.75rem" borderRadius="999px" />
|
||||
</div>
|
||||
<Skeleton width="3rem" height="0.7rem" />
|
||||
<Skeleton width="72%" height="1.45rem" />
|
||||
<Skeleton width="58%" height="0.95rem" />
|
||||
<Skeleton width="100%" height="2.5rem" borderRadius="16px" />
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div v-else class="bot-grid">
|
||||
<NuxtLink
|
||||
v-for="card in botCards"
|
||||
:key="card.bot.id"
|
||||
:to="`/bots/${card.bot.key}`"
|
||||
class="bot-tile"
|
||||
>
|
||||
<div class="bot-tile__top">
|
||||
<div class="bot-tile__icon">
|
||||
<AppLogo size="26px" />
|
||||
</div>
|
||||
<Tag class="bot-tile__count" :value="String(card.totalSignals)" severity="contrast" />
|
||||
</div>
|
||||
|
||||
<div class="bot-tile__body">
|
||||
<p class="bot-tile__eyebrow">Бот</p>
|
||||
<h2>{{ card.bot.name }}</h2>
|
||||
<p class="bot-tile__meta">{{ formatSignalCount(card.totalSignals) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bot-tile__footer">
|
||||
<span class="bot-tile__action">Открыть ленту</span>
|
||||
<i class="pi pi-arrow-right bot-tile__arrow" />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
108
frontend/pages/login.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import type { User } from "~/types";
|
||||
|
||||
const { login, user, refreshMe } = useAuth();
|
||||
const { claimAnonymousPushSubscriptions, consumePendingPushRoute } = usePush();
|
||||
const route = useRoute();
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const showPassword = ref(false);
|
||||
const error = ref("");
|
||||
const redirectTarget = computed(() =>
|
||||
typeof route.query.redirect === "string" ? route.query.redirect : "/"
|
||||
);
|
||||
|
||||
watch(
|
||||
user,
|
||||
(currentUser) => {
|
||||
if (currentUser) {
|
||||
void navigateTo(redirectTarget.value);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const result = await useApi<{ token?: string; user: User }>("/auth/login", {
|
||||
method: "POST",
|
||||
body: { email: email.value, password: password.value }
|
||||
});
|
||||
|
||||
await login(result.token ?? null, result.user);
|
||||
await refreshMe();
|
||||
await claimAnonymousPushSubscriptions();
|
||||
const redirectedFromPush = await consumePendingPushRoute();
|
||||
if (!redirectedFromPush) {
|
||||
await navigateTo(redirectTarget.value);
|
||||
}
|
||||
} catch (submitError) {
|
||||
error.value = submitError instanceof Error ? submitError.message : "Ошибка входа";
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto grid min-h-[calc(100vh-12rem)] w-full items-center gap-8 md:max-w-6xl md:grid-cols-[minmax(0,1.1fr)_minmax(22rem,28rem)] md:[min-height:calc(100vh-10rem)]">
|
||||
<div class="grid max-w-lg gap-3">
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.18em] text-(--muted)">Alpinbet</p>
|
||||
<h1 class="m-0 text-4xl font-semibold leading-tight">Вход в систему</h1>
|
||||
<p class="m-0 text-base leading-7 text-(--muted)">
|
||||
Авторизуйтесь, чтобы открыть доступ к ботам, сигналам и настройкам уведомлений.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="grid w-full max-w-md gap-4 rounded-[28px] border p-4"
|
||||
:style="{ borderColor: 'var(--border)', backgroundColor: 'var(--surface-strong)' }"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.18em] text-(--muted)">Авторизация</p>
|
||||
|
||||
<label class="grid gap-1">
|
||||
<span>Email</span>
|
||||
<input v-model="email" type="email" autocomplete="email" />
|
||||
</label>
|
||||
|
||||
<label class="grid gap-1">
|
||||
<span>Пароль</span>
|
||||
<div class="password-field">
|
||||
<input
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="password-field__toggle"
|
||||
:aria-label="showPassword ? 'Скрыть пароль' : 'Показать пароль'"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<i class="pi" :class="showPassword ? 'pi-eye-slash' : 'pi-eye'" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<NuxtLink class="justify-self-end text-sm text-(--accent-strong)" to="/forgot-password">
|
||||
Забыли пароль?
|
||||
</NuxtLink>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
class="rounded-2xl p-4 text-sm whitespace-pre-line"
|
||||
:style="{ backgroundColor: 'var(--danger-bg)', color: 'var(--danger-text)' }"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<button type="submit">Войти</button>
|
||||
|
||||
<p class="m-0 text-(--muted)">
|
||||
Нет аккаунта?
|
||||
<NuxtLink class="text-(--accent-strong)" to="/register">Зарегистрироваться</NuxtLink>
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
135
frontend/pages/register.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import type { User } from "~/types";
|
||||
|
||||
const { login, user, refreshMe } = useAuth();
|
||||
const { claimAnonymousPushSubscriptions, consumePendingPushRoute } = usePush();
|
||||
const route = useRoute();
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const confirmPassword = ref("");
|
||||
const showPassword = ref(false);
|
||||
const showConfirmPassword = ref(false);
|
||||
const error = ref("");
|
||||
const redirectTarget = computed(() =>
|
||||
typeof route.query.redirect === "string" ? route.query.redirect : "/"
|
||||
);
|
||||
|
||||
watch(
|
||||
user,
|
||||
(currentUser) => {
|
||||
if (currentUser) {
|
||||
void navigateTo(redirectTarget.value);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = "";
|
||||
|
||||
if (!password.value || password.value.length < 6) {
|
||||
error.value = "Пароль должен быть не короче 6 символов";
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = "Пароли не совпадают";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await useApi<{ token?: string; user: User }>("/auth/register", {
|
||||
method: "POST",
|
||||
body: { email: email.value, password: password.value }
|
||||
});
|
||||
|
||||
await login(result.token ?? null, result.user);
|
||||
await refreshMe();
|
||||
await claimAnonymousPushSubscriptions();
|
||||
const redirectedFromPush = await consumePendingPushRoute();
|
||||
if (!redirectedFromPush) {
|
||||
await navigateTo(redirectTarget.value);
|
||||
}
|
||||
} catch (submitError) {
|
||||
error.value = submitError instanceof Error ? submitError.message : "Ошибка регистрации";
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto grid min-h-[calc(100vh-12rem)] w-full items-center gap-8 md:max-w-6xl md:grid-cols-[minmax(0,1.1fr)_minmax(22rem,28rem)] md:[min-height:calc(100vh-10rem)]">
|
||||
<div class="grid max-w-lg gap-3">
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.18em] text-(--muted)">Alpinbet</p>
|
||||
<h1 class="m-0 text-4xl font-semibold leading-tight">Создание аккаунта</h1>
|
||||
<p class="m-0 text-base leading-7 text-(--muted)">
|
||||
После регистрации администратор сможет выдать доступы к нужным ботам и сигналам.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="grid w-full max-w-md gap-4 rounded-[28px] border p-4"
|
||||
:style="{ borderColor: 'var(--border)', backgroundColor: 'var(--surface-strong)' }"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.18em] text-(--muted)">Новый аккаунт</p>
|
||||
|
||||
<label class="grid gap-1">
|
||||
<span>Email</span>
|
||||
<input v-model="email" type="email" autocomplete="email" />
|
||||
</label>
|
||||
|
||||
<label class="grid gap-1">
|
||||
<span>Пароль</span>
|
||||
<div class="password-field">
|
||||
<input
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="password-field__toggle"
|
||||
:aria-label="showPassword ? 'Скрыть пароль' : 'Показать пароль'"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<i class="pi" :class="showPassword ? 'pi-eye-slash' : 'pi-eye'" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="grid gap-1">
|
||||
<span>Повторите пароль</span>
|
||||
<div class="password-field">
|
||||
<input
|
||||
v-model="confirmPassword"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="password-field__toggle"
|
||||
:aria-label="showConfirmPassword ? 'Скрыть пароль' : 'Показать пароль'"
|
||||
@click="showConfirmPassword = !showConfirmPassword"
|
||||
>
|
||||
<i class="pi" :class="showConfirmPassword ? 'pi-eye-slash' : 'pi-eye'" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
class="rounded-2xl p-4 text-sm whitespace-pre-line"
|
||||
:style="{ backgroundColor: 'var(--danger-bg)', color: 'var(--danger-text)' }"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<button type="submit">Создать аккаунт</button>
|
||||
|
||||
<p class="m-0 text-(--muted)">
|
||||
Уже есть аккаунт?
|
||||
<NuxtLink class="text-(--accent-strong)" to="/login">Войти</NuxtLink>
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
130
frontend/pages/reset-password.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const token = computed(() => (typeof route.query.token === "string" ? route.query.token : ""));
|
||||
const password = ref("");
|
||||
const confirmPassword = ref("");
|
||||
const showPassword = ref(false);
|
||||
const showConfirmPassword = ref(false);
|
||||
const error = ref("");
|
||||
const success = ref("");
|
||||
const pending = ref(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = "";
|
||||
success.value = "";
|
||||
|
||||
if (!token.value) {
|
||||
error.value = "Ссылка восстановления некорректна";
|
||||
return;
|
||||
}
|
||||
|
||||
pending.value = true;
|
||||
|
||||
try {
|
||||
const result = await useApi<{ message: string }>("/auth/reset-password", {
|
||||
method: "POST",
|
||||
body: {
|
||||
token: token.value,
|
||||
password: password.value,
|
||||
confirmPassword: confirmPassword.value
|
||||
}
|
||||
});
|
||||
|
||||
success.value = result.message;
|
||||
password.value = "";
|
||||
confirmPassword.value = "";
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 1500);
|
||||
} catch (submitError) {
|
||||
error.value = submitError instanceof Error ? submitError.message : "Не удалось обновить пароль";
|
||||
} finally {
|
||||
pending.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto grid min-h-[calc(100vh-12rem)] w-full items-center gap-8 md:max-w-6xl md:grid-cols-[minmax(0,1.1fr)_minmax(22rem,28rem)] md:[min-height:calc(100vh-10rem)]">
|
||||
<div class="grid max-w-lg gap-3">
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.18em] text-(--muted)">Alpinbet</p>
|
||||
<h1 class="m-0 text-4xl font-semibold leading-tight">Новый пароль</h1>
|
||||
<p class="m-0 text-base leading-7 text-(--muted)">
|
||||
Установите новый пароль для аккаунта. После сохранения можно будет сразу войти в систему.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="grid w-full max-w-md gap-4 rounded-[28px] border p-4"
|
||||
:style="{ borderColor: 'var(--border)', backgroundColor: 'var(--surface-strong)' }"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.18em] text-(--muted)">Смена пароля</p>
|
||||
|
||||
<label class="grid gap-1">
|
||||
<span>Новый пароль</span>
|
||||
<div class="password-field">
|
||||
<input
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="password-field__toggle"
|
||||
:aria-label="showPassword ? 'Скрыть пароль' : 'Показать пароль'"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<i class="pi" :class="showPassword ? 'pi-eye-slash' : 'pi-eye'" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="grid gap-1">
|
||||
<span>Повторите пароль</span>
|
||||
<div class="password-field">
|
||||
<input
|
||||
v-model="confirmPassword"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="password-field__toggle"
|
||||
:aria-label="showConfirmPassword ? 'Скрыть пароль' : 'Показать пароль'"
|
||||
@click="showConfirmPassword = !showConfirmPassword"
|
||||
>
|
||||
<i class="pi" :class="showConfirmPassword ? 'pi-eye-slash' : 'pi-eye'" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
class="rounded-2xl p-4 text-sm whitespace-pre-line"
|
||||
:style="{ backgroundColor: 'var(--danger-bg)', color: 'var(--danger-text)' }"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="success"
|
||||
class="rounded-2xl p-4 text-sm"
|
||||
:style="{ backgroundColor: 'var(--success-bg)', color: 'var(--success-text)' }"
|
||||
>
|
||||
{{ success }}
|
||||
</p>
|
||||
|
||||
<button type="submit" :disabled="pending || !token">
|
||||
{{ pending ? "Сохраняем..." : "Сохранить пароль" }}
|
||||
</button>
|
||||
|
||||
<p class="m-0 text-(--muted)">
|
||||
Нужна новая ссылка?
|
||||
<NuxtLink class="text-(--accent-strong)" to="/forgot-password">Запросить повторно</NuxtLink>
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
127
frontend/pages/settings.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { App as CapacitorApp } from "@capacitor/app";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import type { NotificationSettings, UserBotAccess } from "~/types";
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
});
|
||||
|
||||
const { ensurePushSubscription } = usePush();
|
||||
const settings = ref<NotificationSettings>({
|
||||
signalsPushEnabled: true,
|
||||
resultsPushEnabled: false
|
||||
});
|
||||
const subscriptions = ref<UserBotAccess[]>([]);
|
||||
const message = ref("");
|
||||
const appVersion = ref("");
|
||||
const isAndroidApp = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [notificationSettings, subscriptionItems] = await Promise.all([
|
||||
useApi<NotificationSettings>("/me/notification-settings"),
|
||||
useApi<UserBotAccess[]>("/me/subscriptions")
|
||||
]);
|
||||
|
||||
settings.value = notificationSettings;
|
||||
subscriptions.value = subscriptionItems;
|
||||
} catch {
|
||||
message.value = "";
|
||||
}
|
||||
|
||||
if (!process.client || !Capacitor.isNativePlatform() || Capacitor.getPlatform() !== "android") {
|
||||
return;
|
||||
}
|
||||
|
||||
isAndroidApp.value = true;
|
||||
|
||||
try {
|
||||
const appInfo = await CapacitorApp.getInfo();
|
||||
appVersion.value = appInfo.version?.trim() ?? "";
|
||||
} catch {
|
||||
appVersion.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
settings.value = await useApi<NotificationSettings>("/me/notification-settings", {
|
||||
method: "PATCH",
|
||||
body: settings.value
|
||||
});
|
||||
message.value = "Настройки сохранены";
|
||||
};
|
||||
|
||||
const enablePush = async () => {
|
||||
await ensurePushSubscription();
|
||||
message.value = "Push-уведомления подключены";
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<div class="panel notification-settings">
|
||||
<div class="notification-settings__header">
|
||||
<p class="eyebrow">Уведомления</p>
|
||||
<h1>Настройки уведомлений</h1>
|
||||
<p class="muted">Выберите, какие уведомления должны приходить на это устройство.</p>
|
||||
</div>
|
||||
|
||||
<div class="notification-settings__list">
|
||||
<label class="notification-option">
|
||||
<input v-model="settings.signalsPushEnabled" type="checkbox" />
|
||||
<span class="notification-option__body">
|
||||
<strong>Новые сигналы</strong>
|
||||
<small>Мгновенные push-уведомления при появлении новых матчей и сигналов.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="notification-option">
|
||||
<input v-model="settings.resultsPushEnabled" type="checkbox" />
|
||||
<span class="notification-option__body">
|
||||
<strong>Результаты сигналов</strong>
|
||||
<small>Уведомления после расчёта исхода: выигрыш, проигрыш или возврат.</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="notification-settings__actions">
|
||||
<button @click="save">Сохранить настройки</button>
|
||||
<button class="secondary" @click="enablePush">Подключить Web Push</button>
|
||||
</div>
|
||||
|
||||
<p v-if="message" class="success">{{ message }}</p>
|
||||
</div>
|
||||
|
||||
<div class="panel notification-settings">
|
||||
<div class="notification-settings__header">
|
||||
<p class="eyebrow">Подписки</p>
|
||||
<h2>Доступ к ботам</h2>
|
||||
<p class="muted">Здесь показаны текущие подписки и срок действия доступа.</p>
|
||||
</div>
|
||||
|
||||
<div class="notification-settings__list">
|
||||
<div v-for="subscription in subscriptions" :key="subscription.id" class="notification-option">
|
||||
<span class="notification-option__body">
|
||||
<strong>{{ subscription.bot.name }}</strong>
|
||||
<small>
|
||||
Статус: {{ subscription.status }}
|
||||
<template v-if="subscription.expiresAt">
|
||||
· до {{ new Date(subscription.expiresAt).toLocaleString("ru-RU") }}
|
||||
</template>
|
||||
<template v-else>
|
||||
· без ограничения по сроку
|
||||
</template>
|
||||
</small>
|
||||
<small v-if="subscription.notes">{{ subscription.notes }}</small>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="subscriptions.length === 0" class="muted">Подписок пока нет.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isAndroidApp && appVersion" class="settings-version">
|
||||
Версия приложения для Android: {{ appVersion }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
271
frontend/pages/signals/[id].vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<script setup lang="ts">
|
||||
import Button from "primevue/button";
|
||||
import Card from "primevue/card";
|
||||
import Message from "primevue/message";
|
||||
import Skeleton from "primevue/skeleton";
|
||||
import Tag from "primevue/tag";
|
||||
import type { Signal } from "~/types";
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
});
|
||||
|
||||
type SignalDetails = Signal & {
|
||||
settlement?: {
|
||||
explanation: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const { formatDateTime, browserTimeZone } = useBrowserDateTime();
|
||||
const { copyText } = useClipboard();
|
||||
const signal = ref<SignalDetails | null>(null);
|
||||
const loadError = ref("");
|
||||
|
||||
const botName = computed(() => signal.value?.rawPayload?.botName || null);
|
||||
const botKey = computed(() => signal.value?.rawPayload?.botKey || null);
|
||||
const forecastImageUrl = computed(() => signal.value?.rawPayload?.forecastImageUrl || null);
|
||||
const displayForecast = computed(() => signal.value?.forecast || signal.value?.rawPayload?.forecast || "");
|
||||
const forecastImageFailed = ref(false);
|
||||
const isInactiveForecast = computed(() => signal.value?.rawPayload?.forecastInactive === true);
|
||||
const copiedMatch = ref(false);
|
||||
let copiedMatchResetTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const statusLabels: Record<Signal["status"], string> = {
|
||||
pending: "LIVE",
|
||||
win: "WIN",
|
||||
lose: "LOSE",
|
||||
void: "VOID",
|
||||
manual_review: "CHECK",
|
||||
unpublished: "OFF"
|
||||
};
|
||||
|
||||
const displayStatusLabel = computed(() => {
|
||||
if (!signal.value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (signal.value.status === "pending" && isInactiveForecast.value) {
|
||||
return "OFF";
|
||||
}
|
||||
|
||||
return statusLabels[signal.value.status];
|
||||
});
|
||||
|
||||
const statusSeverity = computed(() => {
|
||||
if (!signal.value) {
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
if (signal.value.status === "pending" && isInactiveForecast.value) {
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
switch (signal.value.status) {
|
||||
case "win":
|
||||
return "success";
|
||||
case "lose":
|
||||
return "danger";
|
||||
case "manual_review":
|
||||
return "warn";
|
||||
case "pending":
|
||||
return "info";
|
||||
default:
|
||||
return "secondary";
|
||||
}
|
||||
});
|
||||
|
||||
const formattedSignalTime = computed(() => {
|
||||
return signal.value ? formatDateTime(signal.value.signalTime) : ""
|
||||
});
|
||||
const formattedOdds = computed(() => (signal.value ? signal.value.odds.toFixed(2) : ""));
|
||||
const formattedLineValue = computed(() => {
|
||||
if (!signal.value || signal.value.lineValue === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return String(signal.value.lineValue);
|
||||
});
|
||||
|
||||
const shouldShowForecastImage = computed(() => Boolean(forecastImageUrl.value) && !forecastImageFailed.value);
|
||||
|
||||
const detailItems = computed(() => {
|
||||
if (!signal.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: "Статус", value: displayStatusLabel.value },
|
||||
// { label: "Источник", value: signal.value.sourceType },
|
||||
{ label: "Время сигнала", value: formattedSignalTime.value },
|
||||
{ label: "Часовой пояс", value: browserTimeZone.value ?? "UTC" },
|
||||
{ label: "Коэффициент", value: formattedOdds.value },
|
||||
{ label: "Линия", value: formattedLineValue.value ?? "Не указана" }
|
||||
];
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
signal.value = await useApi<SignalDetails>(`/signals/${route.params.id}`);
|
||||
loadError.value = "";
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : "Не удалось загрузить сигнал";
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => signal.value?.rawPayload?.forecastImageUrl,
|
||||
() => {
|
||||
forecastImageFailed.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
const copyMatchName = async () => {
|
||||
const copied = await copyText(`${signal.value?.homeTeam ?? ""} - ${signal.value?.awayTeam ?? ""}`);
|
||||
if (!copied) {
|
||||
return;
|
||||
}
|
||||
copiedMatch.value = true;
|
||||
|
||||
if (copiedMatchResetTimeout) {
|
||||
clearTimeout(copiedMatchResetTimeout);
|
||||
}
|
||||
|
||||
copiedMatchResetTimeout = setTimeout(() => {
|
||||
copiedMatch.value = false;
|
||||
}, 1600);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="sakai-page">
|
||||
<div class="sakai-hero-card">
|
||||
<div>
|
||||
<div class="sakai-page-back">
|
||||
<NuxtLink :to="botKey ? `/bots/${botKey}` : '/'">
|
||||
<Button
|
||||
text
|
||||
severity="secondary"
|
||||
icon="pi pi-arrow-left"
|
||||
:label="botKey ? 'Назад к сигналам' : 'На главную'"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<span class="sakai-section-label">Детали сигнала</span>
|
||||
<h2>{{ signal ? `${signal.homeTeam} - ${signal.awayTeam}` : "Загрузка сигнала" }}</h2>
|
||||
</div>
|
||||
<div v-if="signal" class="sakai-hero-card__tags">
|
||||
<Tag class="rounded" :value="signal.sportType" severity="contrast" />
|
||||
<Tag class="rounded" :value="signal.leagueName" severity="info" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Message v-if="loadError" severity="error" :closable="false">{{ loadError }}</Message>
|
||||
|
||||
<div v-else-if="!signal" class="sakai-signal-detail-loading">
|
||||
<Card class="sakai-signal-panel">
|
||||
<template #content>
|
||||
<div class="sakai-signal-detail-skeleton">
|
||||
<Skeleton width="30%" height="1rem" />
|
||||
<Skeleton width="65%" height="2rem" />
|
||||
<Skeleton width="45%" height="1.2rem" />
|
||||
<Skeleton width="100%" height="16rem" />
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div v-else class="sakai-signal-layout sakai-signal-layout--detail">
|
||||
<div class="sakai-signal-detail-main">
|
||||
<Card class="sakai-signal-panel">
|
||||
<template #content>
|
||||
<article class="sakai-signal-card sakai-signal-card--detail">
|
||||
<div class="sakai-signal-card__main">
|
||||
<div class="sakai-signal-card__meta">
|
||||
<span>
|
||||
<i class="pi pi-calendar" />
|
||||
Сигнал: {{ formattedSignalTime }}
|
||||
</span>
|
||||
<span>
|
||||
<i class="pi pi-flag" />
|
||||
{{ signal.leagueName }}
|
||||
</span>
|
||||
<span v-if="botName">
|
||||
<AppLogo size="14px" />
|
||||
{{ botName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="sakai-signal-card__teams">
|
||||
<strong>{{ signal.homeTeam }}</strong>
|
||||
<strong>{{ signal.awayTeam }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="sakai-signal-card__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="sakai-copy-button sakai-copy-button--wide"
|
||||
:aria-label="`Скопировать матч ${signal.homeTeam} - ${signal.awayTeam}`"
|
||||
@click="copyMatchName"
|
||||
>
|
||||
<i class="pi" :class="copiedMatch ? 'pi-check' : 'pi-copy'" aria-hidden="true" />
|
||||
<span>{{ copiedMatch ? "Скопировано" : "Скопировать матч" }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sakai-signal-card__market">
|
||||
<span>{{ signal.marketType }}</span>
|
||||
<span>{{ signal.selection }}</span>
|
||||
<span v-if="formattedLineValue">{{ formattedLineValue }}</span>
|
||||
<span>{{ signal.sourceType }}</span>
|
||||
</div>
|
||||
|
||||
<p class="sakai-signal-detail__timezone">
|
||||
Время показано в часовом поясе: {{ browserTimeZone ?? "UTC" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sakai-signal-card__side sakai-signal-card__side--detail">
|
||||
<div v-if="forecastImageUrl || displayForecast" class="sakai-signal-card__forecast">
|
||||
<img
|
||||
v-if="shouldShowForecastImage"
|
||||
:src="forecastImageUrl"
|
||||
:alt="displayForecast || `${signal.homeTeam} - ${signal.awayTeam}`"
|
||||
class="sakai-signal-card__forecast-image sakai-signal-card__forecast-image--detail"
|
||||
loading="lazy"
|
||||
@error="forecastImageFailed = true"
|
||||
>
|
||||
<p v-else-if="displayForecast" class="sakai-signal-card__forecast-text">
|
||||
{{ displayForecast }}
|
||||
</p>
|
||||
<p v-else class="sakai-signal-card__forecast-text sakai-signal-card__forecast-text--empty">
|
||||
Прогноз недоступен
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sakai-signal-detail__status-row">
|
||||
<Tag class="rounded" :value="displayStatusLabel" :severity="statusSeverity" />
|
||||
<div class="sakai-signal-card__odds">{{ formattedOdds }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="sakai-signal-detail-aside">
|
||||
<Card class="sakai-summary-panel sakai-summary-panel--sticky">
|
||||
<template #title>Сводка</template>
|
||||
<template #content>
|
||||
<div class="sakai-signal-detail-grid">
|
||||
<div v-for="item in detailItems" :key="item.label" class="sakai-signal-detail-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
12
frontend/plugins/01.splash-screen.client.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { SplashScreen } from "@capacitor/splash-screen";
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void SplashScreen.hide().catch(() => {
|
||||
// Ignore hide errors if the native splash is already gone.
|
||||
});
|
||||
});
|
||||
5
frontend/plugins/auth-init.client.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default defineNuxtPlugin(async () => {
|
||||
const { refreshMe } = useAuth();
|
||||
await refreshMe();
|
||||
});
|
||||
|
||||
9
frontend/plugins/native-push.client.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default defineNuxtPlugin(async () => {
|
||||
const { initializeNativePush, isNativeApp } = usePush();
|
||||
|
||||
if (!isNativeApp()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await initializeNativePush();
|
||||
});
|
||||
15
frontend/plugins/primevue.client.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import Aura from "@primeuix/themes/aura";
|
||||
import PrimeVue from "primevue/config";
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: {
|
||||
darkModeSelector: ".app-dark"
|
||||
}
|
||||
},
|
||||
ripple: true,
|
||||
inputVariant: "filled"
|
||||
});
|
||||
});
|
||||
20
frontend/plugins/push-context.client.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export default defineNuxtPlugin(async () => {
|
||||
const { token } = useAuth();
|
||||
const anonymousClientId = useState<string | null>("anonymous-push-client-id", () => null);
|
||||
const { syncServiceWorkerContext, syncNativeSubscription, isNativeApp } = usePush();
|
||||
|
||||
if (isNativeApp()) {
|
||||
await syncNativeSubscription();
|
||||
} else {
|
||||
await syncServiceWorkerContext();
|
||||
}
|
||||
|
||||
watch([token, anonymousClientId], () => {
|
||||
if (isNativeApp()) {
|
||||
void syncNativeSubscription();
|
||||
return;
|
||||
}
|
||||
|
||||
void syncServiceWorkerContext();
|
||||
});
|
||||
});
|
||||
23
frontend/plugins/pwa.client.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export default defineNuxtPlugin(async () => {
|
||||
const { setInstallPromptEvent, isNativeApp } = usePush();
|
||||
|
||||
if (isNativeApp()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
await navigator.serviceWorker.register("/sw.js");
|
||||
}
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (event) => {
|
||||
event.preventDefault();
|
||||
setInstallPromptEvent(event as Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("appinstalled", () => {
|
||||
setInstallPromptEvent(null);
|
||||
});
|
||||
});
|
||||
BIN
frontend/public/icons/app-badge.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
7
frontend/public/icons/app-badge.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" role="img" aria-label="Signals badge">
|
||||
<rect width="96" height="96" rx="24" fill="#ffffff" />
|
||||
<circle cx="32" cy="48" r="12" fill="#08111f" />
|
||||
<circle cx="64" cy="32" r="12" fill="#08111f" />
|
||||
<circle cx="64" cy="64" r="12" fill="#08111f" />
|
||||
<path d="M32 48L64 32L64 64Z" fill="none" stroke="#08111f" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |