customer panel design
Some checks are pending
PHP Composer / build (push) Waiting to run

This commit is contained in:
Hesabix 2025-09-03 22:54:46 +03:30
parent 164904c0fe
commit 7fa8d514ed
39 changed files with 4579 additions and 12 deletions

View file

@ -3,7 +3,12 @@ framework:
secret: '%env(APP_SECRET)%' secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it. # Note that the session will be started ONLY if you read or write from it.
session: true session:
enabled: true
cookie_lifetime: 604800 # 1 week
cookie_secure: false
cookie_httponly: true
cookie_samesite: 'lax'
#esi: true #esi: true
#fragments: true #fragments: true

View file

@ -9,10 +9,41 @@ security:
entity: entity:
class: App\Entity\User class: App\Entity\User
property: email property: email
customer_provider:
entity:
class: App\Entity\Customer
property: email
firewalls: firewalls:
dev: dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/ pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false security: false
admin:
pattern: ^/admin
lazy: true
provider: app_user_provider
form_login:
login_path: login
check_path: login
logout:
path: logout
customer:
pattern: ^/customer
lazy: true
provider: customer_provider
form_login:
login_path: customer_login
check_path: /customer/login_check
default_target_path: customer_dashboard
enable_csrf: true
remember_me: true
remember_me:
secret: '%kernel.secret%'
lifetime: 604800 # 1 week
path: /
always_remember_me: false
logout:
path: customer_logout
target: app_home
main: main:
lazy: true lazy: true
provider: app_user_provider provider: app_user_provider
@ -33,6 +64,7 @@ security:
# Note: Only the *first* access control that matches will be used # Note: Only the *first* access control that matches will be used
access_control: access_control:
- { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/customer/dashboard, roles: ROLE_CUSTOMER }
# - { path: ^/profile, roles: ROLE_USER } # - { path: ^/profile, roles: ROLE_USER }
when@test: when@test:

View file

@ -1,7 +1,8 @@
framework: framework:
default_locale: en default_locale: fa
translator: translator:
default_path: '%kernel.project_dir%/translations' default_path: '%kernel.project_dir%/translations'
fallbacks: fallbacks:
- fa
- en - en
providers: providers:

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250903164119 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE customer (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, phone VARCHAR(20) NOT NULL, is_active TINYINT(1) NOT NULL, email_verified_at DATETIME DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, last_login_at DATETIME DEFAULT NULL, subscription_type VARCHAR(50) DEFAULT NULL, subscription_expires_at DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_CUSTOMER_EMAIL (email), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE password_reset_token (id INT AUTO_INCREMENT NOT NULL, customer_id INT NOT NULL, token VARCHAR(255) NOT NULL, expires_at DATETIME NOT NULL, used_at DATETIME DEFAULT NULL, created_at DATETIME NOT NULL, INDEX IDX_6B7BA4B69395C3F3 (customer_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE password_reset_token ADD CONSTRAINT FK_6B7BA4B69395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_5A8A6C8DF47645AE ON post (url)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE password_reset_token DROP FOREIGN KEY FK_6B7BA4B69395C3F3');
$this->addSql('DROP TABLE customer');
$this->addSql('DROP TABLE password_reset_token');
$this->addSql('DROP INDEX UNIQ_5A8A6C8DF47645AE ON post');
}
}

2413
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,7 @@
"style-loader": "^4.0.0", "style-loader": "^4.0.0",
"webpack": "^5.74.0", "webpack": "^5.74.0",
"webpack-cli": "^5.1.0", "webpack-cli": "^5.1.0",
"webpack-dev-server": "^5.2.2",
"webpack-notifier": "^1.15.0" "webpack-notifier": "^1.15.0"
}, },
"license": "UNLICENSED", "license": "UNLICENSED",

View file

