diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-bridge.css b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-bridge.css
index 1e52f79..d5327cc 100644
--- a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-bridge.css
+++ b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-bridge.css
@@ -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; }
+}
diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-orders.css b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-orders.css
index 801e621..2701b7e 100644
--- a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-orders.css
+++ b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-orders.css
@@ -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;
+}
diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-address-book.js b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-address-book.js
new file mode 100644
index 0000000..d51edc6
--- /dev/null
+++ b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-address-book.js
@@ -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('خطای ارتباط', 'لطفاً اتصال اینترنت را بررسی کنید.');
+ });
+ });
+});
diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js
index eb322e3..48414db 100644
--- a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js
+++ b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js
@@ -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, ''');
+ }
+
+ 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 = '';
+ 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 += '';
+ });
+
+ $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);
+ }
+ });
});
diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/ifnex-bridge.php b/03_WordPress/wp-content/plugins/ifnex-bridge/ifnex-bridge.php
index 250f6b0..b02f318 100644
--- a/03_WordPress/wp-content/plugins/ifnex-bridge/ifnex-bridge.php
+++ b/03_WordPress/wp-content/plugins/ifnex-bridge/ifnex-bridge.php
@@ -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);
}
}
diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php
index f1be33a..ca9afd5 100644
--- a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php
+++ b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php
@@ -799,6 +799,15 @@ function ifnex_order_form_shortcode($atts) {
+
+ 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;
+ }));
+ }
+ ?>
+
+
خطا در دریافت دفترچه آدرس:
+
+
+
+
+
+
+
+
+
+
+
+
+
📒
+
دفترچه آدرس شما خالی است
+
در فرم ثبت سفارش، چکباکس «ذخیره این آدرس» را بزنید یا همینجا آدرس جدید اضافه کنید تا دفعه بعد با یک کلیک پر شود.
+
+
+
+
+
+
+
+
+
+ ⭐ پیشفرض
+
+
+
+
+ 📞
+ 📍
+ 🏷️
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/user-bridge.php b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/user-bridge.php
index 740d332..f6dcc52 100644
--- a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/user-bridge.php
+++ b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/user-bridge.php
@@ -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() {
diff --git a/04_Laravel/.phpunit.result.cache b/04_Laravel/.phpunit.result.cache
index 875fab4..71c53e0 100644
--- a/04_Laravel/.phpunit.result.cache
+++ b/04_Laravel/.phpunit.result.cache
@@ -1 +1 @@
-{"version":2,"defects":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":5,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":7,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":7,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":8,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":8,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":7,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":8},"times":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":0.343,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":0.083,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":0.098,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.549,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.067,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":0.418,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":0.067,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":0.057,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":0.058,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":0.079,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":0.075,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":0.448,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":0.214,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":0.056,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":0.113,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":0.061,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":0.041,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":0.008,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":0.007,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":0.008,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":0.004,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":0.007,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":0.002,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":0.006,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_consumes_code_and_increments_used_count":0.035,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_is_idempotent_for_the_same_shipment":0.004,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_throws_when_usage_limit_is_reached":0.107,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_rejects_inactive_code":0.002,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_rejects_expired_code":0.002,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_does_nothing_when_code_is_empty":0.029,"Tests\\Feature\\Services\\DiscountCodeServiceTest::try_consume_returns_false_instead_of_throwing":0.003,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_applies_discount_from_code_in_pricing":0.005,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_ignores_discount_code_whose_usage_limit_is_reached":0.005}}
\ No newline at end of file
+{"version":2,"defects":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":5,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":7,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":7,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":8,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":8,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":7,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":8,"Tests\\Feature\\Api\\AddressBookControllerTest::it_increments_use_count_and_sets_last_used_at":7},"times":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":0.052,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":0.053,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":0.006,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.004,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.057,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":0.048,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":0.044,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":0.043,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":0.044,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":0.048,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":0.044,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":0.064,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":0.046,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":0.046,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":0.06,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":0.049,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":0.003,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":0.006,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":0.005,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":0.006,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":0.002,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":0.005,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":0.001,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":0.004,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_consumes_code_and_increments_used_count":0.004,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_is_idempotent_for_the_same_shipment":0.004,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_throws_when_usage_limit_is_reached":0.004,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_rejects_inactive_code":0.002,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_rejects_expired_code":0.002,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_does_nothing_when_code_is_empty":0.002,"Tests\\Feature\\Services\\DiscountCodeServiceTest::try_consume_returns_false_instead_of_throwing":0.003,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_applies_discount_from_code_in_pricing":0.01,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_ignores_discount_code_whose_usage_limit_is_reached":0.007,"Tests\\Feature\\Api\\AddressBookControllerTest::it_lists_only_current_user_addresses":0.079,"Tests\\Feature\\Api\\AddressBookControllerTest::it_filters_addresses_by_role":0.049,"Tests\\Feature\\Api\\AddressBookControllerTest::it_requires_authentication":0.046,"Tests\\Feature\\Api\\AddressBookControllerTest::it_creates_address_successfully":0.053,"Tests\\Feature\\Api\\AddressBookControllerTest::it_rejects_invalid_role":0.046,"Tests\\Feature\\Api\\AddressBookControllerTest::it_rejects_missing_required_fields":0.046,"Tests\\Feature\\Api\\AddressBookControllerTest::it_upserts_duplicate_instead_of_creating":0.048,"Tests\\Feature\\Api\\AddressBookControllerTest::it_enforces_max_entries_limit":0.079,"Tests\\Feature\\Api\\AddressBookControllerTest::it_updates_own_address":0.047,"Tests\\Feature\\Api\\AddressBookControllerTest::it_cannot_change_entry_role_on_update":0.047,"Tests\\Feature\\Api\\AddressBookControllerTest::it_prevents_updating_others_address":0.046,"Tests\\Feature\\Api\\AddressBookControllerTest::it_deletes_own_address":0.047,"Tests\\Feature\\Api\\AddressBookControllerTest::it_prevents_deleting_others_address":0.046,"Tests\\Feature\\Api\\AddressBookControllerTest::it_sets_default_and_unsets_previous_default_of_same_role":0.048,"Tests\\Feature\\Api\\AddressBookControllerTest::it_prevents_setting_default_on_others_address":0.047,"Tests\\Feature\\Api\\AddressBookControllerTest::it_increments_use_count_and_sets_last_used_at":0.004,"Tests\\Feature\\Api\\AddressBookControllerTest::it_finds_duplicate_by_role_phone_address":0.003,"Tests\\Feature\\Api\\AddressBookControllerTest::it_returns_form_fields_with_role_prefix":0.001}}
\ No newline at end of file
diff --git a/04_Laravel/app/Http/Controllers/Api/AddressBookController.php b/04_Laravel/app/Http/Controllers/Api/AddressBookController.php
new file mode 100644
index 0000000..18ad907
--- /dev/null
+++ b/04_Laravel/app/Http/Controllers/Api/AddressBookController.php
@@ -0,0 +1,247 @@
+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(),
+ ];
+ }
+}
diff --git a/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php
index 1fa2ea3..8fd17e3 100644
--- a/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php
+++ b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php
@@ -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(),
+ ]);
+ }
+ }
+
+
/**
* تبدیل تاریخ به شمسی
*/
diff --git a/04_Laravel/app/Models/AddressBookEntry.php b/04_Laravel/app/Models/AddressBookEntry.php
new file mode 100644
index 0000000..193c178
--- /dev/null
+++ b/04_Laravel/app/Models/AddressBookEntry.php
@@ -0,0 +1,154 @@
+ '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);
+ }
+}
diff --git a/04_Laravel/database/migrations/2026_11_01_000001_create_address_book_entries_table.php b/04_Laravel/database/migrations/2026_11_01_000001_create_address_book_entries_table.php
new file mode 100644
index 0000000..22c7fc1
--- /dev/null
+++ b/04_Laravel/database/migrations/2026_11_01_000001_create_address_book_entries_table.php
@@ -0,0 +1,60 @@
+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');
+ }
+};
diff --git a/04_Laravel/routes/api.php b/04_Laravel/routes/api.php
index 151e676..b0ddade 100644
--- a/04_Laravel/routes/api.php
+++ b/04_Laravel/routes/api.php
@@ -1,6 +1,7 @@
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']);
diff --git a/04_Laravel/tests/Feature/Api/AddressBookControllerTest.php b/04_Laravel/tests/Feature/Api/AddressBookControllerTest.php
new file mode 100644
index 0000000..aad5700
--- /dev/null
+++ b/04_Laravel/tests/Feature/Api/AddressBookControllerTest.php
@@ -0,0 +1,288 @@
+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);
+ }
+}
diff --git a/AGENT.md b/AGENT.md
index 18ce802..72cd08f 100644
--- a/AGENT.md
+++ b/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) |
diff --git a/CLIENT_DELIVERY.md b/CLIENT_DELIVERY.md
index b558518..8996d5e 100644
--- a/CLIENT_DELIVERY.md
+++ b/CLIENT_DELIVERY.md
@@ -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 (آینده)
diff --git a/README-INSTALL.md b/README-INSTALL.md
deleted file mode 100644
index 09d2e1a..0000000
--- a/README-INSTALL.md
+++ /dev/null
@@ -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های وردپرس، تنظیم درگاه واقعی، لاگ روزانه، کشها).