feat: add shipping address selection to checkout
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user