@ -2,7 +2,7 @@
body { body {
direction: rtl; direction: rtl;
text-align: right; text-align: right;
font-family: 'Vazir', 'Tahoma', sans-serif; /* فونت فارسی دلخواه */ font-family: 'Yekan Bakh FaNum';
} }
.login-wrapper { .login-wrapper {

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z"/>
</svg>

After

Width:  |  Height:  |  Size: 287 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h6V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
</svg>

After

Width:  |  Height:  |  Size: 303 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/>
</svg>

After

Width:  |  Height:  |  Size: 361 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/>
<path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.292-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.292c.415.764-.42 1.6-1.185 1.184l-.292-.159a1.873 1.873 0 0 0-2.692 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.693-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.292A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
</svg>

After

Width:  |  Height:  |  Size: 322 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="m8 2.748-.717-.737C5.6.281 2.514.878 1.4 3.053c-.523 1.023-.641 2.5.314 4.385.92 1.815 2.834 3.989 6.286 6.357 3.452-2.368 5.365-4.542 6.286-6.357.955-1.886.838-3.362.314-4.385C13.486.878 10.4.28 8.717 2.01L8 2.748zM8 15C-7.333 4.868 3.279-3.04 7.824 1.143c.06.055.119.112.176.171a3.12 3.12 0 0 1 .176-.17C12.72-3.042 23.333 4.867 8 15z"/>
</svg>

After

Width:  |  Height:  |  Size: 462 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4.5a.5.5 0 0 0 .5-.5v-4h2v4a.5.5 0 0 0 .5.5H14a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146zM2.5 14V7.707l5.5-5.5 5.5 5.5V14H10v-4a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5v4H2.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 449 B

4
public/img/icons/key.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M0 8a4 4 0 0 1 7.465-2H14a.5.5 0 0 1 .354.146l1.5 1.5a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0L13 9.207l-.646.647a.5.5 0 0 1-.708 0L11 9.207l-.646.647a.5.5 0 0 1-.708 0L9 9.207l-.646.647A.5.5 0 0 1 8 10h-.535A4 4 0 0 1 0 8zm4-3a3 3 0 1 0 2.712 4.285A.5.5 0 0 1 7.163 9h.63l.853-.854a.5.5 0 0 1 .708 0l.646.647.646-.647a.5.5 0 0 1 .708 0l.646.647.646-.647a.5.5 0 0 1 .708 0l.646.647.793-.793-1-1h-6.63a.5.5 0 0 1-.451-.285A3 3 0 0 0 4 5z"/>
<path d="M4 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 614 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2zM5 8h6a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1z"/>
</svg>

After

Width:  |  Height:  |  Size: 319 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M11 3.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zM.5 1.5A.5.5 0 0 1 1 1h3a.5.5 0 0 1 0 1H1v12h3a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5v-13z"/>
<path d="M11.854 7.646a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H1.5a.5.5 0 0 1 0-1h8.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3z"/>
</svg>

After

Width:  |  Height:  |  Size: 419 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0v2z"/>
<path d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z"/>
</svg>

After

Width:  |  Height:  |  Size: 484 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M6 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/>
<path d="M13.5 5a.5.5 0 0 1 .5.5V7h1.5a.5.5 0 0 1 0 1H14v1.5a.5.5 0 0 1-1 0V8h-1.5a.5.5 0 0 1 0-1H13V5.5a.5.5 0 0 1 .5-.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 495 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/>
</svg>

After

Width:  |  Height:  |  Size: 367 B

View file

@ -0,0 +1,218 @@
<?php
namespace App\Controller;
use App\Entity\Customer;
use App\Entity\PasswordResetToken;
use App\Form\CustomerRegistrationFormType;
use App\Form\ForgotPasswordFormType;
use App\Form\ResetPasswordFormType;
use App\Repository\CustomerRepository;
use App\Repository\PasswordResetTokenRepository;
use App\Service\EmailService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class CustomerController extends AbstractController
{
public function __construct(
private EntityManagerInterface $entityManager,
private CustomerRepository $customerRepository,
private PasswordResetTokenRepository $tokenRepository,
private UserPasswordHasherInterface $passwordHasher,
private EmailService $emailService
) {}
#[Route('/customer/login', name: 'customer_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('customer_dashboard');
}
$error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('customer/login.html.twig', [
'error' => $error,
'last_username' => $lastUsername,
]);
}
#[Route('/customer/register', name: 'customer_register')]
public function register(Request $request): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('customer_dashboard');
}
$customer = new Customer();
$form = $this->createForm(CustomerRegistrationFormType::class, $customer);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// بررسی وجود ایمیل
$existingCustomer = $this->customerRepository->findByEmail($customer->getEmail());
if ($existingCustomer) {
$this->addFlash('error', 'این ایمیل قبلاً ثبت شده است.');
return $this->render('customer/register.html.twig', [
'form' => $form->createView(),
]);
}
// هش کردن کلمه عبور
$plainPassword = $form->get('plainPassword')->getData();
$customer->setPassword(
$this->passwordHasher->hashPassword($customer, $plainPassword)
);
// ذخیره مشتری
$this->entityManager->persist($customer);
$this->entityManager->flush();
// ارسال ایمیل تایید
$this->emailService->sendVerificationEmail($customer);
$this->addFlash('success', 'ثبت‌نام با موفقیت انجام شد. لطفاً ایمیل خود را بررسی کنید.');
return $this->redirectToRoute('customer_login');
}
return $this->render('customer/register.html.twig', [
'form' => $form->createView(),
]);
}
#[Route('/customer/verify-email/{token}', name: 'customer_verify_email')]
public function verifyEmail(string $token): Response
{
// برای سادگی، از ایمیل به عنوان توکن استفاده می‌کنیم
// در آینده می‌توان یک سیستم توکن جداگانه پیاده کرد
$customer = $this->customerRepository->findOneBy(['email' => $token]);
if (!$customer) {
$this->addFlash('error', 'لینک تایید نامعتبر است.');
return $this->redirectToRoute('customer_login');
}
if ($customer->getEmailVerifiedAt()) {
$this->addFlash('info', 'ایمیل شما قبلاً تایید شده است.');
return $this->redirectToRoute('customer_login');
}
$customer->setEmailVerifiedAt(new \DateTime());
$customer->setIsActive(true);
$customer->setUpdatedAt(new \DateTime());
$this->entityManager->flush();
$this->addFlash('success', 'ایمیل شما با موفقیت تایید شد. حالا می‌توانید وارد شوید.');
return $this->redirectToRoute('customer_login');
}
#[Route('/customer/forgot-password', name: 'customer_forgot_password')]
public function forgotPassword(Request $request): Response
{
$form = $this->createForm(ForgotPasswordFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$email = $form->get('email')->getData();
$customer = $this->customerRepository->findByEmail($email);
if ($customer) {
// ایجاد توکن بازیابی
$resetToken = new PasswordResetToken();
$resetToken->setCustomer($customer);
$this->entityManager->persist($resetToken);
$this->entityManager->flush();
// ارسال ایمیل بازیابی
$this->emailService->sendPasswordResetEmail($customer, $resetToken);
}
// همیشه پیام موفقیت نشان می‌دهیم (امنیت)
$this->addFlash('success', 'اگر ایمیل شما در سیستم موجود باشد، لینک بازیابی کلمه عبور ارسال خواهد شد.');
return $this->redirectToRoute('customer_login');
}
return $this->render('customer/forgot_password.html.twig', [
'form' => $form->createView(),
]);
}
#[Route('/customer/reset-password/{token}', name: 'customer_reset_password')]
public function resetPassword(string $token, Request $request): Response
{
$resetToken = $this->tokenRepository->findValidByToken($token);
if (!$resetToken) {
$this->addFlash('error', 'لینک بازیابی نامعتبر یا منقضی شده است.');
return $this->redirectToRoute('customer_forgot_password');
}
$customer = $resetToken->getCustomer();
$form = $this->createForm(ResetPasswordFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$newPassword = $form->get('plainPassword')->getData();
// تغییر کلمه عبور
$customer->setPassword(
$this->passwordHasher->hashPassword($customer, $newPassword)
);
$customer->setUpdatedAt(new \DateTime());
// غیرفعال کردن توکن
$resetToken->setUsedAt(new \DateTime());
$this->entityManager->flush();
$this->addFlash('success', 'کلمه عبور شما با موفقیت تغییر کرد. حالا می‌توانید وارد شوید.');
return $this->redirectToRoute('customer_login');
}
return $this->render('customer/reset_password.html.twig', [
'form' => $form->createView(),
'token' => $token,
]);
}
#[Route('/customer/dashboard', name: 'customer_dashboard')]
public function dashboard(): Response
{
$this->denyAccessUnlessGranted('ROLE_CUSTOMER');
$customer = $this->getUser();
// به‌روزرسانی زمان آخرین ورود
if ($customer instanceof Customer) {
$customer->setLastLoginAt(new \DateTime());
$this->entityManager->flush();
}
return $this->render('customer/dashboard.html.twig', [
'customer' => $customer,
]);
}
#[Route('/customer/login_check', name: 'customer_login_check')]
public function loginCheck(): void
{
throw new \LogicException('This method can be blank - it will be intercepted by the login key on your firewall.');
}
#[Route('/customer/logout', name: 'customer_logout')]
public function logout(): void
{
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
}
}

216
src/Entity/Customer.php Normal file
View file

@ -0,0 +1,216 @@
<?php
namespace App\Entity;
use App\Repository\CustomerRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: CustomerRepository::class)]
#[ORM\Table(name: 'customer')]
#[ORM\UniqueConstraint(name: 'UNIQ_CUSTOMER_EMAIL', fields: ['email'])]
class Customer implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'ایمیل الزامی است')]
#[Assert\Email(message: 'فرمت ایمیل صحیح نیست')]
private ?string $email = null;
#[ORM\Column]
private array $roles = [];
#[ORM\Column]
private ?string $password = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'نام الزامی است')]
private ?string $name = null;
#[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'شماره موبایل الزامی است')]
#[Assert\Regex(pattern: '/^09[0-9]{9}$/', message: 'فرمت شماره موبایل صحیح نیست')]
private ?string $phone = null;
#[ORM\Column(type: 'boolean')]
private bool $isActive = false;
#[ORM\Column(type: 'datetime', nullable: true)]
private ?\DateTimeInterface $emailVerifiedAt = null;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $createdAt = null;
#[ORM\Column(type: 'datetime', nullable: true)]
private ?\DateTimeInterface $updatedAt = null;
#[ORM\Column(type: 'datetime', nullable: true)]
private ?\DateTimeInterface $lastLoginAt = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $subscriptionType = null;
#[ORM\Column(type: 'datetime', nullable: true)]
private ?\DateTimeInterface $subscriptionExpiresAt = null;
public function __construct()
{
$this->createdAt = new \DateTime();
$this->roles = ['ROLE_CUSTOMER'];
}
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
public function getUserIdentifier(): string
{
return $this->email ?? '';
}
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_CUSTOMER';
return array_unique($roles);
}
public function setRoles(array $roles): static
{
$this->roles = $roles;
return $this;
}
public function getPassword(): ?string
{
return $this->password;
}
public function setPassword(string $password): static
{
$this->password = $password;
return $this;
}
public function eraseCredentials(): void
{
// If you store any temporary, sensitive data on the user, clear it here
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(string $phone): static
{
$this->phone = $phone;
return $this;
}
public function isActive(): bool
{
return $this->isActive;
}
public function setIsActive(bool $isActive): static
{
$this->isActive = $isActive;
return $this;
}
public function getEmailVerifiedAt(): ?\DateTimeInterface
{
return $this->emailVerifiedAt;
}
public function setEmailVerifiedAt(?\DateTimeInterface $emailVerifiedAt): static
{
$this->emailVerifiedAt = $emailVerifiedAt;
return $this;
}
public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeInterface $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getUpdatedAt(): ?\DateTimeInterface
{
return $this->updatedAt;
}
public function setUpdatedAt(?\DateTimeInterface $updatedAt): static
{
$this->updatedAt = $updatedAt;
return $this;
}
public function getLastLoginAt(): ?\DateTimeInterface
{
return $this->lastLoginAt;
}
public function setLastLoginAt(?\DateTimeInterface $lastLoginAt): static
{
$this->lastLoginAt = $lastLoginAt;
return $this;
}
public function getSubscriptionType(): ?string
{
return $this->subscriptionType;
}
public function setSubscriptionType(?string $subscriptionType): static
{
$this->subscriptionType = $subscriptionType;
return $this;
}
public function getSubscriptionExpiresAt(): ?\DateTimeInterface
{
return $this->subscriptionExpiresAt;
}
public function setSubscriptionExpiresAt(?\DateTimeInterface $subscriptionExpiresAt): static
{
$this->subscriptionExpiresAt = $subscriptionExpiresAt;
return $this;
}
}

