progress in site customer part
Some checks are pending
PHP Composer / build (push) Waiting to run

This commit is contained in:
Hesabix 2025-09-05 09:37:27 +03:30
parent b0d8761d7f
commit 06a2fb398d
59 changed files with 6199 additions and 491 deletions

160
WALLET_INTEGRATION.md Normal file
View file

@ -0,0 +1,160 @@
# راهنمای اتصال کیف پول به شبکه BSC
## ویژگی‌های پیاده‌سازی شده
### 1. **Entity و Database**
- ✅ Entity `Wallet` با فیلدهای کامل
- ✅ رابطه One-to-Many با User
- ✅ Migration برای جدول `wallets`
- ✅ Repository با متدهای مفید
### 2. **Backend API**
- ✅ `WalletController` با endpoints کامل
- ✅ `WalletService` برای منطق کسب‌وکار
- ✅ `WalletConnectFormType` برای فرم‌ها
- ✅ اعتبارسنجی و امنیت
### 3. **Frontend**
- ✅ JavaScript برای اتصال کیف پول‌ها
- ✅ پشتیبانی از MetaMask و Trust Wallet
- ✅ رابط کاربری در پروفایل
- ✅ مدیریت کیف پول‌ها (حذف، فعال/غیرفعال، تنظیم اصلی)
### 4. **امکانات**
- ✅ اتصال حداکثر 5 کیف پول per user
- ✅ تنظیم کیف پول اصلی
- ✅ فعال/غیرفعال کردن کیف پول‌ها
- ✅ حذف کیف پول‌ها
- ✅ تأیید مالکیت با امضای دیجیتال
- ✅ نمایش آدرس کوتاه شده
## API Endpoints
### اتصال کیف پول
```
POST /api/wallet/connect
{
"walletAddress": "0x...",
"walletType": "MetaMask",
"signature": "..."
}
```
### لیست کیف پول‌ها
```
GET /api/wallet/list
```
### تنظیم کیف پول اصلی
```
PUT /api/wallet/{id}/set-primary
```
### تغییر وضعیت
```
PUT /api/wallet/{id}/toggle-status
```
### حذف کیف پول
```
DELETE /api/wallet/{id}
```
### دریافت پیام برای امضا
```
GET /api/wallet/sign-message
```
## نحوه استفاده
### 1. **برای کاربران**
1. وارد پروفایل خود شوید
2. به بخش "مدیریت کیف پول‌ها" بروید
3. نوع کیف پول خود را انتخاب کنید
4. روی "اتصال کیف پول" کلیک کنید
5. در کیف پول خود تراکنش را تأیید کنید
6. کیف پول شما متصل خواهد شد
### 2. **برای توسعه‌دهندگان**
#### استفاده از WalletService
```php
// اتصال کیف پول
$result = $walletService->connectWallet($user, $address, $type, $signature);
// دریافت کیف پول‌های کاربر
$wallets = $walletService->getUserWallets($user);
// تنظیم کیف پول اصلی
$walletService->setPrimaryWallet($user, $walletId);
```
#### استفاده از Repository
```php
// پیدا کردن کیف پول اصلی
$primaryWallet = $walletRepository->findPrimaryWalletByUser($user);
// بررسی وجود آدرس
$exists = $walletRepository->isWalletAddressExists($address);
```
## امنیت
### 1. **اعتبارسنجی**
- بررسی فرمت آدرس کیف پول
- تأیید امضای دیجیتال
- محدودیت تعداد کیف پول‌ها
### 2. **مجوزها**
- فقط کاربران وارد شده
- دسترسی فقط به کیف پول‌های خود
- تأیید برای عملیات حساس
## نکات مهم
### 1. **Web3 Integration**
- نیاز به MetaMask یا Trust Wallet
- پشتیبانی از شبکه BSC
- امضای پیام برای تأیید مالکیت
### 2. **Database**
- جدول `wallets` ایجاد شده
- رابطه با جدول `user`
- Indexes برای عملکرد بهتر
### 3. **Frontend**
- JavaScript در `/public/js/wallet-connect.js`
- CSS در template پروفایل
- آیکون کیف پول اضافه شده
## مراحل بعدی (اختیاری)
1. **بهبود امنیت**
- پیاده‌سازی تأیید امضای واقعی با Web3
- اضافه کردن rate limiting
- لاگ‌گیری عملیات
2. **ویژگی‌های اضافی**
- نمایش موجودی کیف پول
- تاریخچه تراکنش‌ها
- اتصال به سایر شبکه‌ها
3. **بهبود UI/UX**
- انیمیشن‌های بهتر
- پیام‌های خطای دقیق‌تر
- راهنمای استفاده
## تست
برای تست عملکرد:
1. یک کاربر ایجاد کنید
2. وارد پروفایل شوید
3. کیف پول MetaMask را اتصال دهید
4. عملیات مختلف را تست کنید
## پشتیبانی
در صورت بروز مشکل:
1. Console مرورگر را بررسی کنید
2. لاگ‌های Symfony را چک کنید
3. اتصال Web3 را بررسی کنید

View file

@ -10,5 +10,6 @@ import './bootstrap.js';
import './styles/global.scss';
import './styles/app.css';
// the bootstrap module doesn't export/return anything
require('bootstrap');
// Import Bootstrap and make it globally available
import * as bootstrap from 'bootstrap';
window.bootstrap = bootstrap;

View file

@ -315,78 +315,160 @@ export default class extends Controller {
}
window.showApiInstall = function() {
document.getElementById('apiInstallSection').style.display = '';
document.getElementById('ftpOption').style.display = 'none';
const apiInstallSection = document.getElementById('apiInstallSection');
const ftpOption = document.getElementById('ftpOption');
const ftpStep1 = document.getElementById('ftpStep1');
const ftpStep2 = document.getElementById('ftpStep2');
const ftpStep3 = document.getElementById('ftpStep3');
if (apiInstallSection) {
apiInstallSection.style.display = '';
}
if (ftpOption) {
ftpOption.style.display = 'none';
}
// ریست مراحل FTP
document.getElementById('ftpStep1').classList.remove('step-hidden');
document.getElementById('ftpStep2').classList.add('step-hidden');
document.getElementById('ftpStep3').classList.add('step-hidden');
if (ftpStep1) {
ftpStep1.classList.remove('step-hidden');
}
if (ftpStep2) {
ftpStep2.classList.add('step-hidden');
}
if (ftpStep3) {
ftpStep3.classList.add('step-hidden');
}
}
window.showFtpInstall = function() {
document.getElementById('apiInstallSection').style.display = 'none';
const apiInstallSection = document.getElementById('apiInstallSection');
const ftpOption = document.getElementById('ftpOption');
const ftpStep1 = document.getElementById('ftpStep1');
const ftpStep2 = document.getElementById('ftpStep2');
const ftpStep3 = document.getElementById('ftpStep3');
if (apiInstallSection) {
apiInstallSection.style.display = 'none';
}
if (ftpOption) {
ftpOption.style.display = '';
ftpOption.classList.remove('step-hidden');
}
// ریست مراحل FTP
document.getElementById('ftpStep1').classList.remove('step-hidden');
document.getElementById('ftpStep2').classList.add('step-hidden');
document.getElementById('ftpStep3').classList.add('step-hidden');
if (ftpStep1) {
ftpStep1.classList.remove('step-hidden');
}
if (ftpStep2) {
ftpStep2.classList.add('step-hidden');
}
if (ftpStep3) {
ftpStep3.classList.add('step-hidden');
}
}
function showBootstrapAlert(message, type = 'danger', parentId = 'ftpStep1') {
// type: 'danger', 'success', 'info', ...
let parent = document.getElementById(parentId);
if (!parent) return;
let oldAlert = parent.querySelector('.bootstrap-alert');
if (oldAlert) oldAlert.remove();
let stepContent = parent.querySelector('.step-content');
if (!stepContent) return;
let div = document.createElement('div');
div.className = `alert alert-${type} bootstrap-alert`;
div.style.marginTop = '10px';
div.innerHTML = message;
parent.querySelector('.step-content').prepend(div);
stepContent.prepend(div);
}
window.showFtpDbStep = function() {
const ftpHost = document.getElementById('ftpHost').value;
const ftpUsername = document.getElementById('ftpUsername').value;
const ftpPassword = document.getElementById('ftpPassword').value;
const ftpHost = document.getElementById('ftpHost');
const ftpUsername = document.getElementById('ftpUsername');
const ftpPassword = document.getElementById('ftpPassword');
if (!ftpHost || !ftpUsername || !ftpPassword) {
showBootstrapAlert('تمام فیلدهای FTP الزامی است', 'danger', 'ftpStep1');
return;
}
const hostValue = ftpHost.value;
const usernameValue = ftpUsername.value;
const passwordValue = ftpPassword.value;
if (!hostValue || !usernameValue || !passwordValue) {
showBootstrapAlert('تمام فیلدهای FTP الزامی است', 'danger', 'ftpStep1');
return;
}
// حذف هشدار قبلی
showBootstrapAlert('', 'danger', 'ftpStep1');
document.getElementById('ftpStep1').classList.add('step-hidden');
document.getElementById('ftpStep2').classList.remove('step-hidden');
const ftpStep1 = document.getElementById('ftpStep1');
const ftpStep2 = document.getElementById('ftpStep2');
if (ftpStep1) {
ftpStep1.classList.add('step-hidden');
}
document.addEventListener('DOMContentLoaded', function() {
if (ftpStep2) {
ftpStep2.classList.remove('step-hidden');
}
}
function initializeInstallationController() {
// فقط در صفحات نصب اجرا شود
if (document.getElementById('apiInstallSection')) {
showApiInstall();
});
}
}
// اجرا در هر دو حالت
document.addEventListener('DOMContentLoaded', initializeInstallationController);
document.addEventListener('turbo:load', initializeInstallationController);
window.startFtpInstall = async function() {
// مرحله دوم: اطلاعات دیتابیس
const ftpHost = document.getElementById('ftpHost').value;
const ftpUsername = document.getElementById('ftpUsername').value;
const ftpPassword = document.getElementById('ftpPassword').value;
const ftpPort = document.getElementById('ftpPort').value;
const domain = document.querySelector('[data-installation-target="host"]').value;
const databaseName = document.getElementById('ftpDatabaseName').value;
const databaseUser = document.getElementById('ftpDatabaseUser').value;
const databasePassword = document.getElementById('ftpDatabasePassword').value;
if (!databaseName || !databaseUser || !databasePassword) {
const ftpHost = document.getElementById('ftpHost');
const ftpUsername = document.getElementById('ftpUsername');
const ftpPassword = document.getElementById('ftpPassword');
const ftpPort = document.getElementById('ftpPort');
const domainElement = document.querySelector('[data-installation-target="host"]');
const databaseName = document.getElementById('ftpDatabaseName');
const databaseUser = document.getElementById('ftpDatabaseUser');
const databasePassword = document.getElementById('ftpDatabasePassword');
if (!ftpHost || !ftpUsername || !ftpPassword || !ftpPort || !domainElement ||
!databaseName || !databaseUser || !databasePassword) {
showBootstrapAlert('تمام فیلدهای مورد نیاز یافت نشد', 'danger', 'ftpStep2');
return;
}
const hostValue = ftpHost.value;
const usernameValue = ftpUsername.value;
const passwordValue = ftpPassword.value;
const portValue = ftpPort.value;
const domainValue = domainElement.value;
const dbNameValue = databaseName.value;
const dbUserValue = databaseUser.value;
const dbPasswordValue = databasePassword.value;
if (!dbNameValue || !dbUserValue || !dbPasswordValue) {
showBootstrapAlert('تمام فیلدهای دیتابیس الزامی است', 'danger', 'ftpStep2');
return;
}
// حذف هشدار قبلی
showBootstrapAlert('', 'danger', 'ftpStep2');
const payload = {
ftp_host: ftpHost,
ftp_username: ftpUsername,
ftp_password: ftpPassword,
ftp_port: ftpPort,
domain: domain,
database_name: databaseName,
database_user: databaseUser,
database_password: databasePassword
ftp_host: hostValue,
ftp_username: usernameValue,
ftp_password: passwordValue,
ftp_port: portValue,
domain: domainValue,
database_name: dbNameValue,
database_user: dbUserValue,
database_password: dbPasswordValue
};
try {
const response = await fetch('/api/installation/ftp-install', {
method: 'POST',
@ -397,9 +479,19 @@ window.startFtpInstall = async function() {
body: JSON.stringify(payload)
});
const result = await response.json();
document.getElementById('ftpStep2').classList.add('step-hidden');
document.getElementById('ftpStep3').classList.remove('step-hidden');
const ftpStep2 = document.getElementById('ftpStep2');
const ftpStep3 = document.getElementById('ftpStep3');
const msg = document.getElementById('ftpResultMsg');
if (ftpStep2) {
ftpStep2.classList.add('step-hidden');
}
if (ftpStep3) {
ftpStep3.classList.remove('step-hidden');
}
if (msg) {
if (result.success) {
msg.className = 'status-message status-success';
msg.innerHTML = 'نصب با موفقیت از طریق FTP انجام شد!';
@ -407,11 +499,22 @@ window.startFtpInstall = async function() {
msg.className = 'status-message status-error';
msg.innerHTML = 'خطا: ' + (result.error || 'خطای نامشخص');
}
}
} catch (error) {
document.getElementById('ftpStep2').classList.add('step-hidden');
document.getElementById('ftpStep3').classList.remove('step-hidden');
const ftpStep2 = document.getElementById('ftpStep2');
const ftpStep3 = document.getElementById('ftpStep3');
const msg = document.getElementById('ftpResultMsg');
if (ftpStep2) {
ftpStep2.classList.add('step-hidden');
}
if (ftpStep3) {
ftpStep3.classList.remove('step-hidden');
}
if (msg) {
msg.className = 'status-message status-error';
msg.innerHTML = 'خطا در ارتباط با سرور: ' + error.message;
}
}
}

View file

@ -0,0 +1,321 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["walletType", "walletAddress", "connectBtn", "signature", "modal", "modalTitle", "modalBody"]
static values = {
signMessage: String,
csrfToken: String
}
connect() {
console.log('Wallet Connect Controller connected');
this.selectedWallet = null;
this.walletAddress = null;
this.signature = null;
}
async onWalletTypeChange(event) {
this.selectedWallet = event.target.value;
console.log('Selected wallet type:', this.selectedWallet);
if (this.selectedWallet) {
this.connectBtnTarget.disabled = false;
this.connectBtnTarget.textContent = 'اتصال کیف پول';
} else {
this.connectBtnTarget.disabled = true;
this.connectBtnTarget.textContent = 'نوع کیف پول را انتخاب کنید';
}
}
async connectWallet() {
if (!this.selectedWallet) {
this.showMessage('لطفاً نوع کیف پول را انتخاب کنید', 'error');
return;
}
try {
this.connectBtnTarget.disabled = true;
this.connectBtnTarget.textContent = 'در حال اتصال...';
// Check if Web3 is available
if (!window.ethereum) {
this.showMessage('کیف پول Web3 یافت نشد. لطفاً MetaMask یا Trust Wallet را نصب کنید.', 'error');
return;
}
// Connect to wallet
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts'
});
if (accounts.length === 0) {
this.showMessage('هیچ حساب کیف پولی یافت نشد', 'error');
return;
}
this.walletAddress = accounts[0];
console.log('Connected wallet address:', this.walletAddress);
// Get sign message
const signMessage = this.signMessageValue || 'Please sign this message to connect your wallet';
// Sign message
this.signature = await this.signMessage(signMessage);
if (!this.signature) {
return;
}
// Submit to server
await this.submitWalletConnection();
} catch (error) {
console.error('Error connecting wallet:', error);
this.showMessage('خطا در اتصال کیف پول: ' + error.message, 'error');
} finally {
this.connectBtnTarget.disabled = false;
this.connectBtnTarget.textContent = 'اتصال کیف پول';
}
}
async signMessage(message) {
try {
const signature = await window.ethereum.request({
method: 'personal_sign',
params: [message, this.walletAddress]
});
return signature;
} catch (error) {
console.error('Error signing message:', error);
this.showMessage('خطا در امضای پیام: ' + error.message, 'error');
return null;
}
}
async submitWalletConnection() {
try {
const formData = {
walletAddress: this.walletAddress,
walletType: this.selectedWallet,
signature: this.signature
};
console.log('Submitting wallet connection:', formData);
const response = await fetch('/api/wallet/connect', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfTokenValue || ''
},
body: JSON.stringify(formData)
});
console.log('Response status:', response.status);
const data = await response.json();
console.log('Response data:', data);
if (data.success) {
this.showMessage(data.message, 'success');
// Reload page to show updated wallet list
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
this.showMessage(data.message, 'error');
}
} catch (error) {
console.error('Error submitting wallet connection:', error);
this.showMessage('خطا در ارسال اطلاعات: ' + error.message, 'error');
}
}
async setPrimaryWallet(walletId) {
if (!confirm('آیا می‌خواهید این کیف پول را به عنوان اصلی تنظیم کنید؟')) {
return;
}
try {
const response = await fetch(`/api/wallet/${walletId}/set-primary`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfTokenValue || ''
}
});
const data = await response.json();
if (data.success) {
this.showMessage(data.message, 'success');
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
this.showMessage(data.message, 'error');
}
} catch (error) {
console.error('Error setting primary wallet:', error);
this.showMessage('خطا در تنظیم کیف پول اصلی: ' + error.message, 'error');
}
}
async toggleWalletStatus(walletId) {
try {
const response = await fetch(`/api/wallet/${walletId}/toggle-status`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfTokenValue || ''
}
});
const data = await response.json();
if (data.success) {
this.showMessage(data.message, 'success');
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
this.showMessage(data.message, 'error');
}
} catch (error) {
console.error('Error toggling wallet status:', error);
this.showMessage('خطا در تغییر وضعیت کیف پول: ' + error.message, 'error');
}
}
async deleteWallet(walletId) {
if (!confirm('آیا مطمئن هستید که می‌خواهید این کیف پول را حذف کنید؟')) {
return;
}
try {
const response = await fetch(`/api/wallet/${walletId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfTokenValue || ''
}
});
const data = await response.json();
if (data.success) {
this.showMessage(data.message, 'success');
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
this.showMessage(data.message, 'error');
}
} catch (error) {
console.error('Error deleting wallet:', error);
this.showMessage('خطا در حذف کیف پول: ' + error.message, 'error');
}
}
showMessage(message, type = 'info') {
// Create modal if it doesn't exist
if (!this.hasModalTarget) {
this.createModal();
}
// Get modal elements directly from DOM
const modalTitle = document.querySelector('#walletModal .modal-title');
const modalBody = document.querySelector('#walletModal .modal-body');
const modalElement = document.querySelector('#walletModal');
if (!modalTitle || !modalBody || !modalElement) {
console.error('Modal elements not found');
return;
}
// Set modal content
modalTitle.textContent = type === 'success' ? 'موفقیت' :
type === 'error' ? 'خطا' : 'اطلاعات';
modalBody.innerHTML = `
<div class="alert alert-${type === 'success' ? 'success' : type === 'error' ? 'danger' : 'info'}">
${message}
</div>
`;
// Show modal
if (window.bootstrap && window.bootstrap.Modal) {
const modal = new window.bootstrap.Modal(modalElement);
modal.show();
} else {
// Fallback: show modal using basic CSS
modalElement.style.display = 'block';
modalElement.classList.add('show');
document.body.classList.add('modal-open');
// Add backdrop
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop fade show';
backdrop.id = 'walletModalBackdrop';
document.body.appendChild(backdrop);
// Close modal when clicking backdrop
backdrop.addEventListener('click', () => {
this.hideModal();
});
// Close modal when clicking close button
const closeBtn = modalElement.querySelector('[data-bs-dismiss="modal"]');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
this.hideModal();
});
}
}
}
createModal() {
// Check if modal already exists
if (document.querySelector('#walletModal')) {
return;
}
const modalHtml = `
<div class="modal fade" id="walletModal" tabindex="-1" style="direction: rtl; text-align: right;">
<div class="modal-dialog">
<div class="modal-content" style="font-family: 'Yekan Bakh FaNum', 'Tahoma', 'Arial', sans-serif;">
<div class="modal-header" style="direction: rtl; text-align: right;">
<h5 class="modal-title" style="direction: rtl; text-align: right; font-family: 'Yekan Bakh FaNum', 'Tahoma', 'Arial', sans-serif;">اطلاعات</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" style="margin-left: 0; margin-right: auto;"></button>
</div>
<div class="modal-body" style="direction: rtl; text-align: right; font-family: 'Yekan Bakh FaNum', 'Tahoma', 'Arial', sans-serif;">
</div>
<div class="modal-footer" style="direction: rtl; text-align: right;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" style="font-family: 'Yekan Bakh FaNum', 'Tahoma', 'Arial', sans-serif;">بستن</button>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
}
hideModal() {
const modalElement = document.querySelector('#walletModal');
const backdrop = document.querySelector('#walletModalBackdrop');
if (modalElement) {
modalElement.style.display = 'none';
modalElement.classList.remove('show');
}
if (backdrop) {
backdrop.remove();
}
document.body.classList.remove('modal-open');
}
}

View file

@ -63,3 +63,62 @@ pre {
padding: 0.9rem 0.8rem;
color: blue;
}
/* استایل‌های RTL برای Modal */
#walletModal {
direction: rtl;
text-align: right;
}
#walletModal .modal-content {
font-family: 'Yekan Bakh FaNum', 'Tahoma', 'Arial', sans-serif;
direction: rtl;
text-align: right;
}
#walletModal .modal-header {
direction: rtl;
text-align: right;
border-bottom: 1px solid #dee2e6;
}
#walletModal .modal-title {
direction: rtl;
text-align: right;
font-family: 'Yekan Bakh FaNum', 'Tahoma', 'Arial', sans-serif;
font-weight: 600;
}
#walletModal .btn-close {
margin-left: 0;
margin-right: auto;
order: -1;
}
#walletModal .modal-body {
direction: rtl;
text-align: right;
font-family: 'Yekan Bakh FaNum', 'Tahoma', 'Arial', sans-serif;
}
#walletModal .modal-footer {
direction: rtl;
text-align: right;
border-top: 1px solid #dee2e6;
}
#walletModal .modal-footer .btn {
font-family: 'Yekan Bakh FaNum', 'Tahoma', 'Arial', sans-serif;
direction: rtl;
}
#walletModal .alert {
direction: rtl;
text-align: right;
font-family: 'Yekan Bakh FaNum', 'Tahoma', 'Arial', sans-serif;
}
/* استایل‌های backdrop برای RTL */
.modal-backdrop {
direction: rtl;
}

