This commit is contained in:
parent
b0d8761d7f
commit
06a2fb398d
160
WALLET_INTEGRATION.md
Normal file
160
WALLET_INTEGRATION.md
Normal 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 را بررسی کنید
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
if (ftpStep2) {
|
||||
ftpStep2.classList.remove('step-hidden');
|
||||
}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
321
assets/controllers/wallet_connect_controller.js
Normal file
321
assets/controllers/wallet_connect_controller.js
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
128
composer.lock
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,3 +9,4 @@ framework:
|
|||
- submit
|
||||
- authenticate
|
||||
- logout
|
||||
enabled: true
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
59
migrations/Version20250904051657.php
Normal file
59
migrations/Version20250904051657.php
Normal 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');
|
||||
}
|
||||
}
|
||||
110
migrations/Version20250905042717.php
Normal file
110
migrations/Version20250905042717.php
Normal 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');
|
||||
}
|
||||
}
|
||||
349
migrations/Version20250905043249.php
Normal file
349
migrations/Version20250905043249.php
Normal 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');
|
||||
}
|
||||
}
|
||||
33
migrations/Version20250905050647.php
Normal file
33
migrations/Version20250905050647.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
4
public/img/icons/wallet.svg
Normal file
4
public/img/icons/wallet.svg
Normal 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 |
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
429
src/Controller/QA/QAController.php
Normal file
429
src/Controller/QA/QAController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
116
src/Controller/WalletController.php
Normal file
116
src/Controller/WalletController.php
Normal 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
180
src/Entity/Answer.php
Normal 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
85
src/Entity/AnswerVote.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
284
src/Entity/Question.php
Normal 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
149
src/Entity/QuestionTag.php
Normal 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;
|
||||
}
|
||||
}
|
||||
52
src/Entity/QuestionTagRelation.php
Normal file
52
src/Entity/QuestionTagRelation.php
Normal 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;
|
||||
}
|
||||
}
|
||||
85
src/Entity/QuestionVote.php
Normal file
85
src/Entity/QuestionVote.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
177
src/Entity/Wallet.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
40
src/Form/QA/AnswerFormType.php
Normal file
40
src/Form/QA/AnswerFormType.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
60
src/Form/QA/QuestionFormType.php
Normal file
60
src/Form/QA/QuestionFormType.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
73
src/Form/WalletConnectFormType.php
Normal file
73
src/Form/WalletConnectFormType.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
58
src/Repository/AnswerRepository.php
Normal file
58
src/Repository/AnswerRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
29
src/Repository/AnswerVoteRepository.php
Normal file
29
src/Repository/AnswerVoteRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
170
src/Repository/QuestionRepository.php
Normal file
170
src/Repository/QuestionRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
18
src/Repository/QuestionTagRelationRepository.php
Normal file
18
src/Repository/QuestionTagRelationRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
62
src/Repository/QuestionTagRepository.php
Normal file
62
src/Repository/QuestionTagRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
29
src/Repository/QuestionVoteRepository.php
Normal file
29
src/Repository/QuestionVoteRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
125
src/Repository/WalletRepository.php
Normal file
125
src/Repository/WalletRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
21
src/Service/MarkdownService.php
Normal file
21
src/Service/MarkdownService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
232
src/Service/WalletService.php
Normal file
232
src/Service/WalletService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
40
src/Twig/MarkdownExtension.php
Normal file
40
src/Twig/MarkdownExtension.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
218
templates/qa/answer_question.html.twig
Normal file
218
templates/qa/answer_question.html.twig
Normal 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 %}
|
||||
469
templates/qa/ask_question.html.twig
Normal file
469
templates/qa/ask_question.html.twig
Normal 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 %}
|
||||
306
templates/qa/index.html.twig
Normal file
306
templates/qa/index.html.twig
Normal 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"> </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 %}
|
||||
341
templates/qa/question_show.html.twig
Normal file
341
templates/qa/question_show.html.twig
Normal 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 %}
|
||||
237
templates/qa/tag_questions.html.twig
Normal file
237
templates/qa/tag_questions.html.twig
Normal 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 %}
|
||||
87
templates/qa/tags.html.twig
Normal file
87
templates/qa/tags.html.twig
Normal 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 %}
|
||||
Loading…
Reference in a new issue