View file

@ -0,0 +1,114 @@
<?php
namespace App\Entity;
use App\Repository\PasswordResetTokenRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PasswordResetTokenRepository::class)]
#[ORM\Table(name: 'password_reset_token')]
class PasswordResetToken
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\JoinColumn(nullable: false)]
private ?Customer $customer = null;
#[ORM\Column(length: 255)]
private ?string $token = null;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $expiresAt = null;
#[ORM\Column(type: 'datetime', nullable: true)]
private ?\DateTimeInterface $usedAt = null;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $createdAt = null;
public function __construct()
{
$this->createdAt = new \DateTime();
$this->token = bin2hex(random_bytes(32));
$this->expiresAt = new \DateTime('+1 hour');
}
public function getId(): ?int
{
return $this->id;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function setCustomer(?Customer $customer): static
{
$this->customer = $customer;
return $this;
}
public function getToken(): ?string
{
return $this->token;
}
public function setToken(string $token): static
{
$this->token = $token;
return $this;
}
public function getExpiresAt(): ?\DateTimeInterface
{
return $this->expiresAt;
}
public function setExpiresAt(\DateTimeInterface $expiresAt): static
{
$this->expiresAt = $expiresAt;
return $this;
}
public function getUsedAt(): ?\DateTimeInterface
{
return $this->usedAt;
}
public function setUsedAt(?\DateTimeInterface $usedAt): static
{
$this->usedAt = $usedAt;
return $this;
}
public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeInterface $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function isExpired(): bool
{
return $this->expiresAt < new \DateTime();
}
public function isUsed(): bool
{
return $this->usedAt !== null;
}
public function isValid(): bool
{
return !$this->isExpired() && !$this->isUsed();
}
}

View file