View file

@ -12,6 +12,7 @@
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^3.3",
"easycorp/easyadmin-bundle": "^4.20",
"erusev/parsedown": "*",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.0",
"symfony/apache-pack": "^1.0",
@ -45,6 +46,7 @@
"symfony/webpack-encore-bundle": "^2.2",
"symfony/yaml": "7.2.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/markdown-extra": "^3.21",
"twig/twig": "^2.12|^3.0"
},
"require-dev": {

128
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "bf4898b8b7af1fcb2f85b07fb389668f",
"content-hash": "3e1d441e93318caa54bc22a2b9fd60e6",
"packages": [
{
"name": "doctrine/cache",
@ -1396,6 +1396,56 @@
],
"time": "2024-12-27T00:36:43+00:00"
},
{
"name": "erusev/parsedown",
"version": "1.7.4",
"source": {
"type": "git",
"url": "https://github.com/erusev/parsedown.git",
"reference": "cb17b6477dfff935958ba01325f2e8a2bfa6dab3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/erusev/parsedown/zipball/cb17b6477dfff935958ba01325f2e8a2bfa6dab3",
"reference": "cb17b6477dfff935958ba01325f2e8a2bfa6dab3",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35"
},
"type": "library",
"autoload": {
"psr-0": {
"Parsedown": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Emanuil Rusev",
"email": "hello@erusev.com",
"homepage": "http://erusev.com"
}
],
"description": "Parser for Markdown.",
"homepage": "http://parsedown.org",
"keywords": [
"markdown",
"parser"
],
"support": {
"issues": "https://github.com/erusev/parsedown/issues",
"source": "https://github.com/erusev/parsedown/tree/1.7.x"
},
"time": "2019-12-30T22:54:17+00:00"
},
{
"name": "monolog/monolog",
"version": "3.8.1",
@ -7771,6 +7821,78 @@
],
"time": "2024-12-29T10:29:59+00:00"
},
{
"name": "twig/markdown-extra",
"version": "v3.21.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/markdown-extra.git",
"reference": "f4616e1dd375209dacf6026f846e6b537d036ce4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/markdown-extra/zipball/f4616e1dd375209dacf6026f846e6b537d036ce4",
"reference": "f4616e1dd375209dacf6026f846e6b537d036ce4",
"shasum": ""
},
"require": {
"php": ">=8.1.0",
"symfony/deprecation-contracts": "^2.5|^3",
"twig/twig": "^3.13|^4.0"
},
"require-dev": {
"erusev/parsedown": "dev-master as 1.x-dev",
"league/commonmark": "^1.0|^2.0",
"league/html-to-markdown": "^4.8|^5.0",
"michelf/php-markdown": "^1.8|^2.0",
"symfony/phpunit-bridge": "^6.4|^7.0"
},
"type": "library",
"autoload": {
"files": [
"Resources/functions.php"
],
"psr-4": {
"Twig\\Extra\\Markdown\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com",
"homepage": "http://fabien.potencier.org",
"role": "Lead Developer"
}
],
"description": "A Twig extension for Markdown",
"homepage": "https://twig.symfony.com",
"keywords": [
"html",
"markdown",
"twig"
],
"support": {
"source": "https://github.com/twigphp/markdown-extra/tree/v3.21.0"
},
"funding": [
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/twig/twig",
"type": "tidelift"
}
],
"time": "2025-01-31T20:45:36+00:00"
},
{
"name": "twig/twig",
"version": "v3.18.0",
@ -10182,7 +10304,7 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
@ -10190,6 +10312,6 @@
"ext-ctype": "*",
"ext-iconv": "*"
},
"platform-dev": [],
"platform-dev": {},
"plugin-api-version": "2.6.0"
}

View file

@ -9,3 +9,4 @@ framework:
- submit
- authenticate
- logout
enabled: true

View file

@ -9,10 +9,6 @@ security:
entity:
class: App\Entity\User
property: email
customer_provider:
entity:
class: App\Entity\Customer
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
@ -29,7 +25,7 @@ security:
customer:
pattern: ^/customer
lazy: true
provider: customer_provider
provider: app_user_provider
form_login:
login_path: customer_login
check_path: /customer/login_check
@ -45,25 +41,14 @@ security:
path: customer_logout
target: app_home
main:
pattern: ^/(?!customer)
lazy: true
provider: app_user_provider
form_login:
# "app_login" is the name of the route created previously
login_path: login
check_path: login
logout:
path: logout
customer_global:
pattern: ^/
lazy: true
provider: customer_provider
provider: app_user_provider
remember_me:
secret: '%kernel.secret%'
lifetime: 604800 # 1 week
path: /
always_remember_me: false
always_remember_me: true
logout:
path: customer_logout
target: app_home

View file

@ -27,3 +27,7 @@ services:
twigFunctions:
class: App\Service\twigFunctions
arguments: [ "@doctrine.orm.entity_manager" ]
# Markdown extension for Twig
App\Twig\MarkdownExtension:
tags: ['twig.extension']

View file

@ -1,45 +0,0 @@
<?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 Version20250726075416 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 oauth_access_tokens (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, client_id INT NOT NULL, identifier VARCHAR(255) NOT NULL, expiry_date_time DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', scopes JSON NOT NULL, revoked TINYINT(1) NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_CA42527C772E836A (identifier), INDEX IDX_CA42527CA76ED395 (user_id), INDEX IDX_CA42527C19EB6921 (client_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE oauth_authorization_codes (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, client_id INT NOT NULL, identifier VARCHAR(255) NOT NULL, expiry_date_time DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', scopes JSON NOT NULL, redirect_uri VARCHAR(255) NOT NULL, revoked TINYINT(1) NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_98A471C4772E836A (identifier), INDEX IDX_98A471C4A76ED395 (user_id), INDEX IDX_98A471C419EB6921 (client_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE oauth_clients (id INT AUTO_INCREMENT NOT NULL, client_id VARCHAR(255) NOT NULL, client_secret VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, redirect_uris LONGTEXT NOT NULL, is_active TINYINT(1) NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_13CE810119EB6921 (client_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE oauth_access_tokens ADD CONSTRAINT FK_CA42527CA76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)');
$this->addSql('ALTER TABLE oauth_access_tokens ADD CONSTRAINT FK_CA42527C19EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id)');
$this->addSql('ALTER TABLE oauth_authorization_codes ADD CONSTRAINT FK_98A471C4A76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)');
$this->addSql('ALTER TABLE oauth_authorization_codes ADD CONSTRAINT FK_98A471C419EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (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 oauth_access_tokens DROP FOREIGN KEY FK_CA42527CA76ED395');
$this->addSql('ALTER TABLE oauth_access_tokens DROP FOREIGN KEY FK_CA42527C19EB6921');
$this->addSql('ALTER TABLE oauth_authorization_codes DROP FOREIGN KEY FK_98A471C4A76ED395');
$this->addSql('ALTER TABLE oauth_authorization_codes DROP FOREIGN KEY FK_98A471C419EB6921');
$this->addSql('DROP TABLE oauth_access_tokens');
$this->addSql('DROP TABLE oauth_authorization_codes');
$this->addSql('DROP TABLE oauth_clients');
$this->addSql('DROP INDEX UNIQ_5A8A6C8DF47645AE ON post');
}
}

View file

@ -1,37 +0,0 @@
<?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');
}
}

View file

