Files
tehran_taj/apps/user-app-v2/src/views/user/CompleteProfileView.vue
T
s.esmaeiliandClaude Opus 5 bfbac92df5 fix(apps): back button follows real history; screens re-read on entry
Reported: from the profile page, going to login and pressing back landed
on home instead of the profile — with the instruction to audit the whole
structure rather than that one case. Seven defects, four reachable in
ordinary use.

NAVIGATION

PageBar's back arrow pushed a FIXED route name (`backTo`) and ignored
history, so it could not know where the person came from. That was true
of 14 of the 15 PageBar uses in each app — only SearchView used
router.back() — so it was never one screen's bug. Inherited from the
reference, which pushed a fixed name in every component's back arrow;
not replicated (D4).

`backTo` is now a fallback, used only when there is nothing to go back
to (a shared link, a refresh). All 14 declarations are kept untouched,
because that is exactly when they are right.

Answering "is there anywhere to go back to?" needs care: history.length
counts entries from before the app was opened, so a link opened in a
fresh tab would look navigable and back would walk the person out of the
app. navStore records the history `position` of the app's first
navigation and compares the current one against it — direction-proof,
unlike a counter, which only grows and would be wrong after one
back-and-forward. Confirmed vue-router writes `position` on every entry
rather than assuming it.

Three pushes became replaces, each of which left behind a page the
guards immediately bounce, making "back" a no-op or a trip home:
  - after sign-in the login screen stayed in history — the worst, since
    the same file already honoured ?redirect=, so signing in and
    pressing back disagreed about where the person belonged;
  - the 401 handler kept the page whose request had just failed;
  - complete-profile kept a form that is unreachable once the documents
    are in.
CartView's post-order push is left alone: back lands on the emptied
cart, which is a real screen, not a dead end.

READ FRESHNESS

Owner's rule: fetch what a screen needs when it is opened, rather than
caching with a lifetime. Better than the TTL I had proposed — a TTL
guesses how long data stays true, while entering the screen is when it
actually matters.

OrdersView was the worst: it is in the keep-alive list but only loaded
in onMounted, so the second and every later visit made no request at
all, and the first read the cache. Order status changes on the admin
side and nothing here invalidates it, so the list could be stale for a
whole session. Ledger entries had the same shape and a stronger reason —
they are written by the accounting side, which this app can never learn
about. The gold price was forced only by pull-to-refresh on the two
screens that show it most prominently.

The catalogue is deliberately NOT re-read, by the owner's own reasoning
that this must not add server load: it is the entire product list in one
response, it changes when an admin edits it, and pull-to-refresh and
placing an order already invalidate it.

The cache layer itself is sound — concurrent callers share one in-flight
promise, and a response arriving after an invalidate is returned but not
stored, so a write cannot lose a race against a read in flight. Its one
real gap was that nothing ever went stale. The policy is now written at
the top of queryCache.ts so a TTL is not re-introduced.

Both apps vue-tsc clean. Not click-tested in a browser; the back-button
behaviour is worth one manual pass.

اپ: دکمه بازگشت به صفحه قبلی واقعی برمی‌گردد و اطلاعات هر صفحه در هر بازدید به‌روز می‌شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 12:42:42 +03:30

191 lines
5.9 KiB
Vue

