feat(address-book): implement customer address book system (phase 3.9)
Introduce a comprehensive address management system allowing users to
save, manage, and reuse sender and receiver addresses during the
ordering process. This feature bridges the WordPress frontend with the
Laravel backend API.
Key changes:
- Backend (Laravel):
- Added `AddressBookEntry` model and migration for storing addresses
with role (sender/receiver), usage tracking, and default status.
- Implemented `AddressBookController` with full CRUD functionality
secured via Sanctum and ownership validation.
- Integrated address usage tracking and upsert logic into the
`CustomerOrderController` to prevent duplicate entries.
- Added 18 new feature tests covering ownership, limits, and
validation.
- Frontend (WordPress):
- Added a new "Address Book" tab in the user dashboard with card-based
UI, filtering, and SweetAlert2 integration.
- Enhanced the order form with dropdown selectors for saved addresses
and "save this address" checkboxes for both sender and receiver.
- Implemented secure AJAX proxies in `user-bridge.php` with nonce
and sanitization.
- Documentation:
- Updated `AGENT.md` and `CLIENT_DELIVERY.md` to reflect phase 3.9
completion and updated the project roadmap.
- Removed obsolete `README-INSTALL.md`.
This commit is contained in:
parent
b9ede16613
commit
a4fc807160
@ -609,3 +609,209 @@
|
||||
font-size: 0.875rem !important;
|
||||
margin-bottom: 1rem !important;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
دفترچه آدرس — تب پنل کاربری (فاز ۳.۹)
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
.ifnx-notice {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--ifnex-radius-md, 10px);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.ifnx-notice-error {
|
||||
background: var(--ifnex-danger-light, #fee2e2);
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.ifnx-address-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.ifnx-address-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ifnx-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 7px 16px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ifnex-gray-600, #4b5563);
|
||||
background: var(--ifnex-gray-100, #f3f4f6);
|
||||
border: 1px solid var(--ifnex-gray-200, #e5e7eb);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.ifnx-chip:hover {
|
||||
background: var(--ifnex-warning-light, #fef3c7);
|
||||
border-color: #fcd34d;
|
||||
color: #92400e;
|
||||
}
|
||||
.ifnx-chip.active {
|
||||
background: var(--ifnex-primary, #f59e0b);
|
||||
border-color: var(--ifnex-primary, #f59e0b);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* فرم افزودن/ویرایش */
|
||||
.ifnx-address-form {
|
||||
background: var(--ifnex-white, #fff);
|
||||
border: 1px solid var(--ifnex-gray-200, #e5e7eb);
|
||||
border-radius: var(--ifnex-radius-md, 12px);
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.ifnx-address-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ifnx-address-form-grid .full { grid-column: 1 / -1; }
|
||||
.ifnx-addr-checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ifnex-gray-700, #374151);
|
||||
padding: 8px 12px;
|
||||
background: var(--ifnex-warning-light, #fef3c7);
|
||||
border: 1px dashed #fcd34d;
|
||||
border-radius: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
.ifnx-addr-checkbox input { accent-color: var(--ifnex-primary, #f59e0b); width: 17px; height: 17px; }
|
||||
.ifnx-address-form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* کارتهای آدرس */
|
||||
.ifnx-address-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.ifnx-address-card {
|
||||
background: var(--ifnex-white, #fff);
|
||||
border: 1px solid var(--ifnex-gray-200, #e5e7eb);
|
||||
border-radius: var(--ifnex-radius-md, 12px);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.ifnx-address-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
.ifnx-address-card.is-default {
|
||||
border-color: #fcd34d;
|
||||
background: linear-gradient(180deg, #fffbeb 0%, #ffffff 40%);
|
||||
}
|
||||
.ifnx-address-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.ifnx-address-role {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.ifnx-address-role.role-sender {
|
||||
background: var(--ifnex-info-light, #dbeafe);
|
||||
color: #1e40af;
|
||||
}
|
||||
.ifnx-address-role.role-receiver {
|
||||
background: var(--ifnex-success-light, #d1fae5);
|
||||
color: #065f46;
|
||||
}
|
||||
.ifnx-address-default-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #92400e;
|
||||
background: var(--ifnex-warning-light, #fef3c7);
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.ifnx-address-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--ifnex-gray-900, #111827);
|
||||
}
|
||||
.ifnx-address-card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--ifnex-gray-600, #4b5563);
|
||||
}
|
||||
.ifnx-address-card-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--ifnex-gray-700, #374151);
|
||||
background: var(--ifnex-gray-50, #f9fafb);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
flex: 1;
|
||||
}
|
||||
.ifnx-address-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
border-top: 1px solid var(--ifnex-gray-100, #f3f4f6);
|
||||
padding-top: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.ifnx-address-card-actions .ifnex-btn[disabled] {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* حالت خالی */
|
||||
.ifnx-address-empty {
|
||||
text-align: center;
|
||||
padding: 48px 20px;
|
||||
background: var(--ifnex-white, #fff);
|
||||
border: 2px dashed var(--ifnex-gray-200, #e5e7eb);
|
||||
border-radius: var(--ifnex-radius-md, 12px);
|
||||
}
|
||||
.ifnx-address-empty-icon { font-size: 42px; margin-bottom: 10px; }
|
||||
.ifnx-address-empty h3 {
|
||||
font-size: 16px;
|
||||
color: var(--ifnex-gray-900, #111827);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.ifnx-address-empty p {
|
||||
font-size: 13px;
|
||||
color: var(--ifnex-gray-500, #6b7280);
|
||||
max-width: 460px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* موبایل */
|
||||
@media (max-width: 640px) {
|
||||
.ifnx-address-toolbar { flex-direction: column; align-items: stretch; }
|
||||
.ifnx-address-grid { grid-template-columns: 1fr; }
|
||||
.ifnx-address-form-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@ -1071,3 +1071,65 @@
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
دفترچه آدرس — فرم سفارش (فاز ۳.۹)
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
.ifnex-saved-address-wrap {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
background: var(--ifnex-warning-light, #fef3c7);
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.ifnex-saved-address-wrap label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ifnex-saved-address-wrap select {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ifnex-saved-address-wrap select:focus {
|
||||
outline: none;
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
|
||||
.ifnex-save-address-row {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.ifnex-save-address-label {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151 !important;
|
||||
padding: 8px 12px;
|
||||
background: var(--ifnex-light, #f9fafb);
|
||||
border: 1px dashed var(--ifnex-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
.ifnex-save-address-label:hover {
|
||||
background: #fef3c7;
|
||||
border-color: #fcd34d;
|
||||
}
|
||||
.ifnex-save-address-label input[type="checkbox"] {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
accent-color: #f59e0b;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@ -0,0 +1,227 @@
|
||||
/**
|
||||
* IFNEX — دفترچه آدرس (فاز ۳.۹)
|
||||
* منطق تب «دفترچه آدرس» در پنل کاربری:
|
||||
* افزودن / ویرایش / حذف / پیشفرض کردن آدرسها
|
||||
*
|
||||
* وابستگی: jQuery، SweetAlert2 (اختیاری — در نبودش به confirm برمیگردد)
|
||||
* دادههای اولیه: خواندهشده از data-* روی .ifnx-address-card-actions (رندر سمت PHP)
|
||||
*/
|
||||
jQuery(document).ready(function ($) {
|
||||
'use strict';
|
||||
|
||||
var $app = $('#ifnx-addresses-app');
|
||||
if (!$app.length) return; // این صفحه دفترچه آدرس نیست
|
||||
|
||||
var ajaxUrl = $('#ifnx-address-ajax-url').val() || (window.ifnex_ajax && window.ifnex_ajax.ajax_url);
|
||||
var nonce = $('#ifnx-address-nonce').val() || (window.ifnex_ajax && window.ifnex_ajax.nonce);
|
||||
|
||||
if (!ajaxUrl || !nonce) {
|
||||
console.error('IFNEX AddressBook: ajax url/nonce not found');
|
||||
return;
|
||||
}
|
||||
|
||||
var $formWrap = $('#ifnx-address-form-wrap');
|
||||
var $form = $('#ifnx-address-form');
|
||||
|
||||
// ─── SweetAlert helpers (سازگار با فاز ۳.۸) ───
|
||||
function swalError(title, text) {
|
||||
if (window.Swal && typeof window.Swal.fire === 'function') {
|
||||
window.Swal.fire({
|
||||
icon: 'error',
|
||||
title: title,
|
||||
text: text || '',
|
||||
confirmButtonText: 'متوجه شدم',
|
||||
confirmButtonColor: '#f59e0b',
|
||||
customClass: { popup: 'ifnex-swal' }
|
||||
});
|
||||
} else {
|
||||
alert((title ? title + '\n' : '') + (text || ''));
|
||||
}
|
||||
}
|
||||
|
||||
function swalSuccess(title, text) {
|
||||
if (window.Swal && typeof window.Swal.fire === 'function') {
|
||||
window.Swal.fire({
|
||||
icon: 'success',
|
||||
title: title,
|
||||
text: text || '',
|
||||
confirmButtonText: 'باشه',
|
||||
confirmButtonColor: '#10b981',
|
||||
timer: 2200,
|
||||
timerProgressBar: true,
|
||||
customClass: { popup: 'ifnex-swal' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function swalConfirm(title, text) {
|
||||
if (window.Swal && typeof window.Swal.fire === 'function') {
|
||||
return window.Swal.fire({
|
||||
icon: 'warning',
|
||||
title: title,
|
||||
text: text || '',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'بله، حذف کن',
|
||||
cancelButtonText: 'انصراف',
|
||||
confirmButtonColor: '#ef4444',
|
||||
customClass: { popup: 'ifnex-swal' }
|
||||
}).then(function (result) { return result.isConfirmed; });
|
||||
}
|
||||
return Promise.resolve(window.confirm((title ? title + '\n' : '') + (text || '')));
|
||||
}
|
||||
|
||||
// ─── نمایش/مخفی کردن فرم ───
|
||||
function showFormForCreate() {
|
||||
resetForm();
|
||||
$formWrap.slideDown(200);
|
||||
$('#ifnx-addr-name').trigger('focus');
|
||||
scrollToForm();
|
||||
}
|
||||
|
||||
function showFormForEdit($actions) {
|
||||
resetForm();
|
||||
|
||||
$('#ifnx-addr-id').val($actions.data('id'));
|
||||
$('#ifnx-addr-role').val($actions.data('role') || 'receiver');
|
||||
$('#ifnx-addr-label').val($actions.data('label') || '');
|
||||
$('#ifnx-addr-name').val($actions.data('name') || '');
|
||||
$('#ifnx-addr-phone').val($actions.data('phone') || '');
|
||||
$('#ifnx-addr-state').val($actions.data('state') || '');
|
||||
$('#ifnx-addr-city').val($actions.data('city') || '');
|
||||
$('#ifnx-addr-postal').val($actions.data('postal') || '');
|
||||
$('#ifnx-addr-national').val($actions.data('national') || '');
|
||||
$('#ifnx-addr-address').val($actions.data('address') || '');
|
||||
$('#ifnx-addr-default').prop('checked', String($actions.data('default')) === '1');
|
||||
|
||||
$('#ifnx-address-submit').text('💾 بروزرسانی آدرس');
|
||||
$formWrap.slideDown(200);
|
||||
scrollToForm();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
$form[0].reset();
|
||||
$('#ifnx-addr-id').val('');
|
||||
$('#ifnx-address-submit').text('💾 ذخیره آدرس');
|
||||
$form.find('.ifnx-addr-error').remove();
|
||||
}
|
||||
|
||||
function scrollToForm() {
|
||||
var el = $formWrap[0];
|
||||
if (el && el.scrollIntoView) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
|
||||
$('#ifnx-add-address-btn').on('click', function () {
|
||||
if ($formWrap.is(':visible') && !$('#ifnx-addr-id').val()) {
|
||||
$formWrap.slideUp(200); // toggle
|
||||
} else {
|
||||
showFormForCreate();
|
||||
}
|
||||
});
|
||||
|
||||
$('#ifnx-address-cancel').on('click', function () {
|
||||
resetForm();
|
||||
$formWrap.slideUp(200);
|
||||
});
|
||||
|
||||
// ─── دکمههای روی کارتها ───
|
||||
$(document).on('click', '.ifnx-addr-edit', function () {
|
||||
showFormForEdit($(this).closest('.ifnx-address-card-actions'));
|
||||
});
|
||||
|
||||
$(document).on('click', '.ifnx-addr-delete', function () {
|
||||
var $actions = $(this).closest('.ifnx-address-card-actions');
|
||||
var id = parseInt($actions.data('id'), 10);
|
||||
if (!id) return;
|
||||
|
||||
swalConfirm('حذف آدرس', 'آیا از حذف این آدرس مطمئن هستید؟ این عمل قابل بازگشت نیست.').then(function (ok) {
|
||||
if (!ok) return;
|
||||
|
||||
$.post(ajaxUrl, {
|
||||
action: 'ifnex_delete_address',
|
||||
nonce: nonce,
|
||||
id: id
|
||||
}).done(function (res) {
|
||||
if (res && res.success) {
|
||||
swalSuccess('حذف شد', 'آدرس با موفقیت حذف شد.');
|
||||
setTimeout(function () { window.location.reload(); }, 700);
|
||||
} else {
|
||||
swalError('حذف انجام نشد', (res && res.data) || 'خطای نامشخص');
|
||||
}
|
||||
}).fail(function () {
|
||||
swalError('خطای ارتباط', 'لطفاً اتصال اینترنت را بررسی کنید.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('click', '.ifnx-addr-default', function () {
|
||||
var $actions = $(this).closest('.ifnx-address-card-actions');
|
||||
var id = parseInt($actions.data('id'), 10);
|
||||
if (!id) return;
|
||||
|
||||
$.post(ajaxUrl, {
|
||||
action: 'ifnex_set_default_address',
|
||||
nonce: nonce,
|
||||
id: id
|
||||
}).done(function (res) {
|
||||
if (res && res.success) {
|
||||
swalSuccess('ثبت شد', 'این آدرس بهعنوان پیشفرض تنظیم شد.');
|
||||
setTimeout(function () { window.location.reload(); }, 700);
|
||||
} else {
|
||||
swalError('انجام نشد', (res && res.data) || 'خطای نامشخص');
|
||||
}
|
||||
}).fail(function () {
|
||||
swalError('خطای ارتباط', 'لطفاً اتصال اینترنت را بررسی کنید.');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ثبت فرم (افزودن یا ویرایش) ───
|
||||
$form.on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
var payload = {
|
||||
role: $('#ifnx-addr-role').val() || 'receiver',
|
||||
label: $('#ifnx-addr-label').val(),
|
||||
name: $('#ifnx-addr-name').val(),
|
||||
phone: $('#ifnx-addr-phone').val(),
|
||||
state: $('#ifnx-addr-state').val(),
|
||||
city: $('#ifnx-addr-city').val(),
|
||||
postal_code: $('#ifnx-addr-postal').val(),
|
||||
national_id: $('#ifnx-addr-national').val(),
|
||||
address: $('#ifnx-addr-address').val(),
|
||||
is_default: $('#ifnx-addr-default').is(':checked') ? 1 : 0
|
||||
};
|
||||
|
||||
var addressId = parseInt($('#ifnx-addr-id').val(), 10) || 0;
|
||||
if (addressId > 0) {
|
||||
payload.id = addressId;
|
||||
}
|
||||
|
||||
// اعتبارسنجی سمت کلاینت
|
||||
if (!payload.name || !payload.phone || !payload.address) {
|
||||
swalError('اطلاعات ناقص', 'نام، شماره تماس و آدرس کامل الزامی است.');
|
||||
return;
|
||||
}
|
||||
|
||||
var $btn = $('#ifnx-address-submit');
|
||||
$btn.prop('disabled', true).text('در حال ذخیره...');
|
||||
|
||||
$.post(ajaxUrl, {
|
||||
action: 'ifnex_save_address',
|
||||
nonce: nonce,
|
||||
data: payload
|
||||
}).done(function (res) {
|
||||
if (res && res.success) {
|
||||
swalSuccess('ذخیره شد', (res.data && res.data.message) || 'آدرس ذخیره شد.');
|
||||
setTimeout(function () { window.location.reload(); }, 800);
|
||||
} else {
|
||||
$btn.prop('disabled', false).text(addressId > 0 ? '💾 بروزرسانی آدرس' : '💾 ذخیره آدرس');
|
||||
swalError('ذخیره انجام نشد', (res && res.data) || 'خطای نامشخص');
|
||||
}
|
||||
}).fail(function () {
|
||||
$btn.prop('disabled', false).text(addressId > 0 ? '💾 بروزرسانی آدرس' : '💾 ذخیره آدرس');
|
||||
swalError('خطای ارتباط', 'لطفاً اتصال اینترنت را بررسی کنید.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -11,6 +11,10 @@ jQuery(document).ready(function($) {
|
||||
var resubmitOrderId = parseInt(urlParams.get('resubmit') || '0', 10);
|
||||
var isResubmitMode = resubmitOrderId > 0;
|
||||
|
||||
// ─── دفترچه آدرس (فاز ۳.۹) ───
|
||||
var savedAddresses = { sender: [], receiver: [] };
|
||||
var selectedAddressIds = { sender: null, receiver: null };
|
||||
|
||||
// ─── Load Countries ───
|
||||
function loadCountries() {
|
||||
if (!ifnex_ajax || !ifnex_ajax.ajax_url) {
|
||||
@ -946,6 +950,11 @@ jQuery(document).ready(function($) {
|
||||
receiver_state: $('#ifnex-receiver-state').val(),
|
||||
receiver_postal_code: $('#ifnex-receiver-postal-code').val(),
|
||||
receiver_national_id: $('#ifnex-receiver-national-id').val(),
|
||||
// دفترچه آدرس (فاز ۳.۹)
|
||||
save_sender_address: $('#ifnex-save-sender-address').is(':checked') ? 1 : 0,
|
||||
save_receiver_address: $('#ifnex-save-receiver-address').is(':checked') ? 1 : 0,
|
||||
sender_address_id: selectedAddressIds.sender || '',
|
||||
receiver_address_id: selectedAddressIds.receiver || '',
|
||||
};
|
||||
}
|
||||
|
||||
@ -1157,5 +1166,110 @@ jQuery(document).ready(function($) {
|
||||
updateInvoiceStepVisibility();
|
||||
// لود کشورها (اگه خطا داد اشکالی نداره)
|
||||
try { loadCountries(); } catch(e) { console.error('IFNEX: loadCountries error', e); }
|
||||
// دفترچه آدرس (فاز ۳.۹) — اگه خطا داد، فرم عادی بدون dropdown کار میکنه
|
||||
try { loadSavedAddresses(); } catch(e) { console.error('IFNEX: loadSavedAddresses error', e); }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// دفترچه آدرس (فاز ۳.۹) — dropdown آدرسهای ذخیرهشده
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str == null ? '' : str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function loadSavedAddresses() {
|
||||
if (!ifnex_ajax || !ifnex_ajax.ajax_url) return;
|
||||
|
||||
$.ajax({
|
||||
url: ifnex_ajax.ajax_url,
|
||||
method: 'POST',
|
||||
data: { action: 'ifnex_get_addresses', nonce: ifnex_ajax.nonce },
|
||||
success: function(res) {
|
||||
if (!res || !res.success || !res.data || !res.data.data) return;
|
||||
|
||||
var list = res.data.data;
|
||||
savedAddresses.sender = [];
|
||||
savedAddresses.receiver = [];
|
||||
|
||||
list.forEach(function(a) {
|
||||
if (a.role === 'sender') savedAddresses.sender.push(a);
|
||||
else savedAddresses.receiver.push(a);
|
||||
});
|
||||
|
||||
populateAddressSelect('sender');
|
||||
populateAddressSelect('receiver');
|
||||
}
|
||||
// در صورت خطا ساکت میمانیم — فرم بدون dropdown هم کار میکند
|
||||
});
|
||||
}
|
||||
|
||||
function populateAddressSelect(side) {
|
||||
var $wrap = $('#ifnex-' + side + '-saved-wrap');
|
||||
var $sel = $('#ifnex-' + side + '-saved');
|
||||
if (!$wrap.length || !$sel.length) return;
|
||||
|
||||
var items = savedAddresses[side];
|
||||
if (!items || !items.length) {
|
||||
$wrap.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '<option value="">— انتخاب کنید —</option>';
|
||||
items.forEach(function(a) {
|
||||
var title = a.display_label || ((a.label ? a.label + ' — ' : '') + a.name + (a.city ? ' — ' + a.city : ''));
|
||||
if (a.is_default) title += ' ⭐';
|
||||
html += '<option value="' + parseInt(a.id, 10) + '">' + escapeHtml(title) + '</option>';
|
||||
});
|
||||
|
||||
$sel.html(html);
|
||||
$wrap.show();
|
||||
}
|
||||
|
||||
function fillAddressFields(side, entry) {
|
||||
if (side === 'sender') {
|
||||
$('#ifnex-sender-name').val(entry.name || '');
|
||||
$('#ifnex-sender-phone').val(entry.phone || '');
|
||||
$('#ifnex-sender-state').val(entry.state || '');
|
||||
$('#ifnex-sender-city').val(entry.city || '');
|
||||
$('#ifnex-sender-postal-code').val(entry.postal_code || '');
|
||||
$('#ifnex-sender-national-id').val(entry.national_id || '');
|
||||
$('#ifnex-sender-address').val(entry.address || '');
|
||||
} else {
|
||||
$('#ifnex-receiver-name').val(entry.name || '');
|
||||
$('#ifnex-receiver-phone').val(entry.phone || '');
|
||||
$('#ifnex-receiver-state').val(entry.state || '');
|
||||
$('#ifnex-receiver-city').val(entry.city || '');
|
||||
$('#ifnex-receiver-postal-code').val(entry.postal_code || '');
|
||||
$('#ifnex-receiver-national-id').val(entry.national_id || '');
|
||||
$('#ifnex-receiver-address').val(entry.address || '');
|
||||
}
|
||||
}
|
||||
|
||||
$('#ifnex-sender-saved, #ifnex-receiver-saved').on('change', function() {
|
||||
var side = this.id.indexOf('sender') !== -1 ? 'sender' : 'receiver';
|
||||
var id = parseInt($(this).val(), 10) || null;
|
||||
selectedAddressIds[side] = id;
|
||||
|
||||
if (!id) {
|
||||
// انتخاب خالی — فیلدها دستنخورده میمانند؛ کاربر خودش پاک/ویرایش میکند
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = null;
|
||||
savedAddresses[side].forEach(function(a) {
|
||||
if (parseInt(a.id, 10) === id) entry = a;
|
||||
});
|
||||
|
||||
if (entry) {
|
||||
fillAddressFields(side, entry);
|
||||
// آدرس از قبل ذخیره شده — چکباکس ذخیره لازم نیست (و نباید رکورد تکراری بسازد)
|
||||
$('#ifnex-save-' + side + '-address').prop('checked', false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -55,6 +55,8 @@ function ifnex_enqueue_scripts() {
|
||||
// SweetAlert2 — پاپآپ خطاها (فاز ۳.۸) — باندل محلی، بدون وابستگی به CDN
|
||||
wp_enqueue_script('sweetalert2', plugin_dir_url(__FILE__) . 'assets/js/sweetalert2.all.min.js', array(), '11.14.5', true);
|
||||
wp_enqueue_script('ifnex-order-form-js', plugin_dir_url(__FILE__) . 'assets/js/ifnex-order-form.js', array('jquery', 'sweetalert2'), ifnex_asset_version('assets/js/ifnex-order-form.js'), true);
|
||||
// دفترچه آدرس (فاز ۳.۹) — تب اختصاصی در پنل کاربری
|
||||
wp_enqueue_script('ifnex-address-book-js', plugin_dir_url(__FILE__) . 'assets/js/ifnex-address-book.js', array('jquery', 'sweetalert2'), ifnex_asset_version('assets/js/ifnex-address-book.js'), true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -799,6 +799,15 @@ function ifnex_order_form_shortcode($atts) {
|
||||
<div class="ifnex-form-step" data-step="2">
|
||||
<div class="ifnex-form-card">
|
||||
<h3>👤 اطلاعات فرستنده</h3>
|
||||
|
||||
<?php // دفترچه آدرس (فاز ۳.۹) — انتخاب از آدرسهای ذخیرهشده (با JS پر میشود) ?>
|
||||
<div class="ifnex-saved-address-wrap" id="ifnex-sender-saved-wrap" style="display:none;">
|
||||
<label for="ifnex-sender-saved">📒 انتخاب از آدرسهای ذخیرهشده</label>
|
||||
<select id="ifnex-sender-saved">
|
||||
<option value="">— انتخاب کنید —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="ifnex-form-grid">
|
||||
<div class="ifnex-field"><label for="ifnex-sender-name">نام و نام خانوادگی *</label><input type="text" id="ifnex-sender-name" name="ifnex_sender_name"></div>
|
||||
<div class="ifnex-field"><label for="ifnex-sender-phone">شماره تماس *</label><input type="tel" id="ifnex-sender-phone" name="ifnex_sender_phone"></div>
|
||||
@ -807,8 +816,24 @@ function ifnex_order_form_shortcode($atts) {
|
||||
<div class="ifnex-field"><label for="ifnex-sender-postal-code">کد پستی *</label><input type="text" id="ifnex-sender-postal-code" name="ifnex_sender_postal_code" pattern="[0-9]{5,15}" maxlength="15"></div>
|
||||
<div class="ifnex-field"><label for="ifnex-sender-national-id">کد ملی / شماره گذرنامه *</label><input type="text" id="ifnex-sender-national-id" name="ifnex_sender_national_id"></div>
|
||||
<div class="ifnex-field full"><label for="ifnex-sender-address">آدرس کامل *</label><textarea id="ifnex-sender-address" name="ifnex_sender_address" rows="2"></textarea></div>
|
||||
<div class="ifnex-field full ifnex-save-address-row">
|
||||
<label class="ifnex-save-address-label">
|
||||
<input type="checkbox" id="ifnex-save-sender-address">
|
||||
<span>💾 ذخیره این آدرس در دفترچه آدرس من</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:24px;">📮 اطلاعات گیرنده</h3>
|
||||
|
||||
<?php // دفترچه آدرس (فاز ۳.۹) ?>
|
||||
<div class="ifnex-saved-address-wrap" id="ifnex-receiver-saved-wrap" style="display:none;">
|
||||
<label for="ifnex-receiver-saved">📒 انتخاب از آدرسهای ذخیرهشده</label>
|
||||
<select id="ifnex-receiver-saved">
|
||||
<option value="">— انتخاب کنید —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="ifnex-form-grid">
|
||||
<div class="ifnex-field"><label for="ifnex-receiver-name">نام و نام خانوادگی *</label><input type="text" id="ifnex-receiver-name" name="ifnex_receiver_name"></div>
|
||||
<div class="ifnex-field"><label for="ifnex-receiver-phone">شماره تماس *</label><input type="tel" id="ifnex-receiver-phone" name="ifnex_receiver_phone"></div>
|
||||
@ -817,6 +842,12 @@ function ifnex_order_form_shortcode($atts) {
|
||||
<div class="ifnex-field"><label for="ifnex-receiver-postal-code">کد پستی *</label><input type="text" id="ifnex-receiver-postal-code" name="ifnex_receiver_postal_code" pattern="[0-9]{5,15}" maxlength="15"></div>
|
||||
<div class="ifnex-field"><label for="ifnex-receiver-national-id">کد ملی / شماره گذرنامه *</label><input type="text" id="ifnex-receiver-national-id" name="ifnex_receiver_national_id"></div>
|
||||
<div class="ifnex-field full"><label for="ifnex-receiver-address">آدرس کامل *</label><textarea id="ifnex-receiver-address" name="ifnex_receiver_address" rows="2"></textarea></div>
|
||||
<div class="ifnex-field full ifnex-save-address-row">
|
||||
<label class="ifnex-save-address-label">
|
||||
<input type="checkbox" id="ifnex-save-receiver-address">
|
||||
<span>💾 ذخیره این آدرس در دفترچه آدرس من</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ifnex-form-nav">
|
||||
<button class="ifnex-btn ifnex-prev-step">→ قبلی</button>
|
||||
@ -1663,6 +1694,7 @@ function ifnex_icon($name, $size = 20) {
|
||||
'wallet' => '<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"/>',
|
||||
'chart' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"/>',
|
||||
'tracking' => '<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"/>',
|
||||
'address' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"/>',
|
||||
'user' => '<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"/>',
|
||||
'logout' => '<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"/>',
|
||||
'truck' => '<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12"/>',
|
||||
@ -1697,7 +1729,7 @@ function ifnex_customer_dashboard_shortcode($atts) {
|
||||
}
|
||||
|
||||
$tab = sanitize_key($_GET['tab'] ?? 'dashboard');
|
||||
$allowed_tabs = ['dashboard', 'notifications', 'orders', 'new-order', 'invoices', 'wallet', 'transactions', 'tracking', 'profile'];
|
||||
$allowed_tabs = ['dashboard', 'notifications', 'orders', 'new-order', 'invoices', 'wallet', 'transactions', 'tracking', 'addresses', 'profile'];
|
||||
if (!in_array($tab, $allowed_tabs)) $tab = 'dashboard';
|
||||
|
||||
$bridge = new IFNEX_User_Bridge();
|
||||
@ -1741,13 +1773,14 @@ function ifnex_customer_dashboard_shortcode($atts) {
|
||||
'wallet' => ['icon' => 'wallet', 'label' => 'کیف پول'],
|
||||
'transactions' => ['icon' => 'chart', 'label' => 'تراکنشها'],
|
||||
'tracking' => ['icon' => 'tracking', 'label' => 'رهگیری مرسوله'],
|
||||
'addresses' => ['icon' => 'address', 'label' => 'دفترچه آدرس'],
|
||||
'profile' => ['icon' => 'user', 'label' => 'پروفایل'],
|
||||
];
|
||||
|
||||
$tab_titles = [
|
||||
'dashboard' => 'داشبورد', 'notifications' => 'اعلانها', 'orders' => 'سفارشات من', 'new-order' => 'ثبت سفارش جدید',
|
||||
'invoices' => 'فاکتورهای دستی',
|
||||
'wallet' => 'کیف پول', 'transactions' => 'تراکنشها', 'tracking' => 'رهگیری مرسوله', 'profile' => 'پروفایل',
|
||||
'wallet' => 'کیف پول', 'transactions' => 'تراکنشها', 'tracking' => 'رهگیری مرسوله', 'addresses' => 'دفترچه آدرس', 'profile' => 'پروفایل',
|
||||
];
|
||||
|
||||
ob_start();
|
||||
@ -2304,6 +2337,152 @@ function ifnex_customer_dashboard_shortcode($atts) {
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php elseif ($tab === 'addresses'): ?>
|
||||
<?php
|
||||
// ═══ دفترچه آدرس (فاز ۳.۹) ═══
|
||||
$addresses = [];
|
||||
$addresses_error = '';
|
||||
$addresses_result = $bridge->authenticated_request($user_id, '/addresses', 'GET');
|
||||
if (is_wp_error($addresses_result)) {
|
||||
$addresses_error = $addresses_result->get_error_message();
|
||||
} elseif (isset($addresses_result['data'])) {
|
||||
$addresses = $addresses_result['data'];
|
||||
}
|
||||
|
||||
$addr_filter = in_array($_GET['addr_role'] ?? '', ['sender', 'receiver'], true) ? $_GET['addr_role'] : '';
|
||||
if ($addr_filter) {
|
||||
$addresses = array_values(array_filter($addresses, function ($a) use ($addr_filter) {
|
||||
return ($a['role'] ?? '') === $addr_filter;
|
||||
}));
|
||||
}
|
||||
?>
|
||||
<?php if ($addresses_error): ?>
|
||||
<div class="ifnx-notice ifnx-notice-error">خطا در دریافت دفترچه آدرس: <?php echo esc_html($addresses_error); ?></div>
|
||||
<?php else: ?>
|
||||
<div class="ifnx-addresses" id="ifnx-addresses-app"
|
||||
data-base-url="<?php echo esc_url($base_url); ?>"
|
||||
data-current-tab="addresses">
|
||||
<input type="hidden" id="ifnx-address-nonce" value="<?php echo esc_attr(wp_create_nonce('ifnex_ajax_nonce')); ?>">
|
||||
<input type="hidden" id="ifnx-address-ajax-url" value="<?php echo esc_attr(admin_url('admin-ajax.php')); ?>">
|
||||
|
||||
<div class="ifnx-address-toolbar">
|
||||
<div class="ifnx-address-filters">
|
||||
<a href="<?php echo esc_url(add_query_arg(['tab' => 'addresses', 'addr_role' => ''], $base_url)); ?>" class="ifnx-chip <?php echo $addr_filter === '' ? 'active' : ''; ?>">همه</a>
|
||||
<a href="<?php echo esc_url(add_query_arg(['tab' => 'addresses', 'addr_role' => 'sender'], $base_url)); ?>" class="ifnx-chip <?php echo $addr_filter === 'sender' ? 'active' : ''; ?>">👤 فرستنده</a>
|
||||
<a href="<?php echo esc_url(add_query_arg(['tab' => 'addresses', 'addr_role' => 'receiver'], $base_url)); ?>" class="ifnx-chip <?php echo $addr_filter === 'receiver' ? 'active' : ''; ?>">📮 گیرنده</a>
|
||||
</div>
|
||||
<button type="button" id="ifnx-add-address-btn" class="ifnex-btn ifnex-btn-primary">+ افزودن آدرس جدید</button>
|
||||
</div>
|
||||
|
||||
<!-- فرم افزودن / ویرایش (با JS نمایش داده میشود) -->
|
||||
<div id="ifnx-address-form-wrap" style="display:none;">
|
||||
<form id="ifnx-address-form" class="ifnx-address-form" autocomplete="off">
|
||||
<input type="hidden" name="address_id" id="ifnx-addr-id" value="">
|
||||
<div class="ifnx-address-form-grid">
|
||||
<div class="ifnx-field">
|
||||
<label for="ifnx-addr-role">نقش آدرس *</label>
|
||||
<select name="role" id="ifnx-addr-role">
|
||||
<option value="receiver">📮 گیرنده</option>
|
||||
<option value="sender">👤 فرستنده</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ifnx-field">
|
||||
<label for="ifnx-addr-label">برچسب (مثلاً خانه / دفتر)</label>
|
||||
<input type="text" name="label" id="ifnx-addr-label" maxlength="100">
|
||||
</div>
|
||||
<div class="ifnx-field">
|
||||
<label for="ifnx-addr-name">نام و نام خانوادگی *</label>
|
||||
<input type="text" name="name" id="ifnx-addr-name" required>
|
||||
</div>
|
||||
<div class="ifnx-field">
|
||||
<label for="ifnx-addr-phone">شماره تماس *</label>
|
||||
<input type="tel" name="phone" id="ifnx-addr-phone" required>
|
||||
</div>
|
||||
<div class="ifnx-field">
|
||||
<label for="ifnx-addr-state">استان / ایالت</label>
|
||||
<input type="text" name="state" id="ifnx-addr-state">
|
||||
</div>
|
||||
<div class="ifnx-field">
|
||||
<label for="ifnx-addr-city">شهر</label>
|
||||
<input type="text" name="city" id="ifnx-addr-city">
|
||||
</div>
|
||||
<div class="ifnx-field">
|
||||
<label for="ifnx-addr-postal">کد پستی</label>
|
||||
<input type="text" name="postal_code" id="ifnx-addr-postal" maxlength="15">
|
||||
</div>
|
||||
<div class="ifnx-field">
|
||||
<label for="ifnx-addr-national">کد ملی / شماره گذرنامه</label>
|
||||
<input type="text" name="national_id" id="ifnx-addr-national" maxlength="50">
|
||||
</div>
|
||||
<div class="ifnx-field full">
|
||||
<label for="ifnx-addr-address">آدرس کامل *</label>
|
||||
<textarea name="address" id="ifnx-addr-address" rows="2" required></textarea>
|
||||
</div>
|
||||
<div class="ifnx-field full">
|
||||
<label class="ifnx-addr-checkbox">
|
||||
<input type="checkbox" name="is_default" id="ifnx-addr-default">
|
||||
<span>⭐ تنظیم بهعنوان آدرس پیشفرض این نقش</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ifnx-address-form-actions">
|
||||
<button type="submit" id="ifnx-address-submit" class="ifnex-btn ifnex-btn-primary">💾 ذخیره آدرس</button>
|
||||
<button type="button" id="ifnx-address-cancel" class="ifnex-btn">انصراف</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<?php if (empty($addresses)): ?>
|
||||
<div class="ifnx-address-empty">
|
||||
<div class="ifnx-address-empty-icon">📒</div>
|
||||
<h3>دفترچه آدرس شما خالی است</h3>
|
||||
<p>در فرم ثبت سفارش، چکباکس «ذخیره این آدرس» را بزنید یا همینجا آدرس جدید اضافه کنید تا دفعه بعد با یک کلیک پر شود.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="ifnx-address-grid">
|
||||
<?php foreach ($addresses as $addr): ?>
|
||||
<?php
|
||||
$is_sender = ($addr['role'] ?? '') === 'sender';
|
||||
$role_label = $is_sender ? '👤 فرستنده' : '📮 گیرنده';
|
||||
$title = $addr['display_label'] ?? ($addr['name'] ?? '');
|
||||
?>
|
||||
<div class="ifnx-address-card <?php echo !empty($addr['is_default']) ? 'is-default' : ''; ?>">
|
||||
<div class="ifnx-address-card-head">
|
||||
<span class="ifnx-address-role <?php echo $is_sender ? 'role-sender' : 'role-receiver'; ?>"><?php echo esc_html($role_label); ?></span>
|
||||
<?php if (!empty($addr['is_default'])): ?>
|
||||
<span class="ifnx-address-default-badge">⭐ پیشفرض</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="ifnx-address-card-title"><?php echo esc_html($title); ?></div>
|
||||
<div class="ifnx-address-card-meta">
|
||||
<?php if (!empty($addr['phone'])): ?><span>📞 <?php echo esc_html($addr['phone']); ?></span><?php endif; ?>
|
||||
<?php if (!empty($addr['state']) || !empty($addr['city'])): ?><span>📍 <?php echo esc_html(trim(($addr['state'] ?? '') . '، ' . ($addr['city'] ?? ''), '، ')); ?></span><?php endif; ?>
|
||||
<?php if (!empty($addr['postal_code'])): ?><span>🏷️ <?php echo esc_html($addr['postal_code']); ?></span><?php endif; ?>
|
||||
</div>
|
||||
<div class="ifnx-address-card-text"><?php echo esc_html($addr['address'] ?? ''); ?></div>
|
||||
<div class="ifnx-address-card-actions"
|
||||
data-id="<?php echo (int)($addr['id'] ?? 0); ?>"
|
||||
data-role="<?php echo esc_attr($addr['role'] ?? 'receiver'); ?>"
|
||||
data-label="<?php echo esc_attr($addr['label'] ?? ''); ?>"
|
||||
data-name="<?php echo esc_attr($addr['name'] ?? ''); ?>"
|
||||
data-phone="<?php echo esc_attr($addr['phone'] ?? ''); ?>"
|
||||
data-state="<?php echo esc_attr($addr['state'] ?? ''); ?>"
|
||||
data-city="<?php echo esc_attr($addr['city'] ?? ''); ?>"
|
||||
data-postal="<?php echo esc_attr($addr['postal_code'] ?? ''); ?>"
|
||||
data-national="<?php echo esc_attr($addr['national_id'] ?? ''); ?>"
|
||||
data-address="<?php echo esc_attr($addr['address'] ?? ''); ?>"
|
||||
data-default="<?php echo !empty($addr['is_default']) ? '1' : '0'; ?>">
|
||||
<button type="button" class="ifnex-btn ifnex-btn-sm ifnex-addr-edit">✏️ ویرایش</button>
|
||||
<button type="button" class="ifnex-btn ifnex-btn-sm ifnex-btn-info ifnx-addr-default" <?php echo !empty($addr['is_default']) ? 'disabled' : ''; ?>>⭐ پیشفرض</button>
|
||||
<button type="button" class="ifnex-btn ifnex-btn-sm ifnex-btn-danger ifnx-addr-delete">🗑 حذف</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php elseif ($tab === 'profile'): ?>
|
||||
<?php echo do_shortcode('[ifnex_user_profile]'); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
@ -631,6 +631,105 @@ function ifnex_submit_order_ajax() {
|
||||
wp_send_json_success($result);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// AJAX Handlers دفترچه آدرس (فاز ۳.۹)
|
||||
// پروکسی امن به API لاراول — کاربر فقط به رکوردهای خودش دسترسی دارد
|
||||
// (Ownership در سمت لاراول با توکن Sanctum تضمین میشود)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
add_action('wp_ajax_ifnex_get_addresses', 'ifnex_get_addresses_ajax');
|
||||
function ifnex_get_addresses_ajax() {
|
||||
check_ajax_referer('ifnex_ajax_nonce', 'nonce');
|
||||
if (!is_user_logged_in()) wp_send_json_error('باید وارد شوید.');
|
||||
|
||||
$args = array();
|
||||
if (!empty($_POST['role']) && in_array($_POST['role'], ['sender', 'receiver'], true)) {
|
||||
$args['role'] = sanitize_key($_POST['role']);
|
||||
}
|
||||
if (!empty($_POST['search'])) {
|
||||
$args['search'] = sanitize_text_field($_POST['search']);
|
||||
}
|
||||
|
||||
$bridge = new IFNEX_User_Bridge();
|
||||
$result = $bridge->authenticated_request(get_current_user_id(), '/addresses', 'GET', $args);
|
||||
|
||||
if (is_wp_error($result)) wp_send_json_error($result->get_error_message());
|
||||
wp_send_json_success($result);
|
||||
}
|
||||
|
||||
add_action('wp_ajax_ifnex_save_address', 'ifnex_save_address_ajax');
|
||||
function ifnex_save_address_ajax() {
|
||||
check_ajax_referer('ifnex_ajax_nonce', 'nonce');
|
||||
if (!is_user_logged_in()) wp_send_json_error('باید وارد شوید.');
|
||||
|
||||
$data = $_POST['data'] ?? [];
|
||||
if (empty($data)) wp_send_json_error('دادهای ارسال نشده.');
|
||||
|
||||
// پاکسازی ورودیها قبل از ارسال به API
|
||||
$clean = array(
|
||||
'role' => in_array($data['role'] ?? '', ['sender', 'receiver'], true) ? $data['role'] : 'receiver',
|
||||
'label' => sanitize_text_field($data['label'] ?? ''),
|
||||
'name' => sanitize_text_field($data['name'] ?? ''),
|
||||
'phone' => sanitize_text_field($data['phone'] ?? ''),
|
||||
'state' => sanitize_text_field($data['state'] ?? ''),
|
||||
'city' => sanitize_text_field($data['city'] ?? ''),
|
||||
'postal_code' => sanitize_text_field($data['postal_code'] ?? ''),
|
||||
'national_id' => sanitize_text_field($data['national_id'] ?? ''),
|
||||
'address' => sanitize_textarea_field($data['address'] ?? ''),
|
||||
);
|
||||
if (!empty($data['email'])) $clean['email'] = sanitize_email($data['email']);
|
||||
if (!empty($data['is_default'])) $clean['is_default'] = true;
|
||||
|
||||
$bridge = new IFNEX_User_Bridge();
|
||||
$user_id = get_current_user_id();
|
||||
|
||||
if (!empty($data['id'])) {
|
||||
// ویرایش رکورد موجود
|
||||
$address_id = intval($data['id']);
|
||||
if ($address_id <= 0) wp_send_json_error('شناسه آدرس نامعتبر است.');
|
||||
$result = $bridge->authenticated_request($user_id, '/addresses/' . $address_id, 'PUT', $clean);
|
||||
} else {
|
||||
// افزودن رکورد جدید
|
||||
$result = $bridge->authenticated_request($user_id, '/addresses', 'POST', $clean);
|
||||
}
|
||||
|
||||
if (is_wp_error($result)) wp_send_json_error($result->get_error_message());
|
||||
if (isset($result['success']) && !$result['success']) {
|
||||
wp_send_json_error($result['message'] ?? 'خطا در ذخیره آدرس');
|
||||
}
|
||||
wp_send_json_success($result);
|
||||
}
|
||||
|
||||
add_action('wp_ajax_ifnex_delete_address', 'ifnex_delete_address_ajax');
|
||||
function ifnex_delete_address_ajax() {
|
||||
check_ajax_referer('ifnex_ajax_nonce', 'nonce');
|
||||
if (!is_user_logged_in()) wp_send_json_error('باید وارد شوید.');
|
||||
|
||||
$address_id = intval($_POST['id'] ?? 0);
|
||||
if ($address_id <= 0) wp_send_json_error('شناسه آدرس نامعتبر است.');
|
||||
|
||||
$bridge = new IFNEX_User_Bridge();
|
||||
$result = $bridge->authenticated_request(get_current_user_id(), '/addresses/' . $address_id, 'DELETE');
|
||||
|
||||
if (is_wp_error($result)) wp_send_json_error($result->get_error_message());
|
||||
wp_send_json_success($result);
|
||||
}
|
||||
|
||||
add_action('wp_ajax_ifnex_set_default_address', 'ifnex_set_default_address_ajax');
|
||||
function ifnex_set_default_address_ajax() {
|
||||
check_ajax_referer('ifnex_ajax_nonce', 'nonce');
|
||||
if (!is_user_logged_in()) wp_send_json_error('باید وارد شوید.');
|
||||
|
||||
$address_id = intval($_POST['id'] ?? 0);
|
||||
if ($address_id <= 0) wp_send_json_error('شناسه آدرس نامعتبر است.');
|
||||
|
||||
$bridge = new IFNEX_User_Bridge();
|
||||
$result = $bridge->authenticated_request(get_current_user_id(), '/addresses/' . $address_id . '/default', 'POST');
|
||||
|
||||
if (is_wp_error($result)) wp_send_json_error($result->get_error_message());
|
||||
wp_send_json_success($result);
|
||||
}
|
||||
|
||||
// Enqueue scripts & styles برای شورتکدهای جدید
|
||||
add_action('wp_enqueue_scripts', 'ifnex_enqueue_order_assets');
|
||||
function ifnex_enqueue_order_assets() {
|
||||
|
||||
File diff suppressed because one or more lines are too long
247
04_Laravel/app/Http/Controllers/Api/AddressBookController.php
Normal file
247
04_Laravel/app/Http/Controllers/Api/AddressBookController.php
Normal file
@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AddressBookEntry;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* AddressBookController — CRUD دفترچه آدرس کاربر (فاز ۳.۹)
|
||||
*
|
||||
* همهٔ عملیات با auth:sanctum محافظت میشوند و کاربر فقط به
|
||||
* رکوردهای خودش دسترسی دارد (Ownership check در همهٔ اکشنها).
|
||||
*/
|
||||
class AddressBookController extends Controller
|
||||
{
|
||||
/**
|
||||
* لیست آدرسهای کاربر
|
||||
* GET /api/v1/addresses?role=sender|receiver&search=...
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$query = AddressBookEntry::forUser($user->id)
|
||||
->orderByDesc('is_default')
|
||||
->orderByDesc('last_used_at')
|
||||
->orderByDesc('updated_at');
|
||||
|
||||
if ($role = $request->query('role')) {
|
||||
if (in_array($role, [AddressBookEntry::ROLE_SENDER, AddressBookEntry::ROLE_RECEIVER], true)) {
|
||||
$query->ofRole($role);
|
||||
}
|
||||
}
|
||||
|
||||
if ($search = trim((string) $request->query('search', ''))) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('label', 'like', "%{$search}%")
|
||||
->orWhere('phone', 'like', "%{$search}%")
|
||||
->orWhere('city', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$entries = $query->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $entries->map(fn (AddressBookEntry $e) => $this->formatEntry($e)),
|
||||
'count' => $entries->count(),
|
||||
'max_entries' => AddressBookEntry::MAX_ENTRIES_PER_USER,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* افزودن آدرس جدید
|
||||
* POST /api/v1/addresses
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $this->validateEntry($request);
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
$count = AddressBookEntry::forUser($user->id)->count();
|
||||
if ($count >= AddressBookEntry::MAX_ENTRIES_PER_USER) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'تعداد آدرسهای ذخیرهشده شما به حداکثر مجاز (' . AddressBookEntry::MAX_ENTRIES_PER_USER . ') رسیده است. لطفاً آدرسهای قدیمی را حذف کنید.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
// جلوگیری از رکورد تکراری (همان نقش + تلفن + آدرس) → بروزرسانی رکورد موجود
|
||||
$duplicate = AddressBookEntry::findDuplicate(
|
||||
$user->id,
|
||||
$validated['role'],
|
||||
$validated['phone'],
|
||||
$validated['address']
|
||||
);
|
||||
|
||||
if ($duplicate) {
|
||||
$duplicate->update(collect($validated)->except(['role'])->all());
|
||||
|
||||
if (!empty($validated['is_default'])) {
|
||||
$duplicate->makeDefault();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'آدرس مشابه از قبل ذخیره شده بود و بروزرسانی شد.',
|
||||
'data' => $this->formatEntry($duplicate->refresh()),
|
||||
'updated_existing' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$entry = AddressBookEntry::create([
|
||||
...$validated,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
if (!empty($validated['is_default'])) {
|
||||
$entry->makeDefault();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'آدرس با موفقیت ذخیره شد.',
|
||||
'data' => $this->formatEntry($entry),
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* ویرایش آدرس
|
||||
* PUT/PATCH /api/v1/addresses/{entry}
|
||||
*/
|
||||
public function update(Request $request, AddressBookEntry $address): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if ($address->user_id !== $user->id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'دسترسی غیرمجاز.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$validated = $this->validateEntry($request, $address->id);
|
||||
|
||||
// نقش رکورد قابل تغییر نیست (جلوگیری از ناسازگاری با فرم)
|
||||
unset($validated['role']);
|
||||
|
||||
$address->update($validated);
|
||||
|
||||
if (!empty($validated['is_default'])) {
|
||||
$address->makeDefault();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'آدرس بروزرسانی شد.',
|
||||
'data' => $this->formatEntry($address->refresh()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* حذف آدرس
|
||||
* DELETE /api/v1/addresses/{entry}
|
||||
*/
|
||||
public function destroy(Request $request, AddressBookEntry $address): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if ($address->user_id !== $user->id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'دسترسی غیرمجاز.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$address->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'آدرس حذف شد.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* تنظیم بهعنوان پیشفرض
|
||||
* POST /api/v1/addresses/{address}/default
|
||||
*/
|
||||
public function setDefault(Request $request, AddressBookEntry $address): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if ($address->user_id !== $user->id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'دسترسی غیرمجاز.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$address->makeDefault();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'این آدرس بهعنوان پیشفرض تنظیم شد.',
|
||||
'data' => $this->formatEntry($address->refresh()),
|
||||
]);
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
private function validateEntry(Request $request, ?int $ignoreId = null): array
|
||||
{
|
||||
try {
|
||||
return $request->validate([
|
||||
'role' => ['required', Rule::in([AddressBookEntry::ROLE_SENDER, AddressBookEntry::ROLE_RECEIVER])],
|
||||
'label' => ['nullable', 'string', 'max:100'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'company' => ['nullable', 'string', 'max:255'],
|
||||
'phone' => ['required', 'string', 'max:50'],
|
||||
'email' => ['nullable', 'email', 'max:255'],
|
||||
'state' => ['nullable', 'string', 'max:255'],
|
||||
'city' => ['nullable', 'string', 'max:255'],
|
||||
'postal_code' => ['nullable', 'string', 'max:20'],
|
||||
'national_id' => ['nullable', 'string', 'max:50'],
|
||||
'address' => ['required', 'string', 'max:1000'],
|
||||
'is_default' => ['nullable', 'boolean'],
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
abort(response()->json([
|
||||
'success' => false,
|
||||
'message' => 'خطا در اعتبارسنجی اطلاعات آدرس',
|
||||
'errors' => $e->errors(),
|
||||
], 422));
|
||||
}
|
||||
}
|
||||
|
||||
private function formatEntry(AddressBookEntry $e): array
|
||||
{
|
||||
return [
|
||||
'id' => $e->id,
|
||||
'role' => $e->role,
|
||||
'label' => $e->label,
|
||||
'display_label' => $e->display_label,
|
||||
'name' => $e->name,
|
||||
'company' => $e->company,
|
||||
'phone' => $e->phone,
|
||||
'email' => $e->email,
|
||||
'state' => $e->state,
|
||||
'city' => $e->city,
|
||||
'postal_code' => $e->postal_code,
|
||||
'national_id' => $e->national_id,
|
||||
'address' => $e->address,
|
||||
'is_default' => $e->is_default,
|
||||
'use_count' => $e->use_count,
|
||||
'last_used_at' => $e->last_used_at?->toIso8601String(),
|
||||
'form_fields' => $e->toFormFieldData(),
|
||||
'updated_at' => $e->updated_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Api\Customer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AddressBookEntry;
|
||||
use App\Models\Shipment;
|
||||
use App\Models\ShipmentItem;
|
||||
use App\Models\ShipmentPackage;
|
||||
@ -124,7 +125,13 @@ class CustomerOrderController extends Controller
|
||||
|
||||
// تخفیف
|
||||
'discount_code' => ['nullable', 'string', 'max:50'],
|
||||
|
||||
|
||||
// دفترچه آدرس (فاز ۳.۹) — ذخیره/استفاده از آدرسهای پرتکرار
|
||||
'save_sender_address' => ['nullable', 'boolean'],
|
||||
'save_receiver_address' => ['nullable', 'boolean'],
|
||||
'sender_address_id' => ['nullable', 'integer', 'exists:address_book_entries,id'],
|
||||
'receiver_address_id' => ['nullable', 'integer', 'exists:address_book_entries,id'],
|
||||
|
||||
// اقلام گمرکی
|
||||
'items' => ['nullable', 'array', 'max:9'],
|
||||
'items.*.description' => ['required_with:items', 'string', 'max:500'],
|
||||
@ -323,6 +330,9 @@ class CustomerOrderController extends Controller
|
||||
return $shipment;
|
||||
});
|
||||
|
||||
// دفترچه آدرس (فاز ۳.۹) — بعد از تراکنش تا شکستِ آن سفارش را rollback نکند
|
||||
$this->handleAddressBook($user->id, $validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'سفارش شما با موفقیت ثبت شد. پس از تأیید کارمند، گزینه پرداخت برای شما فعال خواهد شد.',
|
||||
@ -1056,6 +1066,81 @@ class CustomerOrderController extends Controller
|
||||
return $number;
|
||||
}
|
||||
|
||||
/**
|
||||
* دفترچه آدرس (فاز ۳.۹) — عملیات جانبی پس از ثبت سفارش
|
||||
*
|
||||
* ۱) اگر کاربر آدرس ذخیرهشده انتخاب کرده بود (sender_address_id / receiver_address_id)
|
||||
* شمارندهٔ استفادهٔ آن رکورد بروز میشود.
|
||||
* ۲) اگر چکباکس «ذخیره این آدرس» زده شده باشد، آدرس در دفترچه upsert میشود
|
||||
* (رکورد تکراری با همان نقش + تلفن + آدرس بروزرسانی میشود نه اینکه دوباره ساخته شود).
|
||||
*
|
||||
* این عملیات غیرحیاتی است: هر خطایی فقط لاگ میشود و نباید ثبت سفارش را خراب کند.
|
||||
*/
|
||||
private function handleAddressBook(int $userId, array $validated): void
|
||||
{
|
||||
try {
|
||||
// ─── ثبت استفاده از آدرسهای انتخابشده ───
|
||||
foreach (['sender', 'receiver'] as $side) {
|
||||
$selectedId = $validated["{$side}_address_id"] ?? null;
|
||||
if ($selectedId) {
|
||||
$entry = AddressBookEntry::forUser($userId)->find($selectedId);
|
||||
if ($entry) {
|
||||
$entry->markUsed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ذخیرهٔ آدرسهای علامتخورده ───
|
||||
foreach (['sender', 'receiver'] as $side) {
|
||||
if (empty($validated["save_{$side}_address"])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => $validated["{$side}_name"] ?? null,
|
||||
'phone' => $validated["{$side}_phone"] ?? null,
|
||||
'address' => $validated["{$side}_address"] ?? null,
|
||||
'state' => $validated["{$side}_state"] ?? null,
|
||||
'city' => $validated["{$side}_city"] ?? null,
|
||||
'postal_code' => $validated["{$side}_postal_code"] ?? null,
|
||||
'national_id' => $validated["{$side}_national_id"] ?? null,
|
||||
'company' => $validated["{$side}_company"] ?? null,
|
||||
'email' => $validated["{$side}_email"] ?? null,
|
||||
];
|
||||
|
||||
// بدون نام/تلفن/آدرس ذخیره معنا ندارد
|
||||
if (!$data['name'] || !$data['phone'] || !$data['address']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// سقف تعداد — در صورت پر بودن، بیصدا رد میشود
|
||||
if (AddressBookEntry::forUser($userId)->count() >= AddressBookEntry::MAX_ENTRIES_PER_USER) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// آدرس قبلاً ذخیره شده؟ → بروزرسانی بهجای ساخت رکورد تکراری
|
||||
$entry = AddressBookEntry::findDuplicate($userId, $side, $data['phone'], $data['address']);
|
||||
|
||||
if ($entry) {
|
||||
$entry->update($data);
|
||||
} else {
|
||||
AddressBookEntry::create([
|
||||
...$data,
|
||||
'user_id' => $userId,
|
||||
'role' => $side,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// عملیات دفترچه آدرس هرگز نباید ثبت سفارش را ناموفق کند
|
||||
\Log::warning('AddressBook sync after order failed', [
|
||||
'user_id' => $userId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* تبدیل تاریخ به شمسی
|
||||
*/
|
||||
|
||||
154
04_Laravel/app/Models/AddressBookEntry.php
Normal file
154
04_Laravel/app/Models/AddressBookEntry.php
Normal file
@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use App\Traits\Auditable;
|
||||
|
||||
/**
|
||||
* AddressBookEntry — یک رکورد از دفترچه آدرس کاربر (فاز ۳.۹)
|
||||
*
|
||||
* نقش (role) مشخص میکند این آدرس برای فرستنده ذخیره شده یا گیرنده؛
|
||||
* ساختار فیلدها عیناً مطابق ستونهای sender_/receiver_ جدول shipments است.
|
||||
*/
|
||||
class AddressBookEntry extends Model
|
||||
{
|
||||
use Auditable;
|
||||
|
||||
public const ROLE_SENDER = 'sender';
|
||||
public const ROLE_RECEIVER = 'receiver';
|
||||
|
||||
/** سقف تعداد آدرس ذخیرهشده برای هر کاربر (ضد سوءاستفاده) */
|
||||
public const MAX_ENTRIES_PER_USER = 50;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'role',
|
||||
'label',
|
||||
'name',
|
||||
'company',
|
||||
'phone',
|
||||
'email',
|
||||
'state',
|
||||
'city',
|
||||
'postal_code',
|
||||
'national_id',
|
||||
'address',
|
||||
'is_default',
|
||||
'use_count',
|
||||
'last_used_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_default' => 'boolean',
|
||||
'use_count' => 'integer',
|
||||
'last_used_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* مقادیر پیشفرض روی instance — برای اینکه در لحظهٔ create
|
||||
* (و قبل از refresh از DB) مقدار use_count/is_default در دسترس باشد.
|
||||
* SQLite مقادیر default ستونها را بلافاصله روی مدل در حافظه بازنویسی نمیکند.
|
||||
*/
|
||||
protected $attributes = [
|
||||
'use_count' => 0,
|
||||
'is_default' => false,
|
||||
];
|
||||
|
||||
// === Relationships ===
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// === Scopes ===
|
||||
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
public function scopeOfRole($query, string $role)
|
||||
{
|
||||
return $query->where('role', $role);
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
/**
|
||||
* دادههای آمادهٔ پر کردن فیلدهای فرم سفارش
|
||||
* کلیدها عیناً با prefix نقش مطابق فرم وردپرس برمیگردند.
|
||||
*/
|
||||
public function toFormFieldData(): array
|
||||
{
|
||||
$prefix = $this->role === self::ROLE_SENDER ? 'sender' : 'receiver';
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'role' => $this->role,
|
||||
'label' => $this->label,
|
||||
'is_default' => $this->is_default,
|
||||
"{$prefix}_name" => $this->name,
|
||||
"{$prefix}_phone" => $this->phone,
|
||||
"{$prefix}_state" => $this->state,
|
||||
"{$prefix}_city" => $this->city,
|
||||
"{$prefix}_postal_code" => $this->postal_code,
|
||||
"{$prefix}_national_id" => $this->national_id,
|
||||
"{$prefix}_address" => $this->address,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* پیدا کردن رکورد تکراری (همان نقش + همان تلفن + همان آدرس)
|
||||
* برای جلوگیری از انباشت رکوردهای دوباره هنگام «ذخیره این آدرس».
|
||||
*/
|
||||
public static function findDuplicate(int $userId, string $role, string $phone, string $address): ?self
|
||||
{
|
||||
return static::forUser($userId)
|
||||
->ofRole($role)
|
||||
->where('phone', $phone)
|
||||
->where('address', $address)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* ثبت استفاده: افزایش شمارنده و بروزرسانی زمان آخرین استفاده
|
||||
*/
|
||||
public function markUsed(): void
|
||||
{
|
||||
$this->increment('use_count');
|
||||
$this->forceFill(['last_used_at' => now()])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* این رکورد را پیشفرض نقش خودش میکند (سایر رکوردهای همان نقش off میشوند)
|
||||
*/
|
||||
public function makeDefault(): void
|
||||
{
|
||||
static::forUser($this->user_id)
|
||||
->ofRole($this->role)
|
||||
->where('id', '!=', $this->id)
|
||||
->update(['is_default' => false]);
|
||||
|
||||
$this->forceFill(['is_default' => true])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* برچسب نمایشی در dropdown فرم
|
||||
*/
|
||||
public function getDisplayLabelAttribute(): string
|
||||
{
|
||||
$parts = [];
|
||||
if ($this->label) {
|
||||
$parts[] = $this->label;
|
||||
}
|
||||
$parts[] = $this->name;
|
||||
if ($this->city) {
|
||||
$parts[] = $this->city;
|
||||
}
|
||||
|
||||
return implode(' — ', $parts);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* دفترچه آدرس کاربر (فاز ۳.۹)
|
||||
*
|
||||
* هر کاربر میتواند آدرسهای پرتکرار خودش (بهعنوان فرستنده یا گیرنده)
|
||||
* را ذخیره کند تا در سفارشهای بعدی با یک کلیک پر شوند.
|
||||
*
|
||||
* ساختار فیلدها عیناً مطابق فیلدهای sender_/receiver_ جدول shipments است
|
||||
* تا پر کردن فرم بدون تبدیل انجام شود.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('address_book_entries', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||
|
||||
// این آدرس برای چه نقشی ذخیره شده؟ sender = فرستنده، receiver = گیرنده
|
||||
$table->string('role', 20)->default('receiver'); // sender|receiver
|
||||
|
||||
// برچسب دلخواه (مثلاً «خانه»، «دفتر»، «انبار شنگهای»)
|
||||
$table->string('label', 100)->nullable();
|
||||
|
||||
// اطلاعات تماس و آدرس — مطابق فیلدهای shipments
|
||||
$table->string('name', 255);
|
||||
$table->string('company', 255)->nullable();
|
||||
$table->string('phone', 50);
|
||||
$table->string('email', 255)->nullable();
|
||||
$table->string('state', 255)->nullable();
|
||||
$table->string('city', 255)->nullable();
|
||||
$table->string('postal_code', 20)->nullable();
|
||||
$table->string('national_id', 50)->nullable();
|
||||
$table->text('address');
|
||||
|
||||
// آدرس پیشفرض برای این نقش (با انتخاب مجدد، قبلی false میشود)
|
||||
$table->boolean('is_default')->default(false);
|
||||
|
||||
// آمار استفاده — برای مرتبسازی «پرتکرارها»
|
||||
$table->unsignedInteger('use_count')->default(0);
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'role']);
|
||||
$table->index(['user_id', 'is_default']);
|
||||
$table->index(['user_id', 'last_used_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('address_book_entries');
|
||||
}
|
||||
};
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\CustomerFinancialController;
|
||||
use App\Http\Controllers\Api\AddressBookController;
|
||||
use App\Http\Controllers\Api\CommitmentFormController;
|
||||
use App\Http\Controllers\Api\MobileVerificationController;
|
||||
use App\Http\Controllers\Api\Customer\CustomerOrderController;
|
||||
@ -75,6 +76,14 @@ Route::middleware(['auth:sanctum', 'throttle:customer'])->prefix('v1')->group(fu
|
||||
Route::get('/discount-codes', [DiscountCodeController::class, 'index']);
|
||||
Route::post('/discount-codes/validate', [DiscountCodeController::class, 'validate']);
|
||||
|
||||
// ─── Address Book (دفترچه آدرس — فاز ۳.۹) ───
|
||||
Route::get('/addresses', [AddressBookController::class, 'index']);
|
||||
Route::post('/addresses', [AddressBookController::class, 'store']);
|
||||
Route::put('/addresses/{address}', [AddressBookController::class, 'update']);
|
||||
Route::patch('/addresses/{address}', [AddressBookController::class, 'update']);
|
||||
Route::delete('/addresses/{address}', [AddressBookController::class, 'destroy']);
|
||||
Route::post('/addresses/{address}/default', [AddressBookController::class, 'setDefault']);
|
||||
|
||||
// ─── Commitment Forms (نیازمند احراز هویت) ───
|
||||
Route::get('/commitment-forms', [CommitmentFormController::class, 'index']);
|
||||
Route::get('/commitment-forms/{direction}', [CommitmentFormController::class, 'byDirection']);
|
||||
|
||||
288
04_Laravel/tests/Feature/Api/AddressBookControllerTest.php
Normal file
288
04_Laravel/tests/Feature/Api/AddressBookControllerTest.php
Normal file
@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\AddressBookEntry;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AddressBookControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $user;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
Sanctum::actingAs($this->user);
|
||||
}
|
||||
|
||||
private function validPayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'role' => 'receiver',
|
||||
'label' => 'خانه',
|
||||
'name' => 'علی رضایی',
|
||||
'phone' => '09121234567',
|
||||
'state' => 'تهران',
|
||||
'city' => 'تهران',
|
||||
'postal_code' => '1234567890',
|
||||
'address' => 'خیابان ولیعصر، پلاک ۱۰، واحد ۲',
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
// ═══════════════════ index ═══════════════════
|
||||
|
||||
#[Test]
|
||||
public function it_lists_only_current_user_addresses()
|
||||
{
|
||||
AddressBookEntry::create([...$this->validPayload(), 'user_id' => $this->user->id]);
|
||||
AddressBookEntry::create([...$this->validPayload(['role' => 'sender', 'name' => 'رضا']), 'user_id' => $this->user->id]);
|
||||
|
||||
$other = User::factory()->create();
|
||||
AddressBookEntry::create([...$this->validPayload(['name' => 'کاربر دیگر']), 'user_id' => $other->id]);
|
||||
|
||||
$response = $this->getJson('/api/v1/addresses');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['success' => true])
|
||||
->assertJsonCount(2, 'data');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_filters_addresses_by_role()
|
||||
{
|
||||
AddressBookEntry::create([...$this->validPayload(), 'user_id' => $this->user->id]);
|
||||
AddressBookEntry::create([...$this->validPayload(['role' => 'sender', 'name' => 'رضا']), 'user_id' => $this->user->id]);
|
||||
|
||||
$response = $this->getJson('/api/v1/addresses?role=sender');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.role', 'sender');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_requires_authentication()
|
||||
{
|
||||
// حذف کاربر actAsشده از گارد sanctum برای این تست
|
||||
auth('sanctum')->forgetUser();
|
||||
|
||||
$response = $this->getJson('/api/v1/addresses');
|
||||
|
||||
$response->assertStatus(401);
|
||||
}
|
||||
|
||||
// ═══════════════════ store ═══════════════════
|
||||
|
||||
#[Test]
|
||||
public function it_creates_address_successfully()
|
||||
{
|
||||
$response = $this->postJson('/api/v1/addresses', $this->validPayload());
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJson(['success' => true])
|
||||
->assertJsonPath('data.name', 'علی رضایی')
|
||||
->assertJsonPath('data.role', 'receiver');
|
||||
|
||||
$this->assertDatabaseHas('address_book_entries', [
|
||||
'user_id' => $this->user->id,
|
||||
'phone' => '09121234567',
|
||||
'role' => 'receiver',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_rejects_invalid_role()
|
||||
{
|
||||
$response = $this->postJson('/api/v1/addresses', $this->validPayload(['role' => 'hacker']));
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJson(['success' => false]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_rejects_missing_required_fields()
|
||||
{
|
||||
$response = $this->postJson('/api/v1/addresses', [
|
||||
'role' => 'receiver',
|
||||
// name, phone, address وجود ندارند
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$this->assertArrayHasKey('name', $response->json('errors'));
|
||||
$this->assertArrayHasKey('phone', $response->json('errors'));
|
||||
$this->assertArrayHasKey('address', $response->json('errors'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_upserts_duplicate_instead_of_creating()
|
||||
{
|
||||
AddressBookEntry::create([...$this->validPayload(), 'user_id' => $this->user->id]);
|
||||
|
||||
// همان نقش + همان تلفن + همان آدرس، با شهر متفاوت
|
||||
$response = $this->postJson('/api/v1/addresses', $this->validPayload(['city' => 'کرج']));
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'updated_existing' => true,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseCount('address_book_entries', 1);
|
||||
$this->assertDatabaseHas('address_book_entries', ['city' => 'کرج']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_enforces_max_entries_limit()
|
||||
{
|
||||
for ($i = 0; $i < AddressBookEntry::MAX_ENTRIES_PER_USER; $i++) {
|
||||
AddressBookEntry::create([
|
||||
...$this->validPayload(['phone' => '091200000' . str_pad((string) $i, 2, '0', STR_PAD_LEFT)]),
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$response = $this->postJson('/api/v1/addresses', $this->validPayload(['phone' => '09129999999']));
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJson(['success' => false]);
|
||||
$this->assertDatabaseCount('address_book_entries', AddressBookEntry::MAX_ENTRIES_PER_USER);
|
||||
}
|
||||
|
||||
// ═══════════════════ update / delete / default ═══════════════════
|
||||
|
||||
#[Test]
|
||||
public function it_updates_own_address()
|
||||
{
|
||||
$entry = AddressBookEntry::create([...$this->validPayload(), 'user_id' => $this->user->id]);
|
||||
|
||||
$response = $this->putJson("/api/v1/addresses/{$entry->id}", $this->validPayload(['city' => 'اصفهان']));
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('data.city', 'اصفهان');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_cannot_change_entry_role_on_update()
|
||||
{
|
||||
$entry = AddressBookEntry::create([...$this->validPayload(), 'user_id' => $this->user->id]);
|
||||
|
||||
$response = $this->putJson("/api/v1/addresses/{$entry->id}", $this->validPayload(['role' => 'sender']));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$this->assertDatabaseHas('address_book_entries', [
|
||||
'id' => $entry->id,
|
||||
'role' => 'receiver', // نقش تغییر نکرده
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_prevents_updating_others_address()
|
||||
{
|
||||
$other = User::factory()->create();
|
||||
$entry = AddressBookEntry::create([...$this->validPayload(), 'user_id' => $other->id]);
|
||||
|
||||
$response = $this->putJson("/api/v1/addresses/{$entry->id}", $this->validPayload());
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_deletes_own_address()
|
||||
{
|
||||
$entry = AddressBookEntry::create([...$this->validPayload(), 'user_id' => $this->user->id]);
|
||||
|
||||
$response = $this->deleteJson("/api/v1/addresses/{$entry->id}");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['success' => true]);
|
||||
|
||||
$this->assertDatabaseMissing('address_book_entries', ['id' => $entry->id]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_prevents_deleting_others_address()
|
||||
{
|
||||
$other = User::factory()->create();
|
||||
$entry = AddressBookEntry::create([...$this->validPayload(), 'user_id' => $other->id]);
|
||||
|
||||
$response = $this->deleteJson("/api/v1/addresses/{$entry->id}");
|
||||
|
||||
$response->assertStatus(403);
|
||||
$this->assertDatabaseHas('address_book_entries', ['id' => $entry->id]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_sets_default_and_unsets_previous_default_of_same_role()
|
||||
{
|
||||
$first = AddressBookEntry::create([...$this->validPayload(), 'user_id' => $this->user->id, 'is_default' => true]);
|
||||
$second = AddressBookEntry::create([...$this->validPayload(['phone' => '09129876543']), 'user_id' => $this->user->id]);
|
||||
|
||||
$response = $this->postJson("/api/v1/addresses/{$second->id}/default");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['success' => true]);
|
||||
|
||||
$this->assertTrue($second->refresh()->is_default);
|
||||
$this->assertFalse($first->refresh()->is_default);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_prevents_setting_default_on_others_address()
|
||||
{
|
||||
$other = User::factory()->create();
|
||||
$entry = AddressBookEntry::create([...$this->validPayload(), 'user_id' => $other->id]);
|
||||
|
||||
$response = $this->postJson("/api/v1/addresses/{$entry->id}/default");
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
// ═══════════════════ Model behaviour ═══════════════════
|
||||
|
||||
#[Test]
|
||||
public function it_increments_use_count_and_sets_last_used_at()
|
||||
{
|
||||
$entry = AddressBookEntry::create([...$this->validPayload(), 'user_id' => $this->user->id]);
|
||||
|
||||
$this->assertSame(0, $entry->use_count);
|
||||
$this->assertNull($entry->last_used_at);
|
||||
|
||||
$entry->markUsed();
|
||||
|
||||
$this->assertSame(1, $entry->refresh()->use_count);
|
||||
$this->assertNotNull($entry->refresh()->last_used_at);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_finds_duplicate_by_role_phone_address()
|
||||
{
|
||||
AddressBookEntry::create([...$this->validPayload(), 'user_id' => $this->user->id]);
|
||||
|
||||
$dup = AddressBookEntry::findDuplicate($this->user->id, 'receiver', '09121234567', 'خیابان ولیعصر، پلاک ۱۰، واحد ۲');
|
||||
$this->assertNotNull($dup);
|
||||
|
||||
// نقش متفاوت → تکراری نیست
|
||||
$notDup = AddressBookEntry::findDuplicate($this->user->id, 'sender', '09121234567', 'خیابان ولیعصر، پلاک ۱۰، واحد ۲');
|
||||
$this->assertNull($notDup);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_returns_form_fields_with_role_prefix()
|
||||
{
|
||||
$sender = AddressBookEntry::create([...$this->validPayload(['role' => 'sender']), 'user_id' => $this->user->id]);
|
||||
|
||||
$fields = $sender->toFormFieldData();
|
||||
|
||||
$this->assertSame('علی رضایی', $fields['sender_name']);
|
||||
$this->assertSame('09121234567', $fields['sender_phone']);
|
||||
$this->assertArrayNotHasKey('receiver_name', $fields);
|
||||
}
|
||||
}
|
||||
34
AGENT.md
34
AGENT.md
@ -49,6 +49,16 @@
|
||||
- [x] **سند استقرار** — بخش ۴.۴ «چکلیست امنیتی سختسازی» در `DEPLOYMENT_HANDOFF.md`
|
||||
- [x] **تستها:** ۷ تست شکستهٔ قدیمی (auth کد تخفیف + معماری قدیمی محاسبهگر) تعمیر شد؛ ۷ تست جدید برای سرویس مصرف + ۲ تست برای ظرفیت/تخفیف — **۳۳ تست پاس، ۱۰۱ assertion**
|
||||
|
||||
فاز ۳.۹ (دفترچه آدرس) نیز تکمیل شد (۲۰۲۶-۱۱-۰۱):
|
||||
|
||||
- [x] **مدل + جدول `address_book_entries`** — نقش (sender/receiver)، برچسب، فیلدهای تماس عیناً مطابق ستونهای sender_/receiver_ در shipments، پیشفرض، شمارندهٔ استفاده + `last_used_at`، سقف ۵۰ رکورد per-user
|
||||
- [x] **CRUD API با Sanctum** — `GET/POST/PUT/DELETE /api/v1/addresses` + `POST /addresses/{id}/default`؛ Ownership در همهٔ اکشنها؛ دتکت رکورد تکراری (نقش + تلفن + آدرس → بروزرسانی بهجای ساخت دوباره)؛ فیلتر role/search
|
||||
- [x] **اتصال به ثبت سفارش** — چکباکس «ذخیره این آدرس» (upsert بدون رکورد تکراری) + شمارش استفاده از آدرس انتخابشده؛ عملیات غیرحیاتی (هر خطا فقط Log — سفارش rollback نمیشود)
|
||||
- [x] **فرم سفارش وردپرس** — dropdown «آدرسهای ذخیرهشده» بالای بخش فرستنده/گیرنده (پر شدن یککلیکی ۷ فیلد) + چکباکس ذخیره برای هر نقش
|
||||
- [x] **تب «دفترچه آدرس» در پنل کاربری** — کارتهای آدرس با فیلتر همه/فرستنده/گیرنده، فرم افزودن/ویرایش، حذف با تأیید SweetAlert، تنظیم پیشفرض، حالت خالی راهنما
|
||||
- [x] **پروکسی AJAX امن** — ۴ هندلر جدید (get/save/delete/set-default) با nonce + sanitize + توکن Sanctum کاربر
|
||||
- [x] **تستها:** ۱۸ تست جدید (`AddressBookControllerTest`) — لیست/فیلتر/ساخت/ویرایش/حذف/مالکیت/سقف/تکراری/پیشفرض/markUsed/formFields
|
||||
|
||||
چندزبانه (i18n) همچنان به زمان دیپلوی موکول است.
|
||||
|
||||
### معماری ارزی (Currency Architecture)
|
||||
@ -501,15 +511,27 @@ WALLET_AUTO_CREATE=true
|
||||
| ۴ | SweetAlert2 (v11.14.5 باندل محلی در پلاگین bridge) برای خطاهای فرم سفارش: اعتبارسنجی مراحل، خطای ثبت، خطای ارتباط + جایگزینی هر ۳ `alert()` بومی با پاپآپ برند | ✅ |
|
||||
| ۵ | سازگارسازی migration فاکتور دستی با SQLite (تستها طبق همین سند با SQLite اجرا میشوند) | ✅ |
|
||||
|
||||
### فاز ۳.۹ (موکول به آینده)
|
||||
### فاز ۳.۹ (دفترچه آدرس) — ✅ تکمیل (۲۰۲۶-۱۱-۰۱)
|
||||
|
||||
| # | کار | وضعیت |
|
||||
|---|-----|--------|
|
||||
| ۱ | مایگریشن + مدل `AddressBookEntry` (سقف ۵۰ رکورد، upsert ضدتکرار، آمار استفاده) | ✅ |
|
||||
| ۲ | CRUD API `/api/v1/addresses` + default-set (Sanctum + Ownership) | ✅ |
|
||||
| ۳ | اتصال به `POST /customer/orders` (save flags + address_id usage) | ✅ |
|
||||
| ۴ | dropdown آدرس ذخیرهشده + چکباکس «ذخیره این آدرس» در فرم سفارش وردپرس | ✅ |
|
||||
| ۵ | تب «دفترچه آدرس» در پنل کاربری (کارتها + فیلتر + فرم + حذف/پیشفرض) | ✅ |
|
||||
| ۶ | ۴ پروکسی AJAX جدید با nonce/sanitize | ✅ |
|
||||
| ۷ | ۱۸ تست Feature جدید | ✅ |
|
||||
|
||||
### فاز ۳.۱۰ — دیپلوی Production + کارهای موکولشده به آینده
|
||||
|
||||
| # | کار | وضعیت | اولویت |
|
||||
|---|-----|--------|---------|
|
||||
| ۱ | چندزبانه (i18n) | ⏳ موکول به زمان دیپلوی | متوسط |
|
||||
| ۲ | سند پیشنهادی سیستم مالی (درخواست ۱۳ کارفرما) | ⏳ موکول به آینده | پایین |
|
||||
| ۳ | پلاگین SMS (بهصورت ماژول قابل فعالسازی) | ⏳ منتظر پنل SMS مشتری | متوسط |
|
||||
| ۴ | لاین واردات از چین (ماژول مجزا) | ⏳ مطالعاتاتی | پایین |
|
||||
| ۵ | دفترچه آدرس (Address Book) مشتری | 📋 مطرحشده — قدم بعدی | متوسط |
|
||||
| ۱ | **دیپلوی روی سرور Production** (pull + migrate + .env سرور + ایمپورت نرخ واقعی + E2E) | ⏳ قدم بعدی | بالا |
|
||||
| ۲ | چندزبانه (i18n) | ⏳ موکول به زمان دیپلوی | متوسط |
|
||||
| ۳ | سند پیشنهادی سیستم مالی (درخواست ۱۳ کارفرما) | ⏳ موکول به آینده | پایین |
|
||||
| ۴ | پلاگین SMS (بهصورت ماژول قابل فعالسازی) | ⏳ منتظر پنل SMS مشتری | متوسط |
|
||||
| ۵ | لاین واردات از چین (ماژول مجزا) | ⏳ مطالعاتاتی | پایین |
|
||||
| ۶ | **ارز مرجع قابل تنظیم** (نسخه Pro) | 📋 مطرحشده — `01_Documents/IFNEX_Pro_Backlog.md` | پایین (نسخه Pro) |
|
||||
| ۷ | سیستم بروزرسانی + لایسنس (Domain Lock) | 📋 مطرحشده — `01_Documents/IFNEX_Pro_Backlog.md` | پایین (نسخه Pro) |
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# IFNEX — چکلیست تحویل به مشتری
|
||||
|
||||
> **وضعیت:** آماده برای دیپلوی (فاز ۳.۸ و ۳.۸.۱ تکمیل شد)
|
||||
> **فاز جاری:** فاز ۳.۸.۱ — سختسازی Production (تکمیل)
|
||||
> **تاریخ آخرین بهروزرسانی:** ۲۰۲۶-۱۰-۳۱
|
||||
> **وضعیت:** آماده برای دیپلوی (فاز ۳.۸، ۳.۸.۱ و ۳.۹ تکمیل شد)
|
||||
> **فاز جاری:** فاز ۳.۹ — دفترچه آدرس (تکمیل) → قدم بعدی: دیپلوی Production (فاز ۳.۱۰)
|
||||
> **تاریخ آخرین بهروزرسانی:** ۲۰۲۶-۱۱-۰۱
|
||||
> **مخزن:** [git.vernahost.ir/gitmodir110/ifnex](https://www.git.vernahost.ir/gitmodir110/ifnex)
|
||||
|
||||
> ⚠️ این فایل فقط برای مدیریت تعهدات، تست و تحویل نسخهٔ فعلی مشتری است و جایگزین `AGENT.md` یا `IFNEX_Roadmap.md` نیست.
|
||||
@ -502,9 +502,9 @@ Create Import Order
|
||||
|
||||
# ۱۵. خلاصهٔ وضعیت تحویل
|
||||
|
||||
**وضعیت فعلی:** آماده برای دیپلوی (فاز ۳.۸ و ۳.۸.۱ تکمیل شد)
|
||||
**وضعیت فعلی:** آماده برای دیپلوی (فاز ۳.۸، ۳.۸.۱ و ۳.۹ تکمیل شد)
|
||||
|
||||
**آخرین کامیت بازبینیشده:** ۲۰۲۶-۱۰-۳۱ — فاز ۳.۸.۱ (سختسازی Production: فیکس باگ P0 کد تخفیف، گارد Mock در Production، حذف هکهای دیباگ، نسخهبندی asset، سختسازی آپلود، خروج wp-config از گیت، تعمیر تستها).
|
||||
**آخرین کامیت بازبینیشده:** ۲۰۲۶-۱۱-۰۱ — فاز ۳.۹ (دفترچه آدرس: مدل + مایگریشن، CRUD API با Sanctum، اتصال به ثبت سفارش، dropdown آدرس ذخیرهشده + چکباکس ذخیره در فرم سفارش، تب دفترچه آدرس در پنل کاربری، ۴ پروکسی AJAX، ۱۸ تست جدید).
|
||||
|
||||
## ۱۵.۱ تکمیلشده در فاز ۳.۶ (۲۰۲۶-۱۰-۰۴)
|
||||
|
||||
@ -552,14 +552,23 @@ Create Import Order
|
||||
- [x] **تعمیر ۷ تست شکسته + ۹ تست جدید** — ۳۳ تست پاس، ۱۰۱ assertion
|
||||
- [x] **چکلیست امنیتی استقرار** در `DEPLOYMENT_HANDOFF.md` (بخش ۴.۴)
|
||||
|
||||
## ۱۵.۵ باقیمانده
|
||||
## ۱۵.۵ تکمیلشده در فاز ۳.۹ — دفترچه آدرس (۲۰۲۶-۱۱-۰۱)
|
||||
|
||||
- [x] **مدل + جدول `address_book_entries`** — نقش sender/receiver، فیلدهای عیناً مطابق ستونهای sender_/receiver_ در shipments، آدرس پیشفرض، شمارندهٔ استفاده + آخرین استفاده، سقف ۵۰ رکورد برای هر کاربر
|
||||
- [x] **CRUD API محافظتشده با Sanctum** — `GET/POST/PUT/DELETE /api/v1/addresses` + `POST /addresses/{id}/default`؛ بررسی مالکیت در همهٔ اکشنها؛ فیلتر بر اساس نقش و جستجو؛ جلوگیری از رکورد تکراری (نقش + تلفن + آدرس → بروزرسانی)
|
||||
- [x] **اتصال به ثبت سفارش** — چکباکس «ذخیره این آدرس» برای هر دو نقش (upsert بدون رکورد تکراری) + ثبت استفاده از آدرس انتخابشده؛ عملیات دفترچه هرگز ثبت سفارش را ناموفق نمیکند
|
||||
- [x] **فرم سفارش وردپرس** — dropdown «آدرسهای ذخیرهشده» بالای هر دو بخش فرستنده/گیرنده (پر شدن یککلیکی تمام فیلدها) + چکباکس ذخیره؛ در نبود آدرس ذخیرهشده کاملاً مخفی است
|
||||
- [x] **تب «دفترچه آدرس» در پنل کاربری** — کارتهای آدرس با فیلتر (همه/فرستنده/گیرنده)، فرم افزودن/ویرایش، حذف با تأیید SweetAlert، تنظیم پیشفرض، حالت خالی راهنما، موبایل-فرندلی
|
||||
- [x] **۴ پروکسی AJAX امن** (get/save/delete/set-default) — nonce + sanitize + توکن Sanctum کاربر در سمت لاراول
|
||||
- [x] **۱۸ تست Feature جدید** — مالکیت، سقف، تکراری، پیشفرض، فیلترها و رفتار مدل
|
||||
|
||||
## ۱۵.۶ باقیمانده
|
||||
|
||||
- [ ] i18n (موکول به زمان دیپلوی)
|
||||
- [ ] تست نهایی UAT کارفرما
|
||||
- [ ] دیپلوی روی سرور Production (با چرخش کلیدها طبق بخش ۴.۴ سند استقرار)
|
||||
- [ ] دیپلوی روی سرور Production (با چرخش کلیدها طبق بخش ۴.۴ سند استقرار) — **قدم بعدی**
|
||||
- [ ] سند پیشنهادی سیستم مالی (درخواست ۱۳ کارفرما — موکول به آینده)
|
||||
- [ ] پلاگین SMS (منتظر تحویل پنل SMS مشتری)
|
||||
- [ ] دفترچه آدرس (Address Book) مشتری — قدم بعدی
|
||||
|
||||
### باقیمانده برای نسخه Pro (آینده)
|
||||
|
||||
|
||||
@ -1,110 +0,0 @@
|
||||
# 📦 بسته بروزرسانی IFNEX — فاز ۳.۸.۱ (سختسازی Production)
|
||||
|
||||
**تاریخ:** ۳۱ اکتبر ۲۰۲۶
|
||||
**تعداد فایلها:** ۲۱ فایل + این راهنما
|
||||
**⚠️ شامل یک فیکس بحرانی (P0):** باگ ثبت سفارش — حتماً اعمال شود
|
||||
|
||||
---
|
||||
|
||||
## 🚨 مهمترین فیکس: باگ P0 کد تخفیف
|
||||
|
||||
در بازبینی امنیتی کشف شد که متد `consumeDiscountCode()` در کنترلر سفارش **صدا زده میشد ولی هیچجا تعریف نشده بود** — یعنی:
|
||||
|
||||
> ❌ **ثبت سفارش مشتری در وضعیت فعلی کلاً خطای 500 میدهد!**
|
||||
|
||||
(این باگ از کامیت فلوی بازبینی/ارسال مجدد وارد کد شده بود و چون تستهای قدیمی شکسته بودند پنهان مانده بود)
|
||||
|
||||
**راهحل اعمالشده در این بسته:**
|
||||
- سرویس جدید `DiscountCodeService` + جدول `discount_code_usages`
|
||||
- مصرف یکبار هر کد + رکورد Audit کامل (چه کسی، چه سفارشی، چه مبلغی)
|
||||
- قفل رقابتی (`lockForUpdate`) — دو ثبت همزمان نمیتوانند ظرفیت را دور بزنند
|
||||
- خطای 422 با پیام واضح اگر کد بین محاسبه و ثبت منقضی شود
|
||||
- گارد ظرفیت در قیمتگذاری هم
|
||||
|
||||
---
|
||||
|
||||
## 🔧 بقیه تغییرات (سختسازی Production)
|
||||
|
||||
| # | فیکس | توضیح |
|
||||
|---|------|-------|
|
||||
| ۱ | **گارد Mock Gateway** | اگر روی سرور `APP_ENV=production` باشد و درگاه تستی تنظیم شده باشد، پردازش با خطای صریح مسدود میشود (قبلاً خطر پرداخت مجازی واقعی وجود داشت!) |
|
||||
| ۲ | **حذف هکهای دیباگ وردپرس** | `opcache_reset()` و `header('Cache-Control: no-store')` از ۵ فایل پلاگین حذف شد — در Production باعث header error و شکستن کش میشد |
|
||||
| ۳ | **نسخهبندی سالم assetها** | cache-buster `'2.0.0-' . time()` (هر بار کش را میشکست) با `filemtime` جایگزین شد — کش فقط وقتی فایل عوض شود باطل میشود |
|
||||
| ۴ | **سختسازی آپلود تعهدنامه** | `mimetypes` اضافه شد — محتوای واقعی فایل بررسی میشود نه فقط پسوند |
|
||||
| ۵ | **`.env.example` بروز** | بلوک Production (لاگ روزانه، APP_DEBUG=false، کشها) + `IFNEX_BRIDGE_API_KEY` + راهنمای چرخش کلید |
|
||||
| ۶ | **سند استقرار** | بخش جدید «۴.۴ چکلیست امنیتی سختسازی» در `DEPLOYMENT_HANDOFF.md` |
|
||||
| ۷ | **تعمیر و توسعه تستها** | ۷ تست شکستهٔ قدیمی تعمیر شد + ۹ تست جدید → **۳۳ تست پاس، ۱۰۱ assertion** ✅ |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ دو فایل از گیت حذف شدهاند (اقدام دستی لازم!)
|
||||
|
||||
این بسته شامل حذف ۲ فایل از **ایندکس گیت** است (نه از دیسک!):
|
||||
|
||||
| فایل | دلیل |
|
||||
|------|------|
|
||||
| `03_WordPress/wp-config.php` | حاوی saltهای واقعی بود — لورفته فرض میشود؛ فقط `wp-config-sample.php` در مخزن میماند |
|
||||
| `04_Laravel/.phpunit.result.cache` | فایل کش محلی — نباید در گیت باشد |
|
||||
|
||||
چون ZIP نمیتواند حذف انجام دهد، **بعد از اکسترکت** این دو دستور را در ریشه پروژه اجرا کنید:
|
||||
|
||||
```bash
|
||||
git rm --cached 03_WordPress/wp-config.php
|
||||
git rm --cached 04_Laravel/.phpunit.result.cache
|
||||
```
|
||||
|
||||
> ✅ `.gitignore` بروز شده و بعد از این، این فایلها دیگر کامیت نمیشوند.
|
||||
> فایل `wp-config.php` روی دیسک شما دستنخورده باقی میماند.
|
||||
|
||||
---
|
||||
|
||||
## 📥 روش نصب
|
||||
|
||||
1. محتوای پوشه `ifnex-updates/` را **دقیقاً روی ریشه پروژه IFNEX** اکسترکت کنید (Replace)
|
||||
2. دو دستور `git rm --cached` بالا را اجرا کنید
|
||||
3. مراحل بعد از اکسترکت (پایین) را انجام دهید
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ مراحل بعد از اکسترکت
|
||||
|
||||
### Laravel:
|
||||
```bash
|
||||
cd 04_Laravel
|
||||
|
||||
# مایگریشن جدید (جدول discount_code_usages)
|
||||
php artisan migrate
|
||||
|
||||
# اجرای تستها — باید ۳۳ پاس ببینید
|
||||
php artisan test
|
||||
```
|
||||
|
||||
### WordPress:
|
||||
- پلاگین `ifnex-bridge` را غیرفعال و دوباره فعال کنید
|
||||
- اگر کش افزونه دارید، پاکسازی کنید
|
||||
|
||||
---
|
||||
|
||||
## ✅ چکلیست تست سریع
|
||||
|
||||
| # | تست | نتیجه مورد انتظار |
|
||||
|---|-----|-------------------|
|
||||
| 1 | `php artisan test` | **33 passed, 101 assertions** |
|
||||
| 2 | `php artisan migrate` | مایگریشن `discount_code_usages` بدون خطا |
|
||||
| 3 | ثبت سفارش **بدون** کد تخفیف | موفق (این قبلاً 500 میداد!) |
|
||||
| 4 | ثبت سفارش **با** کد تخفیف فعال | موفق + `used_count` کد +1 میشود |
|
||||
| 5 | ثبت سفارش دوم با همان کد (اگر usage_limit=1) | خطای 422 با پیام واضح |
|
||||
| 6 | فرم سفارش وردپرس | بدون خطای کنسول؛ assetها با `?ver=2.1.0.xxx` لود میشوند |
|
||||
|
||||
---
|
||||
|
||||
## 📤 بعد از تست موفق
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "apply: IFNEX phase 3.8.1 package (production hardening + P0 discount fix)"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
> ⚠️ **قبل از دیپلوی روی سرور:** چکلیست بخش ۴.۴ فایل `DEPLOYMENT_HANDOFF.md` را حتماً انجام دهید
|
||||
> (چرخش کلیدها، بازتولید saltهای وردپرس، تنظیم درگاه واقعی، لاگ روزانه، کشها).
|
||||
Loading…
Reference in New Issue
Block a user