Three hours of polish: turning a working wizard into a shippable one
The policy-cancellation wizard already worked — users could verify identity, pick a policy, and submit a refund request. Then came a focused afternoon, the kind that actually decides whether a feature ships: cutting the product list to what the insurance core can really cancel, replacing scan-on-every-cancellation with a 30-minute verification window, making the wizard survive page reloads and language switches, and teaching the reason chips to match the product. About three hours of work, none of it a rewrite — all of it the difference between "works" and "ready".

Our cancellation wizard had all its big pieces working: MyID identity check, policy selection, refund calculation, bank details, submission. What it didn't have yet was the layer users actually feel — the part where the feature stops fighting them. This post is about that layer, and about how much of it fits into a single sitting when the groundwork is solid.
Show only what you can actually cancel
The insurance core confirmed cancellation was ready for exactly two product families: compulsory motor insurance and travel (which covers international, domestic, and passenger variants under one product code). Everything else — КАСКО, property, health, accident — must not be offered, even when the policy is active.
So the policy list got a whitelist keyed on product family. And one more filter on top: policies that already have a cancellation request are hidden too. Selecting one could only ever end in an "already exists" error screen — so why let anyone select it? The rule I follow here: never offer a choice you know is doomed. Layered safety still stands behind it — if a cancellation was filed at an office desk our web app doesn't know about, a status probe catches it right after selection.
A 30-minute window instead of a scan per attempt
The original product rule was strict: face-scan for every cancellation. Then a real user pattern surfaced — someone logs in with MyID (a face-scan!), taps "cancel policy", and gets asked to scan again, seconds later. Technically consistent. Practically absurd.
The new product decision: one verification is valid for 30 minutes, and any successful MyID scan counts — the login itself, or the wizard's own step. I implemented it as a "stamp": a small tab-scoped record written at every successful scan and checked when the wizard decides whether to show the verification step. Bound to the user (a different account on the same tab invalidates it), erased on logout, expiring on a fixed clock that nothing can extend. Password logins never stamp — no scan, no stamp.
The subtle part was that the login flows write the stamp before the user's profile has loaded, so the stamp starts anonymous and is adopted by the first profile that validates it — keeping the original scan time, so adoption can't stretch the window. Multiple cancellations in one window now cost one scan, and the boundary is time, not attempts.
Surviving reloads and language switches
Two bugs shared a root cause I found satisfying. First: switching the interface language mid-wizard reset everything to step one. The language switcher navigates to a different URL prefix, which remounts the page component and wipes its state. Fix: a stable page key so all locale variants share one component instance — texts re-translate reactively around untouched state.
That fix immediately caused the second bug: the language stopped switching at all on this page. It turned out the app finalizes locale changes inside page-transition hooks — which only fire on remount. My component no longer remounted, so the pending locale change never completed. The page now finalizes it itself when its URL changes. Lesson: when you opt out of a lifecycle, find out what else was riding on it.
Reloads got the same care. Wizard progress — step, chosen policy, reason text — now persists per tab, and a reload resumes where the user left off behind a single calm loader instead of visibly jumping through steps. The attached PDF is the one thing a reload loses: browsers won't serialize file handles, so the user re-attaches. Everything else comes back.
Reasons that match the product
The reason step's quick-pick chips used to be one static set — including "Sold the vehicle" for a travel policy. The chips are now per-product: motor insurance offers vehicle-related reasons, travel offers trip-related ones, and both share "Incorrect information" and "Other". Selecting a chip inserts its text visibly into the comment field — what the user sees is exactly what our staff receives. And if someone picks a motor reason, goes back, and switches to a travel policy, the foreign chip and its inserted phrase are removed automatically — while every word the user typed themselves stays.
The smallest fix in this batch was my favorite: in Uzbek, "valid until a date" puts the word gacha after the date — "Amal qiladi 25.10.2026 gacha". The phrase was built label-first for all languages, correct in Russian and English, grammatically wrong in Uzbek. It's one translation template with a date placeholder now. Details like that don't show up in any sprint plan, but they're what makes an interface feel like it was written by someone who speaks your language — because it was.
What I keep from this afternoon
Feature-complete is the halfway mark. The second half is subtraction and repair: remove the choices that can't succeed, remove the verification that proves nothing new, remove the state loss users never forgave, remove the grammar that reads foreign. The whole list above took me about three hours — and that speed wasn't luck. It's what a clean wizard structure pays back: when state lives in one place and every rule has one home, polish becomes a checklist instead of an excavation. Together, those three hours are the difference between a demo and a product.