feat: add shipping address selection to checkout

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-09-26 12:53:39 +03:30
co-authored by Copilot
parent c0815a931a
commit c332b99130
70 changed files with 336 additions and 102 deletions
+60 -4
View File
@@ -3,6 +3,7 @@ import { useRouter } from 'vue-router'
import { useCartStore } from '@/stores/cart'
import { useOrderStore } from '@/stores/order'
import { confirm } from '@/composables/useConfirm'
import { addressesApi, type SavedAddress } from '@/services/addresses'
export function useCartPage() {
const router = useRouter()
@@ -11,6 +12,11 @@ export function useCartPage() {
const showFullDialog = ref(false)
const submitting = ref(false)
const submitError = ref('')
const addressPickerOpen = ref(false)
const addresses = ref<SavedAddress[]>([])
const selectedAddressId = ref('')
const newAddress = ref('')
const addressBusy = ref(false)
const items = computed(() => cartStore.activeCart?.items ?? [])
const isEmpty = computed(() => items.value.length === 0)
@@ -36,7 +42,51 @@ export function useCartPage() {
* ثبت سفارش بدون انتخاب تسویه؛ قیمت‌گذاری و انتخاب روش تسویه
* بعد از بررسی مدیریت انجام می‌شود.
*/
async function submitOrder() {
function chosenAddressText(): string {
const saved = addresses.value.find((row) => row.id === selectedAddressId.value)
return (saved?.text || newAddress.value).trim()
}
async function openAddressPicker() {
submitError.value = ''
addressPickerOpen.value = true
addressBusy.value = true
try {
addresses.value = await addressesApi.list()
selectedAddressId.value =
addresses.value.find((row) => row.isDefault)?.id ?? addresses.value[0]?.id ?? ''
} catch (e) {
submitError.value = e instanceof Error ? e.message : 'دریافت آدرس‌ها ناموفق بود.'
addressPickerOpen.value = false
} finally {
addressBusy.value = false
}
}
async function confirmAddressAndOrder() {
const text = chosenAddressText()
if (text.length < 3) {
submitError.value = 'یک آدرس انتخاب کنید یا آدرس جدید بنویسید.'
return
}
addressBusy.value = true
try {
if (!selectedAddressId.value) {
const created = await addressesApi.create({ text })
selectedAddressId.value = created.id
addresses.value = await addressesApi.list()
newAddress.value = ''
}
addressPickerOpen.value = false
await submitOrder(text)
} catch (e) {
submitError.value = e instanceof Error ? e.message : 'ذخیره آدرس ناموفق بود.'
} finally {
addressBusy.value = false
}
}
async function submitOrder(shippingAddress?: string) {
if (!cartStore.activeCart || items.value.length === 0 || submitting.value) return
submitting.value = true
submitError.value = ''
@@ -46,7 +96,7 @@ export function useCartPage() {
submitError.value = 'سبد خرید خالی است یا با سرور همگام نشد.'
return
}
const order = await orderStore.submitOrder(cartStore.activeCart)
const order = await orderStore.submitOrder(cartStore.activeCart, shippingAddress)
await cartStore.hydrate()
router.replace(`/orders/${order.id}/success`)
} catch (e) {
@@ -63,12 +113,12 @@ export function useCartPage() {
showFullDialog.value = true
return
}
void submitOrder()
void openAddressPicker()
}
function proceedAnyway() {
showFullDialog.value = false
void submitOrder()
void openAddressPicker()
}
async function createNewInvoice() {
@@ -90,6 +140,12 @@ export function useCartPage() {
showFullDialog,
submitting,
submitError,
addressPickerOpen,
addresses,
selectedAddressId,
newAddress,
addressBusy,
confirmAddressAndOrder,
removeItem,
proceedToCheckout,
proceedAnyway,
+7 -2
View File
@@ -119,7 +119,7 @@ export const useOrderStore = defineStore('order', () => {
* روش تسویه را انتخاب می‌کند.
* اقلام همه شرکت‌ها در یک فاکتور واحد ثبت می‌شوند.
*/
async function submitOrder(cart: Cart): Promise<Order> {
async function submitOrder(cart: Cart, shippingAddress?: string): Promise<Order> {
if (!cart.items.length) {
throw new Error('سبد خرید خالی است.')
}
@@ -134,12 +134,17 @@ export const useOrderStore = defineStore('order', () => {
throw new Error('شرکت سبد مشخص نیست. سبد را خالی کنید و دوباره اضافه کنید.')
}
const addressLine = shippingAddress?.trim()
? `آدرس ارسال: ${shippingAddress.trim()}`
: ''
const notes = [addressLine, cart.notes?.trim()].filter(Boolean).join('\n')
try {
const order = await apiRequest<Order>('/orders', {
method: 'POST',
body: {
companyId: companyIds[0],
notes: cart.notes,
notes: notes || undefined,
items: cart.items.map((item) => ({
productId: item.productId,
meterage: Number(item.meterage),
+158
View File
@@ -18,6 +18,12 @@ const {
showFullDialog,
submitting,
submitError,
addressPickerOpen,
addresses,
selectedAddressId,
newAddress,
addressBusy,
confirmAddressAndOrder,
removeItem,
proceedToCheckout,
proceedAnyway,
@@ -65,6 +71,49 @@ function goToOrdering() {
@back="goToOrdering"
/>
<div
v-if="addressPickerOpen"
class="dialog-overlay"
@click.self="addressPickerOpen = false"
>
<div class="dialog card">
<h3>آدرس ارسال</h3>
<p class="text-sm">یکی از آدرس‌های ذخیره‌شده را انتخاب کنید، یا آدرس تازه بنویسید.</p>
<ul v-if="addresses.length" class="address-pick">
<li v-for="row in addresses" :key="row.id">
<label class="address-card" :class="{ 'is-on': selectedAddressId === row.id && !newAddress.trim() }">
<input v-model="selectedAddressId" type="radio" :value="row.id" @change="newAddress = ''" />
<span class="address-card__body">
<span class="address-card__title">
<strong>{{ row.label || 'آدرس' }}</strong>
<em v-if="row.isDefault">پیش‌فرض</em>
</span>
<small>{{ row.text }}</small>
</span>
</label>
</li>
</ul>
<label class="address-new" :class="{ 'is-on': Boolean(newAddress.trim()) }">
<span>آدرس جدید</span>
<textarea
v-model="newAddress"
rows="3"
maxlength="500"
placeholder="خیابان، پلاک، واحد…"
@input="selectedAddressId = ''"
/>
</label>
<div class="dialog-actions">
<BaseButton variant="outline" :disabled="addressBusy" @click="addressPickerOpen = false">
انصراف
</BaseButton>
<BaseButton :loading="addressBusy || submitting" @click="confirmAddressAndOrder">
ثبت سفارش
</BaseButton>
</div>
</div>
</div>
<div v-if="showFullDialog" class="dialog-overlay" @click.self="showFullDialog = false">
<div class="dialog card">
<h3>سقف متراژ تکمیل شد</h3>
@@ -99,6 +148,115 @@ function goToOrdering() {
margin: 0 0 12px;
}
.address-pick {
list-style: none;
margin: 12px 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.address-card {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
padding: 10px 12px;
border: 1px solid #e7e5e4;
border-radius: 14px;
background: #fafaf9;
}
.address-card.is-on {
border-color: #1e293b;
background: #f8fafc;
}
.address-card input {
margin-top: 4px;
flex-shrink: 0;
}
.address-card__body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
flex: 1;
}
.address-card__title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.address-card__title strong {
min-width: 0;
overflow-wrap: anywhere;
}
.address-card__title em {
flex-shrink: 0;
padding: 1px 8px;
border-radius: 999px;
background: #1e293b;
color: #fff;
font-style: normal;
font-size: 0.7rem;
}
.address-card small {
display: block;
color: var(--color-text-muted);
line-height: 1.7;
overflow-wrap: anywhere;
}
.address-new {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 4px;
padding: 12px;
border: 1px dashed #cbd5e1;
border-radius: 16px;
background: #f8fafc;
font-size: 0.875rem;
}
.address-new.is-on {
border-style: solid;
border-color: #1e293b;
background: #fff;
}
.address-new span {
font-weight: 700;
color: #1e293b;
}
.address-new textarea {
width: 100%;
box-sizing: border-box;
min-height: 88px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
font: inherit;
line-height: 1.7;
resize: vertical;
}
.address-new textarea:focus {
outline: none;
border-color: #1e293b;
}
@media (min-width: 768px) {
.cart-view {
flex: 1 0 auto;
+24 -11
View File
@@ -93,7 +93,7 @@ const emit = defineEmits<{
<span>حداکثر مجاز</span>
<strong>{{ formatMeterage(maxMeterage) }}</strong>
</div>
<div class="cart-mobile-summary__row">
<div class="cart-mobile-summary__row cart-mobile-summary__row--remain">
<span>باقی مانده</span>
<strong>{{ formatMeterage(remainingCapacity) }}</strong>
</div>
@@ -129,12 +129,15 @@ const emit = defineEmits<{
grid-template-columns: 40px 1fr 40px;
align-items: center;
margin-bottom: 12px;
padding-inline: 8px;
}
.cart-mobile-page__hint {
margin: 0 0 10px;
margin: 0 0 12px;
padding: 0 4px;
font-size: 0.75rem;
line-height: 1.6;
line-height: 1.7;
text-align: center;
color: var(--color-text-muted);
}
@@ -171,6 +174,7 @@ const emit = defineEmits<{
display: flex;
flex-direction: column;
min-height: 0;
padding-inline: 16px;
}
.cart-mobile-page__meta {
@@ -250,19 +254,23 @@ const emit = defineEmits<{
.cart-mobile-page__footer {
flex-shrink: 0;
margin: 0;
padding: 12px 0 calc(12px + env(safe-area-inset-bottom, 0));
position: absolute;
bottom: 0;
right: 0;
left: 0;
padding: 14px 16px calc(16px + env(safe-area-inset-bottom, 0));
border-top: 1px solid var(--color-border);
background: var(--color-bg);
background: #fff;
box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.06);
}
.cart-mobile-summary {
margin-bottom: 12px;
padding: 14px 16px;
background: var(--color-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 4px 14px;
background: #f8fafc;
border: 1px solid #e8edf3;
border-radius: 16px;
color: var(--color-text);
box-shadow: var(--shadow-sm);
}
.cart-mobile-summary__row {
@@ -285,6 +293,11 @@ const emit = defineEmits<{
}
.cart-mobile-summary__row:not(:last-child) {
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid #e8edf3;
}
.cart-mobile-summary__row--remain strong {
color: #1e293b;
font-weight: 800;
}
</style>
@@ -65,6 +65,7 @@ const customerQuickActions: QuickAction[] = [
{ id: 'products', label: 'محصولات', to: '/products', icon: 'vuesax-linear:box-search' },
{ id: 'favorites', label: 'علاقه‌مندی‌ها', to: '/favorites', useHeart: true },
{ id: 'order-status', label: 'وضعیت سفارش', to: '/orders/latest-status', icon: 'vuesax-linear:timer-1' },
{ id: 'addresses', label: 'آدرس‌ها', to: '/profile/addresses', icon: 'vuesax-linear:location' },
{ id: 'catalog', label: 'درخواست کالیته', to: '/categories', icon: 'vuesax-linear:box-1' },
]
@@ -151,10 +152,7 @@ onMounted(async () => {
<template>
<div class="page profile-page">
<div class="page__scroll page__scroll--with-nav profile-scroll">
<header class="hero">
<div class="hero__pin">
<div class="hero__top mobile-only">
<div class="hero__top mobile-only">
<button
type="button"
class="icon-btn"
@@ -180,13 +178,15 @@ onMounted(async () => {
</span>
</button>
</div>
<div class="page__scroll page__scroll--with-nav profile-scroll">
<header class="hero">
<div class="hero__rest">
<UserAvatar
class="hero__avatar"
:src="auth.user?.avatarUrl"
:initials="displayName.charAt(0)"
:size="84"
/>
</div>
<div class="hero__meta">
<h2>{{ displayName }}</h2>
<p v-if="auth.isAgent">{{ codeLabel }}: {{ code }}</p>
@@ -200,6 +200,7 @@ onMounted(async () => {
سطح: {{ levelLabel }}
</span>
</div>
</div>
<div class="hero__desk-actions desktop-only-flex">
<button type="button" class="hero__desk-btn" @click="router.push('/profile/edit')">
ویرایش اطلاعات
@@ -386,29 +387,28 @@ onMounted(async () => {
);
}
.hero__pin {
position: sticky;
top: 0;
z-index: 3;
padding: 12px var(--page-padding) 10px;
background: linear-gradient(
145deg,
var(--color-primary) 0%,
color-mix(in srgb, var(--color-primary) 78%, #0a1f18) 100%
);
}
.hero__top {
position: relative;
z-index: 4;
flex-shrink: 0;
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
margin-bottom: 12px;
padding: 12px var(--page-padding);
background: var(--color-primary);
color: #fff;
}
.hero__rest {
padding: 4px var(--page-padding) 0;
}
.hero__top h1 {
margin: 0;
font-size: 1rem;
font-weight: 700;
color: #fff;
text-align: center;
}
.icon-btn {
@@ -876,11 +876,12 @@ onMounted(async () => {
overflow: hidden;
}
.hero__pin {
position: static;
display: block;
padding: 0;
background: transparent;
.hero__top {
display: none;
}
.hero__rest {
display: contents;
}
.hero__avatar {