@ -0,0 +1,59 @@
<?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 Version20250904051657 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 answer (id INT AUTO_INCREMENT NOT NULL, question_id INT NOT NULL, author_id INT NOT NULL, content LONGTEXT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, is_accepted TINYINT(1) NOT NULL, votes INT NOT NULL, is_active TINYINT(1) NOT NULL, INDEX IDX_DADD4A251E27F6BF (question_id), INDEX IDX_DADD4A25F675F31B (author_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE answer_vote (id INT AUTO_INCREMENT NOT NULL, answer_id INT NOT NULL, user_id INT NOT NULL, is_upvote TINYINT(1) NOT NULL, created_at DATETIME NOT NULL, INDEX IDX_43B66A4AA334807 (answer_id), INDEX IDX_43B66A4A76ED395 (user_id), UNIQUE INDEX unique_answer_user_vote (answer_id, user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE question (id INT AUTO_INCREMENT NOT NULL, author_id INT NOT NULL, title VARCHAR(255) NOT NULL, content LONGTEXT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, is_solved TINYINT(1) NOT NULL, views INT NOT NULL, votes INT NOT NULL, is_active TINYINT(1) NOT NULL, INDEX IDX_B6F7494EF675F31B (author_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE question_tag (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(50) NOT NULL, description VARCHAR(255) DEFAULT NULL, color VARCHAR(7) DEFAULT NULL, usage_count INT NOT NULL, is_active TINYINT(1) NOT NULL, UNIQUE INDEX UNIQ_339D56FB5E237E06 (name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE question_tag_relation (id INT AUTO_INCREMENT NOT NULL, question_id INT NOT NULL, tag_id INT NOT NULL, INDEX IDX_C752C7D01E27F6BF (question_id), INDEX IDX_C752C7D0BAD26311 (tag_id), UNIQUE INDEX unique_question_tag (question_id, tag_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE question_vote (id INT AUTO_INCREMENT NOT NULL, question_id INT NOT NULL, user_id INT NOT NULL, is_upvote TINYINT(1) NOT NULL, created_at DATETIME NOT NULL, INDEX IDX_4FE688B1E27F6BF (question_id), INDEX IDX_4FE688BA76ED395 (user_id), UNIQUE INDEX unique_question_user_vote (question_id, user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE answer ADD CONSTRAINT FK_DADD4A251E27F6BF FOREIGN KEY (question_id) REFERENCES question (id)');
$this->addSql('ALTER TABLE answer ADD CONSTRAINT FK_DADD4A25F675F31B FOREIGN KEY (author_id) REFERENCES customer (id)');
$this->addSql('ALTER TABLE answer_vote ADD CONSTRAINT FK_43B66A4AA334807 FOREIGN KEY (answer_id) REFERENCES answer (id)');
$this->addSql('ALTER TABLE answer_vote ADD CONSTRAINT FK_43B66A4A76ED395 FOREIGN KEY (user_id) REFERENCES customer (id)');
$this->addSql('ALTER TABLE question ADD CONSTRAINT FK_B6F7494EF675F31B FOREIGN KEY (author_id) REFERENCES customer (id)');
$this->addSql('ALTER TABLE question_tag_relation ADD CONSTRAINT FK_C752C7D01E27F6BF FOREIGN KEY (question_id) REFERENCES question (id)');
$this->addSql('ALTER TABLE question_tag_relation ADD CONSTRAINT FK_C752C7D0BAD26311 FOREIGN KEY (tag_id) REFERENCES question_tag (id)');
$this->addSql('ALTER TABLE question_vote ADD CONSTRAINT FK_4FE688B1E27F6BF FOREIGN KEY (question_id) REFERENCES question (id)');
$this->addSql('ALTER TABLE question_vote ADD CONSTRAINT FK_4FE688BA76ED395 FOREIGN KEY (user_id) REFERENCES customer (id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE answer DROP FOREIGN KEY FK_DADD4A251E27F6BF');
$this->addSql('ALTER TABLE answer DROP FOREIGN KEY FK_DADD4A25F675F31B');
$this->addSql('ALTER TABLE answer_vote DROP FOREIGN KEY FK_43B66A4AA334807');
$this->addSql('ALTER TABLE answer_vote DROP FOREIGN KEY FK_43B66A4A76ED395');
$this->addSql('ALTER TABLE question DROP FOREIGN KEY FK_B6F7494EF675F31B');
$this->addSql('ALTER TABLE question_tag_relation DROP FOREIGN KEY FK_C752C7D01E27F6BF');
$this->addSql('ALTER TABLE question_tag_relation DROP FOREIGN KEY FK_C752C7D0BAD26311');
$this->addSql('ALTER TABLE question_vote DROP FOREIGN KEY FK_4FE688B1E27F6BF');
$this->addSql('ALTER TABLE question_vote DROP FOREIGN KEY FK_4FE688BA76ED395');
$this->addSql('DROP TABLE answer');
$this->addSql('DROP TABLE answer_vote');
$this->addSql('DROP TABLE question');
$this->addSql('DROP TABLE question_tag');
$this->addSql('DROP TABLE question_tag_relation');
$this->addSql('DROP TABLE question_vote');
}
}

View file

@ -0,0 +1,110 @@
<?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 Version20250905042717 extends AbstractMigration
{
public function getDescription(): string
{
return 'Migrate Customer data to User and update foreign key references';
}
public function up(Schema $schema): void
{
// First, add new columns to user table
$this->addSql('ALTER TABLE `user` ADD phone VARCHAR(20) DEFAULT NULL, ADD is_active TINYINT(1) NOT NULL, ADD email_verified_at DATETIME DEFAULT NULL, ADD created_at DATETIME NOT NULL, ADD updated_at DATETIME DEFAULT NULL, ADD last_login_at DATETIME DEFAULT NULL, ADD subscription_type VARCHAR(50) DEFAULT NULL, ADD subscription_expires_at DATETIME DEFAULT NULL');
// Migrate customer data to user table (only if not already exists)
$this->addSql('INSERT IGNORE INTO `user` (email, roles, password, name, phone, is_active, email_verified_at, created_at, updated_at, last_login_at, subscription_type, subscription_expires_at)
SELECT email, roles, password, name, phone, is_active,
CASE WHEN email_verified_at = "0000-00-00 00:00:00" OR email_verified_at IS NULL THEN NULL ELSE email_verified_at END,
CASE WHEN created_at = "0000-00-00 00:00:00" OR created_at IS NULL THEN NOW() ELSE created_at END,
CASE WHEN updated_at = "0000-00-00 00:00:00" OR updated_at IS NULL THEN NULL ELSE updated_at END,
CASE WHEN last_login_at = "0000-00-00 00:00:00" OR last_login_at IS NULL THEN NULL ELSE last_login_at END,
subscription_type,
CASE WHEN subscription_expires_at = "0000-00-00 00:00:00" OR subscription_expires_at IS NULL THEN NULL ELSE subscription_expires_at END
FROM customer');
// Update question table to reference user instead of customer
$this->addSql('UPDATE question q
INNER JOIN customer c ON q.author_id = c.id
INNER JOIN `user` u ON c.email = u.email
SET q.author_id = u.id');
// Update answer table to reference user instead of customer
$this->addSql('UPDATE answer a
INNER JOIN customer c ON a.author_id = c.id
INNER JOIN `user` u ON c.email = u.email
SET a.author_id = u.id');
// Update question_vote table to reference user instead of customer
$this->addSql('UPDATE question_vote qv
INNER JOIN customer c ON qv.user_id = c.id
INNER JOIN `user` u ON c.email = u.email
SET qv.user_id = u.id');
// Update answer_vote table to reference user instead of customer
$this->addSql('UPDATE answer_vote av
INNER JOIN customer c ON av.user_id = c.id
INNER JOIN `user` u ON c.email = u.email
SET av.user_id = u.id');
// Update password_reset_token table to reference user instead of customer
$this->addSql('UPDATE password_reset_token prt
INNER JOIN customer c ON prt.customer_id = c.id
INNER JOIN `user` u ON c.email = u.email
SET prt.customer_id = u.id');
// Now update foreign key constraints
$this->addSql('ALTER TABLE answer DROP FOREIGN KEY FK_DADD4A25F675F31B');
$this->addSql('ALTER TABLE answer ADD CONSTRAINT FK_DADD4A25F675F31B FOREIGN KEY (author_id) REFERENCES `user` (id)');
$this->addSql('ALTER TABLE answer_vote DROP FOREIGN KEY FK_43B66A4A76ED395');
$this->addSql('ALTER TABLE answer_vote ADD CONSTRAINT FK_43B66A4A76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)');
$this->addSql('ALTER TABLE password_reset_token DROP FOREIGN KEY FK_6B7BA4B69395C3F3');
$this->addSql('DROP INDEX IDX_6B7BA4B69395C3F3 ON password_reset_token');
$this->addSql('ALTER TABLE password_reset_token CHANGE customer_id user_id INT NOT NULL');
$this->addSql('ALTER TABLE password_reset_token ADD CONSTRAINT FK_6B7BA4B6A76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)');
$this->addSql('CREATE INDEX IDX_6B7BA4B6A76ED395 ON password_reset_token (user_id)');
$this->addSql('ALTER TABLE question DROP FOREIGN KEY FK_B6F7494EF675F31B');
$this->addSql('ALTER TABLE question ADD CONSTRAINT FK_B6F7494EF675F31B FOREIGN KEY (author_id) REFERENCES `user` (id)');
$this->addSql('ALTER TABLE question_vote DROP FOREIGN KEY FK_4FE688BA76ED395');
$this->addSql('ALTER TABLE question_vote ADD CONSTRAINT FK_4FE688BA76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)');
}
public function down(Schema $schema): void
{
// Revert foreign key constraints
$this->addSql('ALTER TABLE answer DROP FOREIGN KEY FK_DADD4A25F675F31B');
$this->addSql('ALTER TABLE answer ADD CONSTRAINT FK_DADD4A25F675F31B FOREIGN KEY (author_id) REFERENCES customer (id) ON UPDATE NO ACTION ON DELETE NO ACTION');
$this->addSql('ALTER TABLE answer_vote DROP FOREIGN KEY FK_43B66A4A76ED395');
$this->addSql('ALTER TABLE answer_vote ADD CONSTRAINT FK_43B66A4A76ED395 FOREIGN KEY (user_id) REFERENCES customer (id) ON UPDATE NO ACTION ON DELETE NO ACTION');
$this->addSql('ALTER TABLE password_reset_token DROP FOREIGN KEY FK_6B7BA4B6A76ED395');
$this->addSql('DROP INDEX IDX_6B7BA4B6A76ED395 ON password_reset_token');
$this->addSql('ALTER TABLE password_reset_token CHANGE user_id customer_id INT NOT NULL');
$this->addSql('ALTER TABLE password_reset_token ADD CONSTRAINT FK_6B7BA4B69395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id) ON UPDATE NO ACTION ON DELETE NO ACTION');
$this->addSql('CREATE INDEX IDX_6B7BA4B69395C3F3 ON password_reset_token (customer_id)');
$this->addSql('ALTER TABLE question DROP FOREIGN KEY FK_B6F7494EF675F31B');
$this->addSql('ALTER TABLE question ADD CONSTRAINT FK_B6F7494EF675F31B FOREIGN KEY (author_id) REFERENCES customer (id) ON UPDATE NO ACTION ON DELETE NO ACTION');
$this->addSql('ALTER TABLE question_vote DROP FOREIGN KEY FK_4FE688BA76ED395');
$this->addSql('ALTER TABLE question_vote ADD CONSTRAINT FK_4FE688BA76ED395 FOREIGN KEY (user_id) REFERENCES customer (id) ON UPDATE NO ACTION ON DELETE NO ACTION');
// Remove new columns from user table
$this->addSql('ALTER TABLE `user` DROP phone, DROP is_active, DROP email_verified_at, DROP created_at, DROP updated_at, DROP last_login_at, DROP subscription_type, DROP subscription_expires_at');
}
}

View file

@ -0,0 +1,349 @@
<?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 Version20250905043249 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 answer (
id INT AUTO_INCREMENT NOT NULL,
question_id INT NOT NULL,
author_id INT NOT NULL,
content LONGTEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME DEFAULT NULL,
is_accepted TINYINT(1) NOT NULL,
votes INT NOT NULL,
is_active TINYINT(1) NOT NULL,
INDEX IDX_DADD4A251E27F6BF (question_id),
INDEX IDX_DADD4A25F675F31B (author_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER
SET
utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE answer_vote (
id INT AUTO_INCREMENT NOT NULL,
answer_id INT NOT NULL,
user_id INT NOT NULL,
is_upvote TINYINT(1) NOT NULL,
created_at DATETIME NOT NULL,
INDEX IDX_43B66A4AA334807 (answer_id),
INDEX IDX_43B66A4A76ED395 (user_id),
UNIQUE INDEX unique_answer_user_vote (answer_id, user_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER
SET
utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE cat (
id INT AUTO_INCREMENT NOT NULL,
label VARCHAR(255) NOT NULL,
code VARCHAR(255) NOT NULL,
PRIMARY KEY(id)
) DEFAULT CHARACTER
SET
utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE comment (
id INT AUTO_INCREMENT NOT NULL,
post_id INT NOT NULL,
body LONGTEXT NOT NULL,
name VARCHAR(255) DEFAULT NULL,
email VARCHAR(255) DEFAULT NULL,
website VARCHAR(255) DEFAULT NULL,
date_submit VARCHAR(255) NOT NULL,
publish TINYINT(1) DEFAULT NULL,
INDEX IDX_9474526C4B89032C (post_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER
SET
utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$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 hsxorder (
id INT AUTO_INCREMENT NOT NULL,
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,
user_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_6B7BA4B6A76ED395 (user_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER
SET
utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE post (
id INT AUTO_INCREMENT NOT NULL,
submitter_id INT DEFAULT NULL,
cat_id INT DEFAULT NULL,
title VARCHAR(255) DEFAULT NULL,
body LONGTEXT DEFAULT NULL,
date_submit VARCHAR(50) NOT NULL,
publish TINYINT(1) DEFAULT NULL,
url VARCHAR(255) DEFAULT NULL,
main_pic VARCHAR(255) DEFAULT NULL,
plain LONGTEXT DEFAULT NULL,
version VARCHAR(255) DEFAULT NULL,
keywords LONGTEXT DEFAULT NULL,
sort VARCHAR(255) DEFAULT NULL,
intro LONGTEXT DEFAULT NULL,
views VARCHAR(255) DEFAULT NULL,
UNIQUE INDEX UNIQ_5A8A6C8DF47645AE (url),
INDEX IDX_5A8A6C8D919E5513 (submitter_id),
INDEX IDX_5A8A6C8DE6ADA943 (cat_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER
SET
utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE post_tree (
post_id INT NOT NULL,
tree_id INT NOT NULL,
INDEX IDX_7E6B39D74B89032C (post_id),
INDEX IDX_7E6B39D778B64A2 (tree_id),
PRIMARY KEY(post_id, tree_id)
) DEFAULT CHARACTER
SET
utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE question (
id INT AUTO_INCREMENT NOT NULL,
author_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
content LONGTEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME DEFAULT NULL,
is_solved TINYINT(1) NOT NULL,
views INT NOT NULL,
votes INT NOT NULL,
is_active TINYINT(1) NOT NULL,
INDEX IDX_B6F7494EF675F31B (author_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER
SET
utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE question_tag (
id INT AUTO_INCREMENT NOT NULL,
name VARCHAR(50) NOT NULL,
description VARCHAR(255) DEFAULT NULL,
color VARCHAR(7) DEFAULT NULL,
usage_count INT NOT NULL,
is_active TINYINT(1) NOT NULL,
UNIQUE INDEX UNIQ_339D56FB5E237E06 (name),
PRIMARY KEY(id)
) DEFAULT CHARACTER
SET
utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE question_tag_relation (
id INT AUTO_INCREMENT NOT NULL,
question_id INT NOT NULL,
tag_id INT NOT NULL,
INDEX IDX_C752C7D01E27F6BF (question_id),
INDEX IDX_C752C7D0BAD26311 (tag_id),
UNIQUE INDEX unique_question_tag (question_id, tag_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER
SET
utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE question_vote (
id INT AUTO_INCREMENT NOT NULL,
question_id INT NOT NULL,
user_id INT NOT NULL,
is_upvote TINYINT(1) NOT NULL,
created_at DATETIME NOT NULL,
INDEX IDX_4FE688B1E27F6BF (question_id),
INDEX IDX_4FE688BA76ED395 (user_id),
UNIQUE INDEX unique_question_user_vote (question_id, user_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER
SET
utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE settings (
id INT AUTO_INCREMENT NOT NULL,
des VARCHAR(255) DEFAULT NULL,
site_keywords LONGTEXT DEFAULT NULL,
bscscan_api VARCHAR(255) DEFAULT NULL,
bsc_priv VARCHAR(255) DEFAULT NULL,
bsc_pub VARCHAR(255) DEFAULT NULL,
zarinpal_chain VARCHAR(255) DEFAULT NULL,
PRIMARY KEY(id)
) DEFAULT CHARACTER
SET
utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE tree (
id INT AUTO_INCREMENT NOT NULL,
cat_id INT NOT NULL,
label VARCHAR(255) NOT NULL,
code VARCHAR(255) NOT NULL,
sort VARCHAR(50) DEFAULT NULL,
INDEX IDX_B73E5EDCE6ADA943 (cat_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER
SET
utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE `user` (
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) DEFAULT 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_IDENTIFIER_EMAIL (email),
PRIMARY KEY(id)
) DEFAULT CHARACTER
SET
utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE messenger_messages (
id BIGINT AUTO_INCREMENT NOT NULL,
body LONGTEXT NOT NULL,
headers LONGTEXT NOT NULL,
queue_name VARCHAR(190) NOT NULL,
created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\',
available_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\',
delivered_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\',
INDEX IDX_75EA56E0FB7336F0 (queue_name),
INDEX IDX_75EA56E0E3BD61CE (available_at),
INDEX IDX_75EA56E016BA31DB (delivered_at),
PRIMARY KEY(id)
) DEFAULT CHARACTER
SET
utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE
answer
ADD
CONSTRAINT FK_DADD4A251E27F6BF FOREIGN KEY (question_id) REFERENCES question (id)');
$this->addSql('ALTER TABLE
answer
ADD
CONSTRAINT FK_DADD4A25F675F31B FOREIGN KEY (author_id) REFERENCES `user` (id)');
$this->addSql('ALTER TABLE
answer_vote
ADD
CONSTRAINT FK_43B66A4AA334807 FOREIGN KEY (answer_id) REFERENCES answer (id)');
$this->addSql('ALTER TABLE
answer_vote
ADD
CONSTRAINT FK_43B66A4A76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)');
$this->addSql('ALTER TABLE
comment
ADD
CONSTRAINT FK_9474526C4B89032C FOREIGN KEY (post_id) REFERENCES post (id)');
$this->addSql('ALTER TABLE
password_reset_token
ADD
CONSTRAINT FK_6B7BA4B6A76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)');
$this->addSql('ALTER TABLE
post
ADD
CONSTRAINT FK_5A8A6C8D919E5513 FOREIGN KEY (submitter_id) REFERENCES `user` (id)');
$this->addSql('ALTER TABLE post ADD CONSTRAINT FK_5A8A6C8DE6ADA943 FOREIGN KEY (cat_id) REFERENCES cat (id)');
$this->addSql('ALTER TABLE
post_tree
ADD
CONSTRAINT FK_7E6B39D74B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE
post_tree
ADD
CONSTRAINT FK_7E6B39D778B64A2 FOREIGN KEY (tree_id) REFERENCES tree (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE
question
ADD
CONSTRAINT FK_B6F7494EF675F31B FOREIGN KEY (author_id) REFERENCES `user` (id)');
$this->addSql('ALTER TABLE
question_tag_relation
ADD
CONSTRAINT FK_C752C7D01E27F6BF FOREIGN KEY (question_id) REFERENCES question (id)');
$this->addSql('ALTER TABLE
question_tag_relation
ADD
CONSTRAINT FK_C752C7D0BAD26311 FOREIGN KEY (tag_id) REFERENCES question_tag (id)');
$this->addSql('ALTER TABLE
question_vote
ADD
CONSTRAINT FK_4FE688B1E27F6BF FOREIGN KEY (question_id) REFERENCES question (id)');
$this->addSql('ALTER TABLE
question_vote
ADD
CONSTRAINT FK_4FE688BA76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)');
$this->addSql('ALTER TABLE tree ADD CONSTRAINT FK_B73E5EDCE6ADA943 FOREIGN KEY (cat_id) REFERENCES cat (id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE answer DROP FOREIGN KEY FK_DADD4A251E27F6BF');
$this->addSql('ALTER TABLE answer DROP FOREIGN KEY FK_DADD4A25F675F31B');
$this->addSql('ALTER TABLE answer_vote DROP FOREIGN KEY FK_43B66A4AA334807');
$this->addSql('ALTER TABLE answer_vote DROP FOREIGN KEY FK_43B66A4A76ED395');
$this->addSql('ALTER TABLE comment DROP FOREIGN KEY FK_9474526C4B89032C');
$this->addSql('ALTER TABLE password_reset_token DROP FOREIGN KEY FK_6B7BA4B6A76ED395');
$this->addSql('ALTER TABLE post DROP FOREIGN KEY FK_5A8A6C8D919E5513');
$this->addSql('ALTER TABLE post DROP FOREIGN KEY FK_5A8A6C8DE6ADA943');
$this->addSql('ALTER TABLE post_tree DROP FOREIGN KEY FK_7E6B39D74B89032C');
$this->addSql('ALTER TABLE post_tree DROP FOREIGN KEY FK_7E6B39D778B64A2');
$this->addSql('ALTER TABLE question DROP FOREIGN KEY FK_B6F7494EF675F31B');
$this->addSql('ALTER TABLE question_tag_relation DROP FOREIGN KEY FK_C752C7D01E27F6BF');
$this->addSql('ALTER TABLE question_tag_relation DROP FOREIGN KEY FK_C752C7D0BAD26311');
$this->addSql('ALTER TABLE question_vote DROP FOREIGN KEY FK_4FE688B1E27F6BF');
$this->addSql('ALTER TABLE question_vote DROP FOREIGN KEY FK_4FE688BA76ED395');
$this->addSql('ALTER TABLE tree DROP FOREIGN KEY FK_B73E5EDCE6ADA943');
$this->addSql('DROP TABLE answer');
$this->addSql('DROP TABLE answer_vote');
$this->addSql('DROP TABLE cat');
$this->addSql('DROP TABLE comment');
$this->addSql('DROP TABLE customer');
$this->addSql('DROP TABLE hsxorder');
$this->addSql('DROP TABLE password_reset_token');
$this->addSql('DROP TABLE post');
$this->addSql('DROP TABLE post_tree');
$this->addSql('DROP TABLE question');
$this->addSql('DROP TABLE question_tag');
$this->addSql('DROP TABLE question_tag_relation');
$this->addSql('DROP TABLE question_vote');
$this->addSql('DROP TABLE settings');
$this->addSql('DROP TABLE tree');
$this->addSql('DROP TABLE `user`');
$this->addSql('DROP TABLE messenger_messages');
}
}

View file

@ -0,0 +1,33 @@
<?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 Version20250905050647 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 wallets (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, wallet_address VARCHAR(42) NOT NULL, wallet_type VARCHAR(50) NOT NULL, is_primary TINYINT(1) NOT NULL, is_active TINYINT(1) NOT NULL, connected_at DATETIME NOT NULL, last_used_at DATETIME DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, INDEX IDX_967AAA6CA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE wallets ADD CONSTRAINT FK_967AAA6CA76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE wallets DROP FOREIGN KEY FK_967AAA6CA76ED395');
$this->addSql('DROP TABLE wallets');
}
}

View file

@ -49,8 +49,8 @@ This set of fonts are used in this project under the license: (.....)
font-style: normal;
font-weight: 600;
font-stretch: normal;
src: url('woff/YekanBakhFaNum-SemiBold.woff') format('woff'),
url('woff2/YekanBakhFaNum-SemiBold.woff2') format('woff2');
src: url('woff/YekanBakhFaNum-Bold.woff') format('woff'),
url('woff2/YekanBakhFaNum-Bold.woff2') format('woff2');
}
@font-face {

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 3a2 2 0 0 1 2-2h13.5a.5.5 0 0 1 0 1H15v2.5a.5.5 0 0 1-1 0V2H2a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V6.5a.5.5 0 0 1 1 0V13a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3z"/>
<path d="M15 4a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1 0-1h1v-.5a.5.5 0 0 1 .5-.5zM4 8a1 1 0 0 1 1-1h1a1 1 0 1 1 0 2H5a1 1 0 0 1-1-1z"/>
</svg>

After

Width:  |  Height:  |  Size: 440 B

View file

@ -2,13 +2,13 @@
namespace App\Controller;
use App\Entity\Customer;
use App\Entity\User;
use App\Entity\PasswordResetToken;
use App\Form\CustomerRegistrationFormType;
use App\Form\ForgotPasswordFormType;
use App\Form\ResetPasswordFormType;
use App\Repository\CustomerRepository;
use App\Form\WalletConnectFormType;
use App\Repository\UserRepository;
use App\Repository\PasswordResetTokenRepository;
use App\Service\EmailService;
use Doctrine\ORM\EntityManagerInterface;
@ -24,7 +24,7 @@ class CustomerController extends AbstractController
{
public function __construct(
private EntityManagerInterface $entityManager,
private CustomerRepository $customerRepository,
private UserRepository $userRepository,
private PasswordResetTokenRepository $tokenRepository,
private UserPasswordHasherInterface $passwordHasher,
private EmailService $emailService
@ -53,32 +53,35 @@ class CustomerController extends AbstractController
return $this->redirectToRoute('customer_dashboard');
}
$customer = new Customer();
$form = $this->createForm(CustomerRegistrationFormType::class, $customer);
$user = new User();
$form = $this->createForm(CustomerRegistrationFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// بررسی وجود ایمیل
$existingCustomer = $this->customerRepository->findByEmail($customer->getEmail());
if ($existingCustomer) {
$existingUser = $this->userRepository->findOneBy(['email' => $user->getEmail()]);
if ($existingUser) {
$this->addFlash('error', 'این ایمیل قبلاً ثبت شده است.');
return $this->render('customer/register.html.twig', [
'form' => $form->createView(),
]);
}
// تنظیم نقش مشتری
$user->setRoles(['ROLE_CUSTOMER']);
// هش کردن کلمه عبور
$plainPassword = $form->get('plainPassword')->getData();
$customer->setPassword(
$this->passwordHasher->hashPassword($customer, $plainPassword)
$user->setPassword(
$this->passwordHasher->hashPassword($user, $plainPassword)
);
// ذخیره مشتری
$this->entityManager->persist($customer);
// ذخیره کاربر
$this->entityManager->persist($user);
$this->entityManager->flush();
// ارسال ایمیل تایید
$this->emailService->sendVerificationEmail($customer);
$this->emailService->sendVerificationEmail($user);
$this->addFlash('success', 'ثبت‌نام با موفقیت انجام شد. لطفاً ایمیل خود را بررسی کنید.');
return $this->redirectToRoute('customer_login');
@ -94,21 +97,21 @@ class CustomerController extends AbstractController
{
// برای سادگی، از ایمیل به عنوان توکن استفاده می‌کنیم
// در آینده می‌توان یک سیستم توکن جداگانه پیاده کرد
$customer = $this->customerRepository->findOneBy(['email' => $token]);
$user = $this->userRepository->findOneBy(['email' => $token]);
if (!$customer) {
if (!$user) {
$this->addFlash('error', 'لینک تایید نامعتبر است.');
return $this->redirectToRoute('customer_login');
}
if ($customer->getEmailVerifiedAt()) {
if ($user->getEmailVerifiedAt()) {
$this->addFlash('info', 'ایمیل شما قبلاً تایید شده است.');
return $this->redirectToRoute('customer_login');
}
$customer->setEmailVerifiedAt(new \DateTime());
$customer->setIsActive(true);
$customer->setUpdatedAt(new \DateTime());
$user->setEmailVerifiedAt(new \DateTime());
$user->setIsActive(true);
$user->setUpdatedAt(new \DateTime());
$this->entityManager->flush();
@ -124,18 +127,18 @@ class CustomerController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
$email = $form->get('email')->getData();
$customer = $this->customerRepository->findByEmail($email);
$user = $this->userRepository->findOneBy(['email' => $email]);
if ($customer) {
if ($user) {
// ایجاد توکن بازیابی
$resetToken = new PasswordResetToken();
$resetToken->setCustomer($customer);
$resetToken->setUser($user);
$this->entityManager->persist($resetToken);
$this->entityManager->flush();
// ارسال ایمیل بازیابی
$this->emailService->sendPasswordResetEmail($customer, $resetToken);
$this->emailService->sendPasswordResetEmail($user, $resetToken);
}
// همیشه پیام موفقیت نشان می‌دهیم (امنیت)
@ -158,7 +161,7 @@ class CustomerController extends AbstractController
return $this->redirectToRoute('customer_forgot_password');
}
$customer = $resetToken->getCustomer();
$user = $resetToken->getUser();
$form = $this->createForm(ResetPasswordFormType::class);
$form->handleRequest($request);
@ -166,10 +169,10 @@ class CustomerController extends AbstractController
$newPassword = $form->get('plainPassword')->getData();
// تغییر کلمه عبور
$customer->setPassword(
$this->passwordHasher->hashPassword($customer, $newPassword)
$user->setPassword(
$this->passwordHasher->hashPassword($user, $newPassword)
);
$customer->setUpdatedAt(new \DateTime());
$user->setUpdatedAt(new \DateTime());
// غیرفعال کردن توکن
$resetToken->setUsedAt(new \DateTime());
@ -187,20 +190,24 @@ class CustomerController extends AbstractController
}
#[Route('/customer/dashboard', name: 'customer_dashboard')]
public function dashboard(): Response
public function dashboard(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_CUSTOMER');
$customer = $this->getUser();
$user = $this->getUser();
// به‌روزرسانی زمان آخرین ورود
if ($customer instanceof Customer) {
$customer->setLastLoginAt(new \DateTime());
if ($user instanceof User) {
$user->setLastLoginAt(new \DateTime());
$this->entityManager->flush();
}
// ایجاد پیام امضا برای کیف پول
$signMessage = 'Please sign this message to connect your wallet to Hesabix.';
return $this->render('customer/dashboard.html.twig', [
'customer' => $customer,
'user' => $user,
'signMessage' => $signMessage,
]);
}

View file

@ -3,6 +3,8 @@
namespace App\Controller;
use App\Entity\Post;
use App\Entity\Question;
use App\Entity\QuestionTag;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@ -25,8 +27,12 @@ class GeneralController extends AbstractController
$response = new Response();
$response->headers->set('Content-Type', 'text/xml');
$posts = $em->getRepository(Post::class)->findAll();
$questions = $em->getRepository(Question::class)->findActiveQuestions(1000, 0);
$tags = $em->getRepository(QuestionTag::class)->findActiveTags();
return $this->render('general/sitemap.html.twig', [
'posts' => $posts
'posts' => $posts,
'questions' => $questions,
'tags' => $tags
], $response);
}

View file

@ -0,0 +1,429 @@
<?php
namespace App\Controller\QA;
use App\Entity\Answer;
use App\Entity\Question;
use App\Entity\QuestionTag;
use App\Entity\QuestionTagRelation;
use App\Entity\QuestionVote;
use App\Entity\AnswerVote;
use App\Form\QA\QuestionFormType;
use App\Form\QA\AnswerFormType;
use App\Repository\AnswerRepository;
use App\Repository\QuestionRepository;
use App\Repository\QuestionTagRepository;
use App\Repository\QuestionVoteRepository;
use App\Repository\AnswerVoteRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/qa', name: 'qa_')]
class QAController extends AbstractController
{
public function __construct(
private EntityManagerInterface $entityManager,
private QuestionRepository $questionRepository,
private AnswerRepository $answerRepository,
private QuestionTagRepository $tagRepository,
private QuestionVoteRepository $questionVoteRepository,
private AnswerVoteRepository $answerVoteRepository
) {}
#[Route('', name: 'index', methods: ['GET'])]
public function index(Request $request): Response
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = 20;
$offset = ($page - 1) * $limit;
$filter = $request->query->get('filter', 'all');
$search = $request->query->get('search', '');
$tag = $request->query->get('tag', '');
$questions = [];
$totalCount = 0;
if ($search) {
$questions = $this->questionRepository->searchQuestions($search, $limit, $offset);
$totalCount = $this->questionRepository->countSearchQuestions($search);
} elseif ($tag) {
$questions = $this->questionRepository->findQuestionsByTag($tag, $limit, $offset);
$totalCount = $this->questionRepository->countQuestionsByTag($tag);
} else {
switch ($filter) {
case 'unsolved':
$questions = $this->questionRepository->findUnsolvedQuestions($limit, $offset);
$totalCount = $this->questionRepository->countUnsolvedQuestions();
break;
case 'solved':
$questions = $this->questionRepository->findSolvedQuestions($limit, $offset);
$totalCount = $this->questionRepository->countSolvedQuestions();
break;
case 'popular':
$questions = $this->questionRepository->findMostVotedQuestions($limit);
$totalCount = $this->questionRepository->countMostVotedQuestions();
break;
default:
$questions = $this->questionRepository->findActiveQuestions($limit, $offset);
$totalCount = $this->questionRepository->getTotalCount();
break;
}
}
$popularTags = $this->tagRepository->findPopularTags(20);
$totalPages = ceil($totalCount / $limit);
return $this->render('qa/index.html.twig', [
'questions' => $questions,
'popularTags' => $popularTags,
'currentPage' => $page,
'totalPages' => $totalPages,
'currentFilter' => $filter,
'currentSearch' => $search,
'currentTag' => $tag,
]);
}
#[Route('/question/{id}', name: 'question_show', methods: ['GET'])]
public function showQuestion(int $id, Request $request): Response
{
$question = $this->questionRepository->find($id);
if (!$question || !$question->isActive()) {
throw $this->createNotFoundException('سوال مورد نظر یافت نشد.');
}
// افزایش تعداد بازدید
$question->incrementViews();
$this->entityManager->flush();
// Pagination برای پاسخ‌ها
$page = max(1, (int) $request->query->get('page', 1));
$limit = 10; // تعداد پاسخ‌ها در هر صفحه
$offset = ($page - 1) * $limit;
$answers = $this->answerRepository->findAnswersByQuestion($id, $limit, $offset);
$totalAnswers = $this->answerRepository->countAnswersByQuestion($id);
$totalPages = ceil($totalAnswers / $limit);
return $this->render('qa/question_show.html.twig', [
'question' => $question,
'answers' => $answers,
'currentPage' => $page,
'totalPages' => $totalPages,
'totalAnswers' => $totalAnswers,
]);
}
#[Route('/ask', name: 'ask', methods: ['GET', 'POST'])]
#[IsGranted('ROLE_CUSTOMER')]
public function askQuestion(Request $request): Response
{
$question = new Question();
$question->setAuthor($this->getUser());
$form = $this->createForm(QuestionFormType::class, $question);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// مدیریت تگ‌ها
$selectedTagIds = $request->request->all('question')['tags'] ?? [];
foreach ($selectedTagIds as $tagId) {
$tag = $this->tagRepository->find($tagId);
if ($tag) {
$tagRelation = new QuestionTagRelation();
$tagRelation->setQuestion($question);
$tagRelation->setTag($tag);
$this->entityManager->persist($tagRelation);
// افزایش تعداد استفاده از تگ
$tag->incrementUsageCount();
}
}
$this->entityManager->persist($question);
$this->entityManager->flush();
$this->addFlash('success', 'سوال شما با موفقیت ثبت شد.');
return $this->redirectToRoute('qa_question_show', ['id' => $question->getId()]);
}
$availableTags = $this->tagRepository->findActiveTags();
return $this->render('qa/ask_question.html.twig', [
'form' => $form,
'availableTags' => $availableTags,
]);
}
#[Route('/question/{id}/answer', name: 'answer', methods: ['GET', 'POST'])]
#[IsGranted('ROLE_CUSTOMER')]
public function answerQuestion(int $id, Request $request): Response
{
$question = $this->questionRepository->find($id);
if (!$question || !$question->isActive()) {
throw $this->createNotFoundException('سوال مورد نظر یافت نشد.');
}
$answer = new Answer();
$answer->setQuestion($question);
$answer->setAuthor($this->getUser());
$form = $this->createForm(AnswerFormType::class, $answer);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->persist($answer);
$this->entityManager->flush();
$this->addFlash('success', 'پاسخ شما با موفقیت ثبت شد.');
return $this->redirectToRoute('qa_question_show', ['id' => $question->getId()]);
}
return $this->render('qa/answer_question.html.twig', [
'question' => $question,
'form' => $form,
]);
}
#[Route('/question/{id}/vote', name: 'question_vote', methods: ['POST'])]
#[IsGranted('ROLE_CUSTOMER')]
public function voteQuestion(int $id, Request $request): JsonResponse
{
$question = $this->questionRepository->find($id);
if (!$question || !$question->isActive()) {
return new JsonResponse(['error' => 'سوال یافت نشد.'], 404);
}
$isUpvote = $request->request->get('upvote') === 'true';
$user = $this->getUser();
// بررسی CSRF token
if (!$this->isCsrfTokenValid('vote', $request->request->get('_token'))) {
return new JsonResponse(['error' => 'CSRF token نامعتبر است.'], 403);
}
// بررسی وجود رای قبلی
$existingVote = $this->questionVoteRepository->findVoteByUserAndQuestion($user->getId(), $id);
if ($existingVote) {
// اگر رای مشابه باشد، رای را حذف کن
if ($existingVote->isUpvote() === $isUpvote) {
$this->entityManager->remove($existingVote);
$question->setVotes($question->getVotes() + ($isUpvote ? -1 : 1));
$existingVote = null; // برای بازگشت null
} else {
// اگر رای متفاوت باشد، رای را تغییر بده
$oldVoteValue = $existingVote->isUpvote() ? 1 : -1;
$newVoteValue = $isUpvote ? 1 : -1;
$existingVote->setIsUpvote($isUpvote);
$question->setVotes($question->getVotes() - $oldVoteValue + $newVoteValue);
}
} else {
// رای جدید
$vote = new QuestionVote();
$vote->setQuestion($question);
$vote->setUser($user);
$vote->setIsUpvote($isUpvote);
$this->entityManager->persist($vote);
$question->setVotes($question->getVotes() + ($isUpvote ? 1 : -1));
}
$this->entityManager->flush();
// بررسی رای نهایی کاربر
$finalVote = $this->questionVoteRepository->findVoteByUserAndQuestion($user->getId(), $id);
return new JsonResponse([
'votes' => $question->getVotes(),
'userVote' => $finalVote ? ($finalVote->isUpvote() ? 'up' : 'down') : null
]);
}
#[Route('/answer/{id}/vote', name: 'answer_vote', methods: ['POST'])]
#[IsGranted('ROLE_CUSTOMER')]
public function voteAnswer(int $id, Request $request): JsonResponse
{
$answer = $this->answerRepository->find($id);
if (!$answer || !$answer->isActive()) {
return new JsonResponse(['error' => 'پاسخ یافت نشد.'], 404);
}
$isUpvote = $request->request->get('upvote') === 'true';
$user = $this->getUser();
// بررسی CSRF token
if (!$this->isCsrfTokenValid('vote', $request->request->get('_token'))) {
return new JsonResponse(['error' => 'CSRF token نامعتبر است.'], 403);
}
// بررسی وجود رای قبلی
$existingVote = $this->answerVoteRepository->findVoteByUserAndAnswer($user->getId(), $id);
if ($existingVote) {
// اگر رای مشابه باشد، رای را حذف کن
if ($existingVote->isUpvote() === $isUpvote) {
$this->entityManager->remove($existingVote);
$answer->setVotes($answer->getVotes() + ($isUpvote ? -1 : 1));
$existingVote = null; // برای بازگشت null
} else {
// اگر رای متفاوت باشد، رای را تغییر بده
$oldVoteValue = $existingVote->isUpvote() ? 1 : -1;
$newVoteValue = $isUpvote ? 1 : -1;
$existingVote->setIsUpvote($isUpvote);
$answer->setVotes($answer->getVotes() - $oldVoteValue + $newVoteValue);
}
} else {
// رای جدید
$vote = new AnswerVote();
$vote->setAnswer($answer);
$vote->setUser($user);
$vote->setIsUpvote($isUpvote);
$this->entityManager->persist($vote);
$answer->setVotes($answer->getVotes() + ($isUpvote ? 1 : -1));
}
$this->entityManager->flush();
// بررسی رای نهایی کاربر
$finalVote = $this->answerVoteRepository->findVoteByUserAndAnswer($user->getId(), $id);
return new JsonResponse([
'votes' => $answer->getVotes(),
'userVote' => $finalVote ? ($finalVote->isUpvote() ? 'up' : 'down') : null
]);
}
#[Route('/answer/{id}/accept', name: 'answer_accept', methods: ['POST'])]
#[IsGranted('ROLE_CUSTOMER')]
public function acceptAnswer(int $id, Request $request): JsonResponse
{
$answer = $this->answerRepository->find($id);
if (!$answer || !$answer->isActive()) {
return new JsonResponse(['error' => 'پاسخ یافت نشد.'], 404);
}
$question = $answer->getQuestion();
$user = $this->getUser();
// بررسی CSRF token
if (!$this->isCsrfTokenValid('accept', $request->request->get('_token'))) {
return new JsonResponse(['error' => 'CSRF token نامعتبر است.'], 403);
}
// فقط نویسنده سوال می‌تواند پاسخ را بپذیرد
if ($question->getAuthor() !== $user) {
return new JsonResponse(['error' => 'شما مجاز به انجام این عمل نیستید.'], 403);
}
// اگر پاسخ قبلاً پذیرفته شده، آن را لغو کن
if ($answer->isAccepted()) {
$answer->setIsAccepted(false);
$question->setIsSolved(false);
} else {
// همه پاسخ‌های قبلی را لغو کن
foreach ($question->getAnswers() as $existingAnswer) {
$existingAnswer->setIsAccepted(false);
}
// پاسخ جدید را بپذیر
$answer->setIsAccepted(true);
$question->setIsSolved(true);
}
$this->entityManager->flush();
return new JsonResponse([
'accepted' => $answer->isAccepted(),
'solved' => $question->isSolved()
]);
}
#[Route('/tags', name: 'tags', methods: ['GET'])]
public function showTags(): Response
{
$tags = $this->tagRepository->findActiveTags();
return $this->render('qa/tags.html.twig', [
'tags' => $tags,
]);
}
#[Route('/tag/create', name: 'tag_create', methods: ['POST'])]
#[IsGranted('ROLE_CUSTOMER')]
public function createTag(Request $request): JsonResponse
{
// بررسی CSRF token
if (!$this->isCsrfTokenValid('vote', $request->request->get('_token'))) {
return new JsonResponse(['error' => 'CSRF token نامعتبر است.'], 403);
}
$tagName = trim($request->request->get('name', ''));
if (empty($tagName)) {
return new JsonResponse(['error' => 'نام تگ الزامی است.'], 400);
}
// بررسی وجود تگ
$existingTag = $this->tagRepository->findByName($tagName);
if ($existingTag) {
return new JsonResponse([
'id' => $existingTag->getId(),
'name' => $existingTag->getName(),
'message' => 'تگ از قبل وجود دارد.'
]);
}
// ایجاد تگ جدید
$tag = new QuestionTag();
$tag->setName($tagName);
$tag->setDescription('تگ ایجاد شده توسط کاربر');
$tag->setColor('#6c757d');
$tag->setUsageCount(0);
$tag->setIsActive(true);
$this->entityManager->persist($tag);
$this->entityManager->flush();
return new JsonResponse([
'id' => $tag->getId(),
'name' => $tag->getName(),
'message' => 'تگ با موفقیت ایجاد شد.'
]);
}
#[Route('/tag/{name}', name: 'tag_questions', methods: ['GET'])]
public function showTagQuestions(string $name, Request $request): Response
{
$tag = $this->tagRepository->findByName($name);
if (!$tag || !$tag->isActive()) {
throw $this->createNotFoundException('تگ مورد نظر یافت نشد.');
}
$page = max(1, (int) $request->query->get('page', 1));
$limit = 20;
$offset = ($page - 1) * $limit;
$questions = $this->questionRepository->findQuestionsByTag($name, $limit, $offset);
$totalCount = $this->questionRepository->countQuestionsByTag($name);
$totalPages = ceil($totalCount / $limit);
return $this->render('qa/tag_questions.html.twig', [
'tag' => $tag,
'questions' => $questions,
'currentPage' => $page,
'totalPages' => $totalPages,
]);
}
}

View file

@ -0,0 +1,116 @@
<?php
namespace App\Controller;
use App\Service\WalletService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
#[Route('/api/wallet', name: 'api_wallet_')]
class WalletController extends AbstractController
{
private WalletService $walletService;
public function __construct(WalletService $walletService)
{
$this->walletService = $walletService;
}
#[Route('/connect', name: 'connect', methods: ['POST'])]
public function connectWallet(Request $request): JsonResponse
{
try {
$data = json_decode($request->getContent(), true);
if (!$data) {
return new JsonResponse([
'success' => false,
'message' => 'داده‌های JSON نامعتبر است'
], 400);
}
if (!isset($data['walletAddress']) || !isset($data['walletType']) || !isset($data['signature'])) {
return new JsonResponse([
'success' => false,
'message' => 'اطلاعات ناقص است'
], 400);
}
$user = $this->getUser();
if (!$user) {
return new JsonResponse([
'success' => false,
'message' => 'کاربر وارد نشده است'
], 401);
}
return $this->walletService->connectWallet(
$user,
$data['walletAddress'],
$data['walletType'],
$data['signature']
);
} catch (\Exception $e) {
return new JsonResponse([
'success' => false,
'message' => 'خطا در سرور: ' . $e->getMessage()
], 500);
}
}
#[Route('/list', name: 'list', methods: ['GET'])]
public function listWallets(): JsonResponse
{
$user = $this->getUser();
$wallets = $this->walletService->getUserWallets($user);
return new JsonResponse([
'success' => true,
'wallets' => $wallets
]);
}
#[Route('/{id}/set-primary', name: 'set_primary', methods: ['PUT'])]
public function setPrimaryWallet(int $id): JsonResponse
{
$user = $this->getUser();
return $this->walletService->setPrimaryWallet($user, $id);
}
#[Route('/{id}/toggle-status', name: 'toggle_status', methods: ['PUT'])]
public function toggleWalletStatus(int $id): JsonResponse
{
$user = $this->getUser();
return $this->walletService->toggleWalletStatus($user, $id);
}
#[Route('/{id}', name: 'delete', methods: ['DELETE'])]
public function deleteWallet(int $id): JsonResponse
{
$user = $this->getUser();
return $this->walletService->deleteWallet($user, $id);
}
#[Route('/sign-message', name: 'sign_message', methods: ['GET'])]
public function getSignMessage(): JsonResponse
{
$user = $this->getUser();
if (!$user) {
return new JsonResponse([
'success' => false,
'message' => 'کاربر وارد نشده است'
], 401);
}
$message = $this->walletService->generateSignMessage($user);
return new JsonResponse([
'success' => true,
'message' => $message
]);
}
}

180
src/Entity/Answer.php Normal file
View file

@ -0,0 +1,180 @@
<?php
namespace App\Entity;
use App\Repository\AnswerRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: AnswerRepository::class)]
#[ORM\Table(name: 'answer')]
class Answer
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: Types::TEXT)]
#[Assert\NotBlank(message: 'متن پاسخ الزامی است')]
#[Assert\Length(min: 10, minMessage: 'متن پاسخ باید حداقل 10 کاراکتر باشد')]
private ?string $content = null;
#[ORM\ManyToOne(targetEntity: Question::class, inversedBy: 'answers')]
#[ORM\JoinColumn(nullable: false)]
private ?Question $question = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $author = null;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $createdAt = null;
#[ORM\Column(type: 'datetime', nullable: true)]
private ?\DateTimeInterface $updatedAt = null;
#[ORM\Column(type: 'boolean')]
private bool $isAccepted = false;
#[ORM\Column(type: 'integer')]
private int $votes = 0;
#[ORM\Column(type: 'boolean')]
private bool $isActive = true;
/**
* @var Collection<int, AnswerVote>
*/
#[ORM\OneToMany(targetEntity: AnswerVote::class, mappedBy: 'answer', orphanRemoval: true)]
private Collection $answerVotes;
public function __construct()
{
$this->answerVotes = new ArrayCollection();
$this->createdAt = new \DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getContent(): ?string
{
return $this->content;
}
public function setContent(string $content): static
{
$this->content = $content;
return $this;
}
public function getQuestion(): ?Question
{
return $this->question;
}
public function setQuestion(?Question $question): static
{
$this->question = $question;
return $this;
}
public function getAuthor(): ?User
{
return $this->author;
}
public function setAuthor(?User $author): static
{
$this->author = $author;
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 isAccepted(): bool
{
return $this->isAccepted;
}
public function setIsAccepted(bool $isAccepted): static
{
$this->isAccepted = $isAccepted;
return $this;
}
public function getVotes(): int
{
return $this->votes;
}
public function setVotes(int $votes): static
{
$this->votes = $votes;
return $this;
}
public function isActive(): bool
{
return $this->isActive;
}
public function setIsActive(bool $isActive): static
{
$this->isActive = $isActive;
return $this;
}
/**
* @return Collection<int, AnswerVote>
*/
public function getAnswerVotes(): Collection
{
return $this->answerVotes;
}
public function addAnswerVote(AnswerVote $answerVote): static
{
if (!$this->answerVotes->contains($answerVote)) {
$this->answerVotes->add($answerVote);
$answerVote->setAnswer($this);
}
return $this;
}
public function removeAnswerVote(AnswerVote $answerVote): static
{
if ($this->answerVotes->removeElement($answerVote)) {
if ($answerVote->getAnswer() === $this) {
$answerVote->setAnswer(null);
}
}
return $this;
}
}

85
src/Entity/AnswerVote.php Normal file
View file

@ -0,0 +1,85 @@
<?php
namespace App\Entity;
use App\Repository\AnswerVoteRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AnswerVoteRepository::class)]
#[ORM\Table(name: 'answer_vote')]
#[ORM\UniqueConstraint(name: 'unique_answer_user_vote', columns: ['answer_id', 'user_id'])]
class AnswerVote
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Answer::class, inversedBy: 'answerVotes')]
#[ORM\JoinColumn(nullable: false)]
private ?Answer $answer = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\Column(type: 'boolean')]
private bool $isUpvote = true;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $createdAt = null;
public function __construct()
{
$this->createdAt = new \DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getAnswer(): ?Answer
{
return $this->answer;
}
public function setAnswer(?Answer $answer): static
{
$this->answer = $answer;
return $this;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function isUpvote(): bool
{
return $this->isUpvote;
}
public function setIsUpvote(bool $isUpvote): static
{
$this->isUpvote = $isUpvote;
return $this;
}
public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeInterface $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
}

View file

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

@ -14,9 +14,9 @@ class PasswordResetToken
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?Customer $customer = null;
private ?User $user = null;
#[ORM\Column(length: 255)]
private ?string $token = null;
@ -42,14 +42,14 @@ class PasswordResetToken
return $this->id;
}
public function getCustomer(): ?Customer
public function getUser(): ?User
{
return $this->customer;
return $this->user;
}
public function setCustomer(?Customer $customer): static
public function setUser(?User $user): static
{
$this->customer = $customer;
$this->user = $user;
return $this;
}

284
src/Entity/Question.php Normal file
View file

@ -0,0 +1,284 @@
<?php
namespace App\Entity;
use App\Repository\QuestionRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: QuestionRepository::class)]
#[ORM\Table(name: 'question')]
class Question
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'عنوان سوال الزامی است')]
#[Assert\Length(min: 10, max: 255, minMessage: 'عنوان سوال باید حداقل 10 کاراکتر باشد', maxMessage: 'عنوان سوال نمی‌تواند بیش از 255 کاراکتر باشد')]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT)]
#[Assert\NotBlank(message: 'متن سوال الزامی است')]
#[Assert\Length(min: 20, minMessage: 'متن سوال باید حداقل 20 کاراکتر باشد')]
private ?string $content = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $author = null;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $createdAt = null;
#[ORM\Column(type: 'datetime', nullable: true)]
private ?\DateTimeInterface $updatedAt = null;
#[ORM\Column(type: 'boolean')]
private bool $isSolved = false;
#[ORM\Column(type: 'integer')]
private int $views = 0;
#[ORM\Column(type: 'integer')]
private int $votes = 0;
#[ORM\Column(type: 'boolean')]
private bool $isActive = true;
/**
* @var Collection<int, Answer>
*/
#[ORM\OneToMany(targetEntity: Answer::class, mappedBy: 'question', orphanRemoval: true, cascade: ['persist'])]
private Collection $answers;
/**
* @var Collection<int, QuestionVote>
*/
#[ORM\OneToMany(targetEntity: QuestionVote::class, mappedBy: 'question', orphanRemoval: true)]
private Collection $questionVotes;
/**
* @var Collection<int, QuestionTagRelation>
*/
#[ORM\OneToMany(targetEntity: QuestionTagRelation::class, mappedBy: 'question', orphanRemoval: true, cascade: ['persist'])]
private Collection $tagRelations;
public function __construct()
{
$this->answers = new ArrayCollection();
$this->questionVotes = new ArrayCollection();
$this->tagRelations = new ArrayCollection();
$this->createdAt = new \DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getContent(): ?string
{
return $this->content;
}
public function setContent(string $content): static
{
$this->content = $content;
return $this;
}
public function getAuthor(): ?User
{
return $this->author;
}
public function setAuthor(?User $author): static
{
$this->author = $author;
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 isSolved(): bool
{
return $this->isSolved;
}
public function setIsSolved(bool $isSolved): static
{
$this->isSolved = $isSolved;
return $this;
}
public function getViews(): int
{
return $this->views;
}
public function setViews(int $views): static
{
$this->views = $views;
return $this;
}
public function incrementViews(): static
{
$this->views++;
return $this;
}
public function getVotes(): int
{
return $this->votes;
}
public function setVotes(int $votes): static
{
$this->votes = $votes;
return $this;
}
public function isActive(): bool
{
return $this->isActive;
}
public function setIsActive(bool $isActive): static
{
$this->isActive = $isActive;
return $this;
}
/**
* @return Collection<int, Answer>
*/
public function getAnswers(): Collection
{
return $this->answers;
}
public function addAnswer(Answer $answer): static
{
if (!$this->answers->contains($answer)) {
$this->answers->add($answer);
$answer->setQuestion($this);
}
return $this;
}
public function removeAnswer(Answer $answer): static
{
if ($this->answers->removeElement($answer)) {
if ($answer->getQuestion() === $this) {
$answer->setQuestion(null);
}
}
return $this;
}
/**
* @return Collection<int, QuestionVote>
*/
public function getQuestionVotes(): Collection
{
return $this->questionVotes;
}
public function addQuestionVote(QuestionVote $questionVote): static
{
if (!$this->questionVotes->contains($questionVote)) {
$this->questionVotes->add($questionVote);
$questionVote->setQuestion($this);
}
return $this;
}
public function removeQuestionVote(QuestionVote $questionVote): static
{
if ($this->questionVotes->removeElement($questionVote)) {
if ($questionVote->getQuestion() === $this) {
$questionVote->setQuestion(null);
}
}
return $this;
}
/**
* @return Collection<int, QuestionTagRelation>
*/
public function getTagRelations(): Collection
{
return $this->tagRelations;
}
public function addTagRelation(QuestionTagRelation $tagRelation): static
{
if (!$this->tagRelations->contains($tagRelation)) {
$this->tagRelations->add($tagRelation);
$tagRelation->setQuestion($this);
}
return $this;
}
public function removeTagRelation(QuestionTagRelation $tagRelation): static
{
if ($this->tagRelations->removeElement($tagRelation)) {
if ($tagRelation->getQuestion() === $this) {
$tagRelation->setQuestion(null);
}
}
return $this;
}
public function getAnswersCount(): int
{
return $this->answers->count();
}
public function getBestAnswer(): ?Answer
{
foreach ($this->answers as $answer) {
if ($answer->isAccepted()) {
return $answer;
}
}
return null;
}
}

149
src/Entity/QuestionTag.php Normal file
View file

@ -0,0 +1,149 @@
<?php
namespace App\Entity;
use App\Repository\QuestionTagRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: QuestionTagRepository::class)]
#[ORM\Table(name: 'question_tag')]
class QuestionTag
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 50, unique: true)]
#[Assert\NotBlank(message: 'نام تگ الزامی است')]
#[Assert\Length(min: 2, max: 50, minMessage: 'نام تگ باید حداقل 2 کاراکتر باشد', maxMessage: 'نام تگ نمی‌تواند بیش از 50 کاراکتر باشد')]
#[Assert\Regex(pattern: '/^[a-zA-Z0-9\u0600-\u06FF\s\-_]+$/', message: 'نام تگ فقط می‌تواند شامل حروف، اعداد، فاصله، خط تیره و زیرخط باشد')]
private ?string $name = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $description = null;
#[ORM\Column(length: 7, nullable: true)]
private ?string $color = null;
#[ORM\Column(type: 'integer')]
private int $usageCount = 0;
#[ORM\Column(type: 'boolean')]
private bool $isActive = true;
/**
* @var Collection<int, QuestionTagRelation>
*/
#[ORM\OneToMany(targetEntity: QuestionTagRelation::class, mappedBy: 'tag', orphanRemoval: true)]
private Collection $tagRelations;
public function __construct()
{
$this->tagRelations = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getColor(): ?string
{
return $this->color;
}
public function setColor(?string $color): static
{
$this->color = $color;
return $this;
}
public function getUsageCount(): int
{
return $this->usageCount;
}
public function setUsageCount(int $usageCount): static
{
$this->usageCount = $usageCount;
return $this;
}
public function incrementUsageCount(): static
{
$this->usageCount++;
return $this;
}
public function decrementUsageCount(): static
{
if ($this->usageCount > 0) {
$this->usageCount--;
}
return $this;
}
public function isActive(): bool
{
return $this->isActive;
}
public function setIsActive(bool $isActive): static
{
$this->isActive = $isActive;
return $this;
}
/**
* @return Collection<int, QuestionTagRelation>
*/
public function getTagRelations(): Collection
{
return $this->tagRelations;
}
public function addTagRelation(QuestionTagRelation $tagRelation): static
{
if (!$this->tagRelations->contains($tagRelation)) {
$this->tagRelations->add($tagRelation);
$tagRelation->setTag($this);
}
return $this;
}
public function removeTagRelation(QuestionTagRelation $tagRelation): static
{
if ($this->tagRelations->removeElement($tagRelation)) {
if ($tagRelation->getTag() === $this) {
$tagRelation->setTag(null);
}
}
return $this;
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Entity;
use App\Repository\QuestionTagRelationRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: QuestionTagRelationRepository::class)]
#[ORM\Table(name: 'question_tag_relation')]
#[ORM\UniqueConstraint(name: 'unique_question_tag', columns: ['question_id', 'tag_id'])]
class QuestionTagRelation
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Question::class, inversedBy: 'tagRelations')]
#[ORM\JoinColumn(nullable: false)]
private ?Question $question = null;
#[ORM\ManyToOne(targetEntity: QuestionTag::class, inversedBy: 'tagRelations')]
#[ORM\JoinColumn(nullable: false)]
private ?QuestionTag $tag = null;
public function getId(): ?int
{
return $this->id;
}
public function getQuestion(): ?Question
{
return $this->question;
}
public function setQuestion(?Question $question): static
{
$this->question = $question;
return $this;
}
public function getTag(): ?QuestionTag
{
return $this->tag;
}
public function setTag(?QuestionTag $tag): static
{
$this->tag = $tag;
return $this;
}
}

View file

@ -0,0 +1,85 @@
<?php
namespace App\Entity;
use App\Repository\QuestionVoteRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: QuestionVoteRepository::class)]
#[ORM\Table(name: 'question_vote')]
#[ORM\UniqueConstraint(name: 'unique_question_user_vote', columns: ['question_id', 'user_id'])]
class QuestionVote
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Question::class, inversedBy: 'questionVotes')]
#[ORM\JoinColumn(nullable: false)]
private ?Question $question = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\Column(type: 'boolean')]
private bool $isUpvote = true;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $createdAt = null;
public function __construct()
{
$this->createdAt = new \DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getQuestion(): ?Question
{
return $this->question;
}
public function setQuestion(?Question $question): static
{
$this->question = $question;
return $this;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function isUpvote(): bool
{
return $this->isUpvote;
}
public function setIsUpvote(bool $isUpvote): static
{
$this->isUpvote = $isUpvote;
return $this;
}
public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeInterface $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
}

View file

@ -8,6 +8,7 @@ use Doctrine\Common\Collections\Collection;
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: UserRepository::class)]
#[ORM\Table(name: '`user`')]
@ -20,6 +21,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private ?int $id = null;
#[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'ایمیل الزامی است')]
#[Assert\Email(message: 'فرمت ایمیل صحیح نیست')]
private ?string $email = null;
/**
@ -35,18 +38,55 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private ?string $password = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'نام الزامی است')]
#[Assert\Length(min: 3, max: 60, minMessage: 'نام باید حداقل 3 کاراکتر باشد', maxMessage: 'نام نمی‌تواند بیش از 60 کاراکتر باشد')]
private ?string $name = null;
#[ORM\Column(length: 20, nullable: true)]
#[Assert\NotBlank(message: 'شماره موبایل الزامی است')]
#[Assert\Regex(pattern: '/^09[0-9]{9}$/', message: 'فرمت شماره موبایل صحیح نیست')]
private ?string $phone = null;
#[ORM\Column(type: 'boolean')]
private bool $isActive = true;
#[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;
/**
* @var Collection<int, Post>
*/
#[ORM\OneToMany(targetEntity: Post::class, mappedBy: 'submitter', orphanRemoval: true)]
private Collection $posts;
/**
* @var Collection<int, Wallet>
*/
#[ORM\OneToMany(targetEntity: Wallet::class, mappedBy: 'user', orphanRemoval: true)]
private Collection $wallets;
public function __construct()
{
$this->posts = new ArrayCollection();
$this->wallets = new ArrayCollection();
$this->createdAt = new \DateTime();
$this->isActive = true;
}
public function getId(): ?int
@ -166,4 +206,149 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
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;
}
/**
* @return Collection<int, Wallet>
*/
public function getWallets(): Collection
{
return $this->wallets;
}
public function addWallet(Wallet $wallet): static
{
if (!$this->wallets->contains($wallet)) {
$this->wallets->add($wallet);
$wallet->setUser($this);
}
return $this;
}
public function removeWallet(Wallet $wallet): static
{
if ($this->wallets->removeElement($wallet)) {
// set the owning side to null (unless already changed)
if ($wallet->getUser() === $this) {
$wallet->setUser(null);
}
}
return $this;
}
/**
* Get primary wallet
*/
public function getPrimaryWallet(): ?Wallet
{
foreach ($this->wallets as $wallet) {
if ($wallet->isPrimary() && $wallet->isActive()) {
return $wallet;
}
}
return null;
}
/**
* Get active wallets count
*/
public function getActiveWalletsCount(): int
{
$count = 0;
foreach ($this->wallets as $wallet) {
if ($wallet->isActive()) {
$count++;
}
}
return $count;
}
}

177
src/Entity/Wallet.php Normal file
View file

@ -0,0 +1,177 @@
<?php
namespace App\Entity;
use App\Repository\WalletRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: WalletRepository::class)]
#[ORM\Table(name: 'wallets')]
class Wallet
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'wallets')]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\Column(length: 42)]
#[Assert\NotBlank]
#[Assert\Length(min: 42, max: 42)]
private ?string $walletAddress = null;
#[ORM\Column(length: 50)]
#[Assert\NotBlank]
#[Assert\Choice(choices: ['MetaMask', 'Trust Wallet', 'WalletConnect', 'Coinbase Wallet', 'Other'])]
private ?string $walletType = null;
#[ORM\Column]
private bool $isPrimary = false;
#[ORM\Column]
private bool $isActive = true;
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
private ?\DateTimeInterface $connectedAt = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $lastUsedAt = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
private ?\DateTimeInterface $createdAt = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
private ?\DateTimeInterface $updatedAt = null;
public function __construct()
{
$this->connectedAt = new \DateTime();
$this->createdAt = new \DateTime();
$this->updatedAt = new \DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getWalletAddress(): ?string
{
return $this->walletAddress;
}
public function setWalletAddress(string $walletAddress): static
{
$this->walletAddress = $walletAddress;
return $this;
}
public function getWalletType(): ?string
{
return $this->walletType;
}
public function setWalletType(string $walletType): static
{
$this->walletType = $walletType;
return $this;
}
public function isPrimary(): bool
{
return $this->isPrimary;
}
public function setIsPrimary(bool $isPrimary): static
{
$this->isPrimary = $isPrimary;
return $this;
}
public function isActive(): bool
{
return $this->isActive;
}
public function setIsActive(bool $isActive): static
{
$this->isActive = $isActive;
return $this;
}
public function getConnectedAt(): ?\DateTimeInterface
{
return $this->connectedAt;
}
public function setConnectedAt(\DateTimeInterface $connectedAt): static
{
$this->connectedAt = $connectedAt;
return $this;
}
public function getLastUsedAt(): ?\DateTimeInterface
{
return $this->lastUsedAt;
}
public function setLastUsedAt(?\DateTimeInterface $lastUsedAt): static
{
$this->lastUsedAt = $lastUsedAt;
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 getShortAddress(): string
{
if (!$this->walletAddress) {
return '';
}
return substr($this->walletAddress, 0, 6) . '...' . substr($this->walletAddress, -4);
}
public function updateLastUsed(): static
{
$this->lastUsedAt = new \DateTime();
$this->updatedAt = new \DateTime();
return $this;
}
}

View file

@ -2,7 +2,7 @@
namespace App\Form;
use App\Entity\Customer;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
@ -106,7 +106,7 @@ class CustomerRegistrationFormType extends AbstractType
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Customer::class,
'data_class' => User::class,
]);
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Form\QA;
use App\Entity\Answer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class AnswerFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('content', TextareaType::class, [
'label' => 'پاسخ شما',
'attr' => [
'class' => 'form-control',
'rows' => 8,
'placeholder' => 'پاسخ خود را وارد کنید...'
],
'constraints' => [
new Assert\NotBlank(['message' => 'متن پاسخ الزامی است']),
new Assert\Length([
'min' => 10,
'minMessage' => 'پاسخ باید حداقل 10 کاراکتر باشد'
])
]
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Answer::class,
]);
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Form\QA;
use App\Entity\Question;
use App\Entity\QuestionTag;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class QuestionFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'label' => 'عنوان سوال',
'attr' => [
'class' => 'form-control',
'placeholder' => 'عنوان سوال خود را وارد کنید...',
'maxlength' => 255
],
'constraints' => [
new Assert\NotBlank(['message' => 'عنوان سوال الزامی است']),
new Assert\Length([
'min' => 10,
'max' => 255,
'minMessage' => 'عنوان سوال باید حداقل 10 کاراکتر باشد',
'maxMessage' => 'عنوان سوال نمی‌تواند بیش از 255 کاراکتر باشد'
])
]
])
->add('content', TextareaType::class, [
'label' => 'متن سوال',
'attr' => [
'class' => 'form-control',
'rows' => 10,
'placeholder' => 'سوال خود را به تفصیل شرح دهید...'
],
'constraints' => [
new Assert\NotBlank(['message' => 'متن سوال الزامی است']),
new Assert\Length([
'min' => 20,
'minMessage' => 'متن سوال باید حداقل 20 کاراکتر باشد'
])
]
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Question::class,
]);
}
}

View file

@ -0,0 +1,73 @@
<?php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class WalletConnectFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('walletType', ChoiceType::class, [
'label' => 'نوع کیف پول',
'choices' => [
'MetaMask' => 'MetaMask',
'Trust Wallet' => 'Trust Wallet',
'WalletConnect' => 'WalletConnect',
'Coinbase Wallet' => 'Coinbase Wallet',
'سایر' => 'Other'
],
'attr' => [
'class' => 'form-control',
'id' => 'walletType'
],
'constraints' => [
new Assert\NotBlank(['message' => 'لطفاً نوع کیف پول را انتخاب کنید'])
]
])
->add('walletAddress', TextType::class, [
'label' => 'آدرس کیف پول',
'attr' => [
'class' => 'form-control',
'id' => 'walletAddress',
'placeholder' => '0x...',
'readonly' => true,
'data-turbo' => 'false'
],
'constraints' => [
new Assert\NotBlank(['message' => 'آدرس کیف پول الزامی است']),
new Assert\Regex([
'pattern' => '/^0x[a-fA-F0-9]{40}$/',
'message' => 'فرمت آدرس کیف پول صحیح نیست'
])
]
])
->add('signature', HiddenType::class, [
'attr' => [
'id' => 'signature'
]
])
->add('connect', SubmitType::class, [
'label' => 'اتصال کیف پول',
'attr' => [
'class' => 'btn btn-primary',
'id' => 'connectWalletBtn'
]
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => null,
]);
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace App\Repository;
use App\Entity\Answer;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Answer>
*/
class AnswerRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Answer::class);
}
public function findAnswersByQuestion(int $questionId, int $limit = 20, int $offset = 0): array
{
return $this->createQueryBuilder('a')
->where('a.question = :questionId')
->andWhere('a.isActive = :active')
->setParameter('questionId', $questionId)
->setParameter('active', true)
->orderBy('a.isAccepted', 'DESC')
->addOrderBy('a.votes', 'DESC')
->addOrderBy('a.createdAt', 'ASC')
->setMaxResults($limit)
->setFirstResult($offset)
->getQuery()
->getResult();
}
public function countAnswersByQuestion(int $questionId): int
{
return $this->createQueryBuilder('a')
->select('COUNT(a.id)')
->where('a.question = :questionId')
->andWhere('a.isActive = :active')
->setParameter('questionId', $questionId)
->setParameter('active', true)
->getQuery()
->getSingleScalarResult();
}
public function findMostVotedAnswers(int $limit = 10): array
{
return $this->createQueryBuilder('a')
->where('a.isActive = :active')
->setParameter('active', true)
->orderBy('a.votes', 'DESC')
->addOrderBy('a.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Repository;
use App\Entity\AnswerVote;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AnswerVote>
*/
class AnswerVoteRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AnswerVote::class);
}
public function findVoteByUserAndAnswer(int $userId, int $answerId): ?AnswerVote
{
return $this->createQueryBuilder('v')
->where('v.user = :userId')
->andWhere('v.answer = :answerId')
->setParameter('userId', $userId)
->setParameter('answerId', $answerId)
->getQuery()
->getOneOrNullResult();
}
}

View file

@ -1,49 +0,0 @@
<?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,170 @@
<?php
namespace App\Repository;
use App\Entity\Question;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Question>
*/
class QuestionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Question::class);
}
public function findActiveQuestions(int $limit = 20, int $offset = 0): array
{
return $this->createQueryBuilder('q')
->where('q.isActive = :active')
->setParameter('active', true)
->orderBy('q.createdAt', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset)
->getQuery()
->getResult();
}
public function findQuestionsByTag(string $tagName, int $limit = 20, int $offset = 0): array
{
return $this->createQueryBuilder('q')
->join('q.tagRelations', 'tr')
->join('tr.tag', 't')
->where('q.isActive = :active')
->andWhere('t.name = :tagName')
->setParameter('active', true)
->setParameter('tagName', $tagName)
->orderBy('q.createdAt', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset)
->getQuery()
->getResult();
}
public function searchQuestions(string $searchTerm, int $limit = 20, int $offset = 0): array
{
return $this->createQueryBuilder('q')
->where('q.isActive = :active')
->andWhere('q.title LIKE :searchTerm OR q.content LIKE :searchTerm')
->setParameter('active', true)
->setParameter('searchTerm', '%' . $searchTerm . '%')
->orderBy('q.createdAt', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset)
->getQuery()
->getResult();
}
public function findMostVotedQuestions(int $limit = 10): array
{
return $this->createQueryBuilder('q')
->where('q.isActive = :active')
->setParameter('active', true)
->orderBy('q.votes', 'DESC')
->addOrderBy('q.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function findUnsolvedQuestions(int $limit = 20, int $offset = 0): array
{
return $this->createQueryBuilder('q')
->where('q.isActive = :active')
->andWhere('q.isSolved = :solved')
->setParameter('active', true)
->setParameter('solved', false)
->orderBy('q.createdAt', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset)
->getQuery()
->getResult();
}
public function findSolvedQuestions(int $limit = 20, int $offset = 0): array
{
return $this->createQueryBuilder('q')
->where('q.isActive = :active')
->andWhere('q.isSolved = :solved')
->setParameter('active', true)
->setParameter('solved', true)
->orderBy('q.createdAt', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset)
->getQuery()
->getResult();
}
public function getTotalCount(): int
{
return $this->createQueryBuilder('q')
->select('COUNT(q.id)')
->where('q.isActive = :active')
->setParameter('active', true)
->getQuery()
->getSingleScalarResult();
}
public function countSearchQuestions(string $searchTerm): int
{
return $this->createQueryBuilder('q')
->select('COUNT(q.id)')
->where('q.isActive = :active')
->andWhere('q.title LIKE :searchTerm OR q.content LIKE :searchTerm')
->setParameter('active', true)
->setParameter('searchTerm', '%' . $searchTerm . '%')
->getQuery()
->getSingleScalarResult();
}
public function countQuestionsByTag(string $tagName): int
{
return $this->createQueryBuilder('q')
->select('COUNT(q.id)')
->join('q.tagRelations', 'tr')
->join('tr.tag', 't')
->where('q.isActive = :active')
->andWhere('t.name = :tagName')
->setParameter('active', true)
->setParameter('tagName', $tagName)
->getQuery()
->getSingleScalarResult();
}
public function countUnsolvedQuestions(): int
{
return $this->createQueryBuilder('q')
->select('COUNT(q.id)')
->where('q.isActive = :active')
->andWhere('q.isSolved = :solved')
->setParameter('active', true)
->setParameter('solved', false)
->getQuery()
->getSingleScalarResult();
}
public function countSolvedQuestions(): int
{
return $this->createQueryBuilder('q')
->select('COUNT(q.id)')
->where('q.isActive = :active')
->andWhere('q.isSolved = :solved')
->setParameter('active', true)
->setParameter('solved', true)
->getQuery()
->getSingleScalarResult();
}
public function countMostVotedQuestions(): int
{
return $this->createQueryBuilder('q')
->select('COUNT(q.id)')
->where('q.isActive = :active')
->setParameter('active', true)
->getQuery()
->getSingleScalarResult();
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\QuestionTagRelation;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<QuestionTagRelation>
*/
class QuestionTagRelationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, QuestionTagRelation::class);
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace App\Repository;
use App\Entity\QuestionTag;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<QuestionTag>
*/
class QuestionTagRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, QuestionTag::class);
}
public function findActiveTags(): array
{
return $this->createQueryBuilder('t')
->where('t.isActive = :active')
->setParameter('active', true)
->orderBy('t.usageCount', 'DESC')
->addOrderBy('t.name', 'ASC')
->getQuery()
->getResult();
}
public function findPopularTags(int $limit = 20): array
{
return $this->createQueryBuilder('t')
->where('t.isActive = :active')
->setParameter('active', true)
->orderBy('t.usageCount', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function findByName(string $name): ?QuestionTag
{
return $this->createQueryBuilder('t')
->where('t.name = :name')
->setParameter('name', $name)
->getQuery()
->getOneOrNullResult();
}
public function searchTags(string $searchTerm, int $limit = 10): array
{
return $this->createQueryBuilder('t')
->where('t.isActive = :active')
->andWhere('t.name LIKE :searchTerm')
->setParameter('active', true)
->setParameter('searchTerm', '%' . $searchTerm . '%')
->orderBy('t.usageCount', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Repository;
use App\Entity\QuestionVote;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<QuestionVote>
*/
class QuestionVoteRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, QuestionVote::class);
}
public function findVoteByUserAndQuestion(int $userId, int $questionId): ?QuestionVote
{
return $this->createQueryBuilder('v')
->where('v.user = :userId')
->andWhere('v.question = :questionId')
->setParameter('userId', $userId)
->setParameter('questionId', $questionId)
->getQuery()
->getOneOrNullResult();
}
}

View file

@ -0,0 +1,125 @@
<?php
namespace App\Repository;
use App\Entity\Wallet;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Wallet>
*/
class WalletRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Wallet::class);
}
/**
* Find all active wallets for a user
*/
public function findActiveWalletsByUser(User $user): array
{
return $this->createQueryBuilder('w')
->andWhere('w.user = :user')
->andWhere('w.isActive = :active')
->setParameter('user', $user)
->setParameter('active', true)
->orderBy('w.isPrimary', 'DESC')
->addOrderBy('w.connectedAt', 'DESC')
->getQuery()
->getResult();
}
/**
* Find primary wallet for a user
*/
public function findPrimaryWalletByUser(User $user): ?Wallet
{
return $this->createQueryBuilder('w')
->andWhere('w.user = :user')
->andWhere('w.isPrimary = :primary')
->andWhere('w.isActive = :active')
->setParameter('user', $user)
->setParameter('primary', true)
->setParameter('active', true)
->getQuery()
->getOneOrNullResult();
}
/**
* Check if wallet address already exists for any user
*/
public function isWalletAddressExists(string $walletAddress): bool
{
$result = $this->createQueryBuilder('w')
->select('COUNT(w.id)')
->andWhere('w.walletAddress = :address')
->setParameter('address', $walletAddress)
->getQuery()
->getSingleScalarResult();
return $result > 0;
}
/**
* Check if wallet address exists for specific user
*/
public function isWalletAddressExistsForUser(User $user, string $walletAddress): bool
{
$result = $this->createQueryBuilder('w')
->select('COUNT(w.id)')
->andWhere('w.user = :user')
->andWhere('w.walletAddress = :address')
->setParameter('user', $user)
->setParameter('address', $walletAddress)
->getQuery()
->getSingleScalarResult();
return $result > 0;
}
/**
* Count active wallets for a user
*/
public function countActiveWalletsByUser(User $user): int
{
return $this->createQueryBuilder('w')
->select('COUNT(w.id)')
->andWhere('w.user = :user')
->andWhere('w.isActive = :active')
->setParameter('user', $user)
->setParameter('active', true)
->getQuery()
->getSingleScalarResult();
}
/**
* Find wallet by address
*/
public function findByWalletAddress(string $walletAddress): ?Wallet
{
return $this->createQueryBuilder('w')
->andWhere('w.walletAddress = :address')
->setParameter('address', $walletAddress)
->getQuery()
->getOneOrNullResult();
}
/**
* Set all wallets as non-primary for a user
*/
public function unsetAllPrimaryWalletsForUser(User $user): void
{
$this->createQueryBuilder('w')
->update()
->set('w.isPrimary', ':primary')
->andWhere('w.user = :user')
->setParameter('primary', false)
->setParameter('user', $user)
->getQuery()
->execute();
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Service;
use Parsedown;
class MarkdownService
{
private Parsedown $parsedown;
public function __construct()
{
$this->parsedown = new Parsedown();
$this->parsedown->setSafeMode(true);
}
public function parse(string $text): string
{
return $this->parsedown->text($text);
}
}

View file

@ -0,0 +1,232 @@
<?php
namespace App\Service;
use App\Entity\User;
use App\Entity\Wallet;
use App\Repository\WalletRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
class WalletService
{
private EntityManagerInterface $entityManager;
private WalletRepository $walletRepository;
public function __construct(EntityManagerInterface $entityManager, WalletRepository $walletRepository)
{
$this->entityManager = $entityManager;
$this->walletRepository = $walletRepository;
}
/**
* Connect a new wallet to user
*/
public function connectWallet(User $user, string $walletAddress, string $walletType, string $signature): JsonResponse
{
// Validate wallet address format
if (!$this->isValidWalletAddress($walletAddress)) {
return new JsonResponse([
'success' => false,
'message' => 'آدرس کیف پول نامعتبر است'
], 400);
}
// Check if wallet already exists
if ($this->walletRepository->isWalletAddressExists($walletAddress)) {
return new JsonResponse([
'success' => false,
'message' => 'این کیف پول قبلاً متصل شده است'
], 400);
}
// Check if user has reached maximum wallet limit
if ($this->walletRepository->countActiveWalletsByUser($user) >= 5) {
return new JsonResponse([
'success' => false,
'message' => 'حداکثر 5 کیف پول می‌توانید متصل کنید'
], 400);
}
// Verify signature (simplified - in real implementation, verify with Web3)
if (!$this->verifySignature($walletAddress, $signature)) {
return new JsonResponse([
'success' => false,
'message' => 'امضای دیجیتال نامعتبر است'
], 400);
}
// Create new wallet
$wallet = new Wallet();
$wallet->setUser($user);
$wallet->setWalletAddress($walletAddress);
$wallet->setWalletType($walletType);
$wallet->setIsPrimary($this->walletRepository->countActiveWalletsByUser($user) === 0);
$wallet->setIsActive(true);
$this->entityManager->persist($wallet);
$this->entityManager->flush();
return new JsonResponse([
'success' => true,
'message' => 'کیف پول با موفقیت متصل شد',
'wallet' => [
'id' => $wallet->getId(),
'address' => $wallet->getWalletAddress(),
'shortAddress' => $wallet->getShortAddress(),
'type' => $wallet->getWalletType(),
'isPrimary' => $wallet->isPrimary(),
'connectedAt' => $wallet->getConnectedAt()->format('Y-m-d H:i:s')
]
]);
}
/**
* Set wallet as primary
*/
public function setPrimaryWallet(User $user, int $walletId): JsonResponse
{
$wallet = $this->walletRepository->find($walletId);
if (!$wallet || $wallet->getUser() !== $user) {
return new JsonResponse([
'success' => false,
'message' => 'کیف پول یافت نشد'
], 404);
}
if (!$wallet->isActive()) {
return new JsonResponse([
'success' => false,
'message' => 'کیف پول غیرفعال است'
], 400);
}
// Unset all primary wallets for this user
$this->walletRepository->unsetAllPrimaryWalletsForUser($user);
// Set this wallet as primary
$wallet->setIsPrimary(true);
$this->entityManager->flush();
return new JsonResponse([
'success' => true,
'message' => 'کیف پول به عنوان اصلی تنظیم شد'
]);
}
/**
* Toggle wallet status (active/inactive)
*/
public function toggleWalletStatus(User $user, int $walletId): JsonResponse
{
$wallet = $this->walletRepository->find($walletId);
if (!$wallet || $wallet->getUser() !== $user) {
return new JsonResponse([
'success' => false,
'message' => 'کیف پول یافت نشد'
], 404);
}
$wallet->setIsActive(!$wallet->isActive());
$this->entityManager->flush();
return new JsonResponse([
'success' => true,
'message' => $wallet->isActive() ? 'کیف پول فعال شد' : 'کیف پول غیرفعال شد'
]);
}
/**
* Delete wallet
*/
public function deleteWallet(User $user, int $walletId): JsonResponse
{
$wallet = $this->walletRepository->find($walletId);
if (!$wallet || $wallet->getUser() !== $user) {
return new JsonResponse([
'success' => false,
'message' => 'کیف پول یافت نشد'
], 404);
}
$this->entityManager->remove($wallet);
$this->entityManager->flush();
return new JsonResponse([
'success' => true,
'message' => 'کیف پول حذف شد'
]);
}
/**
* Get user's wallets
*/
public function getUserWallets(User $user): array
{
$wallets = $this->walletRepository->findActiveWalletsByUser($user);
return array_map(function (Wallet $wallet) {
return [
'id' => $wallet->getId(),
'address' => $wallet->getWalletAddress(),
'shortAddress' => $wallet->getShortAddress(),
'type' => $wallet->getWalletType(),
'isPrimary' => $wallet->isPrimary(),
'isActive' => $wallet->isActive(),
'connectedAt' => $wallet->getConnectedAt()->format('Y-m-d H:i:s'),
'lastUsedAt' => $wallet->getLastUsedAt() ? $wallet->getLastUsedAt()->format('Y-m-d H:i:s') : null
];
}, $wallets);
}
/**
* Update wallet last used time
*/
public function updateWalletLastUsed(Wallet $wallet): void
{
$wallet->updateLastUsed();
$this->entityManager->flush();
}
/**
* Validate wallet address format
*/
private function isValidWalletAddress(string $address): bool
{
// Basic Ethereum address validation
$isValid = preg_match('/^0x[a-fA-F0-9]{40}$/', $address) === 1;
// Log for debugging
error_log("Wallet address validation: $address - " . ($isValid ? 'valid' : 'invalid'));
return $isValid;
}
/**
* Verify digital signature (simplified implementation)
* In real implementation, this should verify the signature using Web3
*/
private function verifySignature(string $walletAddress, string $signature): bool
{
// This is a simplified implementation
// In real implementation, you should verify the signature using Web3 libraries
return !empty($signature) && strlen($signature) > 10;
}
/**
* Generate message for signing
*/
public function generateSignMessage(User $user): string
{
$timestamp = time();
$message = "Connect wallet to Hesabix\n";
$message .= "User: " . $user->getEmail() . "\n";
$message .= "Timestamp: " . $timestamp . "\n";
$message .= "Nonce: " . bin2hex(random_bytes(16));
return $message;
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
class MarkdownExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('markdown', [$this, 'parseMarkdown'], ['is_safe' => ['html']]),
];
}
public function parseMarkdown(string $text): string
{
// تبدیل Markdown ساده به HTML
$text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
// Bold: **text** -> <strong>text</strong>
$text = preg_replace('/\*\*(.*?)\*\*/', '<strong>$1</strong>', $text);
// Italic: *text* -> <em>text</em>
$text = preg_replace('/\*(.*?)\*/', '<em>$1</em>', $text);
// Code: `code` -> <code>code</code>
$text = preg_replace('/`(.*?)`/', '<code>$1</code>', $text);
// Line breaks: \n -> <br>
$text = nl2br($text);
// Lists: - item -> <li>item</li>
$text = preg_replace('/^- (.+)$/m', '<li>$1</li>', $text);
$text = preg_replace('/(<li>.*<\/li>)/s', '<ul>$1</ul>', $text);
return $text;
}
}

View file

@ -1,4 +1,13 @@
{
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
}
},
"doctrine/doctrine-bundle": {
"version": "2.13",
"recipe": {

View file

@ -77,11 +77,105 @@ gtag('config', 'G-K1R1SYQY8E');
background-color: #f8d7da;
color: #721c24;
}
/* استایل‌های گواهی‌های اعتماد */
.trust-seal-loading {
animation: pulse 1.5s ease-in-out infinite;
}
.trust-seals-content img {
transition: opacity 0.3s ease-in-out;
max-height: 50px;
width: auto;
}
.trust-seals-content img:hover {
opacity: 0.8;
transform: scale(1.05);
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
</style>
{% endblock %}
{% block javascripts %}
{{ encore_entry_script_tags('app') }}
<!-- اسکریپت لود غیرهمزمان گواهی‌های اعتماد -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// تابع لود غیرهمزمان اسکریپت
function loadScriptAsync(src, callback) {
const script = document.createElement('script');
script.src = src;
script.async = true;
script.onload = callback;
script.onerror = function() {
console.warn('خطا در بارگذاری اسکریپت:', src);
callback();
};
document.head.appendChild(script);
}
// تابع لود غیرهمزمان تصاویر
function loadImagesAsync() {
const images = document.querySelectorAll('.trust-seals-content img');
let loadedCount = 0;
const totalImages = images.length;
images.forEach(img => {
img.onload = function() {
loadedCount++;
if (loadedCount === totalImages) {
showTrustSeals();
}
};
img.onerror = function() {
loadedCount++;
if (loadedCount === totalImages) {
showTrustSeals();
}
};
});
// اگر هیچ تصویری وجود ندارد، فوراً نمایش بده
if (totalImages === 0) {
showTrustSeals();
}
}
// تابع نمایش گواهی‌های اعتماد
function showTrustSeals() {
const loadingElement = document.querySelector('.trust-seal-loading');
const contentElement = document.querySelector('.trust-seals-content');
if (loadingElement && contentElement) {
loadingElement.style.display = 'none';
contentElement.style.display = 'flex';
contentElement.style.gap = '1rem';
contentElement.style.alignItems = 'center';
contentElement.style.justifyContent = 'center';
}
}
// شروع لود اسکریپت زرین‌پال
loadScriptAsync('https://www.zarinpal.com/webservice/TrustCode', function() {
// بعد از لود اسکریپت زرین‌پال، تصاویر را لود کن
setTimeout(loadImagesAsync, 100);
});
// اگر اسکریپت زرین‌پال لود نشد، باز هم تصاویر را لود کن
setTimeout(function() {
if (document.querySelector('.trust-seal-loading').style.display !== 'none') {
loadImagesAsync();
}
}, 3000);
});
</script>
{% endblock %}
</head>
<body>
@ -105,18 +199,28 @@ gtag('config', 'G-K1R1SYQY8E');
<li class="nav-item">
<a class="nav-link px-3 py-2 rounded-3 transition-all" href="{{path('app_blog_home')}}">وبلاگ</a>
</li>
<li class="nav-item">
<a class="nav-link px-3 py-2 rounded-3 transition-all" href="{{path('qa_index')}}">سوالات</a>
</li>
<li class="nav-item">
<a class="nav-link px-3 py-2 rounded-3 transition-all" href="{{path('app_page',{'url':'sponsors'})}}">حامیان مالی</a>
</li>
<li class="nav-item">
<a class="nav-link px-3 py-2 rounded-3 transition-all" href="{{path('app_page',{'url':'about'})}}">داستان حسابیکس</a>
<li class="nav-item dropdown">
<a class="nav-link px-3 py-2 rounded-3 transition-all dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
درباره ما
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{path('app_page',{'url':'about'})}}">داستان حسابیکس</a>
</li>
<li class="nav-item">
<a class="nav-link px-3 py-2 rounded-3 transition-all" href="{{path('app_page',{'url':'contact'})}}">تماس با ما</a>
<li>
<a class="dropdown-item" href="{{path('app_page',{'url':'contact'})}}">تماس با ما</a>
</li>
</ul>
</li>
</ul>
<div class="d-flex gap-2">
{% if app.user and 'ROLE_CUSTOMER' in app.user.roles %}
{% if app.user and app.user.roles is defined 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">
@ -152,12 +256,23 @@ gtag('config', 'G-K1R1SYQY8E');
{% block body %}{% endblock %}
<footer class="py-3 my-4">
<div class="d-flex justify-content-center align-items-center gap-3 mb-3">
<a referrerpolicy='origin' target='_blank' href='https://trustseal.enamad.ir/?id=614357&Code=4ATiNTREoPRD5Lz3zwc9zyz0zWGJiZL3'><img referrerpolicy='origin' src='https://trustseal.enamad.ir/logo.aspx?id=614357&Code=4ATiNTREoPRD5Lz3zwc9zyz0zWGJiZL3' alt='' style='cursor:pointer' code='4ATiNTREoPRD5Lz3zwc9zyz0zWGJiZL3'></a>
<script src="https://www.zarinpal.com/webservice/TrustCode" type="text/javascript"></script>
<a href="https://bitpay.ir/certificate-230498-hesabix.ir" target="_blank"><img src="https://bitpay.ir/theme/public/images/trusted-logo.svg"/></a>
<img referrerpolicy='origin' id='rgvjoeukesgtapfufukzrgvj' style='cursor:pointer' onclick='window.open("https://logo.samandehi.ir/Verify.aspx?id=380563&p=xlaomcsiobpddshwgvkaxlao", "Popup","toolbar=no, scrollbars=no, location=no, statusbar=no, menubar=no, resizable=0, width=450, height=630, top=30")' alt='logo-samandehi' src='https://logo.samandehi.ir/logo.aspx?id=380563&p=qftiaqgwlymaujynwlbqqfti'/>
<div class="d-flex justify-content-center align-items-center gap-3 mb-3" id="trust-seals-container">
<!-- نشانگرهای بارگذاری -->
<div class="trust-seal-loading d-flex gap-3">
<div class="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></div>
<span class="text-muted small">در حال بارگذاری گواهی‌های اعتماد...</span>
</div>
<!-- محتوای اصلی که بعد از لود نمایش داده می‌شود -->
<div class="trust-seals-content" style="display: none;">
<a referrerpolicy='origin' target='_blank' href='https://trustseal.enamad.ir/?id=614357&Code=4ATiNTREoPRD5Lz3zwc9zyz0zWGJiZL3'>
<img referrerpolicy='origin' src='https://trustseal.enamad.ir/logo.aspx?id=614357&Code=4ATiNTREoPRD5Lz3zwc9zyz0zWGJiZL3' alt='گواهی اعتماد اناماد' style='cursor:pointer' code='4ATiNTREoPRD5Lz3zwc9zyz0zWGJiZL3' loading="lazy">
</a>
<a href="https://bitpay.ir/certificate-230498-hesabix.ir" target="_blank">
<img src="https://bitpay.ir/theme/public/images/trusted-logo.svg" alt="گواهی اعتماد بیت‌پی" loading="lazy"/>
</a>
<img referrerpolicy='origin' id='rgvjoeukesgtapfufukzrgvj' style='cursor:pointer' onclick='window.open("https://logo.samandehi.ir/Verify.aspx?id=380563&p=xlaomcsiobpddshwgvkaxlao", "Popup","toolbar=no, scrollbars=no, location=no, statusbar=no, menubar=no, resizable=0, width=450, height=630, top=30")' alt='گواهی اعتماد ساماندهی' src='https://logo.samandehi.ir/logo.aspx?id=380563&p=qftiaqgwlymaujynwlbqqfti' loading="lazy"/>
</div>
</div>
<ul class="nav justify-content-center border-bottom pb-3 mb-3">

View file

@ -200,6 +200,90 @@
direction: rtl;
text-align: right;
}
/* استایل‌های کیف پول */
.wallet-connect-form {
background: #ffffff;
padding: 25px;
border-radius: 12px;
margin-bottom: 25px;
border: 1px solid #e9ecef;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.wallet-connect-form h5 {
color: #0d6efd;
margin-bottom: 20px;
direction: rtl;
text-align: right;
font-weight: 600;
}
#walletAddress {
font-family: 'Courier New', monospace;
font-size: 14px;
direction: ltr;
text-align: left;
}
#walletAddress:read-only {
background-color: #f8f9fa;
cursor: not-allowed;
border: 1px solid #e9ecef;
}
.wallet-connect-form .input-group-text {
background-color: #e9ecef;
border: 1px solid #e9ecef;
color: #6c757d;
}
.wallet-connect-form .btn-lg {
padding: 12px 24px;
font-size: 1.1rem;
font-weight: 600;
}
.wallet-connect-form .btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.wallets-list table {
direction: rtl;
margin-top: 20px;
}
.wallets-list th,
.wallets-list td {
text-align: right;
vertical-align: middle;
padding: 12px;
}
.wallets-list th {
background-color: #f8f9fa;
font-weight: 600;
border-bottom: 2px solid #dee2e6;
}
.wallets-list code {
font-family: 'Courier New', monospace;
font-size: 12px;
background: #f8f9fa;
padding: 4px 8px;
border-radius: 6px;
border: 1px solid #e9ecef;
}
.btn-group-sm .btn {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.btn-group-sm .btn i {
margin-left: 4px;
}
</style>
{% endblock %}
@ -218,23 +302,23 @@
<div class="info-item">
<span class="info-label">نام و نام خانوادگی:</span>
<span class="info-value">{{ customer.name }}</span>
<span class="info-value">{{ user.name }}</span>
</div>
<div class="info-item">
<span class="info-label">پست الکترونیکی:</span>
<span class="info-value">{{ customer.email }}</span>
<span class="info-value">{{ user.email }}</span>
</div>
<div class="info-item">
<span class="info-label">شماره موبایل:</span>
<span class="info-value">{{ customer.phone }}</span>
<span class="info-value">{{ user.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 class="status-badge {{ user.isActive ? 'status-active' : 'status-inactive' }}">
{{ user.isActive ? 'فعال' : 'غیرفعال' }}
</span>
</div>
</div>
@ -246,33 +330,161 @@
<div class="info-item">
<span class="info-label">تاریخ عضویت:</span>
<span class="info-value">{{ customer.createdAt|date('Y/m/d') }}</span>
<span class="info-value">{{ user.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') : 'هنوز وارد نشده' }}
{{ user.lastLoginAt ? user.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 class="status-badge {{ user.emailVerifiedAt ? 'status-active' : 'status-inactive' }}">
{{ user.emailVerifiedAt ? 'تایید شده' : 'تایید نشده' }}
</span>
</div>
{% if customer.subscriptionType %}
{% if user.subscriptionType %}
<div class="info-item">
<span class="info-label">نوع اشتراک:</span>
<span class="info-value">{{ customer.subscriptionType }}</span>
<span class="info-value">{{ user.subscriptionType }}</span>
</div>
{% endif %}
</div>
</div>
</div>
<!-- بخش کیف پول‌ها -->
<div class="row">
<div class="col-12">
<div class="dashboard-card wallet-section">
<h3><img src="{{ asset('/img/icons/wallet.svg') }}" alt="کیف پول" class="icon-svg icon-wallet"> مدیریت کیف پول‌ها</h3>
<!-- فرم اتصال کیف پول جدید -->
<div class="wallet-connect-form mb-4"
data-controller="wallet-connect"
data-wallet-connect-sign-message-value="{{ signMessage }}"
data-wallet-connect-csrf-token-value="{{ csrf_token('wallet_connect_form') }}">
<h5>اتصال کیف پول جدید</h5>
<div class="row g-3">
<div class="col-md-6">
<label for="walletType" class="form-label">نوع کیف پول</label>
<select class="form-select" id="walletType" data-wallet-connect-target="walletType" data-action="change->wallet-connect#onWalletTypeChange">
<option value="">انتخاب کنید...</option>
<option value="metamask">MetaMask</option>
<option value="trust">Trust Wallet</option>
<option value="walletconnect">WalletConnect</option>
<option value="coinbase">Coinbase Wallet</option>
<option value="other">سایر</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">آدرس کیف پول</label>
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-wallet"></i>
</span>
<input type="text"
class="form-control"
id="walletAddress"
data-wallet-connect-target="walletAddress"
placeholder="آدرس کیف پول پس از اتصال نمایش داده می‌شود"
readonly>
</div>
<div class="form-text">آدرس کیف پول شما پس از اتصال به صورت خودکار پر می‌شود</div>
</div>
<div class="col-12">
<button type="button"
class="btn btn-primary btn-lg w-100"
id="connectBtn"
data-wallet-connect-target="connectBtn"
data-action="click->wallet-connect#connectWallet"
disabled>
نوع کیف پول را انتخاب کنید
</button>
</div>
</div>
<input type="hidden" data-wallet-connect-target="signature">
</div>
<!-- لیست کیف پول‌های متصل -->
<div class="wallets-list">
<h5>کیف پول‌های متصل ({{ user.wallets|length }}/5)</h5>
{% if user.wallets|length > 0 %}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>آدرس کیف پول</th>
<th>نوع</th>
<th>وضعیت</th>
<th>تاریخ اتصال</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
{% for wallet in user.wallets %}
<tr>
<td>
<code>{{ wallet.shortAddress }}</code>
{% if wallet.isPrimary %}
<span class="badge bg-primary ms-2">اصلی</span>
{% endif %}
</td>
<td>
<span class="badge bg-info">{{ wallet.walletType }}</span>
</td>
<td>
<span class="badge {{ wallet.isActive ? 'bg-success' : 'bg-danger' }}">
{{ wallet.isActive ? 'فعال' : 'غیرفعال' }}
</span>
</td>
<td>{{ wallet.connectedAt|date('Y/m/d H:i') }}</td>
<td>
<div class="btn-group btn-group-sm">
{% if not wallet.isPrimary and wallet.isActive %}
<button class="btn btn-outline-primary btn-sm"
data-wallet-id="{{ wallet.id }}"
data-action="click->wallet-connect#setPrimaryWallet">
<i class="fas fa-star"></i> اصلی
</button>
{% endif %}
<button class="btn btn-outline-warning btn-sm"
data-wallet-id="{{ wallet.id }}"
data-action="click->wallet-connect#toggleWalletStatus">
<i class="fas fa-{{ wallet.isActive ? 'pause' : 'play' }}"></i>
{{ wallet.isActive ? 'غیرفعال' : 'فعال' }}
</button>
<button class="btn btn-outline-danger btn-sm"
data-wallet-id="{{ wallet.id }}"
data-action="click->wallet-connect#deleteWallet">
<i class="fas fa-trash"></i> حذف
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-wallet fa-4x mb-4 text-muted"></i>
<h5 class="text-muted">هنوز هیچ کیف پولی متصل نشده است</h5>
<p class="text-muted">برای شروع، نوع کیف پول خود را انتخاب کنید و روی دکمه اتصال کلیک کنید</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="dashboard-card">
@ -297,3 +509,7 @@
</div>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
{% endblock %}

View file

@ -599,7 +599,7 @@
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
function initializeReadMoreButtons() {
const readMoreButtons = document.querySelectorAll('.read-more-btn');
readMoreButtons.forEach(button => {
button.addEventListener('click', function() {
@ -608,6 +608,10 @@
this.textContent = testimonialText.classList.contains('expanded') ? 'بستن' : 'نمایش بیشتر';
});
});
});
}
// اجرا در هر دو حالت
document.addEventListener('DOMContentLoaded', initializeReadMoreButtons);
document.addEventListener('turbo:load', initializeReadMoreButtons);
</script>
{% endblock %}

View file

@ -17,6 +17,16 @@
<lastmod>2025-01-09T06:58:03+00:00</lastmod>
<priority>1.00</priority>
</url>
<url>
<loc>{{ absolute_url(path('qa_index')) }}</loc>
<lastmod>2025-01-09T06:58:03+00:00</lastmod>
<priority>0.90</priority>
</url>
<url>
<loc>{{ absolute_url(path('qa_tags')) }}</loc>
<lastmod>2025-01-09T06:58:03+00:00</lastmod>
<priority>0.80</priority>
</url>
{% for post in posts %}
{% if post.cat.code == 'plain' %}
<url>
@ -44,4 +54,18 @@
</url>
{% endif %}
{% endfor %}
{% for question in questions %}
<url>
<loc>{{ absolute_url(path('qa_question_show', {'id': question.id})) }}</loc>
<lastmod>{{ question.createdAt|date('c') }}</lastmod>
<priority>0.70</priority>
</url>
{% endfor %}
{% for tag in tags %}
<url>
<loc>{{ absolute_url(path('qa_tag_questions', {'name': tag.name})) }}</loc>
<lastmod>2025-01-09T06:58:03+00:00</lastmod>
<priority>0.60</priority>
</url>
{% endfor %}
</urlset>

View file

@ -407,7 +407,7 @@
{{ parent() }}
<script>
// تست ساده برای بررسی عملکرد
document.addEventListener('DOMContentLoaded', function() {
function initializeInstallation() {
console.log('DOM loaded');
// تست دسترسی به المان‌ها
@ -422,6 +422,10 @@
username: !!username,
password: !!password
});
});
}
// اجرا در هر دو حالت
document.addEventListener('DOMContentLoaded', initializeInstallation);
document.addEventListener('turbo:load', initializeInstallation);
</script>
{% endblock %}

View file

@ -0,0 +1,218 @@
{% extends 'base.html.twig' %}
{% block title %}پاسخ به سوال: {{ question.title }} - پرسش و پاسخ{% endblock %}
{% block body %}
<div class="container my-4">
<div class="row justify-content-center">
<div class="col-lg-8">
<!-- نمایش سوال -->
<div class="card mb-4">
<div class="card-header">
<h4 class="mb-0">سوال:</h4>
</div>
<div class="card-body">
<h5 class="card-title">{{ question.title }}</h5>
<div class="question-content">
{{ question.content|nl2br }}
</div>
<div class="mt-3">
<div class="tags">
{% for tagRelation in question.tagRelations %}
<span class="badge bg-light text-dark me-1">
{{ tagRelation.tag.name }}
</span>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- فرم پاسخ -->
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-2">
<polyline points="9,17 4,12 9,7"></polyline>
<path d="M20 18v-2a4 4 0 0 0-4-4H4"></path>
</svg>پاسخ شما
</h4>
</div>
<div class="card-body">
{{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }}
<div class="mb-4">
{{ form_label(form.content) }}
<div class="editor-toolbar mb-2">
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="formatText('bold')" title="پررنگ">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
</svg>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="formatText('italic')" title="کج">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="4" x2="10" y2="4"></line>
<line x1="14" y1="20" x2="5" y2="20"></line>
<line x1="15" y1="4" x2="9" y2="20"></line>
</svg>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="formatText('code')" title="کد">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16,18 22,12 16,6"></polyline>
<polyline points="8,6 2,12 8,18"></polyline>
</svg>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="insertList()" title="لیست">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
</button>
</div>
{{ form_widget(form.content, {'attr': {'class': 'form-control editor-textarea', 'rows': 8}}) }}
{{ form_errors(form.content) }}
<div class="form-text">
پاسخ خود را به صورت کامل و مفصل ارائه دهید. هرچه پاسخ شما دقیق‌تر باشد، برای دیگران مفیدتر خواهد بود.
<br><small class="text-muted">می‌توانید از دکمه‌های بالا برای فرمت کردن متن استفاده کنید.</small>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{{ path('qa_question_show', {'id': question.id}) }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-right me-2"></i>انصراف
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-paper-plane me-2"></i>ارسال پاسخ
</button>
</div>
{{ form_end(form) }}
</div>
</div>
<!-- راهنمای پاسخ دادن -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-lightbulb me-2"></i>راهنمای پاسخ دادن
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-success">✅ پاسخ خوب:</h6>
<ul class="list-unstyled">
<li><i class="fas fa-check text-success me-2"></i>مستقیماً به سوال پاسخ دهید</li>
<li><i class="fas fa-check text-success me-2"></i>از مثال‌های عملی استفاده کنید</li>
<li><i class="fas fa-check text-success me-2"></i>منابع و لینک‌های مفید ارائه دهید</li>
<li><i class="fas fa-check text-success me-2"></i>کد و نمونه‌های کد ارائه دهید</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-danger">❌ پاسخ ضعیف:</h6>
<ul class="list-unstyled">
<li><i class="fas fa-times text-danger me-2"></i>پاسخ مبهم و کلی</li>
<li><i class="fas fa-times text-danger me-2"></i>عدم ارائه راه‌حل عملی</li>
<li><i class="fas fa-times text-danger me-2"></i>تکرار پاسخ‌های قبلی</li>
<li><i class="fas fa-times text-danger me-2"></i>پاسخ نامربوط</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.form-control:focus {
border-color: #0d6efd;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
.card {
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border: none;
}
.card-header {
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.question-content {
line-height: 1.6;
font-size: 1.1rem;
}
.form-text {
font-size: 0.9rem;
color: #6c757d;
}
.list-unstyled li {
margin-bottom: 0.5rem;
}
.tags .badge {
font-size: 0.9rem;
}
.editor-toolbar {
border: 1px solid #dee2e6;
border-bottom: none;
border-radius: 0.375rem 0.375rem 0 0;
padding: 0.5rem;
background-color: #f8f9fa;
}
.editor-textarea {
border-radius: 0 0 0.375rem 0.375rem;
border-top: none;
}
</style>
<script>
// توابع ادیتور متن
function formatText(command) {
const textarea = document.querySelector('.editor-textarea');
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end);
let formattedText = '';
switch(command) {
case 'bold':
formattedText = `**${selectedText}**`;
break;
case 'italic':
formattedText = `*${selectedText}*`;
break;
case 'code':
formattedText = `\`${selectedText}\``;
break;
}
textarea.value = textarea.value.substring(0, start) + formattedText + textarea.value.substring(end);
textarea.focus();
textarea.setSelectionRange(start + formattedText.length, start + formattedText.length);
}
function insertList() {
const textarea = document.querySelector('.editor-textarea');
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end);
const listText = selectedText.split('\n').map(line => `- ${line}`).join('\n');
textarea.value = textarea.value.substring(0, start) + listText + textarea.value.substring(end);
textarea.focus();
}
</script>
{% endblock %}

View file

@ -0,0 +1,469 @@
{% extends 'base.html.twig' %}
{% block title %}پرسیدن سوال جدید - پرسش و پاسخ{% endblock %}
{% block body %}
<div class="container my-4">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h3 class="mb-0">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>پرسیدن سوال جدید
</h3>
</div>
<div class="card-body">
{{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }}
<div class="mb-4">
{{ form_label(form.title) }}
{{ form_widget(form.title) }}
{{ form_errors(form.title) }}
<div class="form-text">
عنوان سوال باید واضح و مختصر باشد. سعی کنید مشکل خود را در یک جمله خلاصه کنید.
</div>
</div>
<div class="mb-4">
{{ form_label(form.content) }}
<div class="editor-toolbar mb-2">
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="formatText('bold')" title="پررنگ">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
</svg>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="formatText('italic')" title="کج">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="4" x2="10" y2="4"></line>
<line x1="14" y1="20" x2="5" y2="20"></line>
<line x1="15" y1="4" x2="9" y2="20"></line>
</svg>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="formatText('code')" title="کد">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16,18 22,12 16,6"></polyline>
<polyline points="8,6 2,12 8,18"></polyline>
</svg>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="insertList()" title="لیست">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
</button>
</div>
{{ form_widget(form.content, {'attr': {'class': 'form-control editor-textarea', 'rows': 10}}) }}
{{ form_errors(form.content) }}
<div class="form-text">
سوال خود را به تفصیل شرح دهید. هرچه جزئیات بیشتری ارائه دهید، پاسخ‌های بهتری دریافت خواهید کرد.
<br><small class="text-muted">می‌توانید از دکمه‌های بالا برای فرمت کردن متن استفاده کنید.</small>
</div>
</div>
<div class="mb-4">
<label class="form-label">تگ‌ها</label>
<div class="tags-container">
<div class="selected-tags mb-2" id="selected-tags"></div>
<div class="input-group">
<input type="text" class="form-control" id="tag-input" placeholder="تگ جدید را وارد کنید یا از لیست انتخاب کنید...">
<button class="btn btn-outline-primary" type="button" id="add-tag-btn">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
<div class="available-tags mt-2" id="available-tags">
<small class="text-muted">تگ‌های موجود:</small>
<div class="tag-suggestions mt-1">
{% for tag in availableTags %}
<span class="badge bg-light text-dark me-1 mb-1 tag-suggestion" data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.name }}">
{{ tag.name }}
</span>
{% endfor %}
</div>
</div>
</div>
<div class="form-text">
تگ‌های مرتبط را انتخاب کنید تا دیگران راحت‌تر بتوانند سوال شما را پیدا کنند.
<br><small class="text-muted">می‌توانید تگ جدید ایجاد کنید یا از تگ‌های موجود انتخاب کنید.</small>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{{ path('qa_index') }}" class="btn btn-outline-secondary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-2">
<path d="M19 12H5"></path>
<polyline points="12,19 5,12 12,5"></polyline>
</svg>انصراف
</a>
<button type="submit" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-2">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22,2 15,22 11,13 2,9 22,2"></polygon>
</svg>ارسال سوال
</button>
</div>
{{ form_end(form) }}
</div>
</div>
<!-- راهنمای پرسیدن سوال -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-2">
<path d="M9 12l2 2 4-4"></path>
<path d="M21 12c.552 0 1-.448 1-1s-.448-1-1-1-1 .448-1 1 .448 1 1 1z"></path>
<path d="M3 12c.552 0 1-.448 1-1s-.448-1-1-1-1 .448-1 1 .448 1 1 1z"></path>
<path d="M12 3c.552 0 1-.448 1-1s-.448-1-1-1-1 .448-1 1 .448 1 1 1z"></path>
<path d="M12 21c.552 0 1-.448 1-1s-.448-1-1-1-1 .448-1 1 .448 1 1 1z"></path>
</svg>راهنمای پرسیدن سوال خوب
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-success">✅ کارهای درست:</h6>
<ul class="list-unstyled">
<li><i class="fas fa-check text-success me-2"></i>عنوان واضح و مختصر</li>
<li><i class="fas fa-check text-success me-2"></i>شرح کامل مشکل</li>
<li><i class="fas fa-check text-success me-2"></i>انتخاب تگ‌های مناسب</li>
<li><i class="fas fa-check text-success me-2"></i>استفاده از کلمات کلیدی</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-danger">❌ کارهای نادرست:</h6>
<ul class="list-unstyled">
<li><i class="fas fa-times text-danger me-2"></i>عنوان مبهم</li>
<li><i class="fas fa-times text-danger me-2"></i>شرح ناکافی</li>
<li><i class="fas fa-times text-danger me-2"></i>عدم انتخاب تگ</li>
<li><i class="fas fa-times text-danger me-2"></i>سوال تکراری</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.form-control:focus, .form-select:focus {
border-color: #0d6efd;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
.card {
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border: none;
}
.card-header {
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.form-text {
font-size: 0.9rem;
color: #6c757d;
}
.list-unstyled li {
margin-bottom: 0.5rem;
}
.editor-toolbar {
border: 1px solid #dee2e6;
border-bottom: none;
border-radius: 0.375rem 0.375rem 0 0;
padding: 0.5rem;
background-color: #f8f9fa;
}
.editor-textarea {
border-radius: 0 0 0.375rem 0.375rem;
border-top: none;
}
.tags-container {
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 1rem;
background-color: #f8f9fa;
}
.selected-tags {
min-height: 40px;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.tag-item {
display: inline-flex;
align-items: center;
background-color: #0d6efd;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.875rem;
}
.tag-remove {
background: none;
border: none;
color: white;
margin-left: 0.5rem;
cursor: pointer;
padding: 0;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.tag-suggestion {
cursor: pointer;
transition: all 0.3s ease;
}
.tag-suggestion:hover {
background-color: #0d6efd !important;
color: white !important;
}
.tag-suggestion.selected {
background-color: #0d6efd !important;
color: white !important;
}
</style>
<script>
let selectedTags = [];
function initializeQuestionForm() {
// ادیتور متن
const textarea = document.querySelector('.editor-textarea');
// سیستم تگ‌ها
const tagInput = document.getElementById('tag-input');
const addTagBtn = document.getElementById('add-tag-btn');
const selectedTagsContainer = document.getElementById('selected-tags');
const tagSuggestions = document.querySelectorAll('.tag-suggestion');
// اضافه کردن تگ
function addTag(tagId, tagName) {
if (selectedTags.find(tag => tag.id === tagId)) {
return;
}
selectedTags.push({ id: tagId, name: tagName });
renderSelectedTags();
updateHiddenInput();
console.log('Tag added:', { id: tagId, name: tagName });
}
// حذف تگ
window.removeTag = function(tagId) {
selectedTags = selectedTags.filter(tag => tag.id !== tagId);
renderSelectedTags();
updateHiddenInput();
}
// نمایش تگ‌های انتخاب شده
function renderSelectedTags() {
selectedTagsContainer.innerHTML = '';
selectedTags.forEach(tag => {
const tagElement = document.createElement('span');
tagElement.className = 'tag-item';
tagElement.innerHTML = `
${tag.name}
<button type="button" class="tag-remove" data-tag-id="${tag.id}">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
`;
// اضافه کردن event listener برای دکمه حذف
const removeBtn = tagElement.querySelector('.tag-remove');
removeBtn.addEventListener('click', function() {
const tagId = this.dataset.tagId;
window.removeTag(tagId);
// حذف کلاس selected از تگ پیشنهادی
const suggestion = document.querySelector(`[data-tag-id="${tagId}"]`);
if (suggestion) {
suggestion.classList.remove('selected');
}
});
selectedTagsContainer.appendChild(tagElement);
});
}
// به‌روزرسانی input مخفی
function updateHiddenInput() {
// حذف تمام input های قبلی
const existingInputs = document.querySelectorAll('input[name="question[tags][]"]');
existingInputs.forEach(input => input.remove());
// اضافه کردن input های جدید
selectedTags.forEach(tag => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'question[tags][]';
input.value = tag.id;
document.querySelector('form').appendChild(input);
});
console.log('Selected tags:', selectedTags);
}
// کلیک روی تگ‌های پیشنهادی
tagSuggestions.forEach(suggestion => {
suggestion.addEventListener('click', function() {
const tagId = this.dataset.tagId;
const tagName = this.dataset.tagName;
if (selectedTags.find(tag => tag.id === tagId)) {
window.removeTag(tagId);
this.classList.remove('selected');
} else {
addTag(tagId, tagName);
this.classList.add('selected');
}
});
});
// اضافه کردن تگ جدید
addTagBtn.addEventListener('click', function() {
const tagName = tagInput.value.trim();
if (tagName) {
// بررسی وجود تگ
const existingTag = Array.from(tagSuggestions).find(suggestion =>
suggestion.dataset.tagName.toLowerCase() === tagName.toLowerCase()
);
if (existingTag) {
const tagId = existingTag.dataset.tagId;
const tagName = existingTag.dataset.tagName;
addTag(tagId, tagName);
existingTag.classList.add('selected');
} else {
// ایجاد تگ جدید
createNewTag(tagName);
}
tagInput.value = '';
}
});
// Enter برای اضافه کردن تگ
tagInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addTagBtn.click();
}
});
// ایجاد تگ جدید
function createNewTag(tagName) {
fetch('/qa/tag/create', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: `name=${encodeURIComponent(tagName)}&_token={{ csrf_token('vote') }}`
})
.then(response => response.json())
.then(data => {
if (data.error) {
alert(data.error);
return;
}
// اضافه کردن تگ به لیست انتخاب شده
addTag(data.id, data.name);
// اضافه کردن تگ به لیست پیشنهادی
const suggestionElement = document.createElement('span');
suggestionElement.className = 'badge bg-light text-dark me-1 mb-1 tag-suggestion selected';
suggestionElement.dataset.tagId = data.id;
suggestionElement.dataset.tagName = data.name;
suggestionElement.textContent = data.name;
suggestionElement.addEventListener('click', function() {
if (selectedTags.find(tag => tag.id === data.id)) {
removeTag(data.id);
this.classList.remove('selected');
} else {
addTag(data.id, data.name);
this.classList.add('selected');
}
});
document.querySelector('.tag-suggestions').appendChild(suggestionElement);
})
.catch(error => {
console.error('Error:', error);
alert('خطا در ایجاد تگ جدید');
});
}
});
// توابع ادیتور متن
function formatText(command) {
const textarea = document.querySelector('.editor-textarea');
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end);
let formattedText = '';
switch(command) {
case 'bold':
formattedText = `**${selectedText}**`;
break;
case 'italic':
formattedText = `*${selectedText}*`;
break;
case 'code':
formattedText = `\`${selectedText}\``;
break;
}
textarea.value = textarea.value.substring(0, start) + formattedText + textarea.value.substring(end);
textarea.focus();
textarea.setSelectionRange(start + formattedText.length, start + formattedText.length);
}
function insertList() {
const textarea = document.querySelector('.editor-textarea');
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end);
const listText = selectedText.split('\n').map(line => `- ${line}`).join('\n');
textarea.value = textarea.value.substring(0, start) + listText + textarea.value.substring(end);
textarea.focus();
}
// اجرا در هر دو حالت
document.addEventListener('DOMContentLoaded', initializeQuestionForm);
document.addEventListener('turbo:load', initializeQuestionForm);
</script>
{% endblock %}

