diff --git a/android.keystore b/android.keystore new file mode 100644 index 00000000..76491275 Binary files /dev/null and b/android.keystore differ diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 00000000..3332c14e --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,213 @@ +/* + * Copyright 2019 Google Inc. + * + * 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 + * + * http://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. + */ + +import groovy.xml.MarkupBuilder + +plugins { + id 'com.android.application' +} + +def twaManifest = [ + applicationId: 'ir.mysalona.testapp.twa', + hostName: 'testapp.mysalona.ir', // The domain being opened in the TWA. + launchUrl: '/', // The start path for the TWA. Must be relative to the domain. + name: 'Salona', // The application name. + launcherName: 'Salona', // The name shown on the Android Launcher. + themeColor: '#AB25C0', // The color used for the status bar. + themeColorDark: '#000000', // The color used for the dark status bar. + navigationColor: '#000000', // The color used for the navigation bar. + navigationColorDark: '#000000', // The color used for the dark navbar. + navigationDividerColor: '#000000', // The navbar divider color. + navigationDividerColorDark: '#000000', // The dark navbar divider color. + backgroundColor: '#AB25C0', // The color used for the splash screen background. + enableNotifications: true, // Set to true to enable notification delegation. + // Every shortcut must include the following fields: + // - name: String that will show up in the shortcut. + // - short_name: Shorter string used if |name| is too long. + // - url: Absolute path of the URL to launch the app with (e.g '/create'). + // - icon: Name of the resource in the drawable folder to use as an icon. + shortcuts: [], + // The duration of fade out animation in milliseconds to be played when removing splash screen. + splashScreenFadeOutDuration: 300, + generatorApp: 'bubblewrap-cli', // Application that generated the Android Project + // The fallback strategy for when Trusted Web Activity is not available. Possible values are + // 'customtabs' and 'webview'. + fallbackType: 'customtabs', + enableSiteSettingsShortcut: 'true', + orientation: 'default', +] + +android { + compileSdkVersion 36 + namespace "ir.mysalona.testapp.twa" + defaultConfig { + applicationId "ir.mysalona.testapp.twa" + minSdkVersion 21 + targetSdkVersion 35 + versionCode 1 + versionName "1" + + // The name for the application + resValue "string", "appName", twaManifest.name + + // The name for the application on the Android Launcher + resValue "string", "launcherName", twaManifest.launcherName + + // The URL that will be used when launching the TWA from the Android Launcher + def launchUrl = "https://" + twaManifest.hostName + twaManifest.launchUrl + resValue "string", "launchUrl", launchUrl + + + + + // The URL the Web Manifest for the Progressive Web App that the TWA points to. This + // is used by Chrome OS and Meta Quest to open the Web version of the PWA instead of + // the TWA, as it will probably give a better user experience for non-mobile devices. + resValue "string", "webManifestUrl", 'https://testapp.mysalona.ir/manifest.webmanifest' + + + + // This is used by Meta Quest. + resValue "string", "fullScopeUrl", 'https://testapp.mysalona.ir/' + + + + + // The hostname is used when building the intent-filter, so the TWA is able to + // handle Intents to open host url of the application. + resValue "string", "hostName", twaManifest.hostName + + // This attribute sets the status bar color for the TWA. It can be either set here or in + // `res/values/colors.xml`. Setting in both places is an error and the app will not + // compile. If not set, the status bar color defaults to #FFFFFF - white. + resValue "color", "colorPrimary", twaManifest.themeColor + + // This attribute sets the dark status bar color for the TWA. It can be either set here or in + // `res/values/colors.xml`. Setting in both places is an error and the app will not + // compile. If not set, the status bar color defaults to #000000 - white. + resValue "color", "colorPrimaryDark", twaManifest.themeColorDark + + // This attribute sets the navigation bar color for the TWA. It can be either set here or + // in `res/values/colors.xml`. Setting in both places is an error and the app will not + // compile. If not set, the navigation bar color defaults to #FFFFFF - white. + resValue "color", "navigationColor", twaManifest.navigationColor + + // This attribute sets the dark navigation bar color for the TWA. It can be either set here + // or in `res/values/colors.xml`. Setting in both places is an error and the app will not + // compile. If not set, the navigation bar color defaults to #000000 - black. + resValue "color", "navigationColorDark", twaManifest.navigationColorDark + + // This attribute sets the navbar divider color for the TWA. It can be either + // set here or in `res/values/colors.xml`. Setting in both places is an error and the app + // will not compile. If not set, the divider color defaults to #00000000 - transparent. + resValue "color", "navigationDividerColor", twaManifest.navigationDividerColor + + // This attribute sets the dark navbar divider color for the TWA. It can be either + // set here or in `res/values/colors.xml`. Setting in both places is an error and the + //app will not compile. If not set, the divider color defaults to #000000 - black. + resValue "color", "navigationDividerColorDark", twaManifest.navigationDividerColorDark + + // Sets the color for the background used for the splash screen when launching the + // Trusted Web Activity. + resValue "color", "backgroundColor", twaManifest.backgroundColor + + // Defines a provider authority for the Splash Screen + resValue "string", "providerAuthority", twaManifest.applicationId + '.fileprovider' + + // The enableNotification resource is used to enable or disable the + // TrustedWebActivityService, by changing the android:enabled and android:exported + // attributes + resValue "bool", "enableNotification", twaManifest.enableNotifications.toString() + + twaManifest.shortcuts.eachWithIndex { shortcut, index -> + resValue "string", "shortcut_name_$index", "$shortcut.name" + resValue "string", "shortcut_short_name_$index", "$shortcut.short_name" + } + + // The splashScreenFadeOutDuration resource is used to set the duration of fade out animation in milliseconds + // to be played when removing splash screen. The default is 0 (no animation). + resValue "integer", "splashScreenFadeOutDuration", twaManifest.splashScreenFadeOutDuration.toString() + + resValue "string", "generatorApp", twaManifest.generatorApp + + resValue "string", "fallbackType", twaManifest.fallbackType + + resValue "bool", "enableSiteSettingsShortcut", twaManifest.enableSiteSettingsShortcut + resValue "string", "orientation", twaManifest.orientation + + + } + buildTypes { + release { + minifyEnabled true + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + lintOptions { + checkReleaseBuilds false + } +} + +task generateShorcutsFile { + assert twaManifest.shortcuts.size() < 5, "You can have at most 4 shortcuts." + twaManifest.shortcuts.eachWithIndex { s, i -> + assert s.name != null, 'Missing `name` in shortcut #' + i + assert s.short_name != null, 'Missing `short_name` in shortcut #' + i + assert s.url != null, 'Missing `icon` in shortcut #' + i + assert s.icon != null, 'Missing `url` in shortcut #' + i + } + + def shortcutsFile = new File("$projectDir/src/main/res/xml", "shortcuts.xml") + + def xmlWriter = new StringWriter() + def xmlMarkup = new MarkupBuilder(new IndentPrinter(xmlWriter, " ", true)) + + xmlMarkup + .'shortcuts'('xmlns:android': 'http://schemas.android.com/apk/res/android') { + twaManifest.shortcuts.eachWithIndex { s, i -> + 'shortcut'( + 'android:shortcutId': 'shortcut' + i, + 'android:enabled': 'true', + 'android:icon': '@drawable/' + s.icon, + 'android:shortcutShortLabel': '@string/shortcut_short_name_' + i, + 'android:shortcutLongLabel': '@string/shortcut_name_' + i) { + 'intent'( + 'android:action': 'android.intent.action.MAIN', + 'android:targetPackage': twaManifest.applicationId, + 'android:targetClass': twaManifest.applicationId + '.LauncherActivity', + 'android:data': s.url) + 'categories'('android:name': 'android.intent.category.LAUNCHER') + } + } + } + shortcutsFile.text = xmlWriter.toString() + '\n' +} + +preBuild.dependsOn(generateShorcutsFile) + +repositories { + +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + + implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.6.2' + +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..5d5c0f30 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/ir/mysalona/testapp/twa/Application.java b/app/src/main/java/ir/mysalona/testapp/twa/Application.java new file mode 100644 index 00000000..b6a289aa --- /dev/null +++ b/app/src/main/java/ir/mysalona/testapp/twa/Application.java @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Google Inc. + * + * 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 + * + * http://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. + */ +package ir.mysalona.testapp.twa; + + + +public class Application extends android.app.Application { + + + + @Override + public void onCreate() { + super.onCreate(); + + } +} diff --git a/app/src/main/java/ir/mysalona/testapp/twa/DelegationService.java b/app/src/main/java/ir/mysalona/testapp/twa/DelegationService.java new file mode 100644 index 00000000..3930568d --- /dev/null +++ b/app/src/main/java/ir/mysalona/testapp/twa/DelegationService.java @@ -0,0 +1,14 @@ +package ir.mysalona.testapp.twa; + + + +public class DelegationService extends + com.google.androidbrowserhelper.trusted.DelegationService { + @Override + public void onCreate() { + super.onCreate(); + + + } +} + diff --git a/app/src/main/java/ir/mysalona/testapp/twa/LauncherActivity.java b/app/src/main/java/ir/mysalona/testapp/twa/LauncherActivity.java new file mode 100644 index 00000000..ac2ca38f --- /dev/null +++ b/app/src/main/java/ir/mysalona/testapp/twa/LauncherActivity.java @@ -0,0 +1,54 @@ +/* + * Copyright 2020 Google Inc. + * + * 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 + * + * http://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. + */ +package ir.mysalona.testapp.twa; + +import android.content.pm.ActivityInfo; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; + + + +public class LauncherActivity + extends com.google.androidbrowserhelper.trusted.LauncherActivity { + + + + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + // Setting an orientation crashes the app due to the transparent background on Android 8.0 + // Oreo and below. We only set the orientation on Oreo and above. This only affects the + // splash screen and Chrome will still respect the orientation. + // See https://github.com/GoogleChromeLabs/bubblewrap/issues/496 for details. + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); + } else { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); + } + } + + @Override + protected Uri getLaunchingUrl() { + // Get the original launch Url. + Uri uri = super.getLaunchingUrl(); + + + + return uri; + } +} diff --git a/app/src/main/res/drawable-anydpi/shortcut_legacy_background.xml b/app/src/main/res/drawable-anydpi/shortcut_legacy_background.xml new file mode 100644 index 00000000..d53c148a --- /dev/null +++ b/app/src/main/res/drawable-anydpi/shortcut_legacy_background.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_notification_icon.png b/app/src/main/res/drawable-hdpi/ic_notification_icon.png new file mode 100644 index 00000000..be2065b0 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_notification_icon.png differ diff --git a/app/src/main/res/drawable-hdpi/splash.png b/app/src/main/res/drawable-hdpi/splash.png new file mode 100644 index 00000000..ccf57873 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_notification_icon.png b/app/src/main/res/drawable-mdpi/ic_notification_icon.png new file mode 100644 index 00000000..60521713 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_notification_icon.png differ diff --git a/app/src/main/res/drawable-mdpi/splash.png b/app/src/main/res/drawable-mdpi/splash.png new file mode 100644 index 00000000..35ed4c07 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_notification_icon.png b/app/src/main/res/drawable-xhdpi/ic_notification_icon.png new file mode 100644 index 00000000..c80160c5 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_notification_icon.png differ diff --git a/app/src/main/res/drawable-xhdpi/splash.png b/app/src/main/res/drawable-xhdpi/splash.png new file mode 100644 index 00000000..1ce4af87 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_notification_icon.png b/app/src/main/res/drawable-xxhdpi/ic_notification_icon.png new file mode 100644 index 00000000..e2a6abf1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_notification_icon.png differ diff --git a/app/src/main/res/drawable-xxhdpi/splash.png b/app/src/main/res/drawable-xxhdpi/splash.png new file mode 100644 index 00000000..242b28e7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_notification_icon.png b/app/src/main/res/drawable-xxxhdpi/ic_notification_icon.png new file mode 100644 index 00000000..db7019da Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_notification_icon.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/splash.png b/app/src/main/res/drawable-xxxhdpi/splash.png new file mode 100644 index 00000000..603fee02 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..04807978 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..e2a6abf1 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_maskable.png b/app/src/main/res/mipmap-hdpi/ic_maskable.png new file mode 100644 index 00000000..5fbde587 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_maskable.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..c80160c5 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_maskable.png b/app/src/main/res/mipmap-mdpi/ic_maskable.png new file mode 100644 index 00000000..42408636 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_maskable.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..db7019da Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_maskable.png b/app/src/main/res/mipmap-xhdpi/ic_maskable.png new file mode 100644 index 00000000..6047aaef Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_maskable.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..d108e411 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_maskable.png b/app/src/main/res/mipmap-xxhdpi/ic_maskable.png new file mode 100644 index 00000000..71201c50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_maskable.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..533ba0cd Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_maskable.png b/app/src/main/res/mipmap-xxxhdpi/ic_maskable.png new file mode 100644 index 00000000..aecb6ada Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_maskable.png differ diff --git a/app/src/main/res/raw/web_app_manifest.json b/app/src/main/res/raw/web_app_manifest.json new file mode 100644 index 00000000..664760d8 --- /dev/null +++ b/app/src/main/res/raw/web_app_manifest.json @@ -0,0 +1 @@ +{"name":"Salona","short_name":"Salona","start_url":"/","display":"standalone","background_color":"#ab25c0","lang":"fa","scope":"/","theme_color":"#000000","dir":"rtl","description":"Salona is a platform to provide innovative tools to hairdressers and beauty salons.","display_override":["standalone","window-controls-overlay","browser"],"icons":[{"src":"pwa-64x64.png","sizes":"64x64","type":"image/png"},{"src":"pwa-192x192.png","sizes":"192x192","type":"image/png"},{"src":"pwa-512x512.png","sizes":"512x512","type":"image/png"}]} \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..e66222d0 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,18 @@ + + + #F5F5F5 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..79084d38 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + standalone + + + + window-controls-overlay + + + + browser + + + + + + + + [{ + \"relation\": [\"delegate_permission/common.handle_all_urls\"], + \"target\": { + \"namespace\": \"web\", + \"site\": \"https://testapp.mysalona.ir\" + } + }] + + + diff --git a/app/src/main/res/xml/filepaths.xml b/app/src/main/res/xml/filepaths.xml new file mode 100644 index 00000000..a5434852 --- /dev/null +++ b/app/src/main/res/xml/filepaths.xml @@ -0,0 +1,18 @@ + + + + diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 00000000..7f3e9c19 --- /dev/null +++ b/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,16 @@ + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..a3d57ce0 --- /dev/null +++ b/build.gradle @@ -0,0 +1,42 @@ +/* + * Copyright 2019 Google Inc. + * + * 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 + * + * http://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. + */ + +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.9.1' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..784a1d43 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,14 @@ +# 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 +android.useAndroidX=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..5c2d1cf0 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..719e7364 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true diff --git a/gradlew b/gradlew new file mode 100644 index 00000000..b740cf13 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/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. +# + +############################################################################## +# +# 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 "${APP_HOME:-./}" > /dev/null && pwd -P ) || 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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..25da30db --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@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 + +@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 diff --git a/manifest-checksum.txt b/manifest-checksum.txt new file mode 100644 index 00000000..04b17e8e --- /dev/null +++ b/manifest-checksum.txt @@ -0,0 +1 @@ +5416212501e1ca781234b854ff6b8b50509b7f01 \ No newline at end of file diff --git a/package.json b/package.json index 039cb3c9..f67b7fb3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "app.salona", "private": true, - "version": "3.121.446", + "version": "3.121.456", "type": "module", "scripts": { "dev": "vite --host", diff --git a/public/.well-known/assetlinks.json b/public/.well-known/assetlinks.json index d3e2f0cf..cedf1f13 100644 --- a/public/.well-known/assetlinks.json +++ b/public/.well-known/assetlinks.json @@ -1,21 +1,19 @@ [ - { - "relation": ["delegate_permission/common.handle_all_urls"], - "target": { - "namespace": "android_app", - "package_name": "ir.mysalona.app.twa", - "sha256_cert_fingerprints": [ - "76:4B:22:4D:E1:80:32:A2:8B:78:51:D6:57:86:99:A1:4D:67:9C:99:4A:6A:5F:D7:BC:55:DF:D7:38:35:E0:0C" - ] + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "ir.mysalona.app.twa", + "sha256_cert_fingerprints": [ + "2B:15:95:EC:CD:A0:FB:4F:E6:8B:4A:C1:98:7A:54:14:D8:BD:A9:3E:2D:F0:5A:E6:F7:31:CD:EE:EF:6E:16:6E" + ] + } + }, + { + "relation": ["check_validation"], + "target": { + "namespace": "cafebazaar_twa", + "package_name": "ir.mysalona.app.twa" + } } - }, - { - "relation": [ - "check_validation" - ], - "target": { - "namespace": "cafebazaar_twa", - "package_name": "ir.mysalona.app.twa" - }} - ] diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..e7b4def4 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +include ':app' diff --git a/src/App.css b/src/App.css index 91022d69..df7d8c08 100644 --- a/src/App.css +++ b/src/App.css @@ -3,490 +3,496 @@ @tailwind utilities; @font-face { - font-family: Vazirmatn; - src: url("./assets/fonts/Vazirmatn-FD/Vazirmatn-FD-Thin.woff2") - format("woff2"); - font-weight: 100; - font-style: normal; - font-display: swap; + font-family: Vazirmatn; + src: url("./assets/fonts/Vazirmatn-FD/Vazirmatn-FD-Thin.woff2") format("woff2"); + font-weight: 100; + font-style: normal; + font-display: swap; } @font-face { - font-family: Vazirmatn; - src: url("./assets/fonts/Vazirmatn-FD/Vazirmatn-FD-ExtraLight.woff2") - format("woff2"); - font-weight: 200; - font-style: normal; - font-display: swap; + font-family: Vazirmatn; + src: url("./assets/fonts/Vazirmatn-FD/Vazirmatn-FD-ExtraLight.woff2") format("woff2"); + font-weight: 200; + font-style: normal; + font-display: swap; } @font-face { - font-family: Vazirmatn; - src: url("./assets/fonts/Vazirmatn-FD/Vazirmatn-FD-Light.woff2") - format("woff2"); - font-weight: 300; - font-style: normal; - font-display: swap; + font-family: Vazirmatn; + src: url("./assets/fonts/Vazirmatn-FD/Vazirmatn-FD-Light.woff2") format("woff2"); + font-weight: 300; + font-style: normal; + font-display: swap; } @font-face { - font-family: Vazirmatn; - src: url("./assets/fonts/Vazirmatn-FD/Vazirmatn-FD-Regular.woff2") - format("woff2"); - font-weight: 400; - font-style: normal; - font-display: swap; + font-family: Vazirmatn; + src: url("./assets/fonts/Vazirmatn-FD/Vazirmatn-FD-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; } @font-face { - font-family: Vazirmatn; - src: url("./assets/fonts/Vazirmatn-FD/Vazirmatn-FD-Medium.woff2") - format("woff2"); - font-weight: 500; - font-style: normal; - font-display: swap; + font-family: Vazirmatn; + src: url("./assets/fonts/Vazirmatn-FD/Vazirmatn-FD-Medium.woff2") format("woff2"); + font-weight: 500; + font-style: normal; + font-display: swap; } @font-face { - font-family: Vazirmatn; - src: url("./assets/fonts/Vazirmatn-FD/Vazirmatn-FD-SemiBold.woff2") - format("woff2"); - font-weight: 600; - font-style: normal; - font-display: swap; + font-family: Vazirmatn; + src: url("./assets/fonts/Vazirmatn-FD/Vazirmatn-FD-SemiBold.woff2") format("woff2"); + font-weight: 600; + font-style: normal; + font-display: swap; } @font-face { - font-family: Vazirmatn; - src: url("./assets/fonts/Vazirmatn-FD/Vazirmatn-FD-Bold.woff2") - format("woff2"); - font-weight: 700; - font-style: normal; - font-display: swap; + font-family: Vazirmatn; + src: url("./assets/fonts/Vazirmatn-FD/Vazirmatn-FD-Bold.woff2") format("woff2"); + font-weight: 700; + font-style: normal; + font-display: swap; } @font-face { - font-family: Vazirmatn; - src: url("./assets/fonts/Vazirmatn-FD/Vazirmatn-FD-ExtraBold.woff2") - format("woff2"); - font-weight: 800; - font-style: normal; - font-display: swap; + font-family: Vazirmatn; + src: url("./assets/fonts/Vazirmatn-FD/Vazirmatn-FD-ExtraBold.woff2") format("woff2"); + font-weight: 800; + font-style: normal; + font-display: swap; } @font-face { - font-family: Vazirmatn; - src: url("./assets/fonts/Vazirmatn-FD/Vazirmatn-FD-Black.woff2") - format("woff2"); - font-weight: 900; - font-style: normal; - font-display: swap; + font-family: Vazirmatn; + src: url("./assets/fonts/Vazirmatn-FD/Vazirmatn-FD-Black.woff2") format("woff2"); + font-weight: 900; + font-style: normal; + font-display: swap; } @font-face { - font-family: "IranNastaliq"; - src: url("./assets/fonts/IranNastaliq/IranNastaliq.woff2") format("woff2"), - url("./assets/fonts/IranNastaliq/IranNastaliq.woff") format("woff"), - url("./assets/fonts/IranNastaliq/IranNastaliq.ttf") format("truetype"); - font-weight: normal; - font-style: normal; - font-display: swap; + font-family: "IranNastaliq"; + src: + url("./assets/fonts/IranNastaliq/IranNastaliq.woff2") format("woff2"), + url("./assets/fonts/IranNastaliq/IranNastaliq.woff") format("woff"), + url("./assets/fonts/IranNastaliq/IranNastaliq.ttf") format("truetype"); + font-weight: normal; + font-style: normal; + font-display: swap; } @font-face { - font-family: "Boblious"; - src: url("./assets/fonts/Boblious.ttf"); + font-family: "Boblious"; + src: url("./assets/fonts/Boblious.ttf"); } @font-face { - font-family: "Hayat"; - src: url("./assets/fonts/Hayat.ttf"); + font-family: "Hayat"; + src: url("./assets/fonts/Hayat.ttf"); } @font-face { - font-family: "DigiMadasi"; - src: url("./assets/fonts/DigiMadasi.ttf"); + font-family: "DigiMadasi"; + src: url("./assets/fonts/DigiMadasi.ttf"); } @font-face { - font-family: "Nahid"; - src: url("./assets/fonts/Nahid/Nahid.eot"); - src: url("./assets/fonts/Nahid/Nahid.eot?#iefix") format("embedded-opentype"); - src: url("./assets/fonts/Nahid/Nahid.woff") format("woff"); - src: url("./assets/fonts/Nahid/Nahid.ttf") format("truetype"); - font-weight: normal; + font-family: "Nahid"; + src: url("./assets/fonts/Nahid/Nahid.eot"); + src: url("./assets/fonts/Nahid/Nahid.eot?#iefix") format("embedded-opentype"); + src: url("./assets/fonts/Nahid/Nahid.woff") format("woff"); + src: url("./assets/fonts/Nahid/Nahid.ttf") format("truetype"); + font-weight: normal; } @font-face { - font-family: Shabnam; - src: url('./assets/fonts/Shabnam/'); - src: url('./assets/fonts/Shabnam/?#iefix') format('embedded-opentype'), - url('./assets/fonts/Shabnam/Shabnam.woff2') format('woff2'), - url('./assets/fonts/Shabnam/Shabnam.woff') format('woff'), - url('./assets/fonts/Shabnam/Shabnam.ttf') format('truetype'); - font-weight: normal; + font-family: Shabnam; + src: url("./assets/fonts/Shabnam/"); + src: + url("./assets/fonts/Shabnam/?#iefix") format("embedded-opentype"), + url("./assets/fonts/Shabnam/Shabnam.woff2") format("woff2"), + url("./assets/fonts/Shabnam/Shabnam.woff") format("woff"), + url("./assets/fonts/Shabnam/Shabnam.ttf") format("truetype"); + font-weight: normal; } @font-face { - font-family: Shabnam; - src: url('./assets/fonts/Shabnam/Shabnam-Bold.eot'); - src: url('./assets/fonts/Shabnam/Shabnam-Bold.eot?#iefix') format('embedded-opentype'), - url('./assets/fonts/Shabnam/Shabnam-Bold.woff2') format('woff2'), - url('./assets/fonts/Shabnam/Shabnam-Bold.woff') format('woff'), - url('./assets/fonts/Shabnam/Shabnam-Bold.ttf') format('truetype'); - font-weight: bold; + font-family: Shabnam; + src: url("./assets/fonts/Shabnam/Shabnam-Bold.eot"); + src: + url("./assets/fonts/Shabnam/Shabnam-Bold.eot?#iefix") format("embedded-opentype"), + url("./assets/fonts/Shabnam/Shabnam-Bold.woff2") format("woff2"), + url("./assets/fonts/Shabnam/Shabnam-Bold.woff") format("woff"), + url("./assets/fonts/Shabnam/Shabnam-Bold.ttf") format("truetype"); + font-weight: bold; } @font-face { - font-family: Shabnam; - src: url('./assets/fonts/Shabnam/Shabnam-Thin.eot'); - src: url('./assets/fonts/Shabnam/Shabnam-Thin.eot?#iefix') format('embedded-opentype'), - url('./assets/fonts/Shabnam/Shabnam-Thin.woff2') format('woff2'), - url('./assets/fonts/Shabnam/Shabnam-Thin.woff') format('woff'), - url('./assets/fonts/Shabnam/Shabnam-Thin.ttf') format('truetype'); - font-weight: 100; + font-family: Shabnam; + src: url("./assets/fonts/Shabnam/Shabnam-Thin.eot"); + src: + url("./assets/fonts/Shabnam/Shabnam-Thin.eot?#iefix") format("embedded-opentype"), + url("./assets/fonts/Shabnam/Shabnam-Thin.woff2") format("woff2"), + url("./assets/fonts/Shabnam/Shabnam-Thin.woff") format("woff"), + url("./assets/fonts/Shabnam/Shabnam-Thin.ttf") format("truetype"); + font-weight: 100; } @font-face { - font-family: Shabnam; - src: url('./assets/fonts/Shabnam/Shabnam-Light.eot'); - src: url('./assets/fonts/Shabnam/Shabnam-Light.eot?#iefix') format('embedded-opentype'), - url('./assets/fonts/Shabnam/Shabnam-Light.woff2') format('woff2'), - url('./assets/fonts/Shabnam/Shabnam-Light.woff') format('woff'), - url('./assets/fonts/Shabnam/Shabnam-Light.ttf') format('truetype'); - font-weight: 300; + font-family: Shabnam; + src: url("./assets/fonts/Shabnam/Shabnam-Light.eot"); + src: + url("./assets/fonts/Shabnam/Shabnam-Light.eot?#iefix") format("embedded-opentype"), + url("./assets/fonts/Shabnam/Shabnam-Light.woff2") format("woff2"), + url("./assets/fonts/Shabnam/Shabnam-Light.woff") format("woff"), + url("./assets/fonts/Shabnam/Shabnam-Light.ttf") format("truetype"); + font-weight: 300; } @font-face { - font-family: Shabnam; - src: url('./assets/fonts/Shabnam/Shabnam-Medium.eot'); - src: url('./assets/fonts/Shabnam/Shabnam-Medium.eot?#iefix') format('embedded-opentype'), - url('./assets/fonts/Shabnam/Shabnam-Medium.woff2') format('woff2'), - url('./assets/fonts/Shabnam/Shabnam-Medium.woff') format('woff'), - url('./assets/fonts/Shabnam/Shabnam-Medium.ttf') format('truetype'); - font-weight: 500; + font-family: Shabnam; + src: url("./assets/fonts/Shabnam/Shabnam-Medium.eot"); + src: + url("./assets/fonts/Shabnam/Shabnam-Medium.eot?#iefix") format("embedded-opentype"), + url("./assets/fonts/Shabnam/Shabnam-Medium.woff2") format("woff2"), + url("./assets/fonts/Shabnam/Shabnam-Medium.woff") format("woff"), + url("./assets/fonts/Shabnam/Shabnam-Medium.ttf") format("truetype"); + font-weight: 500; } #root { - /* height: 100vh; */ - height: 90vh; + /* height: 100vh; */ + height: 90vh; } @layer base { - :root { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; - --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 3.9%; - --radius: 0.5rem; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - } - * { - @apply border-border; - box-sizing: border-box; - } - html { - @apply relative; - direction: rtl; - color-scheme: light; - scroll-behavior: smooth; - -moz-text-size-adjust: 100%; - -webkit-text-size-adjust: 100%; - text-size-adjust: 100%; - } - body { - @apply !font-vazirmatn relative tracking-normal bg-background text-foreground overflow-x-hidden select-none; - -moz-font-feature-settings: "ss02" !important; - -webkit-font-feature-settings: "ss02" !important; - font-feature-settings: "ss02" !important; - transition: background-color 250ms ease; - margin: 0; /* Add this */ - padding: 0; /* Add this */ - } - .break-word { - word-break: break-word; - } - .dark { - --background: 0 0% 3.9%; - --foreground: 0 0% 98%; - --card: 0 0% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 14.9%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - } + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --radius: 0.5rem; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + } + * { + @apply border-border; + box-sizing: border-box; + } + html { + @apply relative; + direction: rtl; + color-scheme: light; + scroll-behavior: smooth; + -moz-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; + } + body { + @apply !font-vazirmatn relative tracking-normal bg-background text-foreground overflow-x-hidden select-none; + -moz-font-feature-settings: "ss02" !important; + -webkit-font-feature-settings: "ss02" !important; + font-feature-settings: "ss02" !important; + transition: background-color 250ms ease; + margin: 0; /* Add this */ + padding: 0; /* Add this */ + } + .break-word { + word-break: break-word; + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } } @media (prefers-color-scheme: dark) { - body { - @apply bg-background text-foreground; - } + body { + @apply bg-background text-foreground; + } } @media (forced-colors: active) { - html { - forced-color-adjust: none; - } + html { + forced-color-adjust: none; + } } div[role="dialog"] { - @apply font-vazirmatn; + @apply font-vazirmatn; } .swal2-modal { - @apply p-5; + @apply p-5; } .swal2-title { - @apply py-0; + @apply py-0; } .swal2-icon { - @apply !mt-0; + @apply !mt-0; } .swal2-backdrop-show { - @apply !bg-black/80; + @apply !bg-black/80; } .swal2-footer { - @apply !border-none; + @apply !border-none; } .swal2-cancel { - background-color: white; - color: hsl(var(--primary)); + background-color: white; + color: hsl(var(--primary)); } .swal2-html-container { - @apply !p-2; + @apply !p-2; } img:where(.swal2-image) { - @apply !mx-auto !my-0; + @apply !mx-auto !my-0; } /* Hide arrow icons in Firefox */ input[type="number"] { - -moz-appearance: textfield; + -moz-appearance: textfield; } /* Hide arrow icons in Chrome, Safari, Edge, and other WebKit browsers */ input[type="number"]::-webkit-outer-spin-button, input[type="number"]::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; + -webkit-appearance: none; + margin: 0; } input[inputMode="tel"] { - direction: ltr; - text-align: right; + direction: ltr; + text-align: right; } .rmdp-calendar-container-mobile { - z-index: 1000 !important; - pointer-events: all; - @media screen and (min-width: 300px) { - scale: 1.3; - } - @media screen and (min-width: 1024px) { - scale: 1.2; - } + z-index: 1000 !important; + pointer-events: all; + @media screen and (min-width: 300px) { + scale: 1.3; + } + @media screen and (min-width: 1024px) { + scale: 1.2; + } } .purple .rmdp-day { - --rmdp-hover-purple: hsl(var(--secondary)); - .rmdp-day-hidden span:hover { - --rmdp-hover-purple: rgba(0, 0, 0, 255) !important; - } + --rmdp-hover-purple: hsl(var(--secondary)); + .rmdp-day-hidden span:hover { + --rmdp-hover-purple: rgba(0, 0, 0, 255) !important; + } } .purple .rmdp-day.rmdp-selected span:not(.highlight) { - background-color: hsl(var(--secondary)) !important; + background-color: hsl(var(--secondary)) !important; } .purple .rmdp-week .rmdp-day.rmdp-disabled span:hover { - background-color: transparent !important; + background-color: transparent !important; } .leaflet-control-attribution.leaflet-control { - @apply !hidden; + @apply !hidden; } .pulse-animation { - animation: pulse-animation 1500ms infinite; + animation: pulse-animation 1500ms infinite; } @keyframes pulse-animation { - 0% { - box-shadow: 0 0 0 0px rgb(188, 43, 255, 0.3); - } - 100% { - box-shadow: 0 0 0 20px rgba(0, 0, 0, 0); - } + 0% { + box-shadow: 0 0 0 0px rgb(188, 43, 255, 0.3); + } + 100% { + box-shadow: 0 0 0 20px rgba(0, 0, 0, 0); + } } .parallelogram { - clip-path: polygon(0% 0%, 80% 0%, 100% 100%, 20% 100%); + clip-path: polygon(0% 0%, 80% 0%, 100% 100%, 20% 100%); } .recharts-brush-texts text.recharts-text:first-child { - transform: translateY(-42px); + transform: translateY(-42px); } .recharts-brush-texts text.recharts-text:last-child { - transform: translateY(38px); + transform: translateY(38px); } .recharts-tooltip-item-value { - display: inline-block; - direction: ltr; + display: inline-block; + direction: ltr; } .rhap_container { - box-shadow: none !important; - background: transparent !important; - .rhap_main-controls-button { - color: hsl(var(--primary)) !important; - } - .rhap_controls-section { - flex: none; - } + box-shadow: none !important; + background: transparent !important; + .rhap_main-controls-button { + color: hsl(var(--primary)) !important; + } + .rhap_controls-section { + flex: none; + } } .loginBackground { - background-image: url("./assets/images/loginbackground.svg"); + background-image: url("./assets/images/loginbackground.svg"); } /* Cover image container */ .relative { - position: relative; + position: relative; } /* Hide ReactPlayer initially */ .player-wrapper { - width: auto; - height: auto; + width: auto; + height: auto; } .react-player { - padding-top: 56.25%; - position: relative; + padding-top: 56.25%; + position: relative; } .react-player > div { - position: absolute; + position: absolute; } @layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } } .leaflet-container { - width: 100%; - height: 500px; + width: 100%; + height: 500px; } html { - scroll-behavior: smooth; + scroll-behavior: smooth; } .scrollable { - overflow-y: auto; /* یا overflow: auto برای دو جهت */ - scroll-behavior: smooth; /* smooth روی خود کانتینر */ - -webkit-overflow-scrolling: touch; /* momentum scrolling روی iOS */ + overflow-y: auto; /* یا overflow: auto برای دو جهت */ + scroll-behavior: smooth; /* smooth روی خود کانتینر */ + -webkit-overflow-scrolling: touch; /* momentum scrolling روی iOS */ } html, body { - overscroll-behavior: none; + overscroll-behavior: none; } .modal-scroll-lock { - overflow: hidden; /* اسکرول اصلی رو مخفی می‌کنه */ - /* height: 100%; بعضی مرورگرها نیاز دارن */ - overscroll-behavior: none; /* جلوگیری از scroll chaining روی مرورگرهای مدرن */ + overflow: hidden; /* اسکرول اصلی رو مخفی می‌کنه */ + /* height: 100%; بعضی مرورگرها نیاز دارن */ + overscroll-behavior: none; /* جلوگیری از scroll chaining روی مرورگرهای مدرن */ } /* پایه: انیمیشن نرم برای opacity و transform */ .parallax { - transition: opacity 300ms cubic-bezier(0.22, 0.9, 0.2, 1), - transform 260ms cubic-bezier(0.22, 0.9, 0.2, 1); - will-change: opacity, transform; - /* در حالت دسکتاپ رفتار عادی را حفظ کن */ + transition: + opacity 300ms cubic-bezier(0.22, 0.9, 0.2, 1), + transform 260ms cubic-bezier(0.22, 0.9, 0.2, 1); + will-change: opacity, transform; + /* در حالت دسکتاپ رفتار عادی را حفظ کن */ } /* حالت اسنَپ: وقتی نصف پوشیده شد — انیمیشن کوتاه و افت ملموس */ .parallax.parallax--snapped { - opacity: 0 !important; - transform: translateY(-12px) scale(0.997); - pointer-events: none; + opacity: 0 !important; + transform: translateY(-12px) scale(0.997); + pointer-events: none; } /* placeholder پیش‌فرض پنهان است؛ فقط در موبایل نمایش داده می‌شود */ .parallax-placeholder { - display: none; + display: none; } /* MOBILE: parallax را fixed کن و placeholder را فعال کن */ @media (max-width: 768px) { - .parallax { - position: fixed; - top: 0; - left: 0; - right: 0; - width: 100%; - z-index: 0 !important; /* تا محتوای بعدی با z-20 بتونه رویش بیفته */ - overflow: visible; - /* اگر می‌خواهی روی parallax هم کلیک بشه: pointer-events: auto; */ - pointer-events: auto; - /* کمی backface-visibility برای روانی */ - backface-visibility: hidden; - } + .parallax { + position: fixed; + top: 0; + left: 0; + right: 0; + width: 100%; + z-index: 0 !important; /* تا محتوای بعدی با z-20 بتونه رویش بیفته */ + overflow: visible; + /* اگر می‌خواهی روی parallax هم کلیک بشه: pointer-events: auto; */ + pointer-events: auto; + /* کمی backface-visibility برای روانی */ + backface-visibility: hidden; + } - .parallax > * { - position: relative; /* آیتم‌های داخل به درستی قرار بگیرند */ - } + .parallax > * { + position: relative; /* آیتم‌های داخل به درستی قرار بگیرند */ + } - .parallax-placeholder { - display: block; - width: 100%; - /* height توسط inline style React تعیین می‌شود */ - } + .parallax-placeholder { + display: block; + width: 100%; + /* height توسط inline style React تعیین می‌شود */ + } } /* (اختیاری) اگر بخواهی در دسکتاپ هم کمی transition داشته باشد */ @media (min-width: 769px) { - .parallax { - position: relative; - } -} - -.picker-item { - transition: all 0.3s ease-in-out; + .parallax { + position: relative; + } } /* Disable img selection everywhere */ img { - -webkit-touch-callout: none; - -webkit-user-select: none; - user-select: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; } - /* Disable text selection everywhere */ * { - -webkit-touch-callout: none; /* iOS */ - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; + -webkit-touch-callout: none; /* iOS */ + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; - -webkit-tap-highlight-color: transparent; - touch-action: manipulation; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; +} + +/* فرض بر اینه که option items کلاس picker-item دارند */ +.picker-item { + backface-visibility: hidden; + /* transition: all 0.3s ease-in-out; */ + transform: translateZ(0); + will-change: transform, opacity; + line-height: 60px; /* مطابقت با optionItemHeight شما */ + height: 60px; + display: flex; + align-items: center; + justify-content: center; } diff --git a/src/apps/new-ui/components/AppointmentComponents/PersonnelServices.tsx b/src/apps/new-ui/components/AppointmentComponents/PersonnelServices.tsx index 55668db0..9fe88329 100644 --- a/src/apps/new-ui/components/AppointmentComponents/PersonnelServices.tsx +++ b/src/apps/new-ui/components/AppointmentComponents/PersonnelServices.tsx @@ -44,8 +44,7 @@ const PersonnelServices = () => { const [isAddServiceOpen, setIsAddServiceOpen] = useState(false); const navigate = useNavigate(); - const appointmentStatus = - typeof window !== "undefined" ? localStorage.getItem("appointmentStatus") : null; + const appointmentStatus = typeof window !== "undefined" ? localStorage.getItem("appointmentStatus") : null; const disableInput = appointmentStatus === "edit"; // ─── State: ID سرویس انتخاب‌شده (خوانده شده از localStorage در اولین بار) ─── @@ -67,17 +66,16 @@ const PersonnelServices = () => { const allServices: Service[] = usingPersonelServiceMode ? personelServiceCards : []; - // ─── بازیابی selectedSlide از لیست سرویس‌ها بر اساس ID ذخیره‌شده ─── - // هر بار که allServices (دیتای API) یا selectedPersonelServiceId تغییر کند + // ─── بازیابی selectedSlide از لیست سرویس‌ها بر اساس ID ذخیره‌شده + // و در صورت عدم وجود انتخاب قبلی، انتخاب پیش‌فرض اولین مورد useEffect(() => { if (!allServices.length) return; if (selectedPersonelServiceId != null) { - const found = allServices.find( - (s: any) => s.personel_service_id === selectedPersonelServiceId, - ); + const found = allServices.find((s: any) => s.personel_service_id === selectedPersonelServiceId); if (found) { setSelectedSlide(found); + return; } else { // ID ذخیره‌شده دیگر در لیست وجود ندارد → ریست setSelectedPersonelServiceId(null); @@ -90,8 +88,29 @@ const PersonnelServices = () => { localStorage.removeItem(EDITED_FLAG); } catch {} } - } else { - setSelectedSlide(null); + } + + // اگر هیچ انتخابی ذخیره نشده بود، اولین گزینه را به‌صورت پیش‌فرض انتخاب کن + if (selectedPersonelServiceId == null && allServices.length > 0) { + const first = allServices[0]; + //@ts-ignore + const firstId = first.personel_service_id ?? null; + setSelectedPersonelServiceId(firstId); + setSelectedSlide(first); + + try { + if (firstId) { + localStorage.setItem(PERSONEL_SERVICE_ID_KEY, String(firstId)); + //@ts-ignore + localStorage.setItem(PERSONEL_ID_KEY, String(first.personel_id ?? "")); + localStorage.setItem("personelServiceId", String(firstId)); + } + } catch {} + + try { + // منتشر کردن تغییر انتخاب پرسنل + emitPersonelIdChange(String(firstId ?? "")); + } catch {} } }, [allServices, selectedPersonelServiceId]); @@ -113,9 +132,7 @@ const PersonnelServices = () => { try { const oldVal = localStorage.getItem(STORAGE_KEY); localStorage.setItem(STORAGE_KEY, newCost ?? ""); - window.dispatchEvent( - new CustomEvent("serviceCostChanged", { detail: { cost: newCost } }), - ); + window.dispatchEvent(new CustomEvent("serviceCostChanged", { detail: { cost: newCost } })); window.dispatchEvent( new StorageEvent("storage", { key: STORAGE_KEY, @@ -174,8 +191,7 @@ const PersonnelServices = () => { // ─── کلیک روی سرویس ─── const handleServiceClick = (service: any) => { - const psid = - service.personel_service_id ?? service?.rawPersonel?.personel_service_id ?? null; + const psid = service.personel_service_id ?? service?.rawPersonel?.personel_service_id ?? null; if (psid === selectedPersonelServiceId) return; @@ -202,9 +218,7 @@ const PersonnelServices = () => { const filteredServices = useMemo(() => { const term = searchTerm.trim().toLowerCase(); if (!term) return allServices; - return allServices.filter((s: any) => - (s.service_name || "").toLowerCase().includes(term), - ); + return allServices.filter((s: any) => (s.service_name || "").toLowerCase().includes(term)); }, [allServices, searchTerm]); // ---------- NameRow ---------- @@ -358,21 +372,15 @@ const PersonnelServices = () => { style={{ touchAction: "manipulation" }} >

+

-

- افزودن خدمات پرسنل -

+

افزودن خدمات پرسنل

) : filteredServices.length > 0 ? (
-
+
{filteredServices.map((service: any, idx) => { - const selected = - service.personel_service_id === selectedPersonelServiceId; + const selected = service.personel_service_id === selectedPersonelServiceId; const fullName = service.first_name ? `${service.first_name} ${service.last_name}`.trim() : (service.name ?? service.service_name ?? ""); @@ -415,8 +423,7 @@ const PersonnelServices = () => {

void; - serviceList?: any[]; - onSelectPersonelServiceId?: (id?: number) => void; - onSelectPersonelId?: (id?: number) => void; - personnelId?: number | null; // ✅ اضافه کنید - setPersonnelId?: (id: number | null) => void; // ✅ اضافه کنید + onChange?: (v: string) => void; + serviceList?: any[]; + onSelectPersonelServiceId?: (id?: number) => void; + onSelectPersonelId?: (id?: number) => void; + personnelId?: number | null; + setPersonnelId?: (id: number | null) => void; }; const PRICE_KEY = "servicePrice"; const PERSONEL_SERVICE_ID_KEY = "selectedPersonelServiceId"; -const PERSONEL_ID_KEY = "selectedPersonelId"; // ✅ کلید جدید localStorage +const PERSONEL_ID_KEY = "selectedPersonelId"; const PersonnelServicesQA: React.FC = ({ - onChange, - serviceList, - onSelectPersonelServiceId, - onSelectPersonelId, // ✅ دریافت پراپ جدید + onChange, + serviceList, + onSelectPersonelServiceId, + onSelectPersonelId, }) => { - const location = useLocation(); - const navigate = useNavigate(); - const scrollContainerRef = useRef(null); + const location = useLocation(); + const navigate = useNavigate(); + const scrollContainerRef = useRef(null); - const [selectedPersonelServiceId, setSelectedPersonelServiceId] = useState< - number | null - >(null); - const [selectedPersonelId, setSelectedPersonelId] = useState(null); // ✅ state جدید برای personel_id - const [selectedPrice, setSelectedPrice] = useState( - () => localStorage.getItem(PRICE_KEY) ?? "", - ); + const [selectedPersonelServiceId, setSelectedPersonelServiceId] = useState(null); + const [selectedPersonelId, setSelectedPersonelId] = useState(null); + const [selectedPrice, setSelectedPrice] = useState(() => localStorage.getItem(PRICE_KEY) ?? ""); - const apiResp = useGetServicesWithPersonels({ enabled: !serviceList }); - const fetchedServices = apiResp?.data?.services ?? []; - const legacyServices: any[] = serviceList ?? []; + const apiResp = useGetServicesWithPersonels({ enabled: !serviceList }); + const fetchedServices = apiResp?.data?.services ?? []; + const legacyServices: any[] = serviceList ?? []; - const personelServiceCards = useMemo(() => { - const out: any[] = []; - for (const svc of fetchedServices) { - const svcName = svc?.service_name ?? ""; - const svcPersonels = Array.isArray(svc?.personels) ? svc.personels : []; - for (const p of svcPersonels) { - out.push({ - personel_service_id: p?.personel_service_id - ? Number(p.personel_service_id) - : undefined, - personel_id: p?.personel_id ? Number(p.personel_id) : undefined, - first_name: p?.first_name ?? p?.personel_name ?? "", - last_name: p?.last_name ?? "", - profile_picture: p?.profile_picture ?? null, - service_name: svcName || p?.service_name || "", - price: p?.amount, - rawPersonel: p, - rawService: svc, - }); - } - } - return out; - }, [fetchedServices]); + const personelServiceCards = useMemo(() => { + const out: any[] = []; + for (const svc of fetchedServices) { + const svcName = svc?.service_name ?? ""; + const svcPersonels = Array.isArray(svc?.personels) ? svc.personels : []; + for (const p of svcPersonels) { + out.push({ + personel_service_id: p?.personel_service_id ? Number(p.personel_service_id) : undefined, + personel_id: p?.personel_id ? Number(p.personel_id) : undefined, + first_name: p?.first_name ?? p?.personel_name ?? "", + last_name: p?.last_name ?? "", + profile_picture: p?.profile_picture ?? null, + service_name: svcName || p?.service_name || "", + //@ts-ignore + price: p?.amount ?? p?.price ?? "", + rawPersonel: p, + rawService: svc, + }); + } + } + return out; + }, [fetchedServices]); - const usingPersonelServiceMode = - !serviceList && personelServiceCards.length > 0; - - // ✅ بازیابی هر دو مقدار از localStorage - useEffect(() => { - const savedPersonelServiceId = localStorage.getItem(PERSONEL_SERVICE_ID_KEY); - if (savedPersonelServiceId) { - const id = Number(savedPersonelServiceId); - if (!isNaN(id)) setSelectedPersonelServiceId(id); - } - - const savedPersonelId = localStorage.getItem(PERSONEL_ID_KEY); - if (savedPersonelId) { - const id = Number(savedPersonelId); - if (!isNaN(id)) setSelectedPersonelId(id); - } - }, []); - - useEffect(() => { - return () => { - // localStorage.removeItem(PERSONEL_SERVICE_ID_KEY); - localStorage.removeItem(PRICE_KEY); - }; - }, [location.pathname]); - - useEffect(() => { - // ابتدا localStorage را پاک کنید - localStorage.removeItem("servicePrice"); - localStorage.removeItem("serviceTitle"); - - // سپس stateهای مربوطه را ریست کنید - setSelectedPrice(""); // یا مقدار پیش‌فرض مورد نظر - setSelectedPersonelServiceId(null); - setSelectedPersonelId(null); // ✅ ریست personel_id - }, []); - - useEffect(() => { - return () => { - if ( - !window.location.pathname.includes("/calendar-management") && - !window.location.pathname.includes("/appointments/quick") - ) { - localStorage.removeItem(PERSONEL_SERVICE_ID_KEY); - localStorage.removeItem(PERSONEL_ID_KEY); // ✅ پاک کردن personel_id - } - }; - }, []); - - useEffect(() => { - if (selectedPrice) - try { - localStorage.setItem(PRICE_KEY, selectedPrice); - } catch {} - }, [selectedPrice]); - - // ✅ ذخیره personel_id در localStorage هنگام تغییر - useEffect(() => { - if (selectedPersonelId !== null) { - try { - localStorage.setItem(PERSONEL_ID_KEY, String(selectedPersonelId)); - } catch {} - } - }, [selectedPersonelId]); - - const handlePersonelServiceClick = (card: any) => { - const personelServiceId = card.personel_service_id ?? null; - const personelId = card.personel_id ?? null; // ✅ دریافت personel_id از کارت - - setSelectedPersonelServiceId(personelServiceId); - setSelectedPersonelId(personelId); // ✅ ذخیره personel_id در state - - if (personelServiceId !== null) { - localStorage.setItem(PERSONEL_SERVICE_ID_KEY, String(personelServiceId)); - } - - if (personelId !== null) { - localStorage.setItem(PERSONEL_ID_KEY, String(personelId)); // ✅ ذخیره در localStorage - } - - if (card.price !== undefined && card.price !== null) { - const pStr = String(card.price); - setSelectedPrice(pStr); - localStorage.setItem(PRICE_KEY, pStr); - } - - onChange?.(String(card.service_name ?? "")); - onSelectPersonelServiceId?.(personelServiceId); - onSelectPersonelId?.(personelId); // ✅ ارسال personel_id به والد - }; - - const handleLegacyServiceClick = (service: any) => { - const p = service.price ?? service.amount ?? ""; - const pStr = String(p); - setSelectedPrice(pStr); - localStorage.setItem(PRICE_KEY, pStr); - - setSelectedPersonelServiceId(null); - setSelectedPersonelId(null); // ✅ ریست personel_id - localStorage.removeItem(PERSONEL_SERVICE_ID_KEY); - localStorage.removeItem(PERSONEL_ID_KEY); // ✅ پاک کردن از localStorage - - onChange?.(String(service.name ?? service.service_name ?? "")); - onSelectPersonelServiceId?.(undefined); - onSelectPersonelId?.(undefined); // ✅ ارسال undefined به والد - }; - - - const NameRow = ({ text }: { text: string }) => { - const wrapperRef = useRef(null); - const textRef = useRef(null); - const [isOverflow, setIsOverflow] = useState(false); - - const recompute = () => { - const w = wrapperRef.current; - const t = textRef.current; - if (!w || !t) return; - const textWidth = t.scrollWidth; - const wrapperWidth = Math.max(0, w.clientWidth); - setIsOverflow(textWidth > wrapperWidth + 1); - }; - - useLayoutEffect(() => { - recompute(); - const raf = requestAnimationFrame(recompute); - return () => cancelAnimationFrame(raf); - }, [text]); + const usingPersonelServiceMode = !serviceList && personelServiceCards.length > 0; + const cards = usingPersonelServiceMode ? personelServiceCards : legacyServices; + // خواندن ذخیره‌شده (اگر وجود داشته باشد) useEffect(() => { - if (!wrapperRef.current) return; - const ro = new ResizeObserver(() => recompute()); - try { - ro.observe(wrapperRef.current); - if (textRef.current) ro.observe(textRef.current); - } catch {} - window.addEventListener("resize", recompute); - return () => { try { - ro.disconnect(); + const savedPersonelServiceId = localStorage.getItem(PERSONEL_SERVICE_ID_KEY); + if (savedPersonelServiceId) { + const id = Number(savedPersonelServiceId); + if (!isNaN(id)) setSelectedPersonelServiceId(id); + } + } catch {} + + try { + const savedPersonelId = localStorage.getItem(PERSONEL_ID_KEY); + if (savedPersonelId) { + const id = Number(savedPersonelId); + if (!isNaN(id)) setSelectedPersonelId(id); + } } catch {} - window.removeEventListener("resize", recompute); - }; }, []); + // cleanup price on pathname change (همان رفتار قبلی) + useEffect(() => { + return () => { + localStorage.removeItem(PRICE_KEY); + }; + }, [location.pathname]); + + // mount reset: پاکسازی عناصری که می‌خواستید ریست شوند + useEffect(() => { + localStorage.removeItem("servicePrice"); + localStorage.removeItem("serviceTitle"); + setSelectedPrice(""); + setSelectedPersonelServiceId(null); + setSelectedPersonelId(null); + }, []); + + useEffect(() => { + if (selectedPrice) + try { + localStorage.setItem(PRICE_KEY, selectedPrice); + } catch {} + }, [selectedPrice]); + + useEffect(() => { + if (selectedPersonelId !== null) { + try { + localStorage.setItem(PERSONEL_ID_KEY, String(selectedPersonelId)); + } catch {} + } + }, [selectedPersonelId]); + + const scrollToItem = (psid?: number | null) => { + if (!psid || !scrollContainerRef.current) return; + try { + const el = scrollContainerRef.current.querySelector(`[data-psid="${psid}"]`) as HTMLElement | null; + if (el && typeof el.scrollIntoView === "function") { + // inline center for horizontal list + el.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" }); + } + } catch {} + }; + + const handlePersonelServiceClick = (card: any) => { + const personelServiceId = card.personel_service_id ?? null; + const personelId = card.personel_id ?? null; + + setSelectedPersonelServiceId(personelServiceId); + setSelectedPersonelId(personelId); + + try { + if (personelServiceId !== null) { + localStorage.setItem(PERSONEL_SERVICE_ID_KEY, String(personelServiceId)); + } else { + localStorage.removeItem(PERSONEL_SERVICE_ID_KEY); + } + } catch {} + + try { + if (personelId !== null) { + localStorage.setItem(PERSONEL_ID_KEY, String(personelId)); + } else { + localStorage.removeItem(PERSONEL_ID_KEY); + } + } catch {} + + if (card.price !== undefined && card.price !== null) { + const pStr = String(card.price); + setSelectedPrice(pStr); + try { + localStorage.setItem(PRICE_KEY, pStr); + } catch {} + } + + onChange?.(String(card.service_name ?? "")); + onSelectPersonelServiceId?.(personelServiceId ?? undefined); + onSelectPersonelId?.(personelId ?? undefined); + + // اسکرول به آیتم انتخاب‌شده (کوتاه مکث برای اطمینان از render شدن) + setTimeout(() => scrollToItem(personelServiceId ?? null), 60); + }; + + const handleLegacyServiceClick = (service: any) => { + const p = service.price ?? service.amount ?? ""; + const pStr = String(p); + setSelectedPrice(pStr); + try { + localStorage.setItem(PRICE_KEY, pStr); + } catch {} + + setSelectedPersonelServiceId(null); + setSelectedPersonelId(null); + try { + localStorage.removeItem(PERSONEL_SERVICE_ID_KEY); + localStorage.removeItem(PERSONEL_ID_KEY); + } catch {} + + onChange?.(String(service.name ?? service.service_name ?? "")); + onSelectPersonelServiceId?.(undefined); + onSelectPersonelId?.(undefined); + }; + + // ---------------------- انتخاب پیش‌فرض اولین آیتم ---------------------- + useEffect(() => { + // اگر در localStorage از قبل آیتمی ذخیره شده باشه، کاری نکن + try { + const saved = localStorage.getItem(PERSONEL_SERVICE_ID_KEY); + if (saved && saved.trim().length > 0) return; + } catch {} + + // فقط وقتی کارت‌ها لود شدند و هنوز هیچ انتخابی در state نداریم => اولین را انتخاب کن + if (cards.length > 0 && (selectedPersonelServiceId === null || selectedPersonelServiceId === undefined)) { + const first = cards[0]; + if (!first) return; + // استفاده از handler مشترک برای ست کردن state / localStorage / کال‌بک‌ها + handlePersonelServiceClick(first); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cards]); // فقط به تغییر لیست کارت‌ها واکنش نشان می‌دهد + // ----------------------------------------------------------------------- + + const NameRow = ({ text }: { text: string }) => { + const wrapperRef = useRef(null); + const textRef = useRef(null); + const [isOverflow, setIsOverflow] = useState(false); + + const recompute = () => { + const w = wrapperRef.current; + const t = textRef.current; + if (!w || !t) return; + const textWidth = t.scrollWidth; + const wrapperWidth = Math.max(0, w.clientWidth); + setIsOverflow(textWidth > wrapperWidth + 1); + }; + + useLayoutEffect(() => { + recompute(); + const raf = requestAnimationFrame(recompute); + return () => cancelAnimationFrame(raf); + }, [text]); + + useEffect(() => { + if (!wrapperRef.current) return; + const ro = new ResizeObserver(() => recompute()); + try { + ro.observe(wrapperRef.current); + if (textRef.current) ro.observe(textRef.current); + } catch {} + window.addEventListener("resize", recompute); + return () => { + try { + ro.disconnect(); + } catch {} + window.removeEventListener("resize", recompute); + }; + }, []); + + return ( +

+
+ {text} +
+
+ ); + }; + return ( -
-
- {text} -
-
- ); - }; - - const cards = usingPersonelServiceMode - ? personelServiceCards - : legacyServices; - - return ( -
- -
-
-

- خدمات -

-
+
+
+

خدمات

+
-
- {cards.length > 0 ? ( -
- {/* اسکرول افقی نرم با تچ ایونت مناسب موبایل */} -
-
- {cards.map((item: any, index: number) => { - if (usingPersonelServiceMode) { - const fullName = - `${item.first_name ?? ""} ${item.last_name ?? ""}`.trim() || - "بدون نام"; - const svc = item.service_name ?? "(نام‌گذاری نشده)"; - const selected = - selectedPersonelServiceId === item.personel_service_id; +
+ {cards.length > 0 ? ( +
+
+
+ {cards.map((item: any, index: number) => { + if (usingPersonelServiceMode) { + const fullName = + `${item.first_name ?? ""} ${item.last_name ?? ""}`.trim() || "بدون نام"; + const svc = item.service_name ?? "(نام‌گذاری نشده)"; + const selected = selectedPersonelServiceId === item.personel_service_id; - return ( -
handlePersonelServiceClick(item)} - style={{ - touchAction: "manipulation", // بهبود تچ ایونت روی موبایل - userSelect: "none", - }} - > -
- -
- -
- {svc.length > 11 ? ( -
-
-

- {svc} -

-
+ return ( +
handlePersonelServiceClick(item)} + style={{ touchAction: "manipulation", userSelect: "none" }} + > +
+ +
+
+ {svc.length > 11 ? ( +
+
+

+ {svc} +

+
+
+ ) : ( +

{svc}

+ )} +
+
+ ); + } else { + return ( +
handleLegacyServiceClick(item)} + style={{ touchAction: "manipulation", userSelect: "none" }} + > + {item.emoji ? ( + + {item.emoji} + + ) : ( +
+ +
+ )} +
{item.name}
+
+ ); + } + })}
- ) : ( -

- {svc} -

- )}
-
- ); - } else { - // const selected = false; - return ( -
handleLegacyServiceClick(item)} - style={{ - touchAction: "manipulation", - userSelect: "none", - }} - > - {item.emoji ? ( - - {item.emoji} - - ) : ( -
- -
- )} -
- {item.name} -
-
- ); - } - })} -
-
- {/* Gradient overlay برای نشان دادن قابلیت اسکرول */} -
-
-
- ) : ( -
-

{"پرسنلی وجود ندارد"}

-
navigate("/personnel")} - className="w-fit px-2 mt-4 flex flex-nowrap gap-1 items-center justify-center border-2 border-[#A780C3] rounded-full cursor-pointer" - style={{ touchAction: "manipulation" }} - > -

+

-

- افزودن خدمات پرسنل -

+
+
+
+ ) : ( +
+

{"پرسنلی وجود ندارد"}

+
navigate("/personnel")} + className="w-fit px-2 mt-4 flex flex-nowrap gap-1 items-center justify-center border-2 border-[#A780C3] rounded-full cursor-pointer" + style={{ touchAction: "manipulation" }} + > +

+

+

افزودن خدمات پرسنل

+
+
+ )}
-
- )} -
-
- ); +
+ ); }; export default PersonnelServicesQA; diff --git a/src/apps/new-ui/components/AppointmentComponents/Services.tsx b/src/apps/new-ui/components/AppointmentComponents/Services.tsx index 9187bc0e..c7511f8b 100644 --- a/src/apps/new-ui/components/AppointmentComponents/Services.tsx +++ b/src/apps/new-ui/components/AppointmentComponents/Services.tsx @@ -97,87 +97,92 @@ const Services = () => { // 3) default -> first service in the list // // Important: do NOT overwrite user's manual edited cost if EDITED_FLAG === "true" - useEffect(() => { - if (!allServices || allServices.length === 0) { - setSelectedSlide(null); - setSelectedSlideIndex(null); - return; - } +useEffect(() => { + if (!allServices || allServices.length === 0) { + setSelectedSlide(null); + setSelectedSlideIndex(null); + return; + } - const appointmentService = localStorage.getItem("editAppointmentService"); - if (appointmentService) { - const found = allServices.find((service) => service.name === appointmentService); - if (found) { - setSelectedSlide(found); - setSelectedSlideIndex(allServices.indexOf(found)); - try { - localStorage.setItem(TITLE_KEY, found.name ?? ""); - } catch {} - // persist price to storage before dispatching + const appointmentService = localStorage.getItem("editAppointmentService"); + if (appointmentService) { + const found = allServices.find((service) => service.name === appointmentService); + if (found) { + setSelectedSlide(found); + setSelectedSlideIndex(allServices.indexOf(found)); + try { localStorage.setItem(TITLE_KEY, found.name ?? ""); } catch {} + // persist price + dispatch on next tick so listeners are ready + if (localStorage.getItem(EDITED_FLAG) !== "true") { try { const priceStr = String(found.price ?? ""); - if (localStorage.getItem(EDITED_FLAG) !== "true") { - localStorage.setItem(STORAGE_KEY, priceStr); - localStorage.removeItem(EDITED_FLAG); - window.dispatchEvent( - new CustomEvent("serviceSelected", { - detail: { title: found.name, price: found.price ?? "" }, - }), - ); - } + setTimeout(() => { + try { localStorage.setItem(STORAGE_KEY, priceStr); } catch {} + try { + window.dispatchEvent( + new CustomEvent("serviceSelected", { + detail: { title: found.name, price: found.price ?? "" }, + }), + ); + } catch {} + }, 0); + localStorage.removeItem(EDITED_FLAG); } catch {} - return; } + return; } + } - const savedTitle = localStorage.getItem(TITLE_KEY); - if (savedTitle) { - const foundBySaved = allServices.find((service) => service.name === savedTitle); - if (foundBySaved) { - setSelectedSlide(foundBySaved); - setSelectedSlideIndex(allServices.indexOf(foundBySaved)); + const savedTitle = localStorage.getItem(TITLE_KEY); + if (savedTitle) { + const foundBySaved = allServices.find((service) => service.name === savedTitle); + if (foundBySaved) { + setSelectedSlide(foundBySaved); + setSelectedSlideIndex(allServices.indexOf(foundBySaved)); + try { localStorage.setItem(TITLE_KEY, foundBySaved.name ?? ""); } catch {} + if (localStorage.getItem(EDITED_FLAG) !== "true") { try { - localStorage.setItem(TITLE_KEY, foundBySaved.name ?? ""); + const priceStr = String(foundBySaved.price ?? ""); + setTimeout(() => { + try { localStorage.setItem(STORAGE_KEY, priceStr); } catch {} + try { + window.dispatchEvent( + new CustomEvent("serviceSelected", { + detail: { title: foundBySaved.name, price: foundBySaved.price ?? "" }, + }), + ); + } catch {} + }, 0); + localStorage.removeItem(EDITED_FLAG); } catch {} - if (localStorage.getItem(EDITED_FLAG) !== "true") { + } + return; + } + } + + // default: first service + const first = allServices[0]; + if (first) { + setSelectedSlide(first); + setSelectedSlideIndex(0); + try { localStorage.setItem(TITLE_KEY, first.name ?? ""); } catch {} + if (localStorage.getItem(EDITED_FLAG) !== "true") { + try { + const priceStr = String(first.price ?? ""); + setTimeout(() => { + try { localStorage.setItem(STORAGE_KEY, priceStr); } catch {} try { - localStorage.setItem(STORAGE_KEY, String(foundBySaved.price ?? "")); - localStorage.removeItem(EDITED_FLAG); window.dispatchEvent( new CustomEvent("serviceSelected", { - detail: { - title: foundBySaved.name, - price: foundBySaved.price ?? "", - }, + detail: { title: first.name, price: first.price ?? "" }, }), ); } catch {} - } - return; - } - } - - // default: first service - const first = allServices[0]; - if (first) { - setSelectedSlide(first); - setSelectedSlideIndex(0); - try { - localStorage.setItem(TITLE_KEY, first.name ?? ""); + }, 0); + localStorage.removeItem(EDITED_FLAG); } catch {} - if (localStorage.getItem(EDITED_FLAG) !== "true") { - try { - localStorage.setItem(STORAGE_KEY, String(first.price ?? "")); - localStorage.removeItem(EDITED_FLAG); - window.dispatchEvent( - new CustomEvent("serviceSelected", { - detail: { title: first.name, price: first.price ?? "" }, - }), - ); - } catch {} - } } - }, [allServices]); + } +}, [allServices]); return (
diff --git a/src/apps/new-ui/components/MobilePicker/MobileDatePicker.tsx b/src/apps/new-ui/components/MobilePicker/MobileDatePicker.tsx index ab55f663..0bdadef5 100644 --- a/src/apps/new-ui/components/MobilePicker/MobileDatePicker.tsx +++ b/src/apps/new-ui/components/MobilePicker/MobileDatePicker.tsx @@ -1,110 +1,86 @@ -import { useEffect, useState } from 'react'; -import { Input } from '@/components/ui/input'; -import { Modal } from '@/components/ui/modal'; -import { Button } from '@/components/ui/button'; -import Picker from './+components/index'; +import { useEffect, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Modal } from "@/components/ui/modal"; +import { Button } from "@/components/ui/button"; +import Picker from "./+components/index"; function renderOptions(options: string[]) { - return options.map((option) => ( - - {({ selected }) => ( -
- {option} -
- )} -
- )); + return options.map((option) => ( + + {({ selected }) => ( +
{option}
+ )} +
+ )); } interface Props { - value?: string | null; - onChange: (newValue: string) => void; - label?: string; - required?: boolean; + value?: string | null; + onChange: (newValue: string) => void; + label?: string; + required?: boolean; } const MobileDatePicker = ({ value, onChange, label, required }: Props) => { - const [modalOpen, setModalOpen] = useState(false); - const [pickerValue, setPickerValue] = useState({ - day: '15', - month: '10', - year: '1380', - }); - const onSubmit = () => { - onChange(`${pickerValue.year}-${pickerValue.month}-${pickerValue.day}`); - setModalOpen(false); - }; - useEffect(() => { - if (modalOpen && value) { - const [year, month, day] = value.split('-'); - setPickerValue({ - day, - month, - year, - }); - } - }, [modalOpen]); + const [modalOpen, setModalOpen] = useState(false); + const [pickerValue, setPickerValue] = useState({ + day: "15", + month: "10", + year: "1380", + }); + const onSubmit = () => { + onChange(`${pickerValue.year}-${pickerValue.month}-${pickerValue.day}`); + setModalOpen(false); + }; + useEffect(() => { + if (modalOpen && value) { + const [year, month, day] = value.split("-"); + setPickerValue({ + day, + month, + year, + }); + } + }, [modalOpen]); - return ( -
- setModalOpen(true)} - /> - setModalOpen(false)} - className="p-2 pt-12 max-w-80 rounded-lg" - size="md" - > - - - {renderOptions( - Array.from({ length: 1404 - 1300 + 1 }, (_, i) => - (1300 + i).toString(), - ), - )} - - / - - {renderOptions( - new Array(12) - .fill('') - .map((_, i) => (i + 1).toString().padStart(2, '0')), - )} - - / - - {renderOptions( - new Array(31) - .fill('') - .map((_, i) => (i + 1).toString().padStart(2, '0')), - )} - - -
- - + return ( +
+ setModalOpen(true)} + /> + setModalOpen(false)} + className="p-2 pt-12 max-w-80 rounded-lg" + size="md" + > + + + {renderOptions(Array.from({ length: 1404 - 1300 + 1 }, (_, i) => (1300 + i).toString()))} + + / + + {renderOptions(new Array(12).fill("").map((_, i) => (i + 1).toString().padStart(2, "0")))} + + / + + {renderOptions(new Array(31).fill("").map((_, i) => (i + 1).toString().padStart(2, "0")))} + + +
+ + +
+
- -
- ); + ); }; export default MobileDatePicker; diff --git a/src/apps/new-ui/components/MobilePicker/MobileScrollDatePicker.tsx b/src/apps/new-ui/components/MobilePicker/MobileScrollDatePicker.tsx index d962aa14..e34f1a5e 100644 --- a/src/apps/new-ui/components/MobilePicker/MobileScrollDatePicker.tsx +++ b/src/apps/new-ui/components/MobilePicker/MobileScrollDatePicker.tsx @@ -1,651 +1,366 @@ -// import "@ncdai/react-wheel-picker/style.css"; -// import { useEffect, useState } from "react"; -// import { Modal } from "@/components/ui/modal"; -// import { Button } from "@/components/ui/button"; -// // مسیر را بر اساس جایی که WheelPicker تعریف شده است تنظیم کنید -// import { WheelPicker, WheelPickerWrapper } from "./wheel-picker"; -// import calenderIcon from "../../assets/appointment/calenderIcon.svg"; - -// interface Props { -// value?: string | null; // فرمت مورد انتظار: "YYYY-MM-DD" شمسی -// onChange: (newValue: string) => void; -// title?: string; -// } - -// /* ------------------ توابع تبدیل (Jalali/Gregorian) ------------------ */ - -// // تابع gregorianToJalali (کپی شده از کد شما) -// function gregorianToJalali(gy: number, gm: number, gd: number): [number, number, number] { -// let gy2 = gy - 1600; -// let gm2 = gm - 1; -// let gd2 = gd - 1; -// let g_day_no = 365 * gy2 + Math.floor((gy2 + 3) / 4) - Math.floor((gy2 + 99) / 100) + Math.floor((gy2 + 399) / 400); -// for (let i = 0; i < gm2; ++i) g_day_no += [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][i]; -// if (gm2 > 1 && ((gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0)) g_day_no += 1; -// g_day_no += gd2; -// let j_day_no = g_day_no - 79; -// const j_np = Math.floor(j_day_no / 12053); -// j_day_no = j_day_no % 12053; -// let jy = 979 + 33 * j_np + 4 * Math.floor(j_day_no / 1461); -// j_day_no = j_day_no % 1461; -// if (j_day_no >= 366) { -// jy += Math.floor((j_day_no - 366) / 365); -// j_day_no = (j_day_no - 366) % 365; -// } -// const jmArr = [31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29]; -// let jm = 0; -// for (let i = 0; i < 11 && j_day_no >= jmArr[i]; ++i) { -// j_day_no -= jmArr[i]; -// jm = i + 1; -// } -// const jd = j_day_no + 1; -// return [jy + 1, jm + 1, jd]; -// } - -// // نسخه اصلاح شده و ساده‌تر برای تشخیص سال کبیسه شمسی -// function isJalaliLeapYear(jy: number): boolean { -// // سال کبیسه شمسی بر اساس چرخه 33 ساله (سال‌های کبیسه: 1، 5، 9، 13، 17، 22، 26، 30) -// const n = jy - 11; // سال 1300 به عنوان نقطه شروع تقریبی -// return (((n % 33) * 8) + Math.floor((n % 33) / 4) - Math.floor(n / 33)) % 33 < 8; -// // روش دقیق‌تر: استفاده از آرایه نقاط عطف بسیار پیچیده است. این روش تقریبی برای اکثر سال‌های رایج کافی است. -// // برای دقت کامل، بهتر است از یک کتابخانه معتبر تبدیل تاریخ استفاده شود. -// // اما برای رفع ایراد سریع، این روش اغلب کار می‌کند. -// const remainder = (jy - 122) % 128; -// return remainder === 31 || remainder === 61 || remainder === 91 || remainder === 121 || remainder === 0; -// } - -// // تابع کمکی برای تولید گزینه‌های عددی -// const generateNumericOptions = (max: number, start = 1) => -// Array.from({ length: max - start + 1 }, (_, i) => -// (start + i).toString().padStart(2, "0") -// ); - -// /* ------------------ کامپوننت ------------------ */ - -// const SimpleDatePicker = ({ value, onChange, title = "تاریخ تولد" }: Props) => { -// const [modalOpen, setModalOpen] = useState(false); -// const persianMonths = [ -// "فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور", -// "مهر", "آبان", "آذر", "دی", "بهمن", "اسفند", -// ]; - -// const [pickerValue, setPickerValue] = useState({ -// year: "1404", -// month: "01", -// day: "01", -// }); - -// /* مقداردهی اولیه اصلاح شده */ -// useEffect(() => { -// if (!modalOpen) return; - -// let initialY: string, initialM: string, initialD: string; - -// if (value) { -// // فرض می‌کنیم اگر تاریخ ورودی وجود دارد، شمسی است و فرمت YYYY-MM-DD دارد -// const parts = value.split("-"); -// const yNum = parseInt(parts[0], 10); -// const mNum = parseInt(parts[1], 10); -// const dNum = parseInt(parts[2], 10); - -// if (parts.length === 3 && !isNaN(yNum) && !isNaN(mNum) && !isNaN(dNum)) { -// // **تنها در صورتی تبدیل میلادی انجام می‌شود که سال ورودی کمتر از 1300 باشد (یعنی میلادی است)** -// if (yNum < 1300) { -// const [jy, jm, jd] = gregorianToJalali(yNum, mNum, dNum); -// initialY = jy.toString(); -// initialM = jm.toString().padStart(2, "0"); -// initialD = jd.toString().padStart(2, "0"); -// } else { -// // فرض بر شمسی بودن (مقداردهی مستقیم) -// initialY = parts[0]; -// initialM = parts[1].padStart(2, "0"); -// initialD = parts[2].padStart(2, "0"); -// } -// } else { -// // اگر فرمت اشتباه بود، امروز را تنظیم کن -// const now = new Date(); -// const [jy, jm, jd] = gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate()); -// initialY = jy.toString(); -// initialM = jm.toString().padStart(2, "0"); -// initialD = jd.toString().padStart(2, "0"); -// } -// } else { -// // مقدار پیش‌فرض تاریخ امروز شمسی -// const now = new Date(); -// const [jy, jm, jd] = gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate()); -// initialY = jy.toString(); -// initialM = jm.toString().padStart(2, "0"); -// initialD = jd.toString().padStart(2, "0"); -// } - -// setPickerValue({ -// year: initialY, -// month: initialM, -// day: initialD, -// }); -// }, [modalOpen, value]); // حذف وابستگی‌های غیرضروری - -// /* تولید روزها برای ماه فعلی (شمسی) با در نظر گرفتن کبیسه */ -// const getDaysInMonth = (yearStr?: string, monthStr?: string) => { -// const year = yearStr ? parseInt(yearStr, 10) : parseInt(pickerValue.year, 10); -// const month = monthStr ? parseInt(monthStr, 10) : parseInt(pickerValue.month, 10); - -// if (isNaN(year) || isNaN(month) || month < 1 || month > 12) return generateNumericOptions(31); - -// if (month <= 6) { -// return generateNumericOptions(31); -// } else if (month <= 11) { -// return generateNumericOptions(30); -// } else { // اسفند (ماه 12) -// // **اینجا اگر isJalaliLeapYear ایراد داشته باشد، روز اشتباه نمایش داده می‌شود.** -// const days = isJalaliLeapYear(year) ? 30 : 29; -// return generateNumericOptions(days); -// } -// }; - -// /* تولید لیست سال‌ها */ -// const getYears = () => { -// const now = new Date(); -// const [jy] = gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate()); -// const currentYear = jy; -// const startYear = 1320; -// const years: string[] = []; -// for (let i = currentYear; i >= startYear; i--) years.push(i.toString()); -// return years; -// }; - -// // لیست ماه‌های شمسی با فرمت option برای WheelPicker -// const monthOptions = persianMonths.map((name, index) => ({ -// value: (index + 1).toString().padStart(2, "0"), -// label: name, -// })); - -// const yearOptions = getYears().map(year => ({ value: year, label: year })); -// // روزها باید بر اساس مقدار فعلی pickerValue محاسبه شوند (که با useEffect به‌روز می‌شود) -// const dayOptions = getDaysInMonth().map(day => ({ value: day, label: day })); - -// const handleMonthChange = (monthValue: string) => { -// // اگر ماه تغییر کرد، روز را مجدداً اعتبارسنجی کن -// const daysForNewMonth = getDaysInMonth(pickerValue.year, monthValue); -// setPickerValue((prev) => ({ -// ...prev, -// month: monthValue, -// // اگر روز قبلی در ماه جدید مجاز نیست، آخرین روز ماه جدید را انتخاب کن -// day: daysForNewMonth.some(d => d === prev.day) -// ? prev.day -// : daysForNewMonth[daysForNewMonth.length - 1], -// })); -// }; - -// const handleYearChange = (yearValue: string) => { -// // اگر سال تغییر کرد، روز ماه را رِنج کن -// const daysForNew = getDaysInMonth(yearValue, pickerValue.month); -// setPickerValue((prev) => ({ -// ...prev, -// year: yearValue, -// day: daysForNew.some(d => d === prev.day) -// ? prev.day -// : daysForNew[daysForNew.length - 1], -// })); -// }; - -// const onSubmit = () => { -// // اطمینان از اینکه مقادیر به درستی ذخیره می شوند (MM و DD دو رقمی) -// const formattedDate = `${pickerValue.year}-${pickerValue.month}-${pickerValue.day}`; -// onChange(formattedDate); -// localStorage.setItem("birthDate", formattedDate); -// setModalOpen(false); -// }; - -// return ( -//
-// {value && ( -// <> -//