<!--
"تکمیل پروفایل" — the customer uploads their three documents.
Ported from henza_pwa/src/views/user/src/completeProfile.vue (route
/complete_profile, name completeProfile), restyled into this app's design
language. The LOGIC is the reference's and is identical in user-app; only
the styling differs.
Kept from the reference:
- the header title "تکمیل پروفایل" (completeProfile.vue:12) and the back
arrow returning to the dashboard (:11)
- exactly three uploads, in the reference's order and with its texts:
جواز کسب, سردر مغازه, کارت ملی (:45-56)
- the submit button "بارگذاری اطلاعات" (:81)
- the client-side all-three check BEFORE posting, with the reference's own
message "لطفا تمامی عکس های خواسته شده را آپلود کنید" (:191-198)
- POST with the three snake_case ids (:193)
- the success dialog with its text and its "متوجه شدم" button (:94-111),
after which the reference navigated home (:175-181)
Deliberately NOT ported: the reference's `window.setInterval(... reload)` in
refresh() (:178-180), which reloaded the whole page every second FOREVER —
an unterminated timer, not a refresh. The store is re-read once instead.
The page is reachable only while the profile is incomplete; see the router
guard, which mirrors the reference's (henza_pwa router/index.ts:266).
-->
<template>
<div class="cpf">
<PageBar title="تکمیل پروفایل" back-to="userPage::hasSubRoute" />
<Spinner :spinning="submitting" tip="در حال ثبت اطلاعات">
<container class="cpf__body">
<p class="cpf__lead">
برای تکمیل پروفایل، سه مدرک زیر را بارگذاری کنید.
</p>
<form class="cpf__form" @submit.prevent="submit">
<SingleImageUploader
upload-text="بارگذاری عکس جواز کسب"
purpose="business-license"
@done="(id: string) => (form.business_license_image_id = id)"
/>
<SingleImageUploader
upload-text="بارگذاری عکس سردر مغازه"
purpose="shop-image"
@done="(id: string) => (form.shop_image_id = id)"
/>
<SingleImageUploader
upload-text="بارگذاری عکس کارت ملی"
purpose="national-card"
@done="(id: string) => (form.ncode_image_id = id)"
/>
<p v-if="error" class="cpf__error">{{ error }}</p>
<button type="submit" class="cpf__submit" :disabled="submitting">
بارگذاری اطلاعات
</button>
</form>
</container>
</Spinner>
<IslandSheet
v-model:show="done"
art="success"
title="تکمیل اطلاعات شما با موفقیت انجام شد."
description="مدارک شما با موفقیت بارگذاری شد."
confirm-text="متوجه شدم"
:close-on-click-overlay="false"
@confirm="finish"
/>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { toast } from '../../composables/useToast'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import IslandSheet from '../../components/ui/IslandSheet.vue'
import SingleImageUploader from '../../components/upload/SingleImageUploader.vue'
import { api } from '../../api/client'
import { KEYS, useQueryCache } from '../../stores/queryCache'
const router = useRouter()
const form = reactive({
business_license_image_id: null as string | null,
shop_image_id: null as string | null,
ncode_image_id: null as string | null,
})
const submitting = ref(false)
const done = ref(false)
const error = ref<string | null>(null)
async function submit() {
error.value = null
// The reference checked all three client-side before posting
// (completeProfile.vue:191-198); the server requires them too.
if (!form.business_license_image_id || !form.shop_image_id || !form.ncode_image_id) {
toast.warning('لطفا تمامی عکس های خواسته شده را آپلود کنید')
return
}
submitting.value = true
try {
await api.completeProfile({
business_license_image_id: form.business_license_image_id,
shop_image_id: form.shop_image_id,
ncode_image_id: form.ncode_image_id,
})
useQueryCache().invalidate(KEYS.profile)
done.value = true
} catch {
error.value = 'ثبت اطلاعات انجام نشد. دوباره تلاش کنید.'
} finally {
submitting.value = false
}
}
/**
* The reference sent the user home after the dialog (:175-177).
*
* `replace`: once the documents are in, this route is no longer reachable — the
* router guard redirects it home — so leaving it in history gives a back button
* that appears to do nothing.
*/
function finish() {
done.value = false
router.replace({ name: 'home' })
}
</script>
<style scoped lang="less">
/*
v2 styling. The template and script above are IDENTICAL to user-app's — only
this block differs. v2 keeps the flat, hairline-separated rhythm of its other
screens, so the three slots run together as a continuous list rather than as
three spaced cards, and the action bar pins to the bottom.
*/
.cpf__body {
padding-top: 84px;
padding-bottom: 110px;
}
.cpf__lead {
margin: 4px 0 6px;
font-size: 13px;
color: @inkMuted;
}
.cpf__form {
display: flex;
flex-direction: column;
}
.cpf__error {
margin: 12px 2px 0;
font-size: 12.5px;
color: @red;
}
.cpf__submit {
position: fixed;
left: 16px;
right: 16px;
bottom: 20px;
z-index: 20;
max-width: 736px;
margin: 0 auto;
padding: 15px;
border: none;
border-radius: 999px;
background: @wine;
color: #fff;
font-family: inherit;
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
.cpf__submit:disabled {
opacity: 0.6;
cursor: progress;
}
</style>