View file

@ -0,0 +1,306 @@
{% extends 'base.html.twig' %}
{% block title %}پرسش و پاسخ - حسابیکس{% endblock %}
{% block body %}
<div class="container my-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="text-primary fw-bold">پرسش و پاسخ</h1>
{% if app.user and 'ROLE_CUSTOMER' in app.user.roles %}
<a href="{{ path('qa_ask') }}" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>سوال جدید
</a>
{% else %}
<a href="{{ path('customer_login') }}" class="btn btn-outline-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
<polyline points="10,17 15,12 10,7"></polyline>
<line x1="15" y1="12" x2="3" y2="12"></line>
</svg>ورود برای پرسیدن سوال
</a>
{% endif %}
</div>
<!-- فیلترها و جستجو -->
<div class="card mb-4">
<div class="card-body">
<form method="GET" class="row g-3">
<div class="col-md-4">
<label for="filter" class="form-label">فیلتر:</label>
<select name="filter" id="filter" class="form-select">
<option value="all" {{ currentFilter == 'all' ? 'selected' : '' }}>همه سوالات</option>
<option value="unsolved" {{ currentFilter == 'unsolved' ? 'selected' : '' }}>سوالات حل نشده</option>
<option value="solved" {{ currentFilter == 'solved' ? 'selected' : '' }}>سوالات حل شده</option>
<option value="popular" {{ currentFilter == 'popular' ? 'selected' : '' }}>محبوب‌ترین</option>
</select>
</div>
<div class="col-md-6">
<label for="search" class="form-label">جستجو:</label>
<input type="text" name="search" id="search" class="form-control"
value="{{ currentSearch }}" placeholder="جستجو در سوالات...">
</div>
<div class="col-md-2">
<label class="form-label">&nbsp;</label>
<button type="submit" class="btn btn-primary w-100">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-1">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>جستجو
</button>
</div>
</form>
</div>
</div>
<div class="row">
<!-- لیست سوالات -->
<div class="col-lg-9">
{% if questions is empty %}
<div class="card">
<div class="card-body text-center py-5">
<i class="fas fa-question-circle fa-3x text-muted mb-3"></i>
<h4 class="text-muted">سوالی یافت نشد</h4>
<p class="text-muted">هنوز سوالی در این دسته‌بندی وجود ندارد.</p>
{% if app.user and 'ROLE_CUSTOMER' in app.user.roles %}
<a href="{{ path('qa_ask') }}" class="btn btn-primary">اولین سوال را بپرسید</a>
{% endif %}
</div>
</div>
{% else %}
{% for question in questions %}
<div class="card mb-3 question-card">
<div class="card-body">
<div class="row">
<div class="col-2 col-md-1 text-center">
<div class="d-flex flex-column align-items-center">
<div class="vote-count {{ question.votes > 0 ? 'text-success' : (question.votes < 0 ? 'text-danger' : 'text-muted') }}">
{{ question.votes }}
</div>
<small class="text-muted">رای</small>
</div>
</div>
<div class="col-2 col-md-1 text-center">
<div class="d-flex flex-column align-items-center">
<div class="answer-count {{ question.answers|length > 0 ? 'text-success' : 'text-muted' }}">
{{ question.answers|length }}
</div>
<small class="text-muted">پاسخ</small>
</div>
</div>
<div class="col-8 col-md-10">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">
<a href="{{ path('qa_question_show', {'id': question.id}) }}"
class="text-decoration-none text-dark">
{{ question.title }}
</a>
</h5>
{% if question.isSolved %}
<span class="badge bg-success">
<i class="fas fa-check me-1"></i>حل شده
</span>
{% endif %}
</div>
<div class="card-text text-muted mb-2 question-preview">
{% set content = question.content|markdown|raw %}
{% set plainText = content|striptags %}
{% if plainText|length > 200 %}
{{ plainText|slice(0, 200) }}...
{% else %}
{{ plainText }}
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center">
<div class="tags">
{% for tagRelation in question.tagRelations %}
<a href="{{ path('qa_tag_questions', {'name': tagRelation.tag.name}) }}"
class="badge bg-light text-dark text-decoration-none me-1">
{{ tagRelation.tag.name }}
</a>
{% endfor %}
</div>
<div class="text-muted small">
<i class="fas fa-user me-1"></i>{{ question.author.name }}
<i class="fas fa-clock ms-3 me-1"></i>{{ question.createdAt|date('Y/m/d H:i') }}
<i class="fas fa-eye ms-3 me-1"></i>{{ question.views }}
</div>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
<!-- صفحه‌بندی -->
{% if totalPages > 1 %}
<nav aria-label="صفحه‌بندی سوالات">
<ul class="pagination justify-content-center">
{% if currentPage > 1 %}
<li class="page-item">
<a class="page-link" href="?page={{ currentPage - 1 }}{{ currentFilter != 'all' ? '&filter=' ~ currentFilter : '' }}{{ currentSearch ? '&search=' ~ currentSearch : '' }}{{ currentTag ? '&tag=' ~ currentTag : '' }}">قبلی</a>
</li>
{% endif %}
{% for page in 1..totalPages %}
{% if page == currentPage %}
<li class="page-item active">
<span class="page-link">{{ page }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="?page={{ page }}{{ currentFilter != 'all' ? '&filter=' ~ currentFilter : '' }}{{ currentSearch ? '&search=' ~ currentSearch : '' }}{{ currentTag ? '&tag=' ~ currentTag : '' }}">{{ page }}</a>
</li>
{% endif %}
{% endfor %}
{% if currentPage < totalPages %}
<li class="page-item">
<a class="page-link" href="?page={{ currentPage + 1 }}{{ currentFilter != 'all' ? '&filter=' ~ currentFilter : '' }}{{ currentSearch ? '&search=' ~ currentSearch : '' }}{{ currentTag ? '&tag=' ~ currentTag : '' }}">بعدی</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endif %}
</div>
<!-- سایدبار -->
<div class="col-lg-3">
<!-- تگ‌های محبوب -->
<div class="card mb-4">
<div class="card-header">
<h6 class="mb-0">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-2">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
<line x1="7" y1="7" x2="7.01" y2="7"></line>
</svg>تگ‌های محبوب
</h6>
</div>
<div class="card-body">
{% for tag in popularTags %}
<a href="{{ path('qa_tag_questions', {'name': tag.name}) }}"
class="badge bg-light text-dark text-decoration-none me-1 mb-2 d-inline-block">
{{ tag.name }}
<span class="text-muted">({{ tag.usageCount }})</span>
</a>
{% endfor %}
<div class="mt-3">
<a href="{{ path('qa_tags') }}" class="btn btn-outline-primary btn-sm">
مشاهده همه تگ‌ها
</a>
</div>
</div>
</div>
<!-- آمار -->
<div class="card">
<div class="card-header">
<h6 class="mb-0">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-2">
<path d="M18 20V10"></path>
<path d="M12 20V4"></path>
<path d="M6 20v-6"></path>
</svg>آمار
</h6>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6">
<div class="h4 text-primary">{{ questions|length }}</div>
<small class="text-muted">سوال</small>
</div>
<div class="col-6">
<div class="h4 text-success">{{ popularTags|length }}</div>
<small class="text-muted">تگ</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.question-card {
transition: all 0.3s ease;
border-left: 4px solid transparent;
}
.question-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
border-left-color: #0d6efd;
}
.vote-count, .answer-count {
font-size: 1.2rem;
font-weight: bold;
}
.tags .badge {
font-size: 0.8rem;
transition: all 0.3s ease;
}
.tags .badge:hover {
background-color: #0d6efd !important;
color: white !important;
}
.card-title a:hover {
color: #0d6efd !important;
}
.question-preview {
line-height: 1.6;
max-height: 4.8em; /* حدود 3 خط */
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.question-card {
transition: all 0.3s ease;
border-left: 4px solid transparent;
}
.question-card:hover {
border-left-color: #0d6efd;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.question-stats {
font-size: 0.9rem;
color: #6c757d;
}
.question-stats .stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem;
}
.question-stats .stat-number {
font-weight: bold;
font-size: 1.1rem;
color: #495057;
}
.question-stats .stat-label {
font-size: 0.8rem;
margin-top: 0.25rem;
}
</style>
{% endblock %}

