Compare commits

..
2 Commits
Author SHA1 Message Date
javad 93873228ff fix order 2026-10-09 11:06:09 +03:30
javad c6d732ff10 fix bug sms 2026-10-09 10:32:47 +03:30
5 changed files with 115 additions and 33 deletions

No files matched your search

+15 -22
View File
@@ -1,37 +1,30 @@
'use client'
import ProductCategoryBox from '@/app/components/layout/shop/products-cat'
import Image from 'next/image'
import axiosInstance from '@/axios/axiosInstance'
import { useEffect, useState } from 'react'
import { useQuery,useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
const Page = ()=>{
const [category, setCategory] = useState([])
const queryClient = useQueryClient()
useEffect(()=>{
},[])
const query = useQuery({ queryKey: ['categoryProducts'], queryFn: async ()=>{
return await axiosInstance.get("shop/v2/product-category/list/")
},refetchOnWindowFocus:false, onSuccess(result:any) {
},refetchOnWindowFocus:true, })
setCategory(result.data)
setTimeout(async ()=>{
const scrollpos = localStorage.getItem('scrollY')
if (scrollpos){
await window.scrollTo(0, parseInt(scrollpos));
const category = query.data?.data ?? []
useEffect(() => {
if (!query.isSuccess) return
const frame = requestAnimationFrame(() => {
try {
const position = Number(localStorage.getItem('scrollY'))
if (Number.isFinite(position) && position > 0) window.scrollTo(0, position)
} catch {
console.warn('Could not restore the saved shop scroll position.')
}
},150)
}, })
})
return () => cancelAnimationFrame(frame)
}, [query.isSuccess])
if(query.isLoading){
return (
+6 -11
View File
@@ -1,11 +1,9 @@
'use client'
import Products from '@/app/components/layout/shop/products'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import axiosInstance from '@/axios/axiosInstance'
import { useState } from 'react'
import {
Table,
TableBody,
@@ -21,16 +19,13 @@ const Page = ({ params }: { params: { id: string } })=>{
const router = useRouter();
const [products, setProducts] = useState<{category_name: string, category_img: string | null,
products:[{id:number, name:string, price: number, view_only: boolean}],
view_only_products:[{id:number, name:string, price: number, view_only: boolean}]
}>()
const query = useQuery({ queryKey: ['productListByCat', params.id], queryFn: async ()=>{
return await axiosInstance.get(`shop/product/list/${params.id}/`)
}, onSuccess(result:any) {
setProducts(result.data)
return await axiosInstance.get<{category_name: string, category_img: string | null,
products: {id:number, name:string, price:number, view_only:boolean}[],
view_only_products: {id:number, name:string, price:number, view_only:boolean}[] | null
}>(`shop/product/list/${params.id}/`)
}, });
const products = query.data?.data
if(query.isLoading){
return (
@@ -25,6 +25,7 @@ export default function CustomerLoginForm({ initialStep = 'mobile' }: { initialS
const [step, setStep] = useState<Step>(() => initialStep === 'verify' && normalizePhone(readLoginMobile()) ? 'verify' : 'mobile')
const [code, setCode] = useState('')
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [options, setOptions] = useState<LoginOption[]>([])
const [recoveryMobile, setRecoveryMobile] = useState('')
@@ -74,6 +75,7 @@ export default function CustomerLoginForm({ initialStep = 'mobile' }: { initialS
if (!normalized) { setError('شماره موبایل معتبر وارد کنید.'); return }
await run(async () => {
setRecoveryMobile('')
setNotice('')
let response
try {
response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/login/mobile/', { mobile: normalized }, { timeout: 30000 })
@@ -91,6 +93,9 @@ export default function CustomerLoginForm({ initialStep = 'mobile' }: { initialS
setCode('')
setOptions([])
setStep('verify') // Never depend on storage or route navigation after SMS.
if (response.data?.result?.delivery_status === 'unconfirmed') {
setNotice('وضعیت ارسال پیامک تأیید نشد. اگر کد را دریافت کرده‌اید، آن را وارد کنید؛ در غیر این صورت پس از پایان شمارش دوباره درخواست دهید.')
}
const wait = Number(response.data?.result?.retry_after || 60)
setNow(Date.now())
setResendAt(Date.now() + (Number.isFinite(wait) ? Math.min(Math.max(wait, 1), 3600) : 60) * 1000)
@@ -125,6 +130,7 @@ export default function CustomerLoginForm({ initialStep = 'mobile' }: { initialS
setCode('')
setOptions([])
setError('')
setNotice('')
}
return <div className="mx-auto w-full sm:w-[30rem]" dir="rtl">
<form onSubmit={step === 'mobile' ? sendCode : verify} className="flex flex-col gap-y-3 px-8" aria-busy={busy}>
@@ -153,6 +159,7 @@ export default function CustomerLoginForm({ initialStep = 'mobile' }: { initialS
<button type="button" disabled={busy} onClick={changeMobile} className="underline disabled:opacity-50">تغییر شماره</button>
</>}
{remaining > 0 && <p role="status">ارسال دوبارهٔ کد پس از {remaining} ثانیه امکان‌پذیر است.</p>}
{notice && <p role="status" className="bg-amber-50 rounded p-2">{notice}</p>}
{error && <p role="alert" className="text-red-700 bg-red-100 rounded p-2">{error}</p>}
</form>
</div>
+30
View File
@@ -224,6 +224,36 @@ function customerForm(h, props = {}) {
const customerOption = name => ({ uuid: name, cuser_role: { sys_ws: { name }, role_type: { role_name_en: 'workspace_customer' } } });
const settle = () => new Promise(resolve => setImmediate(resolve));
test('unconfirmed SMS reply opens OTP and the received code completes login', async () => {
const h = harness(); const form = customerForm(h); let sends = 0;
h.axios.defaults.adapter = config => {
if (config.url.endsWith('/auth/login/mobile/')) {
sends++;
return Promise.resolve(response(config, { result: { mobile: 9123456789, retry_after: 60, delivery_status: 'unconfirmed' } }, 202));
}
if (config.url.endsWith('/auth/login/mobile/verify/')) {
assert.equal(JSON.parse(config.data).opt, '123456');
return Promise.resolve(response(config, [customerOption('customer-panel')]));
}
return Promise.resolve(response(config, { access: 'access.customer', refresh: 'refresh.customer' }));
};
form.input('09123456789'); await form.submit();
assert.equal(elements(form.render(), item => item.props?.autoComplete === 'one-time-code').length, 1);
assert.ok(textContent(form.render()).includes('وضعیت ارسال پیامک تأیید نشد'));
assert.equal(elements(form.render(), item => item.props?.role === 'alert').length, 0);
form.input('123456'); await form.submit();
assert.equal(sends, 1); assert.equal(h.auth.getAccessToken(), 'access.customer');
assert.deepEqual(h.redirects, ['/shop']);
});
test('explicit SMS rejection retains the real error and does not open OTP', async () => {
const h = harness(); const form = customerForm(h);
h.axios.defaults.adapter = config => Promise.reject(failure(config, 503, { detail: 'ارسال پیامک انجام نشد. لطفاً کمی بعد دوباره تلاش کنید.' }));
form.input('09123456789'); await form.submit();
assert.ok(textContent(form.render()).includes('ارسال پیامک انجام نشد'));
assert.equal(elements(form.render(), item => item.props?.autoComplete === 'one-time-code').length, 0);
assert.equal(h.auth.getAccessToken(), null);
});
test('SMS success displays OTP in the same page even when sessionStorage is blocked', async () => {
const h = harness(); const form = customerForm(h);
h.window.sessionStorage.setItem = () => { throw new Error('Storage denied'); };
+57
View File
@@ -0,0 +1,57 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const ts = require('typescript');
// Render the actual pages with cached React Query responses; no onSuccess call.
function pageHarness(filename, data) {
const state = { data: { data }, isSuccess: true, isLoading: false, isError: false, dataUpdatedAt: 1 };
const module = { exports: {} }; let options;
const source = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020,
esModuleInterop: true, jsx: ts.JsxEmit.ReactJSX,
} }).outputText;
const requireStub = name => {
if (name === 'react/jsx-runtime') return require(name);
if (name === 'react') return { useEffect() {}, useState: () => [undefined, () => {}] };
if (name === '@tanstack/react-query') return { useQuery: value => { options = value; return state; } };
if (name === 'next/navigation') return { useRouter: () => ({ push() {} }) };
if (name === '@/axios/axiosInstance') return { __esModule: true, default: { get() {} } };
if (name === '@/components/ui/table') return Object.fromEntries(['Table', 'TableBody', 'TableCaption', 'TableCell', 'TableFooter', 'TableHead', 'TableHeader', 'TableRow'].map(key => [key, key]));
return { __esModule: true, default: name.endsWith('products-cat') ? 'CategoryCard' : name.endsWith('products') ? 'ProductCard' : 'Image' };
};
vm.runInNewContext('(function(require,module,exports){' + source + '\n})', {})(requireStub, module, module.exports);
return { state, render: () => module.exports.default({ params: { id: '9' } }), get options() { return options; } };
}
function elements(tree, type) {
if (tree == null || typeof tree !== 'object') return [];
if (Array.isArray(tree)) return tree.flatMap(item => elements(item, type));
return [...(tree.type === type ? [tree] : []), ...elements(tree.props?.children, type)];
}
const categoryPage = 'src/app/(index)/shop/page.tsx';
const productPage = 'src/app/(index)/shop/product/[id]/page.tsx';
test('shop displays cached categories in the API order, including after updated data', () => {
const h = pageHarness(categoryPage, [{ id: 8, name: 'Second alphabetically' }, { id: 1, name: 'First alphabetically' }]);
assert.deepEqual(elements(h.render(), 'CategoryCard').map(card => card.props.iteam.id), [8, 1]);
h.state.data = { data: [{ id: 1 }, { id: 8 }] }; h.state.dataUpdatedAt++;
assert.deepEqual(elements(h.render(), 'CategoryCard').map(card => card.props.iteam.id), [1, 8]);
assert.equal(h.options.refetchOnWindowFocus, true);
});
test('category page preserves cached and refreshed product order for both product groups', () => {
const data = { category_name: 'Category', category_img: null,
products: [{ id: 20, name: 'Z', view_only: false }, { id: 2, name: 'A', view_only: false }],
view_only_products: [{ id: 19, name: 'Custom Z' }, { id: 1, name: 'Custom A' }] };
const h = pageHarness(productPage, data);
assert.deepEqual(elements(h.render(), 'ProductCard').map(card => card.props.iteam.id), [20, 2]);
assert.deepEqual(elements(h.render(), 'TableCell').filter(cell => cell.props.children !== '-----').map(cell => cell.props.children), ['Custom Z', 'Custom A']);
h.state.data = { data: { ...data, products: [...data.products].reverse(), view_only_products: [...data.view_only_products].reverse() } };
assert.deepEqual(elements(h.render(), 'ProductCard').map(card => card.props.iteam.id), [2, 20]);
assert.deepEqual(elements(h.render(), 'TableCell').filter(cell => cell.props.children !== '-----').map(cell => cell.props.children), ['Custom A', 'Custom Z']);
});
test('a category without custom products renders its ordered ordinary products', () => {
const h = pageHarness(productPage, { category_name: 'Category', category_img: null, products: [{ id: 3, view_only: false }], view_only_products: null });
assert.deepEqual(elements(h.render(), 'ProductCard').map(card => card.props.iteam.id), [3]);
assert.equal(elements(h.render(), 'TableRow').length, 0);
});