This commit is contained in:
talorr
2026-03-27 03:36:08 +03:00
parent 8a97ce6d54
commit cda36918e8
225 changed files with 35641 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

15
frontend/Dockerfile Normal file
View 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
View File

@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

3
frontend/android/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

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

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

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

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

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

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

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

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}

View File

@@ -0,0 +1,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>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

View File

@@ -0,0 +1,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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

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

View File

@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Антигол</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>

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

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>

View File

@@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}

View File

@@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.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
}

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

View 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

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
frontend/android/gradlew vendored Normal file
View 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
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%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

View File

@@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'

View File

@@ -0,0 +1,16 @@
ext {
minSdkVersion = 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
View 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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

View 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 || "Не удалось выполнить запрос к чату");
});
}

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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.
});
});

View File

@@ -0,0 +1,5 @@
export default defineNuxtPlugin(async () => {
const { refreshMe } = useAuth();
await refreshMe();
});

View File

@@ -0,0 +1,9 @@
export default defineNuxtPlugin(async () => {
const { initializeNativePush, isNativeApp } = usePush();
if (!isNativeApp()) {
return;
}
await initializeNativePush();
});

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View 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

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