parity(api,apps): delivery-mode gaps — master OTP, non-blocking SMS, faToEn, v2 cart cap
D20: OTP_MASTER_CODE login bypass read only from the environment, disabled when unset, audited on every use, with a boot banner while armed. SMS no longer aborts the boot in production — it warns and declines silently, so no message can stop an operation. D19: port Utility::faTOen onto admin-entered mobiles (before the uniqueness lookup, which could previously be bypassed with Persian digits), and the create-only welcome SMS, byte-identical to the reference and fire-and-forget. user-app-v2 was missing the cart stock ceiling entirely: capQty/maxQty, the un-orderable guard and the qty clamp are ported, leaving v2's own design intact. The two cart stores are now byte-identical. isVariantOrderable and the orderable-first default selection ported too. jest 45/45; typecheck clean on api, admin-panel, user-app, user-app-v2. [ref: henza_server/app/Http/Controllers/Adm/AdminController.php:333,348,364,378-387] [ref: henza_server/app/Models/Utility.php:26] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2194b3d4d9
commit
460cf276b6
@@ -36,7 +36,13 @@
|
||||
not last, to land there. Keep both steppers' DOM order in sync if
|
||||
either one changes. -->
|
||||
<div class="ci__qty">
|
||||
<button type="button" class="ci__qtyBtn" aria-label="افزودن" @click="increment">
|
||||
<button
|
||||
type="button"
|
||||
class="ci__qtyBtn"
|
||||
aria-label="افزودن"
|
||||
:disabled="!canIncrement"
|
||||
@click="increment"
|
||||
>
|
||||
<Icon icon="vuesax-linear:add" size="16" />
|
||||
</button>
|
||||
<span class="ci__qtyValue">{{ value.qty }}</span>
|
||||
@@ -77,7 +83,13 @@ const emit = defineEmits<{ (e: 'note', value: number): void }>()
|
||||
const cart = useCartStore()
|
||||
const index = cart.find_index(props.value)
|
||||
|
||||
// Stock ceiling, same rule as the product page: uncapped for a pre-order
|
||||
// variant, otherwise limited to what is on hand. The store clamps as well.
|
||||
const maxQty = computed(() => cart.maxQty(props.value))
|
||||
const canIncrement = computed(() => maxQty.value === null || props.value.qty < maxQty.value)
|
||||
|
||||
const increment = () => {
|
||||
if (!canIncrement.value) return
|
||||
cart.updateFeild(props.value.qty + 1, index, props.value, 'qty')
|
||||
}
|
||||
const decrement = () => {
|
||||
|
||||
@@ -149,7 +149,8 @@
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="pd__chip"
|
||||
:class="{ 'is-on': selectedVariant?.id === item.id }"
|
||||
:class="{ 'is-on': selectedVariant?.id === item.id, 'is-out': !isOrderable(item) }"
|
||||
:disabled="!isOrderable(item)"
|
||||
@click="selectedVariant = item"
|
||||
>
|
||||
{{ item.title }}
|
||||
@@ -324,7 +325,7 @@ import GallerySlider from './GallerySlider.vue'
|
||||
import Spinner from '../Spinner.vue'
|
||||
import NotifyVant from '../NotifyVant.vue'
|
||||
import DialogVant from '../DialogVant.vue'
|
||||
import { useProduct } from '../../composables/useProduct'
|
||||
import { useProduct, isVariantOrderable } from '../../composables/useProduct'
|
||||
import { useProductDetail } from '../../composables/useProductDetail'
|
||||
import { useFavoritesStore } from '../../stores/favoritesStore'
|
||||
import { useFiltersStore } from '../../stores/filtersStore'
|
||||
@@ -477,7 +478,14 @@ const inCart = computed(() => cart.isInCart(storeProduct.value))
|
||||
const cartIndex = computed(() => cart.find_index(storeProduct.value))
|
||||
const cartQty = computed(() => (cartIndex.value > -1 ? cart.items[cartIndex.value].qty : 0))
|
||||
|
||||
const increment = () => cart.updateFeild(cartQty.value + 1, cartIndex.value, storeProduct.value, 'qty')
|
||||
// Stock ceiling: a pre-order variant may exceed what is on hand, anything else
|
||||
// stops at its qty. The store clamps too — this just keeps the button honest.
|
||||
const cartMaxQty = computed(() => cart.maxQty(storeProduct.value))
|
||||
const canIncrement = computed(() => cartMaxQty.value === null || cartQty.value < cartMaxQty.value)
|
||||
const increment = () => {
|
||||
if (!canIncrement.value) return
|
||||
cart.updateFeild(cartQty.value + 1, cartIndex.value, storeProduct.value, 'qty')
|
||||
}
|
||||
const decrement = () => {
|
||||
if (cartQty.value <= 1) {
|
||||
cart.remove(storeProduct.value)
|
||||
@@ -486,8 +494,11 @@ const decrement = () => {
|
||||
cart.updateFeild(cartQty.value - 1, cartIndex.value, storeProduct.value, 'qty')
|
||||
}
|
||||
|
||||
const isOrderable = (v: Parameters<typeof isVariantOrderable>[0]) => isVariantOrderable(v)
|
||||
|
||||
const addToCart = () => {
|
||||
cart.addToCart(storeProduct.value)
|
||||
// The store refuses an un-orderable variant; only confirm when it accepted.
|
||||
if (!cart.addToCart(storeProduct.value)) return
|
||||
notify.value = true
|
||||
}
|
||||
|
||||
@@ -1127,6 +1138,12 @@ onBeforeUnmount(() => {
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
}
|
||||
/* Same treatment as v1's .pd__variant.is-out: an un-orderable weight stays
|
||||
visible (so the customer can see it exists) but reads as unavailable. */
|
||||
.pd__chip.is-out {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Blocks ──────────────────────────────────────────────── */
|
||||
.pd__block {
|
||||
|
||||
@@ -21,9 +21,20 @@
|
||||
import { computed, ref, unref, type Ref } from 'vue'
|
||||
import type { ProductSummary, ProductVariantSummary } from '../api/client'
|
||||
|
||||
/** A variant can be ordered when it has stock, or when it is flagged pre-order. */
|
||||
export function isVariantOrderable(v: ProductVariantSummary): boolean {
|
||||
return (v.qty ?? 0) > 0 || Boolean(v.isPreOrder)
|
||||
}
|
||||
|
||||
function getCheapestVariant(variants: ProductVariantSummary[] | undefined): ProductVariantSummary | null {
|
||||
if (!variants || variants.length === 0) return null
|
||||
return variants.reduce((cheapest, v) =>
|
||||
// Prefer something the customer can actually order: defaulting to an
|
||||
// out-of-stock variant means the page opens on a disabled button even though
|
||||
// other weights are available. Falls back to the full list when nothing is
|
||||
// orderable, so the page still shows a selection.
|
||||
const pool = variants.filter(isVariantOrderable)
|
||||
const candidates = pool.length ? pool : variants
|
||||
return candidates.reduce((cheapest, v) =>
|
||||
parseFloat(v.defaultWagePercent) < parseFloat(cheapest.defaultWagePercent) ? v : cheapest,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,13 +54,42 @@ export const useCartStore = defineStore('cart', {
|
||||
}
|
||||
},
|
||||
addToCart(data: CartItem) {
|
||||
// Refuse a variant that cannot be ordered — out of stock and not flagged
|
||||
// pre-order. The same guard the admin cart applies (stores/cart.ts), kept
|
||||
// here so it holds even if a caller forgets to disable its button.
|
||||
const target = data.product?.variants?.find((v) => v.id === data.vid)
|
||||
if (!target) return false
|
||||
if (!((target.qty ?? 0) > 0 || target.isPreOrder)) return false
|
||||
|
||||
const index = this.find_index(data)
|
||||
if (index === -1) this.items.push(data)
|
||||
if (index === -1) this.items.push({ ...data, qty: this.capQty(data.qty, target) })
|
||||
else {
|
||||
this.items[index].product = data.product
|
||||
this.items[index].qty = data.qty
|
||||
this.items[index].qty = this.capQty(data.qty, target)
|
||||
}
|
||||
persist(this.items)
|
||||
return true
|
||||
},
|
||||
|
||||
/**
|
||||
* Stock ceiling for a cart line. A pre-order variant is orderable beyond
|
||||
* what is on hand (that is the point of pre-ordering), so it is uncapped;
|
||||
* anything else stops at its on-hand quantity.
|
||||
*/
|
||||
capQty(want: number, variant: { qty?: number; isPreOrder?: boolean }) {
|
||||
const n = Math.max(1, Math.floor(want) || 1)
|
||||
if (variant.isPreOrder) return n
|
||||
const max = variant.qty ?? 0
|
||||
return max > 0 ? Math.min(n, max) : n
|
||||
},
|
||||
|
||||
/** The ceiling for an item already in the cart; null when uncapped. */
|
||||
maxQty(item: Pick<CartItem, 'pid' | 'vid'>) {
|
||||
const i = this.find_index(item)
|
||||
if (i === -1) return null
|
||||
const v = this.variant(this.items[i])
|
||||
if (!v || v.isPreOrder) return null
|
||||
return (v.qty ?? 0) > 0 ? (v.qty as number) : null
|
||||
},
|
||||
remove(item: CartItem) {
|
||||
const index = this.find_index(item)
|
||||
@@ -72,6 +101,13 @@ export const useCartStore = defineStore('cart', {
|
||||
updateFeild(value: unknown, index: number | null, item: Pick<CartItem, 'pid' | 'vid'>, field: keyof CartItem) {
|
||||
if (!this.isInCart(item)) return false
|
||||
const i = index ?? this.find_index(item)
|
||||
// A quantity change is clamped to what is actually in stock (unless the
|
||||
// variant is pre-orderable), so the ceiling holds no matter which screen
|
||||
// drives the stepper.
|
||||
if (field === 'qty') {
|
||||
const v = this.variant(this.items[i])
|
||||
value = v ? this.capQty(Number(value), v) : Math.max(1, Number(value) || 1)
|
||||
}
|
||||
;(this.items[i] as any)[field] = value
|
||||
persist(this.items)
|
||||
return true
|
||||
|
||||
Reference in New Issue
Block a user