diff --git a/WALLET_INTEGRATION.md b/WALLET_INTEGRATION.md new file mode 100644 index 0000000..902aaa6 --- /dev/null +++ b/WALLET_INTEGRATION.md @@ -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 را بررسی کنید diff --git a/assets/app.js b/assets/app.js index b81903c..c5b1619 100644 --- a/assets/app.js +++ b/assets/app.js @@ -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; diff --git a/assets/controllers/installation_controller.js b/assets/controllers/installation_controller.js index 16ede19..4e03b09 100644 --- a/assets/controllers/installation_controller.js +++ b/assets/controllers/installation_controller.js @@ -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'); - ftpOption.style.display = ''; - ftpOption.classList.remove('step-hidden'); + 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() { - showApiInstall(); -}); +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,21 +479,42 @@ 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 (result.success) { - msg.className = 'status-message status-success'; - msg.innerHTML = 'نصب با موفقیت از طریق FTP انجام شد!'; - } else { - msg.className = 'status-message status-error'; - msg.innerHTML = 'خطا: ' + (result.error || 'خطای نامشخص'); + + 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 انجام شد!'; + } else { + 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'); - msg.className = 'status-message status-error'; - msg.innerHTML = 'خطا در ارتباط با سرور: ' + error.message; + + 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; + } } } \ No newline at end of file diff --git a/assets/controllers/wallet_connect_controller.js b/assets/controllers/wallet_connect_controller.js new file mode 100644 index 0000000..1161e1b --- /dev/null +++ b/assets/controllers/wallet_connect_controller.js @@ -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 = ` +
+ ${message} +
+ `; + + // 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 = ` + + `; + + 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'); + } +} diff --git a/assets/styles/app.css b/assets/styles/app.css index b903606..a9bb020 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -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; +} diff --git a/composer.json b/composer.json index 562b54a..fe8940f 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/composer.lock b/composer.lock index 60e3723..a395382 100644 --- a/composer.lock +++ b/composer.lock @@ -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" } diff --git a/config/packages/csrf.yaml b/config/packages/csrf.yaml index 40d4040..c102b17 100644 --- a/config/packages/csrf.yaml +++ b/config/packages/csrf.yaml @@ -9,3 +9,4 @@ framework: - submit - authenticate - logout + enabled: true diff --git a/config/packages/security.yaml b/config/packages/security.yaml index a2080b4..09763d7 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -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 diff --git a/config/services.yaml b/config/services.yaml index a3de766..82618cf 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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'] \ No newline at end of file diff --git a/migrations/Version20250726075416.php b/migrations/Version20250726075416.php deleted file mode 100644 index bfc2143..0000000 --- a/migrations/Version20250726075416.php +++ /dev/null @@ -1,45 +0,0 @@ -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'); - } -} diff --git a/migrations/Version20250903164119.php b/migrations/Version20250903164119.php deleted file mode 100644 index 0c5d865..0000000 --- a/migrations/Version20250903164119.php +++ /dev/null @@ -1,37 +0,0 @@ -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'); - } -} diff --git a/migrations/Version20250904051657.php b/migrations/Version20250904051657.php new file mode 100644 index 0000000..09e6d17 --- /dev/null +++ b/migrations/Version20250904051657.php @@ -0,0 +1,59 @@ +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'); + } +} diff --git a/migrations/Version20250905042717.php b/migrations/Version20250905042717.php new file mode 100644 index 0000000..3a7fc9b --- /dev/null +++ b/migrations/Version20250905042717.php @@ -0,0 +1,110 @@ +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'); + } +} \ No newline at end of file diff --git a/migrations/Version20250905043249.php b/migrations/Version20250905043249.php new file mode 100644 index 0000000..30cd295 --- /dev/null +++ b/migrations/Version20250905043249.php @@ -0,0 +1,349 @@ +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'); + } +} diff --git a/migrations/Version20250905050647.php b/migrations/Version20250905050647.php new file mode 100644 index 0000000..77ed50b --- /dev/null +++ b/migrations/Version20250905050647.php @@ -0,0 +1,33 @@ +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'); + } +} diff --git a/public/fonts/yekanbakh/fontiran.css b/public/fonts/yekanbakh/fontiran.css index 1ba9f12..f2ef12f 100644 --- a/public/fonts/yekanbakh/fontiran.css +++ b/public/fonts/yekanbakh/fontiran.css @@ -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 { diff --git a/public/img/icons/wallet.svg b/public/img/icons/wallet.svg new file mode 100644 index 0000000..58e458c --- /dev/null +++ b/public/img/icons/wallet.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Controller/CustomerController.php b/src/Controller/CustomerController.php index a81d95f..0a46c92 100644 --- a/src/Controller/CustomerController.php +++ b/src/Controller/CustomerController.php @@ -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, ]); } diff --git a/src/Controller/GeneralController.php b/src/Controller/GeneralController.php index 612be6f..2c546f7 100644 --- a/src/Controller/GeneralController.php +++ b/src/Controller/GeneralController.php @@ -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); } diff --git a/src/Controller/QA/QAController.php b/src/Controller/QA/QAController.php new file mode 100644 index 0000000..adc8142 --- /dev/null +++ b/src/Controller/QA/QAController.php @@ -0,0 +1,429 @@ +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, + ]); + } +} diff --git a/src/Controller/WalletController.php b/src/Controller/WalletController.php new file mode 100644 index 0000000..780541e --- /dev/null +++ b/src/Controller/WalletController.php @@ -0,0 +1,116 @@ +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 + ]); + } +} diff --git a/src/Entity/Answer.php b/src/Entity/Answer.php new file mode 100644 index 0000000..377c47b --- /dev/null +++ b/src/Entity/Answer.php @@ -0,0 +1,180 @@ + + */ + #[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 + */ + 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; + } +} diff --git a/src/Entity/AnswerVote.php b/src/Entity/AnswerVote.php new file mode 100644 index 0000000..3381379 --- /dev/null +++ b/src/Entity/AnswerVote.php @@ -0,0 +1,85 @@ +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; + } +} diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php deleted file mode 100644 index e2c68ce..0000000 --- a/src/Entity/Customer.php +++ /dev/null @@ -1,216 +0,0 @@ -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; - } -} diff --git a/src/Entity/PasswordResetToken.php b/src/Entity/PasswordResetToken.php index a6c67b5..ffdbca3 100644 --- a/src/Entity/PasswordResetToken.php +++ b/src/Entity/PasswordResetToken.php @@ -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; } diff --git a/src/Entity/Question.php b/src/Entity/Question.php new file mode 100644 index 0000000..57bfeb5 --- /dev/null +++ b/src/Entity/Question.php @@ -0,0 +1,284 @@ + + */ + #[ORM\OneToMany(targetEntity: Answer::class, mappedBy: 'question', orphanRemoval: true, cascade: ['persist'])] + private Collection $answers; + + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: QuestionVote::class, mappedBy: 'question', orphanRemoval: true)] + private Collection $questionVotes; + + /** + * @var Collection + */ + #[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 + */ + 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 + */ + 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 + */ + 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; + } +} diff --git a/src/Entity/QuestionTag.php b/src/Entity/QuestionTag.php new file mode 100644 index 0000000..a2266ef --- /dev/null +++ b/src/Entity/QuestionTag.php @@ -0,0 +1,149 @@ + + */ + #[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 + */ + 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; + } +} diff --git a/src/Entity/QuestionTagRelation.php b/src/Entity/QuestionTagRelation.php new file mode 100644 index 0000000..4f6eb13 --- /dev/null +++ b/src/Entity/QuestionTagRelation.php @@ -0,0 +1,52 @@ +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; + } +} diff --git a/src/Entity/QuestionVote.php b/src/Entity/QuestionVote.php new file mode 100644 index 0000000..56b1dce --- /dev/null +++ b/src/Entity/QuestionVote.php @@ -0,0 +1,85 @@ +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; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 021b27d..b04c42c 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -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 */ #[ORM\OneToMany(targetEntity: Post::class, mappedBy: 'submitter', orphanRemoval: true)] private Collection $posts; + /** + * @var Collection + */ + #[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 + */ + 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; + } + } diff --git a/src/Entity/Wallet.php b/src/Entity/Wallet.php new file mode 100644 index 0000000..2153696 --- /dev/null +++ b/src/Entity/Wallet.php @@ -0,0 +1,177 @@ +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; + } +} diff --git a/src/Form/CustomerRegistrationFormType.php b/src/Form/CustomerRegistrationFormType.php index a3aaf1e..843ec57 100644 --- a/src/Form/CustomerRegistrationFormType.php +++ b/src/Form/CustomerRegistrationFormType.php @@ -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, ]); } } diff --git a/src/Form/QA/AnswerFormType.php b/src/Form/QA/AnswerFormType.php new file mode 100644 index 0000000..2427308 --- /dev/null +++ b/src/Form/QA/AnswerFormType.php @@ -0,0 +1,40 @@ +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, + ]); + } +} diff --git a/src/Form/QA/QuestionFormType.php b/src/Form/QA/QuestionFormType.php new file mode 100644 index 0000000..42ff653 --- /dev/null +++ b/src/Form/QA/QuestionFormType.php @@ -0,0 +1,60 @@ +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, + ]); + } +} diff --git a/src/Form/WalletConnectFormType.php b/src/Form/WalletConnectFormType.php new file mode 100644 index 0000000..9613a62 --- /dev/null +++ b/src/Form/WalletConnectFormType.php @@ -0,0 +1,73 @@ +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, + ]); + } +} diff --git a/src/Repository/AnswerRepository.php b/src/Repository/AnswerRepository.php new file mode 100644 index 0000000..f27bbc4 --- /dev/null +++ b/src/Repository/AnswerRepository.php @@ -0,0 +1,58 @@ + + */ +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(); + } +} diff --git a/src/Repository/AnswerVoteRepository.php b/src/Repository/AnswerVoteRepository.php new file mode 100644 index 0000000..0401c24 --- /dev/null +++ b/src/Repository/AnswerVoteRepository.php @@ -0,0 +1,29 @@ + + */ +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(); + } +} diff --git a/src/Repository/CustomerRepository.php b/src/Repository/CustomerRepository.php deleted file mode 100644 index d7fe84f..0000000 --- a/src/Repository/CustomerRepository.php +++ /dev/null @@ -1,49 +0,0 @@ - - */ -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 - ]); - } -} diff --git a/src/Repository/QuestionRepository.php b/src/Repository/QuestionRepository.php new file mode 100644 index 0000000..c58b34b --- /dev/null +++ b/src/Repository/QuestionRepository.php @@ -0,0 +1,170 @@ + + */ +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(); + } +} diff --git a/src/Repository/QuestionTagRelationRepository.php b/src/Repository/QuestionTagRelationRepository.php new file mode 100644 index 0000000..2b7accd --- /dev/null +++ b/src/Repository/QuestionTagRelationRepository.php @@ -0,0 +1,18 @@ + + */ +class QuestionTagRelationRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, QuestionTagRelation::class); + } +} diff --git a/src/Repository/QuestionTagRepository.php b/src/Repository/QuestionTagRepository.php new file mode 100644 index 0000000..061a02e --- /dev/null +++ b/src/Repository/QuestionTagRepository.php @@ -0,0 +1,62 @@ + + */ +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(); + } +} diff --git a/src/Repository/QuestionVoteRepository.php b/src/Repository/QuestionVoteRepository.php new file mode 100644 index 0000000..85e023c --- /dev/null +++ b/src/Repository/QuestionVoteRepository.php @@ -0,0 +1,29 @@ + + */ +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(); + } +} diff --git a/src/Repository/WalletRepository.php b/src/Repository/WalletRepository.php new file mode 100644 index 0000000..3594d26 --- /dev/null +++ b/src/Repository/WalletRepository.php @@ -0,0 +1,125 @@ + + */ +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(); + } +} diff --git a/src/Service/MarkdownService.php b/src/Service/MarkdownService.php new file mode 100644 index 0000000..0ed217c --- /dev/null +++ b/src/Service/MarkdownService.php @@ -0,0 +1,21 @@ +parsedown = new Parsedown(); + $this->parsedown->setSafeMode(true); + } + + public function parse(string $text): string + { + return $this->parsedown->text($text); + } +} diff --git a/src/Service/WalletService.php b/src/Service/WalletService.php new file mode 100644 index 0000000..40cce6b --- /dev/null +++ b/src/Service/WalletService.php @@ -0,0 +1,232 @@ +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; + } +} diff --git a/src/Twig/MarkdownExtension.php b/src/Twig/MarkdownExtension.php new file mode 100644 index 0000000..14bc4e7 --- /dev/null +++ b/src/Twig/MarkdownExtension.php @@ -0,0 +1,40 @@ + ['html']]), + ]; + } + + public function parseMarkdown(string $text): string + { + // تبدیل Markdown ساده به HTML + $text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); + + // Bold: **text** -> text + $text = preg_replace('/\*\*(.*?)\*\*/', '$1', $text); + + // Italic: *text* -> text + $text = preg_replace('/\*(.*?)\*/', '$1', $text); + + // Code: `code` -> code + $text = preg_replace('/`(.*?)`/', '$1', $text); + + // Line breaks: \n ->
+ $text = nl2br($text); + + // Lists: - item ->
  • item
  • + $text = preg_replace('/^- (.+)$/m', '
  • $1
  • ', $text); + $text = preg_replace('/(
  • .*<\/li>)/s', '
      $1
    ', $text); + + return $text; + } +} diff --git a/symfony.lock b/symfony.lock index 2a77d30..557673a 100644 --- a/symfony.lock +++ b/symfony.lock @@ -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": { diff --git a/templates/base.html.twig b/templates/base.html.twig index ed5ca14..c5e8fdd 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -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; } + } {% endblock %} {% block javascripts %} {{ encore_entry_script_tags('app') }} + + + {% endblock %} @@ -105,18 +199,28 @@ gtag('config', 'G-K1R1SYQY8E');
  • + - -
    - {% 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 %} {# کاربر وارد شده - نمایش منوی داشبورد #}
    + +
    +
    +
    +

    کیف پول مدیریت کیف پول‌ها

    + + +
    +
    اتصال کیف پول جدید
    +
    +
    + + +
    +
    + +
    + + + + +
    +
    آدرس کیف پول شما پس از اتصال به صورت خودکار پر می‌شود
    +
    +
    + +
    +
    + +
    + + +
    +
    کیف پول‌های متصل ({{ user.wallets|length }}/5)
    + + {% if user.wallets|length > 0 %} +
    + + + + + + + + + + + + {% for wallet in user.wallets %} + + + + + + + + {% endfor %} + +
    آدرس کیف پولنوعوضعیتتاریخ اتصالعملیات
    + {{ wallet.shortAddress }} + {% if wallet.isPrimary %} + اصلی + {% endif %} + + {{ wallet.walletType }} + + + {{ wallet.isActive ? 'فعال' : 'غیرفعال' }} + + {{ wallet.connectedAt|date('Y/m/d H:i') }} +
    + {% if not wallet.isPrimary and wallet.isActive %} + + {% endif %} + + + + +
    +
    +
    + {% else %} +
    + +
    هنوز هیچ کیف پولی متصل نشده است
    +

    برای شروع، نوع کیف پول خود را انتخاب کنید و روی دکمه اتصال کلیک کنید

    +
    + {% endif %} +
    +
    +
    +
    +
    @@ -297,3 +509,7 @@
    {% endblock %} + +{% block javascripts %} + {{ parent() }} +{% endblock %} diff --git a/templates/general/home.html.twig b/templates/general/home.html.twig index 94fceb8..ce57621 100644 --- a/templates/general/home.html.twig +++ b/templates/general/home.html.twig @@ -599,15 +599,19 @@ } {% endblock %} diff --git a/templates/general/sitemap.html.twig b/templates/general/sitemap.html.twig index 209ecef..876e63b 100644 --- a/templates/general/sitemap.html.twig +++ b/templates/general/sitemap.html.twig @@ -17,6 +17,16 @@ 2025-01-09T06:58:03+00:00 1.00 + + {{ absolute_url(path('qa_index')) }} + 2025-01-09T06:58:03+00:00 + 0.90 + + + {{ absolute_url(path('qa_tags')) }} + 2025-01-09T06:58:03+00:00 + 0.80 + {% for post in posts %} {% if post.cat.code == 'plain' %} @@ -44,4 +54,18 @@ {% endif %} {% endfor %} + {% for question in questions %} + + {{ absolute_url(path('qa_question_show', {'id': question.id})) }} + {{ question.createdAt|date('c') }} + 0.70 + + {% endfor %} + {% for tag in tags %} + + {{ absolute_url(path('qa_tag_questions', {'name': tag.name})) }} + 2025-01-09T06:58:03+00:00 + 0.60 + + {% endfor %} diff --git a/templates/installation/index.html.twig b/templates/installation/index.html.twig index f622b7a..a9952cd 100644 --- a/templates/installation/index.html.twig +++ b/templates/installation/index.html.twig @@ -407,7 +407,7 @@ {{ parent() }} {% endblock %} \ No newline at end of file diff --git a/templates/qa/answer_question.html.twig b/templates/qa/answer_question.html.twig new file mode 100644 index 0000000..e764ecc --- /dev/null +++ b/templates/qa/answer_question.html.twig @@ -0,0 +1,218 @@ +{% extends 'base.html.twig' %} + +{% block title %}پاسخ به سوال: {{ question.title }} - پرسش و پاسخ{% endblock %} + +{% block body %} +
    +
    +
    + +
    +
    +

    سوال:

    +
    +
    +
    {{ question.title }}
    +
    + {{ question.content|nl2br }} +
    +
    +
    + {% for tagRelation in question.tagRelations %} + + {{ tagRelation.tag.name }} + + {% endfor %} +
    +
    +
    +
    + + +
    +
    +

    + + + + پاسخ شما +

    +
    +
    + {{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }} + +
    + {{ form_label(form.content) }} +
    + + + + +
    + {{ form_widget(form.content, {'attr': {'class': 'form-control editor-textarea', 'rows': 8}}) }} + {{ form_errors(form.content) }} +
    + پاسخ خود را به صورت کامل و مفصل ارائه دهید. هرچه پاسخ شما دقیق‌تر باشد، برای دیگران مفیدتر خواهد بود. +
    می‌توانید از دکمه‌های بالا برای فرمت کردن متن استفاده کنید. +
    +
    + +
    + + انصراف + + +
    + + {{ form_end(form) }} +
    +
    + + +
    +
    +
    + راهنمای پاسخ دادن +
    +
    +
    +
    +
    +
    ✅ پاسخ خوب:
    +
      +
    • مستقیماً به سوال پاسخ دهید
    • +
    • از مثال‌های عملی استفاده کنید
    • +
    • منابع و لینک‌های مفید ارائه دهید
    • +
    • کد و نمونه‌های کد ارائه دهید
    • +
    +
    +
    +
    ❌ پاسخ ضعیف:
    +
      +
    • پاسخ مبهم و کلی
    • +
    • عدم ارائه راه‌حل عملی
    • +
    • تکرار پاسخ‌های قبلی
    • +
    • پاسخ نامربوط
    • +
    +
    +
    +
    +
    +
    +
    +
    + + + + +{% endblock %} diff --git a/templates/qa/ask_question.html.twig b/templates/qa/ask_question.html.twig new file mode 100644 index 0000000..6b1a7d9 --- /dev/null +++ b/templates/qa/ask_question.html.twig @@ -0,0 +1,469 @@ +{% extends 'base.html.twig' %} + +{% block title %}پرسیدن سوال جدید - پرسش و پاسخ{% endblock %} + +{% block body %} +
    +
    +
    +
    +
    +

    + + + + + پرسیدن سوال جدید +

    +
    +
    + {{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }} + +
    + {{ form_label(form.title) }} + {{ form_widget(form.title) }} + {{ form_errors(form.title) }} +
    + عنوان سوال باید واضح و مختصر باشد. سعی کنید مشکل خود را در یک جمله خلاصه کنید. +
    +
    + +
    + {{ form_label(form.content) }} +
    + + + + +
    + {{ form_widget(form.content, {'attr': {'class': 'form-control editor-textarea', 'rows': 10}}) }} + {{ form_errors(form.content) }} +
    + سوال خود را به تفصیل شرح دهید. هرچه جزئیات بیشتری ارائه دهید، پاسخ‌های بهتری دریافت خواهید کرد. +
    می‌توانید از دکمه‌های بالا برای فرمت کردن متن استفاده کنید. +
    +
    + +
    + +
    +
    +
    + + +
    +
    + تگ‌های موجود: +
    + {% for tag in availableTags %} + + {{ tag.name }} + + {% endfor %} +
    +
    +
    +
    + تگ‌های مرتبط را انتخاب کنید تا دیگران راحت‌تر بتوانند سوال شما را پیدا کنند. +
    می‌توانید تگ جدید ایجاد کنید یا از تگ‌های موجود انتخاب کنید. +
    +
    + +
    + + + + + انصراف + + +
    + + {{ form_end(form) }} +
    +
    + + +
    +
    +
    + + + + + + + راهنمای پرسیدن سوال خوب +
    +
    +
    +
    +
    +
    ✅ کارهای درست:
    +
      +
    • عنوان واضح و مختصر
    • +
    • شرح کامل مشکل
    • +
    • انتخاب تگ‌های مناسب
    • +
    • استفاده از کلمات کلیدی
    • +
    +
    +
    +
    ❌ کارهای نادرست:
    +
      +
    • عنوان مبهم
    • +
    • شرح ناکافی
    • +
    • عدم انتخاب تگ
    • +
    • سوال تکراری
    • +
    +
    +
    +
    +
    +
    +
    +
    + + + + +{% endblock %} diff --git a/templates/qa/index.html.twig b/templates/qa/index.html.twig new file mode 100644 index 0000000..b6cdce8 --- /dev/null +++ b/templates/qa/index.html.twig @@ -0,0 +1,306 @@ +{% extends 'base.html.twig' %} + +{% block title %}پرسش و پاسخ - حسابیکس{% endblock %} + +{% block body %} +
    +
    +
    +
    +

    پرسش و پاسخ

    + {% if app.user and 'ROLE_CUSTOMER' in app.user.roles %} + + + + + سوال جدید + + {% else %} + + + + + + ورود برای پرسیدن سوال + + {% endif %} +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + +
    + +
    + {% if questions is empty %} +
    +
    + +

    سوالی یافت نشد

    +

    هنوز سوالی در این دسته‌بندی وجود ندارد.

    + {% if app.user and 'ROLE_CUSTOMER' in app.user.roles %} + اولین سوال را بپرسید + {% endif %} +
    +
    + {% else %} + {% for question in questions %} +
    +
    +
    +
    +
    +
    + {{ question.votes }} +
    + رای +
    +
    +
    +
    +
    + {{ question.answers|length }} +
    + پاسخ +
    +
    +
    +
    +
    + + {{ question.title }} + +
    + {% if question.isSolved %} + + حل شده + + {% endif %} +
    + +
    + {% set content = question.content|markdown|raw %} + {% set plainText = content|striptags %} + {% if plainText|length > 200 %} + {{ plainText|slice(0, 200) }}... + {% else %} + {{ plainText }} + {% endif %} +
    + +
    +
    + {% for tagRelation in question.tagRelations %} + + {{ tagRelation.tag.name }} + + {% endfor %} +
    +
    + {{ question.author.name }} + {{ question.createdAt|date('Y/m/d H:i') }} + {{ question.views }} +
    +
    +
    +
    +
    +
    + {% endfor %} + + + {% if totalPages > 1 %} + + {% endif %} + {% endif %} +
    + + +
    + +
    +
    +
    + + + + تگ‌های محبوب +
    +
    +
    + {% for tag in popularTags %} + + {{ tag.name }} + ({{ tag.usageCount }}) + + {% endfor %} + +
    +
    + + +
    +
    +
    + + + + + آمار +
    +
    +
    +
    +
    +
    {{ questions|length }}
    + سوال +
    +
    +
    {{ popularTags|length }}
    + تگ +
    +
    +
    +
    +
    +
    +
    +
    +
    + + +{% endblock %} diff --git a/templates/qa/question_show.html.twig b/templates/qa/question_show.html.twig new file mode 100644 index 0000000..46cad6b --- /dev/null +++ b/templates/qa/question_show.html.twig @@ -0,0 +1,341 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ question.title }} - پرسش و پاسخ{% endblock %} + +{% block body %} +
    +
    +
    + +
    +
    +
    +
    +
    + +
    + {{ question.votes }} +
    + +
    +
    +
    +
    +

    {{ question.title }}

    + {% if question.isSolved %} + + حل شده + + {% endif %} +
    + +
    + {{ question.content|markdown|raw }} +
    + +
    +
    + {% for tagRelation in question.tagRelations %} + + {{ tagRelation.tag.name }} + + {% endfor %} +
    +
    + {{ question.author.name }} + {{ question.createdAt|date('Y/m/d H:i') }} + {{ question.views }} بازدید +
    +
    +
    +
    +
    +
    + + +
    +
    +

    + پاسخ‌ها + {{ totalAnswers }} +

    + {% if app.user and 'ROLE_CUSTOMER' in app.user.roles %} + + پاسخ دهید + + {% else %} + + ورود برای پاسخ دادن + + {% endif %} +
    + + {% if answers is empty %} +
    +
    + +

    هنوز پاسخی داده نشده

    +

    اولین کسی باشید که به این سوال پاسخ می‌دهد.

    +
    +
    + {% else %} + {% for answer in answers %} +
    +
    +
    +
    +
    + +
    + {{ answer.votes }} +
    + +
    +
    +
    +
    +
    + {{ answer.content|markdown|raw }} +
    + {% if answer.isAccepted %} + + پاسخ پذیرفته شده + + {% endif %} +
    + +
    +
    + {{ answer.author.name }} + {{ answer.createdAt|date('Y/m/d H:i') }} +
    + + {% if app.user and app.user == question.author and not answer.isAccepted %} + + {% endif %} +
    +
    +
    +
    +
    + {% endfor %} + + + {% if totalPages > 1 %} + + {% endif %} + {% endif %} +
    + + + +
    +
    +
    + + + + +{% endblock %} diff --git a/templates/qa/tag_questions.html.twig b/templates/qa/tag_questions.html.twig new file mode 100644 index 0000000..e6d5d6a --- /dev/null +++ b/templates/qa/tag_questions.html.twig @@ -0,0 +1,237 @@ +{% extends 'base.html.twig' %} + +{% block title %}سوالات تگ {{ tag.name }} - پرسش و پاسخ{% endblock %} + +{% block body %} +
    +
    +
    +
    +
    +

    + سوالات تگ "{{ tag.name }}" +

    + {% if tag.description %} +

    {{ tag.description }}

    + {% endif %} +
    + + بازگشت به همه سوالات + +
    + +
    +
    + {% if questions is empty %} +
    +
    + +

    سوالی یافت نشد

    +

    هنوز سوالی با تگ "{{ tag.name }}" وجود ندارد.

    + {% if app.user and 'ROLE_CUSTOMER' in app.user.roles %} + اولین سوال را بپرسید + {% endif %} +
    +
    + {% else %} + {% for question in questions %} +
    +
    +
    +
    +
    +
    + {{ question.votes }} +
    + رای +
    +
    +
    +
    +
    + {{ question.answers|length }} +
    + پاسخ +
    +
    +
    +
    +
    + + {{ question.title }} + +
    + {% if question.isSolved %} + + حل شده + + {% endif %} +
    + +

    + {% set content = question.content|markdown|raw %} + {% set plainText = content|striptags %} + {% if plainText|length > 200 %} + {{ plainText|slice(0, 200) }}... + {% else %} + {{ plainText }} + {% endif %} +

    + +
    +
    + {% for tagRelation in question.tagRelations %} + + {{ tagRelation.tag.name }} + + {% endfor %} +
    +
    + {{ question.author.name }} + {{ question.createdAt|date('Y/m/d H:i') }} + {{ question.views }} +
    +
    +
    +
    +
    +
    + {% endfor %} + + + {% if totalPages > 1 %} + + {% endif %} + {% endif %} +
    + + +
    + +
    +
    +
    + اطلاعات تگ +
    +
    +
    +
    + {{ tag.name }} + {% if tag.color %} +
    + {% endif %} +
    + + {% if tag.description %} +

    {{ tag.description }}

    + {% endif %} + +
    +
    +
    {{ questions|length }}
    + سوال +
    +
    +
    {{ tag.usageCount }}
    + استفاده +
    +
    +
    +
    + + +
    +
    +
    + تگ‌های مرتبط +
    +
    +
    +

    تگ‌های مشابه که ممکن است برای شما مفید باشند:

    + + +
    +
    +
    +
    +
    +
    +
    + + +{% endblock %} diff --git a/templates/qa/tags.html.twig b/templates/qa/tags.html.twig new file mode 100644 index 0000000..4dff0f4 --- /dev/null +++ b/templates/qa/tags.html.twig @@ -0,0 +1,87 @@ +{% extends 'base.html.twig' %} + +{% block title %}تگ‌ها - پرسش و پاسخ{% endblock %} + +{% block body %} +
    +
    +
    + + + {% if tags is empty %} +
    +
    + +

    تگی یافت نشد

    +

    هنوز تگی در سیستم ثبت نشده است.

    +
    +
    + {% else %} +
    + {% for tag in tags %} +
    +
    +
    +
    +
    + + {{ tag.name }} + +
    + {{ tag.usageCount }} +
    + + {% if tag.description %} +

    + {{ tag.description }} +

    + {% endif %} + +
    + + مشاهده سوالات + + {% if tag.color %} +
    + {% endif %} +
    +
    +
    +
    + {% endfor %} +
    + {% endif %} +
    +
    +
    + + +{% endblock %}