{title}

-// -// )} - -// setModalOpen(true)} -// className="border-2 w-full rounded-full border-[#76558F] px-4 py-2 text-sm text-[#2D0A48]" -// placeholder={title} -// /> - -//
setModalOpen(true)} -// > -// calendar -//
- -// {/* Date Picker Modal with WheelPicker */} -// setModalOpen(false)} -// title={`انتخاب ${title}`} -// className="p-4 rounded-lg w-full max-w-sm" -// > -//
-// {/* Live Preview */} -//
-// {pickerValue.day} {persianMonths[parseInt(pickerValue.month) - 1] || "---"} {pickerValue.year} -//
- -//
-// - -// {/* روزها (سمت راست به دلیل RTL) */} -//
-// -// // اینجا چون dayOptions از مقادیر اعتبارسنجی شده ساخته شده، نیازی به پد کردن مجدد نیست مگر اینکه بخواهیم مطمئن شویم -// setPickerValue((p) => ({ ...p, day: String(v).padStart(2, "0") })) -// } -// optionItemHeight={60} -// visibleCount={12} -// classNames={{ optionItem: "text-sm picker-item" }} -// /> -//
- -//
-// : -//
- -// {/* ماه‌ها (وسط) */} -//
-// -//
- -//
-// : -//
- -// {/* سال‌ها (سمت چپ) */} -//
-// -//
- -//
-//
- -//
-// -// -//
-//
- -//
-//
-// ); -// }; - -// export default SimpleDatePicker; -import "@ncdai/react-wheel-picker/style.css"; -import { useEffect, useState } from "react"; +// SimpleDatePicker.tsx +import "@ncdai/react-wheel-picker/style.css"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { Modal } from "@/components/ui/modal"; import { Button } from "@/components/ui/button"; // مسیر را بر اساس جایی که WheelPicker تعریف شده است تنظیم کنید -import { WheelPicker, WheelPickerWrapper } from "./wheel-picker"; -import calenderIcon from "../../assets/appointment/calenderIcon.svg"; +import { WheelPicker, WheelPickerWrapper } from "./wheel-picker"; +import calenderIcon from "../../assets/appointment/calenderIcon.svg"; interface Props { - value?: string | null; // فرمت مورد انتظار: "YYYY-MM-DD" شمسی - onChange: (newValue: string) => void; - title?: string; - defaultToToday?: boolean; // پراپ جدید: اگر true و value خالی بود، تاریخ امروز را نمایش بده + value?: string | null; // فرمت مورد انتظار: "YYYY-MM-DD" شمسی + onChange: (newValue: string) => void; + title?: string; + defaultToToday?: boolean; } /* ------------------ توابع تبدیل (Jalali/Gregorian) ------------------ */ -// تابع gregorianToJalali (کپی شده از کد شما) function gregorianToJalali(gy: number, gm: number, gd: number): [number, number, number] { - let gy2 = gy - 1600; - let gm2 = gm - 1; - let gd2 = gd - 1; - let g_day_no = 365 * gy2 + Math.floor((gy2 + 3) / 4) - Math.floor((gy2 + 99) / 100) + Math.floor((gy2 + 399) / 400); - for (let i = 0; i < gm2; ++i) g_day_no += [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][i]; - if (gm2 > 1 && ((gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0)) g_day_no += 1; - g_day_no += gd2; - let j_day_no = g_day_no - 79; - const j_np = Math.floor(j_day_no / 12053); - j_day_no = j_day_no % 12053; - let jy = 979 + 33 * j_np + 4 * Math.floor(j_day_no / 1461); - j_day_no = j_day_no % 1461; - if (j_day_no >= 366) { - jy += Math.floor((j_day_no - 366) / 365); - j_day_no = (j_day_no - 366) % 365; - } - const jmArr = [31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29]; - let jm = 0; - for (let i = 0; i < 11 && j_day_no >= jmArr[i]; ++i) { - j_day_no -= jmArr[i]; - jm = i + 1; - } - const jd = j_day_no + 1; - return [jy + 1, jm + 1, jd]; + let gy2 = gy - 1600; + let gm2 = gm - 1; + let gd2 = gd - 1; + let g_day_no = + 365 * gy2 + + Math.floor((gy2 + 3) / 4) - + Math.floor((gy2 + 99) / 100) + + Math.floor((gy2 + 399) / 400); + for (let i = 0; i < gm2; ++i) + g_day_no += [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][i]; + if (gm2 > 1 && ((gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0)) g_day_no += 1; + g_day_no += gd2; + let j_day_no = g_day_no - 79; + const j_np = Math.floor(j_day_no / 12053); + j_day_no = j_day_no % 12053; + let jy = 979 + 33 * j_np + 4 * Math.floor(j_day_no / 1461); + j_day_no = j_day_no % 1461; + if (j_day_no >= 366) { + jy += Math.floor((j_day_no - 366) / 365); + j_day_no = (j_day_no - 366) % 365; + } + const jmArr = [31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29]; + let jm = 0; + for (let i = 0; i < 11 && j_day_no >= jmArr[i]; ++i) { + j_day_no -= jmArr[i]; + jm = i + 1; + } + const jd = j_day_no + 1; + return [jy + 1, jm + 1, jd]; } -// تابع تشخیص سال کبیسه شمسی +// تابع تشخیص سال کبیسه شمسی (روش مرسوم مورد استفاده در کد قبلی) function isJalaliLeapYear(jy: number): boolean { - const remainder = (jy - 122) % 128; - return remainder === 31 || remainder === 61 || remainder === 91 || remainder === 121 || remainder === 0; + const remainder = (jy - 122) % 128; + return remainder === 31 || remainder === 61 || remainder === 91 || remainder === 121 || remainder === 0; } -// تابع کمکی برای تولید گزینه‌های عددی -const generateNumericOptions = (max: number, start = 1) => - Array.from({ length: max - start + 1 }, (_, i) => - (start + i).toString().padStart(2, "0") - ); +/* ------------------ cache و تولید گزینه‌های عددی ------------------ */ + +const _numericCache = new Map(); +const generateNumericOptions = (max: number, start = 1) => { + const key = `${start}-${max}`; + if (_numericCache.has(key)) return _numericCache.get(key)!; + const arr = Array.from({ length: max - start + 1 }, (_, i) => + (start + i).toString().padStart(2, "0") + ); + _numericCache.set(key, arr); + return arr; +}; -// تابع کمکی برای گرفتن تاریخ امروز شمسی const getTodayJalali = (): { year: string; month: string; day: string } => { - const now = new Date(); - const [jy, jm, jd] = gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate()); - return { - year: jy.toString(), - month: jm.toString().padStart(2, "0"), - day: jd.toString().padStart(2, "0"), - }; + const now = new Date(); + const [jy, jm, jd] = gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate()); + return { + year: jy.toString(), + month: jm.toString().padStart(2, "0"), + day: jd.toString().padStart(2, "0"), + }; }; /* ------------------ کامپوننت ------------------ */ -const SimpleDatePicker = ({ value, onChange, title = "تاریخ تولد", defaultToToday = false }: Props) => { - const [modalOpen, setModalOpen] = useState(false); - const persianMonths = [ - "فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور", - "مهر", "آبان", "آذر", "دی", "بهمن", "اسفند", - ]; +const SimpleDatePicker: React.FC = ({ + value, + onChange, + title = "تاریخ تولد", + defaultToToday = false, +}) => { + const [modalOpen, setModalOpen] = useState(false); - const [pickerValue, setPickerValue] = useState({ - year: "1404", - month: "01", - day: "01", - }); + const persianMonths = useMemo( + () => [ + "فروردین", + "اردیبهشت", + "خرداد", + "تیر", + "مرداد", + "شهریور", + "مهر", + "آبان", + "آذر", + "دی", + "بهمن", + "اسفند", + ], + [] + ); - // محاسبه تاریخ پیش‌فرض بر اساس پراپ defaultToToday - const getDefaultDate = () => { - if (defaultToToday) { - return getTodayJalali(); - } - // اگر defaultToToday=false باشد، تاریخ ثابت 1404/01/01 برگردانده می‌شود - return { - year: "1404", - month: "01", - day: "01", - }; - }; + const [pickerValue, setPickerValue] = useState({ + year: "1404", + month: "01", + day: "01", + }); - /* مقداردهی اولیه اصلاح شده */ - useEffect(() => { - if (!modalOpen) return; + const getDefaultDate = () => { + if (defaultToToday) return getTodayJalali(); + return { year: "1404", month: "01", day: "01" }; + }; - let initialY: string, initialM: string, initialD: string; + // مقداردهی اولیه هنگام باز شدن مودال + useEffect(() => { + if (!modalOpen) return; - if (value) { - // اگر تاریخ ورودی وجود دارد، آن را پردازش کن - const parts = value.split("-"); - const yNum = parseInt(parts[0], 10); - const mNum = parseInt(parts[1], 10); - const dNum = parseInt(parts[2], 10); + let initialY: string, initialM: string, initialD: string; + if (value) { + const parts = value.split("-"); + const yNum = parseInt(parts[0], 10); + const mNum = parseInt(parts[1], 10); + const dNum = parseInt(parts[2], 10); - if (parts.length === 3 && !isNaN(yNum) && !isNaN(mNum) && !isNaN(dNum)) { - // اگر سال ورودی کمتر از 1300 باشد (میلادی است)، به شمسی تبدیل کن - if (yNum < 1300) { - const [jy, jm, jd] = gregorianToJalali(yNum, mNum, dNum); - initialY = jy.toString(); - initialM = jm.toString().padStart(2, "0"); - initialD = jd.toString().padStart(2, "0"); - } else { - // فرض بر شمسی بودن - initialY = parts[0]; - initialM = parts[1].padStart(2, "0"); - initialD = parts[2].padStart(2, "0"); - } - } else { - // اگر فرمت اشتباه بود، از تاریخ پیش‌فرض استفاده کن - const defaultDate = getDefaultDate(); - initialY = defaultDate.year; - initialM = defaultDate.month; - initialD = defaultDate.day; - } + if (parts.length === 3 && !isNaN(yNum) && !isNaN(mNum) && !isNaN(dNum)) { + if (yNum < 1300) { + const [jy, jm, jd] = gregorianToJalali(yNum, mNum, dNum); + initialY = jy.toString(); + initialM = jm.toString().padStart(2, "0"); + initialD = jd.toString().padStart(2, "0"); } else { - // اگر value خالی بود، از تاریخ پیش‌فرض استفاده کن - const defaultDate = getDefaultDate(); - initialY = defaultDate.year; - initialM = defaultDate.month; - initialD = defaultDate.day; + initialY = parts[0]; + initialM = parts[1].padStart(2, "0"); + initialD = parts[2].padStart(2, "0"); } + } else { + const defaultDate = getDefaultDate(); + initialY = defaultDate.year; + initialM = defaultDate.month; + initialD = defaultDate.day; + } + } else { + const defaultDate = getDefaultDate(); + initialY = defaultDate.year; + initialM = defaultDate.month; + initialD = defaultDate.day; + } - setPickerValue({ - year: initialY, - month: initialM, - day: initialD, - }); - }, [modalOpen, value, defaultToToday]); // اضافه کردن defaultToToday به وابستگی‌ها + setPickerValue({ year: initialY, month: initialM, day: initialD }); + }, [modalOpen, value, defaultToToday]); - // افکت جداگانه برای مقداردهی اولیه input وقتی کامپوننت mount می‌شود - useEffect(() => { - // اگر value خالی است و defaultToToday=true است، مقدار پیش‌فرض را به والد بدهیم - if (!value && defaultToToday) { - const today = getTodayJalali(); - const formattedDate = `${today.year}-${today.month}-${today.day}`; - onChange(formattedDate); - } - }, []); // فقط یک بار در زمان mount اجرا شود + // در mount: اگر value خالی و defaultToToday=true، به والد اطلاع بده + useEffect(() => { + if (!value && defaultToToday) { + const today = getTodayJalali(); + const formattedDate = `${today.year}-${today.month}-${today.day}`; + onChange(formattedDate); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // فقط در mount اجرا شود - /* تولید روزها برای ماه فعلی (شمسی) با در نظر گرفتن کبیسه */ - const getDaysInMonth = (yearStr?: string, monthStr?: string) => { - const year = yearStr ? parseInt(yearStr, 10) : parseInt(pickerValue.year, 10); - const month = monthStr ? parseInt(monthStr, 10) : parseInt(pickerValue.month, 10); + // محاسبه روزها با در نظر گرفتن کبیسه + const getDaysInMonth = (yearStr?: string, monthStr?: string) => { + const year = yearStr ? parseInt(yearStr, 10) : parseInt(pickerValue.year, 10); + const month = monthStr ? parseInt(monthStr, 10) : parseInt(pickerValue.month, 10); - if (isNaN(year) || isNaN(month) || month < 1 || month > 12) return generateNumericOptions(31); + if (isNaN(year) || isNaN(month) || month < 1 || month > 12) return generateNumericOptions(31); - if (month <= 6) { - return generateNumericOptions(31); - } else if (month <= 11) { - return generateNumericOptions(30); - } else { // اسفند (ماه 12) - const days = isJalaliLeapYear(year) ? 30 : 29; - return generateNumericOptions(days); - } - }; + if (month <= 6) return generateNumericOptions(31); + else if (month <= 11) return generateNumericOptions(30); + else { + const days = isJalaliLeapYear(year) ? 30 : 29; + return generateNumericOptions(days); + } + }; - /* تولید لیست سال‌ها */ - const getYears = () => { - const now = new Date(); - const [jy] = gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate()); - const currentYear = jy; - const startYear = 1320; - const years: string[] = []; - for (let i = currentYear; i >= startYear; i--) years.push(i.toString()); - return years; - }; + const getYears = () => { + const now = new Date(); + const [jy] = gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate()); + const currentYear = jy; + const startYear = 1320; + const years: string[] = []; + for (let i = currentYear; i >= startYear; i--) years.push(i.toString()); + return years; + }; - // لیست ماه‌های شمسی با فرمت option برای WheelPicker - const monthOptions = persianMonths.map((name, index) => ({ + // memoize آپشن‌ها + const monthOptions = useMemo( + () => + persianMonths.map((name, index) => ({ value: (index + 1).toString().padStart(2, "0"), label: name, - })); + })), + [persianMonths] + ); - const yearOptions = getYears().map(year => ({ value: year, label: year })); - const dayOptions = getDaysInMonth().map(day => ({ value: day, label: day })); + const yearOptions = useMemo(() => getYears().map((y) => ({ value: y, label: y })), []); - const handleMonthChange = (monthValue: string) => { - const daysForNewMonth = getDaysInMonth(pickerValue.year, monthValue); - setPickerValue((prev) => ({ - ...prev, - month: monthValue, - day: daysForNewMonth.some(d => d === prev.day) - ? prev.day - : daysForNewMonth[daysForNewMonth.length - 1], - })); + const dayOptions = useMemo( + () => getDaysInMonth(pickerValue.year, pickerValue.month).map((d) => ({ value: d, label: d })), + [pickerValue.year, pickerValue.month] + ); + + // debounce / delay برای اصلاح روز (تا هنگام اسکرول سریع موجب jump نشود) + const adjustDayTimeoutRef = useRef(null); + + const clearAdjustTimeout = () => { + if (adjustDayTimeoutRef.current !== null) { + window.clearTimeout(adjustDayTimeoutRef.current); + adjustDayTimeoutRef.current = null; + } + }; + + const handleMonthChange = (monthValue: string) => { + // به‌سرعت ماه را به‌روز کن برای تجربهٔ کاربری؛ اما اصلاح روز را با تاخیر انجام می‌دهیم + setPickerValue((prev) => ({ ...prev, month: monthValue })); + + clearAdjustTimeout(); + adjustDayTimeoutRef.current = window.setTimeout(() => { + setPickerValue((prev) => { + const daysForNew = getDaysInMonth(prev.year, monthValue); + const newDay = daysForNew.some((d) => d === prev.day) ? prev.day : daysForNew[daysForNew.length - 1]; + return { ...prev, day: newDay }; + }); + adjustDayTimeoutRef.current = null; + }, 120); + }; + + const handleYearChange = (yearValue: string) => { + setPickerValue((prev) => ({ ...prev, year: yearValue })); + + clearAdjustTimeout(); + adjustDayTimeoutRef.current = window.setTimeout(() => { + setPickerValue((prev) => { + const daysForNew = getDaysInMonth(yearValue, prev.month); + const newDay = daysForNew.some((d) => d === prev.day) ? prev.day : daysForNew[daysForNew.length - 1]; + return { ...prev, day: newDay }; + }); + adjustDayTimeoutRef.current = null; + }, 120); + }; + + // اگر WheelPicker قابلیت onScrollEnd داشت بهتر است از آن استفاده کنی و debounce را حذف کنی. + // cleanup هنگام unmount یا بسته شدن مودال + useEffect(() => { + return () => { + clearAdjustTimeout(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const handleYearChange = (yearValue: string) => { - const daysForNew = getDaysInMonth(yearValue, pickerValue.month); - setPickerValue((prev) => ({ - ...prev, - year: yearValue, - day: daysForNew.some(d => d === prev.day) - ? prev.day - : daysForNew[daysForNew.length - 1], - })); - }; + const onSubmit = () => { + const formattedDate = `${pickerValue.year}-${pickerValue.month}-${pickerValue.day}`; + onChange(formattedDate); + try { + localStorage.setItem("birthDate", formattedDate); + } catch (e) { + // ignore if storage unavailable + } + setModalOpen(false); + }; - const onSubmit = () => { - const formattedDate = `${pickerValue.year}-${pickerValue.month}-${pickerValue.day}`; - onChange(formattedDate); - localStorage.setItem("birthDate", formattedDate); - setModalOpen(false); - }; + const getPlaceholder = () => { + if (defaultToToday && !value) { + const today = getTodayJalali(); + return `${today.year}/${today.month}/${today.day}`; + } + return title; + }; - // نمایش متن placeholder مناسب بر اساس defaultToToday - const getPlaceholder = () => { - if (defaultToToday && !value) { - const today = getTodayJalali(); - return `${today.year}/${today.month}/${today.day}`; - } - return title; - }; + return ( +
+ {value &&

{title}

} - return ( -
- {value && ( -

{title}

- )} + setModalOpen(true)} + className="border-2 w-full rounded-full border-[#76558F] px-4 py-2 text-sm text-[#2D0A48]" + placeholder={getPlaceholder()} + /> - setModalOpen(true)} - className="border-2 w-full rounded-full border-[#76558F] px-4 py-2 text-sm text-[#2D0A48]" - placeholder={getPlaceholder()} - /> +
setModalOpen(true)} + > + calendar +
-
setModalOpen(true)} - > - calendar -
+ setModalOpen(false)} title={`انتخاب ${title}`} className="p-4 rounded-lg w-full max-w-sm"> +
+
+ {pickerValue.day} {persianMonths[parseInt(pickerValue.month, 10) - 1] || "---"} {pickerValue.year} +
- {/* Date Picker Modal with WheelPicker */} - setModalOpen(false)} - title={`انتخاب ${title}`} - className="p-4 rounded-lg w-full max-w-sm" - > -
- {/* Live Preview */} -
- {pickerValue.day} {persianMonths[parseInt(pickerValue.month) - 1] || "---"} {pickerValue.year} -
+
+ + {/* روزها (سمت راست به دلیل RTL) */} +
+ + setPickerValue((p) => ({ ...p, day: String(v).padStart(2, "0") })) + } + optionItemHeight={60} + visibleCount={12} + classNames={{ optionItem: "text-sm picker-item" }} + /> +
-
- +
+ : +
- {/* روزها (سمت راست به دلیل RTL) */} -
- - setPickerValue((p) => ({ ...p, day: String(v).padStart(2, "0") })) - } - optionItemHeight={60} - visibleCount={12} - classNames={{ optionItem: "text-sm picker-item" }} - /> -
+ {/* ماه‌ها (وسط) */} +
+ handleMonthChange(String(v).padStart(2, "0"))} + optionItemHeight={60} + visibleCount={12} + classNames={{ optionItem: "text-sm picker-item" }} + /> +
-
- : -
+
+ : +
- {/* ماه‌ها (وسط) */} -
- -
+ {/* سال‌ها (سمت چپ) */} +
+ handleYearChange(String(v))} + optionItemHeight={60} + visibleCount={12} + classNames={{ optionItem: "text-sm picker-item" }} + /> +
+
+
-
- : -
- - {/* سال‌ها (سمت چپ) */} -
- -
- -
-
- -
- - -
-
- -
+
+ + +
- ); +
+
+ ); }; export default SimpleDatePicker; \ No newline at end of file diff --git a/src/apps/new-ui/components/Qr-code/QrCodeScanner.tsx b/src/apps/new-ui/components/Qr-code/QrCodeScanner.tsx index e16f04df..4ac96917 100644 --- a/src/apps/new-ui/components/Qr-code/QrCodeScanner.tsx +++ b/src/apps/new-ui/components/Qr-code/QrCodeScanner.tsx @@ -6,7 +6,6 @@ import toast from "react-hot-toast"; import { useGetAllExtension } from "../../services/Explore"; import { runtimeConfigReady, getRuntimeConfig } from "@/config/runtime-config"; -// Make sure the worker file is available at /qr-scanner-worker.min.js QrScanner.WORKER_PATH = "/qr-scanner-worker.min.js"; export default function QrUltimateStyled({ onResult }: { onResult?: (text: string) => void }) { @@ -21,28 +20,46 @@ export default function QrUltimateStyled({ onResult }: { onResult?: (text: strin const [_devices, setDevices] = useState([]); const [selectedDeviceId, _setSelectedDeviceId] = useState(undefined); - // Allowed domains اولیه (ثابت) + // ✅ دامنه‌های مجاز اولیه const DEFAULT_ALLOWED = [ "https://api.salonaapp.ir", "https://aptest.mysalona.ir", "https://cardtest.mysalona.ir", "https://card.mysalona.ir", "https://app.mysalona.ir", - // don't reference dynamic value here at module-level ]; - const [allowedDomains, setAllowedDomains] = useState(DEFAULT_ALLOWED); + // ✅ استفاده از useRef به جای useState برای جلوگیری از stale closure + const allowedDomainsRef = useRef(DEFAULT_ALLOWED); + + // ✅ state فقط برای UI (اختیاری) + const [, setAllowedDomains] = useState(DEFAULT_ALLOWED); + + // ✅ flag برای آماده بودن config + const [isConfigReady, setIsConfigReady] = useState(false); - //دریافت فرمت‌های مجاز const { data: extensionsData } = useGetAllExtension(); - // Helper to check allowed url using current allowedDomains - function isAllowedUrl(url: string) { + // ✅ تابع بررسی URL - از ref استفاده می‌کند (همیشه تازه) + function isAllowedUrl(url: string): boolean { if (!url) return false; - return allowedDomains.some((domain) => url.startsWith(domain)); + const allowed = allowedDomainsRef.current; + const result = allowed.some((domain) => url.startsWith(domain)); + return result; + } + + // ✅ تابع برای اضافه کردن دامنه جدید + function addAllowedDomain(domain: string) { + if (!domain) return; + // نرمال‌سازی - حذف / انتهایی + const normalized = domain.replace(/\/+$/, ""); + + if (!allowedDomainsRef.current.includes(normalized)) { + allowedDomainsRef.current = [...allowedDomainsRef.current, normalized]; + setAllowedDomains([...allowedDomainsRef.current]); // برای UI + } } - // ... باقی فانکشن‌ها (isAllowedFileType, getAcceptString, handleScanResult و بقیه) function isAllowedFileType(file: File): boolean { if (!extensionsData?.allowed_extensions || extensionsData.allowed_extensions.length === 0) { return true; @@ -70,19 +87,16 @@ export default function QrUltimateStyled({ onResult }: { onResult?: (text: strin function handleScanResult(text: string) { setResult(text); - // بررسی URL مجاز + // ✅ از isAllowedUrl استفاده می‌کند که از ref می‌خواند if (isAllowedUrl(text)) { onResult?.(text); toast.success("QR Code معتبر است! در حال انتقال..."); } else { toast.error("این QR Code برای این اپلیکیشن معتبر نیست!"); - setTimeout(() => { - console.log("خروج از اپلیکیشن به دلیل QR Code نامعتبر"); - }, 3000); + setTimeout(() => {}, 3000); } } - // device helpers (بدون تغییر) async function enumerateDevices() { try { const all = await navigator.mediaDevices.enumerateDevices(); @@ -136,12 +150,9 @@ export default function QrUltimateStyled({ onResult }: { onResult?: (text: strin v.srcObject = null; } v?.pause(); - } catch (e) { - /* ignore */ - } + } catch (e) {} } - // tryQrScanner, tryZxing, startFlow و handleFile را عینِ قبلی نگه دارید (به جز اینکه startFlow زمانی اجرا شود که runtime config آماده باشد) async function tryQrScanner(deviceId?: string) { if (!videoRef.current) throw new Error("video element missing"); qrScannerRef.current = new QrScanner( @@ -213,16 +224,12 @@ export default function QrUltimateStyled({ onResult }: { onResult?: (text: strin try { await tryQrScanner(deviceToTry); return; - } catch (e) { - /* fallback */ - } + } catch (e) {} try { await tryZxing(deviceToTry); return; - } catch (e) { - /* fallback */ - } + } catch (e) {} try { const s = await navigator.mediaDevices.getUserMedia({ @@ -249,7 +256,7 @@ export default function QrUltimateStyled({ onResult }: { onResult?: (text: strin setScanning(true); return; } catch (e) { - setError("All attempts failed to start camera. Try Chrome for Android or a native solution."); + setError("All attempts failed to start camera."); } } @@ -263,7 +270,7 @@ export default function QrUltimateStyled({ onResult }: { onResult?: (text: strin if (!isAllowedFileType(file)) { const allowedFormats = extensionsData?.allowed_extensions?.join(", ") || "heic, jpg, jpeg, gif, png"; setError(`فرمت فایل مجاز نیست. فقط فرمت‌های ${allowedFormats} قابل قبول هستند.`); - toast.error(`فرمت فایل مجاز نیست. فقط فرمت‌های ${allowedFormats} قابل قبول هستند.`); + toast.error(`فرمت فایل مجاز نیست.`); e.currentTarget.value = ""; return; } @@ -281,13 +288,9 @@ export default function QrUltimateStyled({ onResult }: { onResult?: (text: strin let scannedResult: string | null = null; try { - const result = await QrScanner.scanImage(file, { - returnDetailedScanResult: true, - }); + const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true }); scannedResult = result?.data || null; - } catch (qrError) { - console.log("QrScanner failed, trying zxing...", qrError); - } + } catch (qrError) {} if (!scannedResult) { try { @@ -295,9 +298,7 @@ export default function QrUltimateStyled({ onResult }: { onResult?: (text: strin const zxingReader = new BrowserQRCodeReader(); const result = await zxingReader.decodeFromImageUrl(objectUrl); scannedResult = result?.getText() || null; - } catch (zxingError) { - console.log("ZXing also failed", zxingError); - } + } catch (zxingError) {} } if (scannedResult) { @@ -308,51 +309,60 @@ export default function QrUltimateStyled({ onResult }: { onResult?: (text: strin } } catch (err) { console.error("QR scan error:", err); - setError("خواندن کیوآر کد با خطا مواجه شد. لطفاً تصویر واضح‌تری انتخاب کنید"); - toast.error("خواندن کیوآر کد با خطا مواجه شد. لطفاً تصویر واضح‌تری انتخاب کنید"); + setError("خواندن کیوآر کد با خطا مواجه شد."); + toast.error("خواندن کیوآر کد با خطا مواجه شد."); } finally { if (objectUrl) URL.revokeObjectURL(objectUrl); e.currentTarget.value = ""; } } - // mount: ابتدا منتظر runtimeConfig می‌مانیم سپس startFlow را اجرا می‌کنیم + // ✅ Effect 1: بارگذاری config و آماده‌سازی دامنه‌ها useEffect(() => { let mounted = true; (async () => { try { - // منتظر آماده شدن runtimeConfig (اگر قبلاً آماده باشد، فوراً resolve می‌شود) await runtimeConfigReady; if (!mounted) return; + try { const { testAppApiUrl } = getRuntimeConfig(); + if (testAppApiUrl) { - setAllowedDomains((prev) => Array.from(new Set([...prev, testAppApiUrl]))); + addAllowedDomain(testAppApiUrl); } } catch (e) { - // اگر getRuntimeConfig خطا داد، تلاش می‌کنیم از env fallback استفاده کنیم const fallback = (import.meta.env.VITE_TEST_APP_API_URL as string) || ""; - if (fallback) setAllowedDomains((prev) => Array.from(new Set([...prev, fallback]))); + if (fallback) addAllowedDomain(fallback); } } catch (err) { - // اگر runtimeConfigReady هر چگونه reject شد، ما fallback را می‌زنیم const fallback = (import.meta.env.VITE_TEST_APP_API_URL as string) || ""; - if (fallback) setAllowedDomains((prev) => Array.from(new Set([...prev, fallback]))); + if (fallback) addAllowedDomain(fallback); } - // حالا که allowedDomains به‌روز شد، شروع به کار می‌کنیم + // ✅ بعد از آماده‌سازی دامنه‌ها، flag را true می‌کنیم if (mounted) { - startFlow(); + setIsConfigReady(true); } })(); return () => { mounted = false; + }; + }, []); + + // ✅ Effect 2: شروع اسکن فقط بعد از آماده شدن config + useEffect(() => { + if (!isConfigReady) return; + + startFlow(); + + return () => { stopAll(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [isConfigReady]); const galleryButton = (