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:
s.esmaeili
2026-09-22 18:35:33 +03:30
co-authored by Claude Opus 5
parent 2194b3d4d9
commit 460cf276b6
128 changed files with 756 additions and 181 deletions
@@ -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 {
+12 -1
View File
@@ -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,
)
}
+38 -2
View File
@@ -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