@ -0,0 +1,112 @@
<?php
namespace App\Form;
use App\Entity\Customer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\IsTrue;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
class CustomerRegistrationFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'label' => 'نام و نام خانوادگی',
'attr' => [
'class' => 'form-control',
'placeholder' => 'نام و نام خانوادگی خود را وارد کنید',
'dir' => 'rtl'
],
'constraints' => [
new NotBlank([
'message' => 'لطفاً نام و نام خانوادگی خود را وارد کنید',
]),
],
])
->add('email', EmailType::class, [
'label' => 'پست الکترونیکی',
'attr' => [
'class' => 'form-control',
'placeholder' => 'example@domain.com',
'dir' => 'ltr'
],
'constraints' => [
new NotBlank([
'message' => 'لطفاً آدرس ایمیل خود را وارد کنید',
]),
],
])
->add('phone', TelType::class, [
'label' => 'شماره موبایل',
'attr' => [
'class' => 'form-control',
'placeholder' => '09123456789',
'dir' => 'ltr'
],
'constraints' => [
new NotBlank([
'message' => 'لطفاً شماره موبایل خود را وارد کنید',
]),
],
])
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'first_options' => [
'label' => 'کلمه عبور',
'attr' => [
'class' => 'form-control',
'placeholder' => 'کلمه عبور خود را وارد کنید',
'dir' => 'ltr'
],
],
'second_options' => [
'label' => 'تکرار کلمه عبور',
'attr' => [
'class' => 'form-control',
'placeholder' => 'کلمه عبور را مجدداً وارد کنید',
'dir' => 'ltr'
],
],
'invalid_message' => 'کلمه‌های عبور یکسان نیستند',
'mapped' => false,
'constraints' => [
new NotBlank([
'message' => 'لطفاً کلمه عبور خود را وارد کنید',
]),
new Length([
'min' => 8,
'minMessage' => 'کلمه عبور باید حداقل {{ limit }} کاراکتر باشد',
'max' => 4096,
]),
],
])
->add('agreeTerms', CheckboxType::class, [
'label' => false,
'mapped' => false,
'constraints' => [
new IsTrue([
'message' => 'لطفاً قوانین و مقررات را بپذیرید',
]),
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Customer::class,
]);
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
class ForgotPasswordFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, [
'label' => 'پست الکترونیکی',
'attr' => [
'class' => 'form-control',
'placeholder' => 'ایمیل خود را وارد کنید',
'dir' => 'ltr'
],
'constraints' => [
new NotBlank([
'message' => 'لطفاً آدرس ایمیل خود را وارد کنید',
]),
],
])
->add('submit', SubmitType::class, [
'label' => '<i class="fas fa-paper-plane"></i> ارسال لینک بازیابی',
'label_html' => true,
'attr' => [
'class' => 'btn btn-primary w-100 mb-3'
]
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}

View file

@ -0,0 +1,64 @@
<?php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
class ResetPasswordFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'first_options' => [
'label' => 'کلمه عبور جدید',
'attr' => [
'class' => 'form-control',
'placeholder' => 'کلمه عبور جدید خود را وارد کنید',
'dir' => 'ltr'
],
],
'second_options' => [
'label' => 'تکرار کلمه عبور جدید',
'attr' => [
'class' => 'form-control',
'placeholder' => 'کلمه عبور جدید را مجدداً وارد کنید',
'dir' => 'ltr'
],
],
'invalid_message' => 'کلمه‌های عبور یکسان نیستند',
'mapped' => false,
'constraints' => [
new NotBlank([
'message' => 'لطفاً کلمه عبور جدید خود را وارد کنید',
]),
new Length([
'min' => 8,
'minMessage' => 'کلمه عبور باید حداقل {{ limit }} کاراکتر باشد',
'max' => 4096,
]),
],
])
->add('submit', SubmitType::class, [
'label' => '<i class="fas fa-save"></i> تغییر کلمه عبور',
'label_html' => true,
'attr' => [
'class' => 'btn btn-primary w-100 mb-3'
]
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Repository;
use App\Entity\Customer;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Customer>
*/
class CustomerRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Customer::class);
}
public function save(Customer $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(Customer $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function findByEmail(string $email): ?Customer
{
return $this->findOneBy(['email' => $email]);
}
public function findActiveByEmail(string $email): ?Customer
{
return $this->findOneBy([
'email' => $email,
'isActive' => true
]);
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace App\Repository;
use App\Entity\PasswordResetToken;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PasswordResetToken>
*/
class PasswordResetTokenRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PasswordResetToken::class);
}
public function save(PasswordResetToken $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(PasswordResetToken $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function findByToken(string $token): ?PasswordResetToken
{
return $this->findOneBy(['token' => $token]);
}
public function findValidByToken(string $token): ?PasswordResetToken
{
$tokenEntity = $this->findByToken($token);
if ($tokenEntity && $tokenEntity->isValid()) {
return $tokenEntity;
}
return null;
}
public function removeExpiredTokens(): int
{
$qb = $this->createQueryBuilder('t');
$qb->delete()
->where('t.expiresAt < :now')
->setParameter('now', new \DateTime());
return $qb->getQuery()->execute();
}
}

View file

@ -0,0 +1,144 @@
<?php
namespace App\Service;
use App\Entity\Customer;
use App\Entity\PasswordResetToken;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class EmailService
{
public function __construct(
private MailerInterface $mailer,
private UrlGeneratorInterface $urlGenerator
) {}
public function sendVerificationEmail(Customer $customer): void
{
// برای سادگی، از ایمیل به عنوان توکن استفاده می‌کنیم
$verificationUrl = $this->urlGenerator->generate(
'customer_verify_email',
['token' => $customer->getEmail()],
UrlGeneratorInterface::ABSOLUTE_URL
);
$email = (new Email())
->from('noreply@hesabix.ir')
->to($customer->getEmail())
->subject('تایید ایمیل - باشگاه مشتریان حسابیکس')
->html($this->getVerificationEmailTemplate($customer->getName(), $verificationUrl));
$this->mailer->send($email);
}
public function sendPasswordResetEmail(Customer $customer, PasswordResetToken $token): void
{
$resetUrl = $this->urlGenerator->generate(
'customer_reset_password',
['token' => $token->getToken()],
UrlGeneratorInterface::ABSOLUTE_URL
);
$email = (new Email())
->from('noreply@hesabix.ir')
->to($customer->getEmail())
->subject('بازیابی کلمه عبور - باشگاه مشتریان حسابیکس')
->html($this->getPasswordResetEmailTemplate($customer->getName(), $resetUrl));
$this->mailer->send($email);
}
private function getVerificationEmailTemplate(string $name, string $verificationUrl): string
{
return "
<!DOCTYPE html>
<html dir='rtl' lang='fa'>
<head>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>تایید ایمیل</title>
<style>
body { font-family: 'Tahoma', Arial, sans-serif; direction: rtl; text-align: right; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #0d6efd; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
.content { background: #f8f9fa; padding: 30px; border-radius: 0 0 8px 8px; }
.button { display: inline-block; background: #0d6efd; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; margin: 20px 0; }
.footer { text-align: center; margin-top: 30px; color: #6c757d; font-size: 14px; }
</style>
</head>
<body>
<div class='container'>
<div class='header'>
<h1>باشگاه مشتریان حسابیکس</h1>
</div>
<div class='content'>
<h2>سلام {$name} عزیز!</h2>
<p>از عضویت شما در باشگاه مشتریان حسابیکس خوشحالیم.</p>
<p>برای فعال‌سازی حساب کاربری خود، لطفاً روی دکمه زیر کلیک کنید:</p>
<div style='text-align: center;'>
<a href='{$verificationUrl}' class='button'>تایید ایمیل</a>
</div>
<p>اگر دکمه بالا کار نمی‌کند، می‌توانید لینک زیر را در مرورگر خود کپی کنید:</p>
<p style='word-break: break-all; background: #e9ecef; padding: 10px; border-radius: 4px;'>{$verificationUrl}</p>
<p><strong>نکته:</strong> این لینک تا 24 ساعت معتبر است.</p>
</div>
<div class='footer'>
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
<p>© 2024 حسابیکس - نرم‌افزار حسابداری آنلاین</p>
</div>
</div>
</body>
</html>
";
}
private function getPasswordResetEmailTemplate(string $name, string $resetUrl): string
{
return "
<!DOCTYPE html>
<html dir='rtl' lang='fa'>
<head>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>بازیابی کلمه عبور</title>
<style>
body { font-family: 'Tahoma', Arial, sans-serif; direction: rtl; text-align: right; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #dc3545; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
.content { background: #f8f9fa; padding: 30px; border-radius: 0 0 8px 8px; }
.button { display: inline-block; background: #dc3545; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; margin: 20px 0; }
.footer { text-align: center; margin-top: 30px; color: #6c757d; font-size: 14px; }
.warning { background: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 4px; margin: 20px 0; }
</style>
</head>
<body>
<div class='container'>
<div class='header'>
<h1>بازیابی کلمه عبور</h1>
</div>
<div class='content'>
<h2>سلام {$name} عزیز!</h2>
<p>درخواست بازیابی کلمه عبور برای حساب کاربری شما در باشگاه مشتریان حسابیکس دریافت شد.</p>
<p>برای تغییر کلمه عبور، لطفاً روی دکمه زیر کلیک کنید:</p>
<div style='text-align: center;'>
<a href='{$resetUrl}' class='button'>تغییر کلمه عبور</a>
</div>
<p>اگر دکمه بالا کار نمی‌کند، می‌توانید لینک زیر را در مرورگر خود کپی کنید:</p>
<p style='word-break: break-all; background: #e9ecef; padding: 10px; border-radius: 4px;'>{$resetUrl}</p>
<div class='warning'>
<strong>هشدار امنیتی:</strong> اگر شما این درخواست را ارسال نکرده‌اید، لطفاً این ایمیل را نادیده بگیرید. کلمه عبور شما تغییر نخواهد کرد.
</div>
<p><strong>نکته:</strong> این لینک تا 1 ساعت معتبر است.</p>
</div>
<div class='footer'>
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
<p>© 2024 حسابیکس - نرم‌افزار حسابداری آنلاین</p>
</div>
</div>
</body>
</html>
";
}
}

View file

@ -37,6 +37,47 @@ gtag('config', 'G-K1R1SYQY8E');
{% block stylesheets %} {% block stylesheets %}
{# 'app' must match the first argument to addEntry() in webpack.config.js #} {# 'app' must match the first argument to addEntry() in webpack.config.js #}
{{ encore_entry_link_tags('app') }} {{ encore_entry_link_tags('app') }}
<style>
/* آیکون‌های SVG در نوار ناوبری */
.icon-svg {
width: 16px;
height: 16px;
display: inline-block;
vertical-align: middle;
}
.icon-svg svg {
fill: currentColor;
width: 100%;
height: 100%;
}
/* آیکون‌های رنگی */
.icon-user svg { fill: #3498db; }
.icon-cogs svg { fill: #95a5a6; }
.icon-sign-out svg { fill: #e74c3c; }
/* بهبود نمایش منوی dropdown */
.dropdown-menu {
border-radius: 10px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
border: none;
padding: 10px 0;
}
.dropdown-item {
padding: 10px 20px;
transition: all 0.3s ease;
display: flex;
align-items: center;
}
.dropdown-item.text-danger:hover {
background-color: #f8d7da;
color: #721c24;
}
</style>
{% endblock %} {% endblock %}
{% block javascripts %} {% block javascripts %}
@ -74,7 +115,33 @@ gtag('config', 'G-K1R1SYQY8E');
<a class="nav-link px-3 py-2 rounded-3 transition-all" href="{{path('app_page',{'url':'contact'})}}">تماس با ما</a> <a class="nav-link px-3 py-2 rounded-3 transition-all" href="{{path('app_page',{'url':'contact'})}}">تماس با ما</a>
</li> </li>
</ul> </ul>
<div class="d-flex"> <div class="d-flex gap-2">
{% if app.user and 'ROLE_CUSTOMER' in app.user.roles %}
{# کاربر وارد شده - نمایش منوی داشبورد #}
<div class="dropdown">
<button class="btn btn-outline-primary rounded-4 px-3 py-2 fw-bold transition-all dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<img src="{{ asset('/img/icons/user.svg') }}" alt="کاربر" class="icon-svg icon-user me-2">{{ app.user.name }}
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item" href="{{ path('customer_dashboard') }}">
<img src="{{ asset('/img/icons/cogs.svg') }}" alt="داشبورد" class="icon-svg icon-cogs me-2">داشبورد
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item text-danger" href="{{ path('customer_logout') }}">
<img src="{{ asset('/img/icons/sign-out.svg') }}" alt="خروج" class="icon-svg icon-sign-out me-2">خروج
</a>
</li>
</ul>
</div>
{% else %}
{# کاربر وارد نشده - نمایش دکمه ورود #}
<a class="btn btn-outline-primary rounded-4 px-3 py-2 fw-bold transition-all" href="{{ path('customer_login') }}">
باشگاه مشتریان
</a>
{% endif %}
<a target="_blank" class="btn btn-primary rounded-4 px-4 py-2 fw-bold transition-all" href="https://app.hesabix.ir"> <a target="_blank" class="btn btn-primary rounded-4 px-4 py-2 fw-bold transition-all" href="https://app.hesabix.ir">
ورود / عضویت ورود / عضویت
</a> </a>

View file

@ -0,0 +1,282 @@
{% extends 'base.html.twig' %}
{% block title %}باشگاه مشتریان حسابیکس - {{ block('page_title') }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<style>
.customer-auth-container {
min-height: 80vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px 0;
direction: rtl;
text-align: right;
}
.customer-auth-card {
background: white;
border-radius: 15px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
overflow: hidden;
width: 100%;
max-width: 450px;
direction: rtl;
}
.customer-auth-header {
background: linear-gradient(135deg, #0d6efd 0%, #6610f2 100%);
color: white;
padding: 30px;
text-align: center;
direction: rtl;
}
.customer-auth-header h1 {
margin: 0;
font-size: 1.8rem;
font-weight: bold;
direction: rtl;
}
.customer-auth-header p {
margin: 10px 0 0 0;
opacity: 0.9;
direction: rtl;
}
.customer-auth-body {
padding: 40px 30px;
direction: rtl;
}
.form-floating {
margin-bottom: 20px;
direction: rtl;
}
.form-floating label {
color: #6c757d;
right: 0.75rem;
left: auto;
direction: rtl;
}
.form-control {
border: 2px solid #e9ecef;
border-radius: 10px;
padding: 15px;
font-size: 16px;
transition: all 0.3s ease;
direction: rtl;
text-align: right;
}
.form-control:focus {
border-color: #0d6efd;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
/* فیلدهای انگلیسی (ایمیل و کلمه عبور) */
.form-control[type="email"],
.form-control[type="password"] {
direction: ltr;
text-align: left;
}
.form-control[type="email"] + label,
.form-control[type="password"] + label {
right: auto;
left: 0.75rem;
}
.btn-primary {
background: linear-gradient(135deg, #0d6efd 0%, #6610f2 100%);
border: none;
border-radius: 10px;
padding: 15px;
font-size: 16px;
font-weight: bold;
transition: all 0.3s ease;
direction: rtl;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(13, 110, 253, 0.4);
}
.form-check {
margin: 20px 0;
direction: rtl;
text-align: right;
}
.form-check-input {
margin-left: 0.5em;
margin-right: 0;
}
.form-check-input:checked {
background-color: #0d6efd;
border-color: #0d6efd;
}
.form-check-label {
direction: rtl;
text-align: right;
}
.auth-links {
text-align: center;
margin-top: 30px;
direction: rtl;
}
.auth-links a {
color: #0d6efd;
text-decoration: none;
font-weight: 500;
direction: rtl;
}
.auth-links a:hover {
text-decoration: underline;
}
.alert {
border-radius: 10px;
border: none;
padding: 15px 20px;
margin-bottom: 20px;
direction: rtl;
text-align: right;
}
.alert-success {
background-color: #d1edff;
color: #0c5460;
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
}
.alert-info {
background-color: #d1ecf1;
color: #0c5460;
}
.hesabix-logo {
width: 60px;
height: 60px;
margin-bottom: 15px;
}
.back-to-home {
position: absolute;
top: 20px;
left: 20px;
color: white;
text-decoration: none;
font-weight: 500;
opacity: 0.9;
direction: rtl;
}
.back-to-home:hover {
color: white;
opacity: 1;
}
/* بهبود نمایش آیکون‌های SVG */
.icon-svg {
width: 16px;
height: 16px;
margin-left: 5px;
margin-right: 0;
display: inline-block;
vertical-align: middle;
}
.icon-svg-large {
width: 48px;
height: 48px;
margin: 0 auto 20px;
display: block;
}
/* تغییر رنگ آیکون‌های SVG */
.icon-svg svg,
.icon-svg-large svg {
fill: currentColor;
width: 100%;
height: 100%;
}
/* آیکون‌های رنگی */
.icon-heart svg { fill: #e74c3c; }
.icon-user svg { fill: #3498db; }
.icon-calendar svg { fill: #f39c12; }
.icon-cogs svg { fill: #95a5a6; }
.icon-key svg { fill: #9b59b6; }
.icon-home svg { fill: #2ecc71; }
.icon-sign-out svg { fill: #e74c3c; }
.icon-exclamation svg { fill: #e74c3c; }
.icon-sign-in svg { fill: #27ae60; }
.icon-user-plus svg { fill: #3498db; }
.icon-check svg { fill: #27ae60; }
.icon-info svg { fill: #3498db; }
.icon-lock svg { fill: #95a5a6; }
.icon-arrow-left svg { fill: #7f8c8d; }
/* آیکون‌های بزرگ */
.icon-svg-large.icon-heart svg { fill: #e74c3c; }
.icon-svg-large.icon-key svg { fill: #9b59b6; }
.icon-svg-large.icon-lock svg { fill: #95a5a6; }
/* بهبود نمایش متن‌های فارسی */
.text-muted {
direction: rtl;
text-align: center;
}
</style>
{% endblock %}
{% block body %}
<div class="customer-auth-container">
<div class="customer-auth-card">
<div class="customer-auth-header">
<img src="{{ asset('/favicon/favicon.svg') }}" alt="حسابیکس" class="hesabix-logo">
<h1>باشگاه مشتریان حسابیکس</h1>
<p>{{ block('page_subtitle') }}</p>
</div>
<div class="customer-auth-body">
{% for message in app.flashes('success') %}
<div class="alert alert-success" role="alert">
<img src="{{ asset('/img/icons/check-circle.svg') }}" alt="موفقیت" class="icon-svg icon-check"> {{ message }}
</div>
{% endfor %}
{% for message in app.flashes('error') %}
<div class="alert alert-danger" role="alert">
<img src="{{ asset('/img/icons/exclamation-circle.svg') }}" alt="خطا" class="icon-svg icon-exclamation"> {{ message }}
</div>
{% endfor %}
{% for message in app.flashes('info') %}
<div class="alert alert-info" role="alert">
<img src="{{ asset('/img/icons/info-circle.svg') }}" alt="اطلاعات" class="icon-svg icon-info"> {{ message }}
</div>
{% endfor %}
{% block auth_content %}{% endblock %}
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,313 @@
{% extends 'base.html.twig' %}
{% block title %}داشبورد - باشگاه مشتریان حسابیکس{% endblock %}
{% block stylesheets %}
{{ parent() }}
<style>
/* تنظیمات کلی برای فارسی */
.customer-dashboard * {
font-family: 'Yekan Bakh FaNum', 'Tahoma', 'Arial', sans-serif;
}
.customer-dashboard {
min-height: 80vh;
padding: 40px 0;
direction: rtl;
text-align: right;
}
.dashboard-header {
background: linear-gradient(135deg, #0d6efd 0%, #6610f2 100%);
color: white;
padding: 40px 0;
margin-bottom: 40px;
direction: rtl;
}
.dashboard-header h1 {
direction: rtl;
text-align: right;
}
.dashboard-header p {
direction: rtl;
text-align: right;
}
.dashboard-card {
background: white;
border-radius: 15px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
padding: 30px;
margin-bottom: 30px;
transition: transform 0.3s ease;
direction: rtl;
}
.dashboard-card:hover {
transform: translateY(-5px);
}
.dashboard-card h3 {
color: #0d6efd;
margin-bottom: 20px;
font-weight: bold;
direction: rtl;
text-align: right;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #e9ecef;
direction: rtl;
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
font-weight: 600;
color: #495057;
direction: rtl;
}
.info-value {
color: #6c757d;
direction: rtl;
}
.status-badge {
padding: 5px 15px;
border-radius: 20px;
font-size: 12px;
font-weight: bold;
direction: rtl;
}
.status-active {
background: #d1edff;
color: #0c5460;
}
.status-inactive {
background: #f8d7da;
color: #721c24;
}
.action-buttons {
margin-top: 30px;
direction: rtl;
text-align: center;
}
.btn-customer {
background: linear-gradient(135deg, #0d6efd 0%, #6610f2 100%);
border: none;
border-radius: 10px;
padding: 12px 25px;
color: white;
text-decoration: none;
font-weight: 500;
margin: 5px;
display: inline-block;
transition: all 0.3s ease;
direction: rtl;
}
.btn-customer:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(13, 110, 253, 0.4);
color: white;
}
.welcome-message {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
color: white;
padding: 20px;
border-radius: 10px;
margin-bottom: 30px;
direction: rtl;
text-align: right;
}
.welcome-message h4 {
direction: rtl;
text-align: right;
}
.welcome-message p {
direction: rtl;
text-align: right;
}
/* بهبود نمایش آیکون‌های SVG */
.icon-svg {
width: 16px;
height: 16px;
margin-left: 5px;
margin-right: 0;
display: inline-block;
vertical-align: middle;
}
/* تغییر رنگ آیکون‌های SVG */
.icon-svg svg {
fill: currentColor;
width: 100%;
height: 100%;
}
/* رنگ‌های مختلف برای آیکون‌ها */
.text-primary .icon-svg svg {
fill: #0d6efd;
}
.text-success .icon-svg svg {
fill: #198754;
}
.text-danger .icon-svg svg {
fill: #dc3545;
}
.text-warning .icon-svg svg {
fill: #ffc107;
}
.text-info .icon-svg svg {
fill: #0dcaf0;
}
.text-muted .icon-svg svg {
fill: #6c757d;
}
.text-white .icon-svg svg {
fill: #ffffff;
}
/* بهبود نمایش متن‌های فارسی */
.text-center {
direction: rtl;
}
.text-end {
direction: rtl;
text-align: right;
}
</style>
{% endblock %}
{% block body %}
<div class="customer-dashboard">
<div class="dashboard-header">
<div class="container">
<div class="row align-items-center">
<div class="col-md-8">
<h1>خوش آمدید، {{ customer.name }} عزیز!</h1>
<p class="mb-0">به باشگاه مشتریان حسابیکس خوش آمدید</p>
</div>
<div class="col-md-4 text-end">
<img src="{{ asset('/favicon/favicon.svg') }}" alt="حسابیکس" width="80" height="80">
</div>
</div>
</div>
</div>
<div class="container">
<div class="welcome-message">
<h4><img src="{{ asset('/img/icons/heart.svg') }}" alt="قلب" class="icon-svg icon-heart"> از عضویت شما در باشگاه مشتریان حسابیکس سپاسگزاریم!</h4>
<p class="mb-0">در این بخش می‌توانید اطلاعات حساب کاربری خود را مشاهده و مدیریت کنید.</p>
</div>
<div class="row">
<div class="col-md-6">
<div class="dashboard-card">
<h3><img src="{{ asset('/img/icons/user.svg') }}" alt="کاربر" class="icon-svg icon-user"> اطلاعات شخصی</h3>
<div class="info-item">
<span class="info-label">نام و نام خانوادگی:</span>
<span class="info-value">{{ customer.name }}</span>
</div>
<div class="info-item">
<span class="info-label">پست الکترونیکی:</span>
<span class="info-value">{{ customer.email }}</span>
</div>
<div class="info-item">
<span class="info-label">شماره موبایل:</span>
<span class="info-value">{{ customer.phone }}</span>
</div>
<div class="info-item">
<span class="info-label">وضعیت حساب:</span>
<span class="status-badge {{ customer.isActive ? 'status-active' : 'status-inactive' }}">
{{ customer.isActive ? 'فعال' : 'غیرفعال' }}
</span>
</div>
</div>
</div>
<div class="col-md-6">
<div class="dashboard-card">
<h3><img src="{{ asset('/img/icons/calendar.svg') }}" alt="تقویم" class="icon-svg icon-calendar"> اطلاعات عضویت</h3>
<div class="info-item">
<span class="info-label">تاریخ عضویت:</span>
<span class="info-value">{{ customer.createdAt|date('Y/m/d') }}</span>
</div>
<div class="info-item">
<span class="info-label">آخرین ورود:</span>
<span class="info-value">
{{ customer.lastLoginAt ? customer.lastLoginAt|date('Y/m/d H:i') : 'هنوز وارد نشده' }}
</span>
</div>
<div class="info-item">
<span class="info-label">تایید ایمیل:</span>
<span class="status-badge {{ customer.emailVerifiedAt ? 'status-active' : 'status-inactive' }}">
{{ customer.emailVerifiedAt ? 'تایید شده' : 'تایید نشده' }}
</span>
</div>
{% if customer.subscriptionType %}
<div class="info-item">
<span class="info-label">نوع اشتراک:</span>
<span class="info-value">{{ customer.subscriptionType }}</span>
</div>
{% endif %}
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="dashboard-card">
<h3><img src="{{ asset('/img/icons/cogs.svg') }}" alt="تنظیمات" class="icon-svg icon-cogs"> عملیات</h3>
<div class="action-buttons">
<a href="{{ path('customer_forgot_password') }}" class="btn-customer">
<img src="{{ asset('/img/icons/key.svg') }}" alt="کلید" class="icon-svg icon-key"> بازیابی کلمه عبور
</a>
<a href="{{ path('app_home') }}" class="btn-customer">
<img src="{{ asset('/img/icons/home.svg') }}" alt="خانه" class="icon-svg icon-home"> بازگشت به صفحه اصلی
</a>
<a href="{{ path('customer_logout') }}" class="btn-customer" style="background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);">
<img src="{{ asset('/img/icons/sign-out.svg') }}" alt="خروج" class="icon-svg icon-sign-out"> خروج
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,33 @@
{% extends 'customer/base.html.twig' %}
{% block page_title %}بازیابی کلمه عبور{% endblock %}
{% block page_subtitle %}کلمه عبور خود را بازیابی کنید{% endblock %}
{% block auth_content %}
<div class="text-center mb-4">
<img src="{{ asset('/img/icons/key.svg') }}" alt="کلید" class="icon-svg-large icon-key text-primary">
<p class="text-muted">ایمیل خود را وارد کنید تا لینک بازیابی کلمه عبور برای شما ارسال شود.</p>
</div>
{{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }}
<div class="form-floating mb-3">
{{ form_widget(form.email, {'attr': {'class': 'form-control', 'placeholder': 'ایمیل خود را وارد کنید'}}) }}
{{ form_label(form.email) }}
{{ form_errors(form.email) }}
</div>
{{ form_end(form) }}
<div class="auth-links">
<a href="{{ path('customer_login') }}">
<img src="{{ asset('/img/icons/arrow-left.svg') }}" alt="بازگشت" class="icon-svg icon-arrow-left"> بازگشت به صفحه ورود
</a>
<br><br>
<p>حساب کاربری ندارید؟
<a href="{{ path('customer_register') }}">
<img src="{{ asset('/img/icons/user-plus.svg') }}" alt="عضویت" class="icon-svg icon-user-plus"> عضویت در باشگاه
</a>
</p>
</div>
{% endblock %}

View file

@ -0,0 +1,63 @@
{% extends 'customer/base.html.twig' %}
{% block page_title %}ورود{% endblock %}
{% block page_subtitle %}وارد حساب کاربری خود شوید{% endblock %}
{% block auth_content %}
{% if error %}
<div class="alert alert-danger" role="alert">
<img src="{{ asset('/img/icons/exclamation-circle.svg') }}" alt="خطا" class="icon-svg icon-exclamation"> {{ error.messageKey|trans(error.messageData, 'security', 'fa') }}
</div>
{% endif %}
<form method="post" action="{{ path('customer_login_check') }}">
<div class="form-floating mb-3">
<input type="email"
class="form-control"
id="inputEmail"
name="_username"
value="{{ last_username }}"
placeholder="ایمیل خود را وارد کنید"
required
autofocus
dir="ltr">
<label for="inputEmail">پست الکترونیکی</label>
</div>
<div class="form-floating mb-3">
<input type="password"
class="form-control"
id="inputPassword"
name="_password"
placeholder="کلمه عبور خود را وارد کنید"
required
dir="ltr">
<label for="inputPassword">کلمه عبور</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="remember_me" name="_remember_me" checked>
<label class="form-check-label" for="remember_me">
مرا به یاد داشته باش
</label>
</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<button class="btn btn-primary w-100 mb-3" type="submit">
<img src="{{ asset('/img/icons/sign-in.svg') }}" alt="ورود" class="icon-svg icon-sign-in"> ورود
</button>
</form>
<div class="auth-links">
<a href="{{ path('customer_forgot_password') }}">
<img src="{{ asset('/img/icons/key.svg') }}" alt="کلید" class="icon-svg icon-key"> فراموشی کلمه عبور
</a>
<br><br>
<p>حساب کاربری ندارید؟
<a href="{{ path('customer_register') }}">
<img src="{{ asset('/img/icons/user-plus.svg') }}" alt="عضویت" class="icon-svg icon-user-plus"> عضویت در باشگاه
</a>
</p>
</div>
{% endblock %}

View file

@ -0,0 +1,60 @@
{% extends 'customer/base.html.twig' %}
{% block page_title %}عضویت{% endblock %}
{% block page_subtitle %}به باشگاه مشتریان حسابیکس بپیوندید{% endblock %}
{% block auth_content %}
{{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }}
<div class="form-floating mb-3">
{{ form_widget(form.name, {'attr': {'class': 'form-control', 'placeholder': 'نام و نام خانوادگی خود را وارد کنید'}}) }}
{{ form_label(form.name) }}
{{ form_errors(form.name) }}
</div>
<div class="form-floating mb-3">
{{ form_widget(form.email, {'attr': {'class': 'form-control', 'placeholder': 'example@domain.com'}}) }}
{{ form_label(form.email) }}
{{ form_errors(form.email) }}
</div>
<div class="form-floating mb-3">
{{ form_widget(form.phone, {'attr': {'class': 'form-control', 'placeholder': '09123456789'}}) }}
{{ form_label(form.phone) }}
{{ form_errors(form.phone) }}
</div>
<div class="form-floating mb-3">
{{ form_widget(form.plainPassword.first, {'attr': {'class': 'form-control', 'placeholder': 'کلمه عبور خود را وارد کنید'}}) }}
{{ form_label(form.plainPassword.first) }}
{{ form_errors(form.plainPassword.first) }}
</div>
<div class="form-floating mb-3">
{{ form_widget(form.plainPassword.second, {'attr': {'class': 'form-control', 'placeholder': 'کلمه عبور را مجدداً وارد کنید'}}) }}
{{ form_label(form.plainPassword.second) }}
{{ form_errors(form.plainPassword.second) }}
</div>
<div class="form-check mb-3">
{{ form_widget(form.agreeTerms, {'attr': {'class': 'form-check-input'}}) }}
<label class="form-check-label" for="{{ form.agreeTerms.vars.id }}">
<a href="{{ path('app_page', {'url': 'terms'}) }}" target="_blank">قوانین و مقررات</a> را می‌پذیرم
</label>
{{ form_errors(form.agreeTerms) }}
</div>
<button class="btn btn-primary w-100 mb-3" type="submit">
<img src="{{ asset('/img/icons/user-plus.svg') }}" alt="عضویت" class="icon-svg icon-user-plus"> عضویت در باشگاه
</button>
{{ form_end(form) }}
<div class="auth-links">
<p>قبلاً عضو شده‌اید؟
<a href="{{ path('customer_login') }}">
<img src="{{ asset('/img/icons/sign-in.svg') }}" alt="ورود" class="icon-svg icon-sign-in"> ورود
</a>
</p>
</div>
{% endblock %}

View file

@ -0,0 +1,33 @@
{% extends 'customer/base.html.twig' %}
{% block page_title %}تغییر کلمه عبور{% endblock %}
{% block page_subtitle %}کلمه عبور جدید خود را وارد کنید{% endblock %}
{% block auth_content %}
<div class="text-center mb-4">
<img src="{{ asset('/img/icons/lock.svg') }}" alt="قفل" class="icon-svg-large icon-lock text-primary">
<p class="text-muted">کلمه عبور جدید خود را وارد کنید.</p>
</div>
{{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }}
<div class="form-floating mb-3">
{{ form_widget(form.plainPassword.first, {'attr': {'class': 'form-control', 'placeholder': 'کلمه عبور جدید خود را وارد کنید'}}) }}
{{ form_label(form.plainPassword.first) }}
{{ form_errors(form.plainPassword.first) }}
</div>
<div class="form-floating mb-3">
{{ form_widget(form.plainPassword.second, {'attr': {'class': 'form-control', 'placeholder': 'کلمه عبور جدید را مجدداً وارد کنید'}}) }}
{{ form_label(form.plainPassword.second) }}
{{ form_errors(form.plainPassword.second) }}
</div>
{{ form_end(form) }}
<div class="auth-links">
<a href="{{ path('customer_login') }}">
<img src="{{ asset('/img/icons/arrow-left.svg') }}" alt="بازگشت" class="icon-svg icon-arrow-left"> بازگشت به صفحه ورود
</a>
</div>
{% endblock %}

View file

@ -0,0 +1,154 @@
# پیام‌های عمومی سیستم
'Welcome': 'خوش آمدید'
'Login': 'ورود'
'Register': 'عضویت'
'Logout': 'خروج'
'Dashboard': 'داشبورد'
'Profile': 'پروفایل'
'Settings': 'تنظیمات'
'Save': 'ذخیره'
'Cancel': 'لغو'
'Delete': 'حذف'
'Edit': 'ویرایش'
'Create': 'ایجاد'
'Update': 'به‌روزرسانی'
'Search': 'جستجو'
'Filter': 'فیلتر'
'Sort': 'مرتب‌سازی'
'Export': 'خروجی'
'Import': 'ورودی'
'Download': 'دانلود'
'Upload': 'آپلود'
'Submit': 'ارسال'
'Reset': 'بازنشانی'
'Clear': 'پاک کردن'
'Close': 'بستن'
'Open': 'باز کردن'
'View': 'مشاهده'
'Details': 'جزئیات'
'Back': 'بازگشت'
'Next': 'بعدی'
'Previous': 'قبلی'
'First': 'اول'
'Last': 'آخر'
'Yes': 'بله'
'No': 'خیر'
'OK': 'تأیید'
'Error': 'خطا'
'Success': 'موفقیت'
'Warning': 'هشدار'
'Info': 'اطلاعات'
'Loading': 'در حال بارگذاری'
'Please wait': 'لطفاً صبر کنید'
'Processing': 'در حال پردازش'
'Complete': 'تکمیل شده'
'Incomplete': 'ناتمام'
'Active': 'فعال'
'Inactive': 'غیرفعال'
'Enabled': 'فعال'
'Disabled': 'غیرفعال'
'Public': 'عمومی'
'Private': 'خصوصی'
'Draft': 'پیش‌نویس'
'Published': 'منتشر شده'
'Pending': 'در انتظار'
'Approved': 'تأیید شده'
'Rejected': 'رد شده'
'Expired': 'منقضی شده'
'Valid': 'معتبر'
'Invalid': 'نامعتبر'
'Required': 'الزامی'
'Optional': 'اختیاری'
'Available': 'موجود'
'Unavailable': 'ناموجود'
'Online': 'آنلاین'
'Offline': 'آفلاین'
'Connected': 'متصل'
'Disconnected': 'قطع شده'
'New': 'جدید'
'Old': 'قدیمی'
'Recent': 'اخیر'
'Popular': 'محبوب'
'Featured': 'ویژه'
'Recommended': 'پیشنهادی'
'Best': 'بهترین'
'Top': 'برتر'
'Latest': 'جدیدترین'
'Updated': 'به‌روزرسانی شده'
'Created': 'ایجاد شده'
'Modified': 'تغییر یافته'
'Deleted': 'حذف شده'
'Restored': 'بازیابی شده'
'Archived': 'بایگانی شده'
'Unarchived': 'خارج از بایگانی'
'Locked': 'قفل شده'
'Unlocked': 'باز شده'
'Hidden': 'مخفی'
'Visible': 'قابل مشاهده'
'Read': 'خوانده شده'
'Unread': 'خوانده نشده'
'Mark as read': 'علامت‌گذاری به عنوان خوانده شده'
'Mark as unread': 'علامت‌گذاری به عنوان خوانده نشده'
'Star': 'ستاره'
'Unstar': 'حذف ستاره'
'Favorite': 'مورد علاقه'
'Unfavorite': 'حذف از علاقه‌مندی‌ها'
'Like': 'لایک'
'Unlike': 'حذف لایک'
'Share': 'اشتراک‌گذاری'
'Copy': 'کپی'
'Paste': 'چسباندن'
'Cut': 'برش'
'Undo': 'برگردان'
'Redo': 'تکرار'
'Refresh': 'تازه‌سازی'
'Reload': 'بارگذاری مجدد'
'Restart': 'راه‌اندازی مجدد'
'Stop': 'توقف'
'Start': 'شروع'
'Pause': 'مکث'
'Resume': 'ادامه'
'Play': 'پخش'
'Record': 'ضبط'
'Stop recording': 'توقف ضبط'
'Pause recording': 'مکث ضبط'
'Resume recording': 'ادامه ضبط'
'Delete recording': 'حذف ضبط'
'Download recording': 'دانلود ضبط'
'Upload recording': 'آپلود ضبط'
'Share recording': 'اشتراک‌گذاری ضبط'
'Copy link': 'کپی لینک'
'Copy URL': 'کپی آدرس'
'Copy text': 'کپی متن'
'Copy image': 'کپی تصویر'
'Copy file': 'کپی فایل'
'Copy folder': 'کپی پوشه'
'Move': 'انتقال'
'Rename': 'تغییر نام'
'Duplicate': 'تکثیر'
'Archive': 'بایگانی'
'Extract': 'استخراج'
'Compress': 'فشرده‌سازی'
'Decompress': 'باز کردن فشرده'
'Encrypt': 'رمزگذاری'
'Decrypt': 'رمزگشایی'
'Sign': 'امضا'
'Verify': 'تأیید'
'Authenticate': 'احراز هویت'
'Authorize': 'مجوزدهی'
'Login required': 'ورود الزامی است'
'Access denied': 'دسترسی رد شد'
'Permission denied': 'مجوز رد شد'
'Not found': 'یافت نشد'
'Not available': 'در دسترس نیست'
'Not supported': 'پشتیبانی نمی‌شود'
'Not implemented': 'پیاده‌سازی نشده'
'Not configured': 'پیکربندی نشده'
'Not initialized': 'مقداردهی نشده'
'Not ready': 'آماده نیست'
'Not connected': 'متصل نیست'
'Not authenticated': 'احراز هویت نشده'
'Not authorized': 'مجاز نیست'
'Not permitted': 'مجاز نیست'
'Not allowed': 'مجاز نیست'
'Not valid': 'معتبر نیست'

View file

@ -0,0 +1,16 @@
# پیام‌های خطای احراز هویت
'Invalid credentials.': 'اطلاعات ورود نامعتبر است.'
'Bad credentials.': 'اطلاعات ورود اشتباه است.'
'Username could not be found.': 'نام کاربری یافت نشد.'
'Invalid CSRF token.': 'توکن امنیتی نامعتبر است.'
'Account is disabled.': 'حساب کاربری غیرفعال است.'
'Account is locked.': 'حساب کاربری قفل شده است.'
'User account has expired.': 'حساب کاربری منقضی شده است.'
'User credentials have expired.': 'اعتبارات کاربر منقضی شده است.'
'Authentication request could not be processed due to a system problem.': 'درخواست احراز هویت به دلیل مشکل سیستم قابل پردازش نیست.'
'Authentication service temporarily unavailable.': 'سرویس احراز هویت موقتاً در دسترس نیست.'
'Too many failed login attempts, please try again later.': 'تعداد تلاش‌های ناموفق زیاد است، لطفاً بعداً تلاش کنید.'
'Session has expired.': 'جلسه منقضی شده است.'
'Authentication required.': 'احراز هویت الزامی است.'
'Access denied.': 'دسترسی رد شد.'
'You are not authorized to access this resource.': 'شما مجاز به دسترسی به این منبع نیستید.'