View file

@ -0,0 +1,341 @@
{% extends 'base.html.twig' %}
{% block title %}{{ question.title }} - پرسش و پاسخ{% endblock %}
{% block body %}
<div class="container my-4">
<div class="row">
<div class="col-12">
<!-- سوال -->
<div class="card mb-4">
<div class="card-body">
<div class="row">
<div class="col-1 text-center">
<div class="vote-section">
<button class="btn btn-outline-success btn-sm vote-btn"
data-type="question"
data-id="{{ question.id }}"
data-upvote="true"
{% if not app.user or 'ROLE_CUSTOMER' not in app.user.roles %}disabled{% endif %}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="18,15 12,9 6,15"></polyline>
</svg>
</button>
<div class="vote-count mt-2 mb-2" id="question-votes-{{ question.id }}">
{{ question.votes }}
</div>
<button class="btn btn-outline-danger btn-sm vote-btn"
data-type="question"
data-id="{{ question.id }}"
data-upvote="false"
{% if not app.user or 'ROLE_CUSTOMER' not in app.user.roles %}disabled{% endif %}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6,9 12,15 18,9"></polyline>
</svg>
</button>
</div>
</div>
<div class="col-11">
<div class="d-flex justify-content-between align-items-start mb-3">
<h1 class="card-title mb-0">{{ question.title }}</h1>
{% if question.isSolved %}
<span class="badge bg-success fs-6">
<i class="fas fa-check me-1"></i>حل شده
</span>
{% endif %}
</div>
<div class="question-content mb-3">
{{ question.content|markdown|raw }}
</div>
<div class="d-flex justify-content-between align-items-center">
<div class="tags">
{% for tagRelation in question.tagRelations %}
<a href="{{ path('qa_tag_questions', {'name': tagRelation.tag.name}) }}"
class="badge bg-light text-dark text-decoration-none me-1">
{{ tagRelation.tag.name }}
</a>
{% endfor %}
</div>
<div class="text-muted small">
<i class="fas fa-user me-1"></i>{{ question.author.name }}
<i class="fas fa-clock ms-3 me-1"></i>{{ question.createdAt|date('Y/m/d H:i') }}
<i class="fas fa-eye ms-3 me-1"></i>{{ question.views }} بازدید
</div>
</div>
</div>
</div>
</div>
</div>
<!-- پاسخ‌ها -->
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h3>
<i class="fas fa-comments me-2"></i>پاسخ‌ها
<span class="badge bg-primary">{{ totalAnswers }}</span>
</h3>
{% if app.user and 'ROLE_CUSTOMER' in app.user.roles %}
<a href="{{ path('qa_answer', {'id': question.id}) }}" class="btn btn-primary">
<i class="fas fa-plus me-2"></i>پاسخ دهید
</a>
{% else %}
<a href="{{ path('customer_login') }}" class="btn btn-outline-primary">
<i class="fas fa-sign-in-alt me-2"></i>ورود برای پاسخ دادن
</a>
{% endif %}
</div>
{% if answers is empty %}
<div class="card">
<div class="card-body text-center py-5">
<i class="fas fa-comment-slash fa-3x text-muted mb-3"></i>
<h4 class="text-muted">هنوز پاسخی داده نشده</h4>
<p class="text-muted">اولین کسی باشید که به این سوال پاسخ می‌دهد.</p>
</div>
</div>
{% else %}
{% for answer in answers %}
<div class="card mb-3 answer-card {{ answer.isAccepted ? 'border-success' : '' }}">
<div class="card-body">
<div class="row">
<div class="col-1 text-center">
<div class="vote-section">
<button class="btn btn-outline-success btn-sm vote-btn"
data-type="answer"
data-id="{{ answer.id }}"
data-upvote="true"
{% if not app.user or 'ROLE_CUSTOMER' not in app.user.roles %}disabled{% endif %}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="18,15 12,9 6,15"></polyline>
</svg>
</button>
<div class="vote-count mt-2 mb-2" id="answer-votes-{{ answer.id }}">
{{ answer.votes }}
</div>
<button class="btn btn-outline-danger btn-sm vote-btn"
data-type="answer"
data-id="{{ answer.id }}"
data-upvote="false"
{% if not app.user or 'ROLE_CUSTOMER' not in app.user.roles %}disabled{% endif %}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6,9 12,15 18,9"></polyline>
</svg>
</button>
</div>
</div>
<div class="col-11">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="answer-content">
{{ answer.content|markdown|raw }}
</div>
{% if answer.isAccepted %}
<span class="badge bg-success ms-2">
<i class="fas fa-check me-1"></i>پاسخ پذیرفته شده
</span>
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center">
<div class="text-muted small">
<i class="fas fa-user me-1"></i>{{ answer.author.name }}
<i class="fas fa-clock ms-3 me-1"></i>{{ answer.createdAt|date('Y/m/d H:i') }}
</div>
{% if app.user and app.user == question.author and not answer.isAccepted %}
<button class="btn btn-outline-success btn-sm accept-answer-btn"
data-answer-id="{{ answer.id }}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-1">
<polyline points="20,6 9,17 4,12"></polyline>
</svg>پذیرفتن پاسخ
</button>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endfor %}
<!-- صفحه‌بندی پاسخ‌ها -->
{% if totalPages > 1 %}
<nav aria-label="صفحه‌بندی پاسخ‌ها" class="mt-4">
<ul class="pagination justify-content-center">
{% if currentPage > 1 %}
<li class="page-item">
<a class="page-link" href="?page={{ currentPage - 1 }}">قبلی</a>
</li>
{% endif %}
{% for page in 1..totalPages %}
{% if page == currentPage %}
<li class="page-item active">
<span class="page-link">{{ page }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="?page={{ page }}">{{ page }}</a>
</li>
{% endif %}
{% endfor %}
{% if currentPage < totalPages %}
<li class="page-item">
<a class="page-link" href="?page={{ currentPage + 1 }}">بعدی</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endif %}
</div>
<!-- دکمه بازگشت -->
<div class="text-center">
<a href="{{ path('qa_index') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-right me-2"></i>بازگشت به لیست سوالات
</a>
</div>
</div>
</div>
</div>
<style>
.vote-section {
display: flex;
flex-direction: column;
align-items: center;
}
.vote-btn {
width: 30px;
height: 30px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.vote-count {
font-size: 1.1rem;
font-weight: bold;
min-width: 30px;
text-align: center;
}
.answer-card.border-success {
border-left: 4px solid #198754 !important;
}
.question-content, .answer-content {
line-height: 1.6;
font-size: 1.1rem;
}
.tags .badge {
font-size: 0.9rem;
transition: all 0.3s ease;
}
.tags .badge:hover {
background-color: #0d6efd !important;
color: white !important;
}
</style>
<script>
function initializeVoteButtons() {
// رای‌دهی
document.querySelectorAll('.vote-btn').forEach(button => {
button.addEventListener('click', function() {
if (this.disabled) return;
const type = this.dataset.type;
const id = this.dataset.id;
const upvote = this.dataset.upvote === 'true';
fetch(`/qa/${type}/${id}/vote`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: `upvote=${upvote}&_token={{ csrf_token('vote') }}`
})
.then(response => response.json())
.then(data => {
if (data.error) {
alert(data.error);
return;
}
document.getElementById(`${type}-votes-${id}`).textContent = data.votes;
// تغییر رنگ دکمه‌ها
const buttons = document.querySelectorAll(`[data-type="${type}"][data-id="${id}"]`);
buttons.forEach(btn => {
btn.classList.remove('btn-success', 'btn-danger', 'btn-outline-success', 'btn-outline-danger');
btn.classList.add(btn.dataset.upvote === 'true' ? 'btn-outline-success' : 'btn-outline-danger');
});
if (data.userVote) {
const activeButton = document.querySelector(`[data-type="${type}"][data-id="${id}"][data-upvote="${data.userVote === 'up' ? 'true' : 'false'}"]`);
if (activeButton) {
activeButton.classList.remove('btn-outline-success', 'btn-outline-danger');
activeButton.classList.add(data.userVote === 'up' ? 'btn-success' : 'btn-danger');
}
} else {
// اگر رای حذف شده، همه دکمه‌ها را به حالت عادی برگردان
buttons.forEach(btn => {
btn.classList.remove('btn-success', 'btn-danger');
btn.classList.add(btn.dataset.upvote === 'true' ? 'btn-outline-success' : 'btn-outline-danger');
});
}
})
.catch(error => {
console.error('Error:', error);
alert('خطا در ارسال رای');
});
});
});
// پذیرفتن پاسخ
document.querySelectorAll('.accept-answer-btn').forEach(button => {
button.addEventListener('click', function() {
const answerId = this.dataset.answerId;
if (!confirm('آیا مطمئن هستید که می‌خواهید این پاسخ را بپذیرید؟')) {
return;
}
fetch(`/qa/answer/${answerId}/accept`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: `_token={{ csrf_token('accept') }}`
})
.then(response => response.json())
.then(data => {
if (data.error) {
alert(data.error);
return;
}
location.reload();
})
.catch(error => {
console.error('Error:', error);
alert('خطا در پذیرفتن پاسخ');
});
});
});
}
// اجرا در هر دو حالت
document.addEventListener('DOMContentLoaded', initializeVoteButtons);
document.addEventListener('turbo:load', initializeVoteButtons);
</script>
{% endblock %}

View file

@ -0,0 +1,237 @@
{% extends 'base.html.twig' %}
{% block title %}سوالات تگ {{ tag.name }} - پرسش و پاسخ{% endblock %}
{% block body %}
<div class="container my-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="text-primary fw-bold mb-2">
<i class="fas fa-tag me-2"></i>سوالات تگ "{{ tag.name }}"
</h1>
{% if tag.description %}
<p class="text-muted mb-0">{{ tag.description }}</p>
{% endif %}
</div>
<a href="{{ path('qa_index') }}" class="btn btn-outline-primary">
<i class="fas fa-arrow-right me-2"></i>بازگشت به همه سوالات
</a>
</div>
<div class="row">
<div class="col-lg-8">
{% if questions is empty %}
<div class="card">
<div class="card-body text-center py-5">
<i class="fas fa-question-circle fa-3x text-muted mb-3"></i>
<h4 class="text-muted">سوالی یافت نشد</h4>
<p class="text-muted">هنوز سوالی با تگ "{{ tag.name }}" وجود ندارد.</p>
{% if app.user and 'ROLE_CUSTOMER' in app.user.roles %}
<a href="{{ path('qa_ask') }}" class="btn btn-primary">اولین سوال را بپرسید</a>
{% endif %}
</div>
</div>
{% else %}
{% for question in questions %}
<div class="card mb-3 question-card">
<div class="card-body">
<div class="row">
<div class="col-2 col-md-1 text-center">
<div class="d-flex flex-column align-items-center">
<div class="vote-count {{ question.votes > 0 ? 'text-success' : (question.votes < 0 ? 'text-danger' : 'text-muted') }}">
{{ question.votes }}
</div>
<small class="text-muted">رای</small>
</div>
</div>
<div class="col-2 col-md-1 text-center">
<div class="d-flex flex-column align-items-center">
<div class="answer-count {{ question.answers|length > 0 ? 'text-success' : 'text-muted' }}">
{{ question.answers|length }}
</div>
<small class="text-muted">پاسخ</small>
</div>
</div>
<div class="col-8 col-md-10">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">
<a href="{{ path('qa_question_show', {'id': question.id}) }}"
class="text-decoration-none text-dark">
{{ question.title }}
</a>
</h5>
{% if question.isSolved %}
<span class="badge bg-success">
<i class="fas fa-check me-1"></i>حل شده
</span>
{% endif %}
</div>
<p class="card-text text-muted mb-2">
{% set content = question.content|markdown|raw %}
{% set plainText = content|striptags %}
{% if plainText|length > 200 %}
{{ plainText|slice(0, 200) }}...
{% else %}
{{ plainText }}
{% endif %}
</p>
<div class="d-flex justify-content-between align-items-center">
<div class="tags">
{% for tagRelation in question.tagRelations %}
<a href="{{ path('qa_tag_questions', {'name': tagRelation.tag.name}) }}"
class="badge bg-light text-dark text-decoration-none me-1 {{ tagRelation.tag.name == tag.name ? 'bg-primary text-white' : '' }}">
{{ tagRelation.tag.name }}
</a>
{% endfor %}
</div>
<div class="text-muted small">
<i class="fas fa-user me-1"></i>{{ question.author.name }}
<i class="fas fa-clock ms-3 me-1"></i>{{ question.createdAt|date('Y/m/d H:i') }}
<i class="fas fa-eye ms-3 me-1"></i>{{ question.views }}
</div>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
<!-- صفحه‌بندی -->
{% if totalPages > 1 %}
<nav aria-label="صفحه‌بندی سوالات">
<ul class="pagination justify-content-center">
{% if currentPage > 1 %}
<li class="page-item">
<a class="page-link" href="?page={{ currentPage - 1 }}">قبلی</a>
</li>
{% endif %}
{% for page in 1..totalPages %}
{% if page == currentPage %}
<li class="page-item active">
<span class="page-link">{{ page }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="?page={{ page }}">{{ page }}</a>
</li>
{% endif %}
{% endfor %}
{% if currentPage < totalPages %}
<li class="page-item">
<a class="page-link" href="?page={{ currentPage + 1 }}">بعدی</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endif %}
</div>
<!-- سایدبار -->
<div class="col-lg-4">
<!-- اطلاعات تگ -->
<div class="card mb-4">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-info-circle me-2"></i>اطلاعات تگ
</h6>
</div>
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<span class="badge bg-primary fs-6 me-2">{{ tag.name }}</span>
{% if tag.color %}
<div class="tag-color" style="background-color: {{ tag.color }}; width: 20px; height: 20px; border-radius: 50%; border: 2px solid #dee2e6;"></div>
{% endif %}
</div>
{% if tag.description %}
<p class="text-muted mb-3">{{ tag.description }}</p>
{% endif %}
<div class="row text-center">
<div class="col-6">
<div class="h4 text-primary">{{ questions|length }}</div>
<small class="text-muted">سوال</small>
</div>
<div class="col-6">
<div class="h4 text-success">{{ tag.usageCount }}</div>
<small class="text-muted">استفاده</small>
</div>
</div>
</div>
</div>
<!-- تگ‌های مرتبط -->
<div class="card">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-tags me-2"></i>تگ‌های مرتبط
</h6>
</div>
<div class="card-body">
<p class="text-muted small mb-3">تگ‌های مشابه که ممکن است برای شما مفید باشند:</p>
<!-- در اینجا می‌توانید تگ‌های مرتبط را نمایش دهید -->
<div class="text-center">
<a href="{{ path('qa_tags') }}" class="btn btn-outline-primary btn-sm">
مشاهده همه تگ‌ها
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.question-card {
transition: all 0.3s ease;
border-left: 4px solid transparent;
}
.question-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
border-left-color: #0d6efd;
}
.vote-count, .answer-count {
font-size: 1.2rem;
font-weight: bold;
}
.tags .badge {
font-size: 0.8rem;
transition: all 0.3s ease;
}
.tags .badge:hover {
background-color: #0d6efd !important;
color: white !important;
}
.card-title a:hover {
color: #0d6efd !important;
}
.tag-color {
border: 2px solid #dee2e6;
}
.question-preview {
line-height: 1.6;
max-height: 4.8em; /* حدود 3 خط */
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
</style>
{% endblock %}

View file

@ -0,0 +1,87 @@
{% extends 'base.html.twig' %}
{% block title %}تگ‌ها - پرسش و پاسخ{% endblock %}
{% block body %}
<div class="container my-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="text-primary fw-bold">
<i class="fas fa-tags me-2"></i>تگ‌ها
</h1>
<a href="{{ path('qa_index') }}" class="btn btn-outline-primary">
<i class="fas fa-arrow-right me-2"></i>بازگشت به سوالات
</a>
</div>
{% if tags is empty %}
<div class="card">
<div class="card-body text-center py-5">
<i class="fas fa-tags fa-3x text-muted mb-3"></i>
<h4 class="text-muted">تگی یافت نشد</h4>
<p class="text-muted">هنوز تگی در سیستم ثبت نشده است.</p>
</div>
</div>
{% else %}
<div class="row">
{% for tag in tags %}
<div class="col-lg-4 col-md-6 mb-4">
<div class="card tag-card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<h5 class="card-title mb-0">
<a href="{{ path('qa_tag_questions', {'name': tag.name}) }}"
class="text-decoration-none text-dark">
{{ tag.name }}
</a>
</h5>
<span class="badge bg-primary">{{ tag.usageCount }}</span>
</div>
{% if tag.description %}
<p class="card-text text-muted mb-3">
{{ tag.description }}
</p>
{% endif %}
<div class="d-flex justify-content-between align-items-center">
<a href="{{ path('qa_tag_questions', {'name': tag.name}) }}"
class="btn btn-outline-primary btn-sm">
<i class="fas fa-eye me-1"></i>مشاهده سوالات
</a>
{% if tag.color %}
<div class="tag-color" style="background-color: {{ tag.color }}; width: 20px; height: 20px; border-radius: 50%;"></div>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
<style>
.tag-card {
transition: all 0.3s ease;
border-left: 4px solid transparent;
}
.tag-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
border-left-color: #0d6efd;
}
.tag-card .card-title a:hover {
color: #0d6efd !important;
}
.tag-color {
border: 2px solid #dee2e6;
}
</style>
{% endblock %}