Enhance portal profile/support modules and add rebuild prompt doc

Add profile (verify-to-reveal, KYC rules, contact-change OTP flows,
notifications/consent tabs, devices) and support (5 channels, message
center, ticket routing, live chat, help center) updates, the geo BFF
route, login auth flow, and a self-contained module rebuild prompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Goutam0612
2026-06-29 21:02:05 +05:30
parent 783c7da7a1
commit e44e24ace2
20 changed files with 2779 additions and 388 deletions
+38
View File
@@ -0,0 +1,38 @@
# YEIDA Portal — Consent Options (for review)
Prepared as the meeting action item. **Consent** governs *what may be collected, disclosed or processed*. **Notification** governs *how we reach you* (email / SMS / WhatsApp / call / post) — these are separate and live under Profile → Notifications & Privacy.
## A. Individual consent options (toggles)
| # | Consent | Default | Locked? | What it controls |
|---|---------|---------|---------|------------------|
| 1 | **Data Processing** (statutory) | ON | 🔒 Yes | Administer the allotment, payments & statutory notices. Required by law — cannot be turned off. |
| 2 | **Profile Visibility to Authority Officers** | ON | No | Show your name & plot to the officers handling your case. |
| 3 | **Analytics & Preference Cookies** | OFF | No | Usage analytics to improve the portal. |
| 4 | **Empanelled Partner Sharing** | OFF | No | Share with empanelled service partners (contractors, banks) for services you request. |
| 5 | **Identity Disclosure for Transfers** | OFF | No | Disclose allotment identity to a prospective buyer during a plot transfer. |
| 6 | **KYC / Document Sharing** | ON | No | Share KYC docs with authority cells for verification (needed to complete KYC). |
| 7 | **Cross-border Data Transfer (NRI)** | OFF | No | Process data for an overseas account holder / allottee. |
| 8 | **Marketing Communications** | OFF | No | Newsletter / promotions / product updates (granular per type). |
## B. Dummy consent levels (tiered variant — for the dropdown demo)
A single selector that bundles the toggles above, for users who prefer one choice:
- **Level 0 — Essential only**: #1 only. Everything else OFF.
- **Level 1 — Standard**: Essential + #2 (officer visibility) + #6 (KYC sharing).
- **Level 2 — Enhanced**: Standard + #3 (analytics) + product updates.
- **Level 3 — Full**: Enhanced + #4 (partner sharing) + #5 (transfer disclosure) + #8 (marketing).
Selecting a level pre-sets the individual toggles; the user can still fine-tune any toggle afterwards (level becomes "Custom").
## C. Consent vs Notification (kept separate)
- **Consent** → the table above (governance / disclosure control).
- **Notification** → delivery channels per contact (primary/secondary email & phone): Email, SMS, WhatsApp, Call, Post — plus preferred time window & timezone. At least one channel always stays enabled.
## Open items for next meeting
- Confirm the final consent labels & defaults.
- Decide: tiered levels (Section B) **and** granular toggles (Section A), or granular only.
- Confirm which consents are statutory/locked vs user-controllable.
- Source of truth: pull existing consent from the authority platform vs portal-managed.
+353
View File
@@ -0,0 +1,353 @@
# PROMPT — Rebuild the "Profile" + "Support Center" modules
> Copy everything below this line into the other tool/website. It is a complete, framework-agnostic spec. It describes a real allottee-portal demo for a government land authority (the example is "YEIDA — Yamuna Expressway Industrial Development Authority"). Build the **Profile** module and the **Support Center** module exactly as described. You may adapt the brand name, but keep every behaviour, rule, field, screen, state and copy-tone identical.
---
## 0. Tech + look & feel
- React (function components + hooks). Single-page feel; each module is one screen rendered inside an authenticated portal shell with a left sidebar and a content area.
- Inline styles + a small set of utility CSS classes (list below). No component library — everything is hand-built.
- Visual language: clean SaaS dashboard. White cards with `1px` light borders, rounded corners (`1014px`), soft shadows, a green/navy authority palette, blue accents for interactive elements, amber for "pending/attention", red for errors, emerald-green for "success/verified".
- Everything is a **front-end demo with mocked data** — no real backend. OTP verification is mocked: **entering `000000` "verifies" any OTP**. File uploads only capture the file name.
- All money in INR (`₹`), dates as `DD Mon YYYY`, Indian number grouping.
### CSS utility classes / tokens to implement (or map to your own)
Classes used throughout: `row` (flex row, center), `col` (flex column), `spread` (justify-between), `gap-2/3/4`, `card`, `card-pad`, `card-hover`, `card-pad`, `btn` + variants `btn-primary btn-outline btn-ghost btn-emerald btn-sm btn-block`, `icon-btn`, `input`, `input-icon` (icon-prefixed input wrapper), `select`, `field` (label + control group), `label`, `badge` + tones `badge-blue badge-green badge-amber badge-navy badge-gray badge-red`, `chip` (+`.active`), `tbl` (table), `progress` (bar), `muted`, `faint`, `num` (tabular numerals), `dot` (status dot), `sec-eyebrow`, `anim-fade-up`, `anim-fade-in`, `anim-pop`.
CSS variables (colors): `--ink, --ink-2, --muted, --faint, --line, --line-2, --bg, --bg-2, --blue-50/100/400/500/600/700, --navy-700/800, --emerald-50/500/600/700, --amber-50/200/500/600, --red-50/500/600, --sh-xs/sm/md/lg, --font-display`. Keyframe animations needed: `spin`, `pulseDot` (typing dots), `growBar`, fade/pop entrances.
### Shared primitives you must build first
1. **`Icon({name, size})`** — an icon set. Names referenced: `user, mail, phone, lock, shield, bell, device, edit, check, checkCircle, x, plus, search, upload, download, file, documents, building, map, mapPin, plot, grid, layers, calendar, clock, info, alert, flag, bookmark, ticket, messages, message/chat, send, paperclip, sparkles, support, funnel, arrowR, chevR, chevD, chevU, refresh, home, transfer, payments, construction, users, trend, rupee, eye, sun, moon, settings`.
2. **`Avatar({initials, size, src, style})`** — circular; shows image if `src`, else initials.
3. **`PageHead({title, subtitle, children})`** — big H1 + muted subtitle, optional right-aligned action buttons.
4. **`useToast()``[toastNode, toast(msg)]`** — bottom-right toast stack, auto-dismiss ~3.2s.
5. **`Modal({title, sub, icon, onClose, children, footer, width=520})`** — centered overlay (`rgba(8,26,57,.55)` + blur), white rounded card, header with icon chip + title/sub + close X, scrollable body, optional footer row (right-aligned). Click backdrop to close; stop propagation on the card.
6. **`Toggle({label, on, onChange})`** — pill switch (emerald when on).
7. **`OtpField({label, sent, value, onChange, verified, onVerify})`** — 6-digit numeric input + "Verify" button (enabled at 6 digits). When verified: green border, check icon, "Verified ✓". Helper text: `OTP sent to {sent} · enter 000000 to verify (demo)`.
---
## 1. MOCK DATA (single source of truth)
Create one data object `D` with these fields. Keep the exact shapes; values can be tweaked but keep them realistic.
```js
const D = {
user: {
name: 'Rajeev Malhotra', firstName: 'Rajeev', lastName: 'Malhotra', initials: 'RM',
allotment: 'YEA-654321', sector: 'Sector 18', pocket: 'Pocket B', plot: '181',
category: 'Residential', size: '300 sq.m',
mobile: '+91 98xxxxxx12', email: 'r.malhotra@email.com', memberSince: 'Mar 2019',
relationship: 'Self', // Self | Son | Daughter | Spouse | Relative | Authorized Representative
isAllottee: true,
allotteeFirstName: 'Rajeev', allotteeLastName: 'Malhotra',
mailingAddress: { line1:'C-204, Orchid Residency, Sector 50', line2:'Near City Centre', city:'Noida', state:'Uttar Pradesh', pin:'201301' },
},
relationshipOptions: ['Self','Son','Daughter','Spouse','Relative','Authorized Representative'],
// ----- SUPPORT -----
// Ticket categories drive dropdown routing. attach:true => attachment required to submit.
ticketCategories: [
{ id:'technical', label:'Technical Support', icon:'device', color:'blue', dept:'IT Helpdesk', sla:'Replies within 4 hrs',
subs:['Login / OTP not working','Portal error or bug','Document upload failing','Page not loading','Other technical issue'] },
{ id:'payment', label:'Payments & Challan', icon:'payments', color:'emerald', dept:'Payments Desk', sla:'Replies within 1 working day',
subs:['Payment not reflecting','Challan / receipt not generated','Refund request','Dues / interest query','Failed online payment'] },
{ id:'kyc', label:'KYC & Documents', icon:'documents', color:'navy', dept:'Records Cell', sla:'Replies within 2 working days', attach:true,
subs:['KYC document rejected','Update / re-upload KYC','Lease deed correction','Certificate copy request','Name / detail correction'] },
{ id:'construction',label:'Construction Approvals',icon:'construction', color:'amber', dept:'Construction Cell',sla:'Replies within 2 working days',
subs:['Building plan query','Plan re-submission','Inspection / progress','Completion certificate','Map / setback / FAR query'] },
{ id:'allotment', label:'Allotment & Transfer', icon:'transfer', color:'blue', dept:'Allotment Cell', sla:'Replies within 2 working days', attach:true,
subs:['Allotment detail correction','Plot transfer / sale','Possession query','Mutation / succession','Address update'] },
{ id:'general', label:'General / Other', icon:'messages', color:'navy', dept:'Customer Care', sla:'Replies within 1 working day',
subs:['Account question','Feedback / suggestion','How do I…','Something else'] },
],
tickets: [ // "My Tickets" table rows
{ id:'TKT-4821', subject:'Lease deed correction request', category:'Records & Documents', status:'Open', priority:'High', updated:'2h ago' },
{ id:'TKT-4790', subject:'Challan not reflecting after UPI payment',category:'Payments & Challan', status:'Resolved', priority:'Medium', updated:'3d ago' },
{ id:'TKT-4755', subject:'Building plan re-submission query', category:'Construction Approvals', status:'In Progress', priority:'Medium', updated:'5d ago' },
],
supportThreads: [ // secure Message Center inbox threads
{ id:'MSG-4821', subject:'Lease deed correction request',
agent:{ name:'Anjali Sharma', role:'Records Officer', initials:'AS', online:true }, unread:2, time:'2h ago',
messages:[
{from:'me', text:'There is a spelling error in the name on my registered lease deed. How can I get it corrected?', time:'Mon 10:12'},
{from:'agent',text:'Hello Rajeev, thanks for reaching out. We can process a name-correction request against allotment YEA-654321. Could you share a self-attested ID proof?', time:'Mon 10:21'},
{from:'me', text:'Sure, I have uploaded my Aadhaar in the Documents section.', time:'Mon 10:25'},
{from:'agent',text:'Received. Your correction request is logged as TKT-4821 and forwarded to the Records cell. Expected TAT is 7 working days.', time:'Mon 10:31'},
] },
{ id:'MSG-4790', subject:'Challan not reflecting after UPI payment',
agent:{ name:'Vikram Singh', role:'Payments Desk', initials:'VS', online:true }, unread:0, time:'Yesterday',
messages:[
{from:'me', text:'I paid ₹38,625 via UPI yesterday but the challan is not showing under Payments.', time:'Tue 16:40'},
{from:'agent',text:'Apologies for the delay — UPI reconciliations can take up to 24 hours. I can see the payment now; challan CH-2026-0128-04 has been generated against your account.', time:'Tue 17:02'},
{from:'me', text:'Great, I can see it now. Thank you!', time:'Tue 17:05'},
] },
{ id:'MSG-4755', subject:'Building plan re-submission query',
agent:{ name:'Neha Gupta', role:'Construction Cell', initials:'NG', online:false }, unread:1, time:'5d ago',
messages:[
{from:'me', text:'My building plan was returned for revision. What changes are required?', time:'Thu 11:15'},
{from:'agent',text:'The rear-side setback needs to be increased to 3m as per the updated FAR norms. Please revise and re-upload the architect-stamped plan.', time:'Thu 12:02'},
] },
],
supportTeam: [
{ name:'Aarav', role:'Chief AI Support', initials:'AI', online:true, ai:true, desc:'Instant answers, available 24/7' },
{ name:'Anjali Sharma', role:'Records Officer', initials:'AS', online:true, desc:'Lease deed, allotment & records' },
{ name:'Vikram Singh', role:'Payments Desk', initials:'VS', online:true, desc:'Challans, dues & refunds' },
{ name:'Neha Gupta', role:'Construction Cell',initials:'NG', online:false, desc:'Building plans & approvals' },
],
helpTopics: [ // Help Center categories, each with expandable Q&A articles
{ id:'payments', icon:'payments', color:'emerald', title:'Payments & Challan', desc:'Dues, e-challan, receipts & refunds', articles:[ /* {q,a} … */ ] },
{ id:'kyc', icon:'documents', color:'navy', title:'KYC & Documents', desc:'KYC, lease deed & certificates', articles:[ /* … */ ] },
{ id:'construction', icon:'construction',color:'amber', title:'Construction', desc:'Plans, approvals & progress', articles:[ /* … */ ] },
{ id:'account', icon:'user', color:'blue', title:'Account & Profile', desc:'Login, contacts & security', articles:[ /* … */ ] },
{ id:'property', icon:'building', color:'navy', title:'Property Verification',desc:'Allotment, plot & possession checks',articles:[ /* … */ ] },
{ id:'technical', icon:'device', color:'blue', title:'Technical Support', desc:'Login, OTP, errors & the portal', articles:[ /* … */ ] },
{ id:'documents', icon:'documents', color:'amber', title:'Documents & Uploads', desc:'Uploading, formats & retrieving files',articles:[ /* … */ ] },
],
contactTimes: [ // for callback "preferred time"
{id:'morning',label:'Morning',hint:'9 AM 12 PM',icon:'sun'},
{id:'afternoon',label:'Afternoon',hint:'12 PM 4 PM',icon:'sun'},
{id:'evening',label:'Evening',hint:'4 PM 8 PM',icon:'moon'},
{id:'anytime',label:'Anytime',hint:'No preference',icon:'clock'},
],
countryCodes: [
{code:'+91',label:'IN',name:'India',digits:10}, {code:'+971',label:'AE',name:'UAE',digits:9},
{code:'+1',label:'US',name:'United States',digits:10}, {code:'+44',label:'UK',name:'United Kingdom',digits:10},
{code:'+65',label:'SG',name:'Singapore',digits:8},
],
// ----- PROFILE / KYC -----
kycDocTypes: [ // proof: 'identity' | 'address' | 'photo'; alsoAddress, expires drive rules
{id:'aadhaar', label:'Aadhaar', proof:'identity', alsoAddress:true, expires:false, hint:'Identity + address proof · does not expire'},
{id:'pan', label:'PAN', proof:'identity', alsoAddress:false, expires:false, hint:'Identity proof · does not expire'},
{id:'passport',label:'Passport', proof:'identity', alsoAddress:true, expires:true, hint:'Identity + address proof · has an expiry date'},
{id:'dl', label:'Driving Licence', proof:'identity', alsoAddress:true, expires:true, hint:'Identity + address proof · has an expiry date'},
{id:'utility', label:'Electricity Bill', proof:'address', alsoAddress:true, expires:true, hint:'Address proof · recent bill (within 3 months)'},
{id:'water', label:'Water Bill', proof:'address', alsoAddress:true, expires:true, hint:'Address proof · recent bill (within 3 months)'},
{id:'housetax',label:'House Tax Receipt',proof:'address', alsoAddress:true, expires:false, hint:'Address proof · latest receipt'},
{id:'bankstmt',label:'Bank Statement', proof:'address', alsoAddress:true, expires:true, hint:'Address proof · recent statement showing your name & address'},
{id:'photo', label:'Photograph', proof:'photo', alsoAddress:false, expires:false, hint:'Recent passport-size photograph'},
],
kyc: {
status:'verified', verifiedOn:'12 Mar 2019', contractSigned:true, viaPOA:false,
documents:[
{type:'aadhaar', number:'•••• 4821', proof:'identity+address', status:'verified', expiry:null},
{type:'pan', number:'•••• 234F', proof:'identity', status:'verified', expiry:null},
{type:'passport',number:'•••• 6789', proof:'identity+address', status:'verified', expiry:'14 Aug 2028'},
],
},
contactPrefs: {
emails:[
{id:'e1', value:'r.malhotra@email.com', primary:true, verified:true, owner:'holder'},
{id:'e2', value:'rajeev.work@company.com', primary:false, verified:true, owner:'holder'},
{id:'e3', value:'allottee.nri@email.com', primary:false, verified:true, owner:'allottee'},
],
phones:[
{id:'p1', value:'+91 98xxxxxx12', primary:true, verified:true, owner:'holder'},
{id:'p2', value:'+91 99xxxxxx77', primary:false, verified:false, owner:'holder'},
{id:'p3', value:'+971 50xxxxxx89',primary:false, verified:true, owner:'allottee'},
],
marketing:{ newsletter:false, promotions:false, productUpdates:true },
preferredTime:'anytime', preferredTz:'IST',
consent:{ dataProcessing:true, profileVisibility:true, shareWithFamily:false, kycSharing:true,
transferDisclosure:false, authorizedReps:false, analyticsCookies:false, thirdPartySharing:false,
crossBorder:false, platformUpdates:true, marketingConsent:false },
},
contactTimezones:[
{id:'IST',abbr:'IST',label:'India — IST',utc:'UTC+5:30'}, {id:'GST',abbr:'GST',label:'UAE / Gulf — GST',utc:'UTC+4:00'},
{id:'GMT',abbr:'GMT',label:'UK — GMT',utc:'UTC+0:00'}, {id:'ET',abbr:'ET',label:'US Eastern — ET',utc:'UTC5:00'},
{id:'PT',abbr:'PT',label:'US Pacific — PT',utc:'UTC8:00'}, {id:'SGT',abbr:'SGT',label:'Singapore — SGT',utc:'UTC+8:00'},
],
loginActivity:[
{action:'Successful login', device:'MacBook Pro · Chrome', ip:'49.36.x.x', location:'Greater Noida, IN', time:'Today, 09:14', ok:true},
{action:'Password changed', device:'MacBook Pro · Chrome', ip:'49.36.x.x', location:'Greater Noida, IN', time:'Yesterday, 18:02', ok:true},
{action:'New device login', device:'iPhone 14 · YEIDA App',ip:'49.36.x.x', location:'Greater Noida, IN', time:'2 days ago', ok:true},
{action:'Failed login attempt',device:'Unknown · Firefox', ip:'103.21.x.x',location:'Mumbai, IN', time:'4 days ago', ok:false},
{action:'Login alert consent enabled',device:'Windows PC · Edge',ip:'157.32.x.x',location:'Delhi, IN', time:'5 days ago', ok:true},
],
};
```
> For `helpTopics[].articles`, write 34 realistic `{q, a}` pairs per topic in the same helpful tone (e.g. Payments: "How do I generate an e-Challan?", KYC: "How do I update my KYC?" reminding ID & address must be two different docs, Account: "How do I change my email or mobile?" noting OTP goes to registered mobile, Property: "How do I verify my allotment details?" via verify-to-reveal, Technical: "I am not receiving my OTP", Documents: "Which file formats and sizes are accepted?" → JPG/PNG/PDF up to 10 MB). Each `{q,a}` becomes an accordion article.
A tone→color map used for category chips/cards:
```js
const TONE = { blue:['var(--blue-50)','var(--blue-600)'], emerald:['var(--emerald-50)','var(--emerald-700)'],
navy:['#e9eefb','var(--navy-700)'], amber:['var(--amber-50)','var(--amber-600)'] };
```
A persistent **user-photo store** + **profile-override store** backed by `localStorage` (so the uploaded avatar and edited name survive route changes): expose `useUserPhoto() → [photo, setPhoto]`, `readImageFile(file, cb)` (FileReader → data URL), `setUserProfile(patch)`, and `useUser(base)` which merges saved overrides over `D.user`.
---
## 2. SUPPORT CENTER module
A single `Support({ go, toast })` screen. `go(route)` navigates the portal (e.g. `go('assistant')` opens the AI assistant). Layout top-to-bottom:
### 2.1 Header
`PageHead title="Support Center" subtitle="Raise tickets, chat with us, or browse the help center"` with a right-side **"Talk to AI"** outline button (calls `talkAI`).
### 2.2 Five channel cards (responsive grid, `minmax(180px,1fr)`)
Each is a clickable card: icon chip (tone-colored), bold title, muted subtitle.
1. **AI Assistant** — "Instant answers · available 24/7" — `sparkles`, blue → `talkAI` = `go('assistant')`.
2. **Live Chat** — "Choose AI or a human officer" — `chat`, emerald → opens a **chat-choice modal**.
3. **Request Callback** — "We'll call you at your preferred time" — `phone`, navy → opens **CallbackForm** (kind `call`).
4. **Talk to Human Agent** — "Connect to a support officer" — `support`, amber → opens **LiveChatModal** (human).
5. **Create Ticket** — "Title, details & attachments" — `ticket`, emerald → opens **NewTicketForm**.
**Chat-choice modal** (title "Start a live chat", sub "Who would you like to chat with?"): two big option rows — "Chat with AI Assistant" (→ talkAI) and "Chat with a Human Agent" (→ open LiveChatModal). Each row: icon chip + title + description + chevron.
### 2.3 "Your support team" card
Header "Your support team." + green "● Support online" badge. 4-column grid of `supportTeam` cards: avatar (AI member gets a blue→navy gradient avatar + an "AI" badge) with an online green dot, name, role, description, and a full-width button — "Ask AI" (→ talkAI) for the AI member, else "Message" (→ toast "Message sent to {firstName}").
### 2.4 Message Center (`MessageCenter`)
Secure two-pane messaging, **inbox + live thread**:
- Card header: gradient icon chip, "Message Center", sub "Secure messaging with YEIDA support officers", green "● Support online" badge.
- **Left inbox** (300px, scroll): "Inbox" + a total-unread pill (`N new`). Each thread row: agent avatar (with online dot), agent name + time, **subject** (truncated), last-message preview (prefix `You: ` if last from me), and a blue unread count bubble. Active thread has blue left border + blue-50 bg. Opening a thread zeroes its unread.
- **Right thread** (470px tall flex column): header with agent avatar/name/role + online state + a gray badge with the thread id. Scrollable message list: my bubbles are blue, right-aligned, tail bottom-right; agent bubbles white with border + small avatar, tail bottom-left; each with a tiny timestamp. Auto-scroll to bottom on new messages.
- **Composer**: paperclip (attach), text input (Enter to send), send button. On send: append my message (time "Just now"), show a 3-dot **typing** indicator (~1s), then append a canned agent reply: `Thank you for your message. {agentFirstName} from the {role} will review this and get back to you shortly.`
### 2.5 New Ticket form (`NewTicketForm`) — inline card, not a modal
Header: ticket icon, "Raise a support ticket", sub "Pick a category so we route it to the right department — no need to call", close X. Fields:
1. **Category** dropdown (from `ticketCategories`, shows `label`). When chosen, show a department chip (`cat.dept`) next to the label, and the select icon adopts the category icon/color. Helper line lists example categories.
2. **Sub-reason** dropdown (appears after a category is chosen; options = `cat.subs`). Helper shows `{dept} · {sla}` with a clock icon.
3. **Title** (text) and **Description** (textarea).
4. **Attachments** — two upload buttons: "Attach screenshot" (`image/*`) and "Attach document" (`image/*,application/pdf`). Capture file name only. If `cat.attach === true`, mark **"· required"** and show an amber "Attach a document to submit" badge until something is attached.
5. **Auto-routing note** (tone-colored): "This ticket is auto-assigned to **{dept}** — the right officer picks it up. {sla}."
6. Footer: a left **"Talk to AI instead"** ghost button (→ talkAI), plus Cancel + **Submit ticket**. Submit is enabled only when category + sub-reason + title + description are filled **and** (if attachment required) something is attached. On submit → toast: `Ticket created & auto-routed to {dept} — the right officer will be assigned and you'll be notified`.
### 2.6 My Tickets table
Card header "My Tickets" + hint "Click a ticket to check status & add an update". Table columns: Ticket id, Subject, Department (gray badge from `category`), Priority (red badge if "High" else gray), Status (badge tone via the status map below), Updated, chevron. Clicking a row opens **TicketDetailModal**.
**Ticket status tones (single source):** `Open→amber, In Progress→blue, Waiting for User→amber, Resolved→green, Closed→gray`.
**TicketDetailModal**: header = ticket id + subject. Body:
- Badge row: status badge, category badge, priority badge (red if High), "Updated {updated}".
- **Status timeline** — vertical stepper with fixed order `Open → In Progress → Waiting for User → Resolved → Closed`, each with a sub-label (Open="Logged and classified in the system", In Progress="Assigned to the handling officer", Waiting for User="Awaiting your reply or documents", Resolved="Resolution provided", Closed="Ticket closed"). Steps before current = done (emerald + check), current = blue with a "Current" badge, future = muted. Connector line emerald up to current.
- If status is Resolved/Closed → show an emerald "This ticket is resolved…" note. Otherwise show an **"Add an update / reply"** textarea + Attach + **Post update** button (enabled when non-empty) → toast `Update added to {id}` and close.
### 2.7 Callback / Email form (`CallbackForm({kind})`) — a Modal, `kind` ∈ `call|email`
- **Topic** dropdown (from `ticketCategories` labels) — required.
- **call**: country-code select (with flag emoji from ISO label; map UK→GB) + mobile number input; **preferred date** (date input) + **preferred time (IST)** — a select of `contactTimes` plus a "Specific time…" option that swaps to a `<time>` input (with an X to revert to a window). Valid when topic set and ≥8 digits.
- **email**: name, mobile (optional), email (required, validated). Valid when topic + name + valid email.
- Optional reason/notes textarea.
- On submit → a centered **thank-you** confirmation state inside the modal: green check, "Thank you!", and a personalized line: "Our team will contact you shortly **on {cc} {phone}** / **at {email}**" + (if call & specific) "at around {time}" / (if call & a window) "during your preferred {window} window" + (if topic) "regarding your {topic} query". "You'll also receive a confirmation by email." Footer Done.
### 2.8 Live Chat with a human (`LiveChatModal`) — ChatGPT-style, 840px modal
- Left **conversations sidebar** (252px): a **"New chat"** button on top, then "Previous conversations" = the `supportThreads` (seeded as past convos) plus the current new chat. Each item: avatar (or a "+" chip for the new chat), title, time, last-message snippet.
- Right **chat area**: header with the (human) agent avatar + online dot + role + state ("Online now" / "Connecting…"); thread id badge for past convos.
- **New-chat handshake**: on starting a new chat run a timed sequence — `connecting` (spinner "Connecting you to support…", ~1s) → `waiting` ("Please wait — an agent will join shortly…") → typing dots → **joined**: insert agent message `Hi, this is {firstName} from the {role}. I've joined the chat — how can I help you today?` and show "{agent} joined the chat". The input is **disabled until joined**.
- Pick the human agent as the one named "Anjali" from `supportThreads` (fallback first). Sending a message shows typing (~1.1s) then a canned reply: "Thanks for the details — let me check that for you right away." "New chat" resets the new conversation and restarts the handshake.
### 2.9 Help Center (`HelpCenter`) — Amazon/ServiceNow-style self-service, at the bottom
- Gradient header with support icon, "Help Center", sub "Find answers fast — most questions are solved here without calling", and a **search box** ("Search help — e.g. challan, KYC, transfer fee, OTP").
- **Search mode**: when the query is non-empty, flatten all topics' articles, filter by `(q + a)` containing the query, show "N results for "query"" and render each as an accordion (`Article`), first one open by default. Empty → "No articles matched — try rephrasing, or raise a ticket below."
- **Browse mode**: left list of topics (icon chip in topic color, title, "{n} articles"); right pane shows the active topic's articles as single-open accordions.
- Footer bar: "Didn't find your answer?" + **Ask AI** (→ talkAI) and **Raise a ticket** (→ open NewTicketForm).
- **`Article`** = an accordion: question row (optional gray tag badge) + chevron; expands to the answer text.
---
## 3. PROFILE module
A single `Profile({ go })` screen. Owns a toast. Layout:
### 3.1 Profile header card
Green radial-gradient banner (height ~100px). Overlapping avatar (84px; show uploaded photo if any, else initials) with a small blue camera/edit button to upload a photo (`image/*` → data URL via `readImageFile``setPhoto` + toast "Profile photo updated"). Beside it: user name (H2) and a sub line: `{isAllottee ? 'Allottee' : relationship + ' · on behalf of allottee'} · {allotment}`. Right side: green "🛡 KYC Verified" badge + navy "Member since {memberSince}" badge.
### 3.2 Left tab rail + content
Tabs (icon + label): **Personal Info** (`user`), **KYC Verification** (`shield`), **Security** (`lock`), **Notifications** (`bell`), **Privacy & Consent** (`shield`), **Devices** (`device`). Active tab = blue-50 bg, blue text, bold. Content card re-mounts per tab (keyed) for a fade-in.
### 3.3 Personal Info tab (`PersonalTab`)
Three sub-sections plus contact-change and verify-to-reveal flows.
**(a) Account Holder** — editable. Edit/Cancel/Save toggle in the header.
- First Name, Last Name (editable only in edit mode).
- Email Address + Mobile Number shown **read-only**, each with a **"Change with verification"** ghost button → opens **ChangeContactModal**.
- Intro copy: "These are the **account holder's** details. Your name and contact (email & mobile) are editable — the allottee may be a different person (see below). Allotment identity fields are locked to authority records after verification."
- Save → `setUserProfile({...})` (name, initials, email, relationship, isAllottee, allottee names) + toast "Personal information saved".
**(b) Allottee Details** — the allottee may differ from the account holder.
- **Relationship to Allottee** select (`relationshipOptions`), editable in edit mode.
- If relationship is **Self**: show "Allottee Name" = account-holder name, read-only, with note "You are the original allottee (same as account holder)."
- If **not Self**: show Allottee First Name, Last Name, **Allottee Email**, **Allottee Mobile** (seed allottee email/phone from `contactPrefs` where `owner==='allottee'`), plus an info note: "Allottee ≠ account holder — registered as **{relationship}**. Their email & number can receive reminders (set in Notifications & Privacy)." Copy above: "The allottee may be someone other than the account holder — for example, the plot is in your spouse's name while you manage the account. The email and mobile above always belong to the account holder."
**(c) Allotment Identity** — **verify-to-reveal** (the key security pattern).
- Fields: Allotment Number, Sector, Plot Number, Pocket — all locked/read-only.
- Until verified they are **masked** with bullets (`•••••`, letter-spaced, muted) and each shows a lock icon. Section header shows a gray "Verification required" badge.
- Show an amber banner: "Sensitive details are hidden — Verify your identity to view your allotment number, plot & sector." with a **"Verify to reveal"** button → opens **AllotmentVerifyModal**.
- After verify: reveal real values, header badge turns green "Verified", toast "Allotment identity verified".
**AllotmentVerifyModal** (`expectedPlot`, `registeredMobile`): info box "For your security, your allotment number, plot & sector stay hidden until you confirm the plot number on record. A confirmation is logged against {registeredMobile}." + a **Plot Number** input. On "Verify & reveal": fake ~700ms lookup (spinner); if normalized input (strip spaces/hyphens, lowercase) equals `expectedPlot``onVerified()`, else inline error "That plot number doesn't match our records. Please try again." Helper: "Enter the plot number from your allotment (demo: 181)."
**ChangeContactModal** (`kind ∈ email|phone`, `current`, `registeredMobile`, `verifyOldEmailPolicy=false`): rules —
- **Email**: identity is always confirmed by an **OTP sent to the registered MOBILE** (not the old email). Show a blue security note saying so. Steps inside a dashed box once the new email is valid: (1) "Verify identity (registered mobile)" OTP — always required; (2) "Confirm old email (policy)" OTP — only if `verifyOldEmailPolicy` true and not lost-access; (3) "Verify new email" OTP. A "I lost access to my old email" checkbox skips the old-email step (then show: identity is confirmed via registered mobile, change logged for review). When policy off, show "Old-email confirmation is currently not required (system-configurable by the Authority)."
- **Mobile**: verify the **old number** then the **new number**; "I lost access to my old phone" checkbox skips the old-number step (only the new number is verified, change logged for review).
- All OTPs use the `OtpField` (demo `000000`). Save enabled only when the new value is valid and the required OTP steps are verified. On save → update the field + toast "{Email|Mobile} updated & verified".
### 3.4 KYC Verification tab (`KycTab`)
Collected "once you become a customer, after the allotment contract is signed". Intro: identity proof, address proof (incl. electricity/utility bill) and a **recent photograph are ALL mandatory** here (photo was optional at sign-up).
- **Completion summary card**: count of verified requirements, "{n} items still pending" or "All KYC requirements complete", a big percentage + progress bar (amber if pending, emerald if complete). `verifiedOn` shown when fully verified.
- If `kyc.viaPOA` → blue note about Power of Attorney.
- **Inline upload panel** (always on screen, NOT a popup) — `KycUploadPanel`: pick document **type** from a 3-col grid of `kycDocTypes` (icon by proof type: photo→user, address→mapPin, else file), show the type's `hint`, then choose a file (`image/*,application/pdf`, name only) and **Upload document**. On upload: append a doc `{type, number:fileName, proof, status:'pending', expiry: expires?'To be read from document':null}` (proof becomes `identity+address` if the type is identity with `alsoAddress`), toast "{label} uploaded — pending verification".
- **Requirements checklist** — three required categories: **Identity Proof**, **Address Proof** ("A different document from your identity proof — e.g. Electricity / Utility bill"), **Photograph**. Each shows status (Verified/green, Pending review/amber, Not uploaded/gray), a "Mandatory" badge, and an Upload/Re-upload button that scrolls to + preselects the upload panel's type. **Sort pending/missing first, verified last.**
- **Rule: Identity proof and Address proof must be TWO DIFFERENT documents.** If the same document is relied on for both (e.g. Aadhaar for both) → red conflict banner: "Your identity and address proof are the same document. Please add a **different** document for address proof…". Otherwise a blue reminder "Identity proof and address proof must be two **different** documents." (Address requirement is only satisfied by an address-proof of a *different type* than the ID proof.)
- **Documents on file** list: each row = type label, proof-category badge, masked number, expiry (amber "Expires {date}" if the type expires, else "No expiry"; none for photo), and a Verified/Pending badge. Verified sorted last.
- **Acceptable documents** reference grid listing every `kycDocTypes` entry with its hint (clock icon if it expires, else green check).
### 3.5 Security tab (`SecurityTab`)
Rows:
- **Password** — "Last changed 3 months ago" → "Change" opens **ChangePasswordModal**.
- **Two-Factor Authentication** — shows current method label ("OTP via mobile" / "Authenticator app" / "OTP via email") "enabled" → "Manage" opens **TwoFAModal**.
- **Authenticator App** — verify a QR + code to enable app-based 2FA; shows a "Verified" badge once done → "Verify to set up" / "Re-verify".
- **Login Alerts** toggle — "Email me on every new-device login" → toast on change.
**ChangePasswordModal**: current password + new + confirm. Password policy: **min 8 chars with uppercase, lowercase, a number AND a special char**. Show a live checklist (At least 8, Uppercase, Lowercase, Number, Special character) turning green as satisfied; red border on weak; "Passwords do not match" when confirm differs. Update enabled only when current filled, new is strong, and confirm matches → toast "Password updated".
**TwoFAModal**: choose method from SMS OTP / Authenticator App / Email OTP (radio cards). Authenticator shows a faux QR. **A change is only allowed when the CURRENT PASSWORD is entered AND an OTP/TP is verified** (demo `000000`) — otherwise Save stays blocked with the note "Change stays blocked until your current password is entered and the OTP is verified." Save → toast "2FA method saved" (or "Authenticator app verified & enabled" via the authenticator entry point).
### 3.6 Notifications tab (`NotifTab`) — "how we reach you" (vs Consent = "what is shared")
A single card "Notification Preferences / How communication is sent to you", with:
1. **Preferred channels** — high-level on/off pill switches for Email, SMS, WhatsApp, Phone Call, and an optional (dashed) Postal Mail.
2. **Preferred contact time** — multi-select windows **Morning (9 AM12 PM)**, **Afternoon (12 PM4 PM)**, **Evening (4 PM8 PM)** (selected = blue filled), an **Anytime** option (clears windows), and a **Timezone** select from `contactTimezones` (label + UTC). A live info line states which windows + timezone abbreviation will be used. (Optionally support a specific fromto time window.)
3. **Mobile numbers table** — editable rows from `contactPrefs.phones`: number input, a **role** select (Primary / Secondary / Allottee), verified/unverified badge with inline **Verify** (→ `VerifyContactModal`), and per-row channel checkboxes for **SMS, WhatsApp, Call, OTP**. Add/remove numbers. Copy: "WhatsApp availability is detected from numbers added at registration — we won't ask again."
4. **Email addresses** — editable rows from `contactPrefs.emails`: email input, role select (Primary/Secondary), a "Notify" checkbox, Verify (if unverified), remove. Add email.
5. **Summary** info box: "The system will only contact you via **{channels}** during **{time}** ({tz}). Urgent security alerts may be sent at any time."
6. Pointer line: "These are **notification** preferences (how we reach you). Manage **consent** … in the **Privacy & Consent** tab."
Notes: changing primary uses a radio per list (only one primary); setting role to Allottee tags `owner:'allottee'`. Use **plain render-helper functions, NOT nested components**, so inputs keep focus across re-renders.
**VerifyContactModal**: an `OtpField` (demo `000000`) to verify a single email/number; Done → mark verified + toast.
### 3.7 Privacy & Consent tab (`ConsentTab`) — its own tab, separate from notifications
Intro: "**Consent** controls *what* data can be shared, stored or used — separate from your **Notification** preferences …which control *how* we reach you." A gray "Synced from authority records" badge.
Data-driven consent list grouped into **Essential / Disclosure / Data & cookies / Communications**. Each item: title, description, and an emerald pill switch (or a locked "On" badge for required items). Items:
- *Essential*: **Consent to data processing & storage** (locked on) — "Required to administer your allotment, payments & statutory notices."
- *Disclosure*: Profile visible to handling officers; Share profile information with family members; Share information with authorised representatives (POA); KYC & document sharing; Disclose identity during a plot transfer.
- *Data & cookies*: Analytics & preference cookies; Share with empanelled partners; Cross-border data transfer (NRI).
- *Communications*: Allow platform updates & announcements; Receive marketing communications. When **marketing** is on, reveal granular toggles: Newsletter, Promotional emails, Product & service updates.
Toggling any → toast "Consent preference saved". Footer note: "Withdrawing a consent stops the related processing going forward. Statutory data processing stays on while your allotment is active…"
### 3.8 Devices tab (`DevicesTab`)
- **Connected Devices** list (hard-coded sample): e.g. "MacBook Pro · Chrome / Greater Noida · Active now / current", "iPhone 14 · YEIDA App / 2h ago", "Windows PC · Edge / Delhi · 5 days ago". Current device shows a green "This device" badge; others a red "Sign out" button → toast "Signed out of device".
- **Recent Activity** timeline from `loginActivity`: each entry = a circular icon (emerald check if `ok`, else red alert), action, "{device} · {location}", and "IP {ip} · {time}", connected by a vertical line.
---
## 4. Behaviour rules checklist (don't miss these)
1. **OTP is mocked**: `000000` verifies; the `OtpField` always says "enter 000000 to verify (demo)".
2. **Verify-to-reveal**: allotment identity (allotment no., plot, sector, pocket) stays masked until the user confirms their **plot number** (demo `181`).
3. **Email change** is authorised by an OTP to the **registered mobile**, never the old email; old-email confirmation is policy-gated and skippable via "lost access".
4. **Mobile change** verifies old then new number; "lost access" skips the old-number step.
5. **KYC**: identity proof and address proof must be **two different documents**; photograph is **mandatory at KYC** (was optional at registration). Aadhaar can serve identity+address but cannot be used for *both* requirements simultaneously.
6. **Password policy**: ≥8 chars + upper + lower + number + special.
7. **2FA change** requires current password **and** a verified OTP — otherwise blocked.
8. **Ticket routing**: category → department + sub-reasons; some categories require an attachment; submit auto-routes (no "call for everything").
9. **Ticket lifecycle**: Open → In Progress → Waiting for User → Resolved → Closed (timeline + badge tones above).
10. **Support channels** are exactly five: AI Assistant, Live Chat (AI/human chooser), Request Callback, Talk to Human, Create Ticket.
11. **Notifications ≠ Consent**: keep them in separate tabs with the cross-pointers.
12. **Allottee may differ from account holder** (NRI/spouse case): per-contact `owner` of `holder|allottee`, allottee can have own email/phone, reminders can route to them.
13. Everything writes to local React state (+ localStorage for photo/profile overrides) and surfaces a toast; no network calls.
Build it pixel-clean and production-looking, matching the SaaS-dashboard aesthetic described in §0. Use the exact copy and field names above.
+2 -2
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import '@/components/portal/site-header.css'; import '@/components/portal/site-header.css';
import Header from '@/components/Header'; import Header from '@/components/Header';
import { LoginSelect } from '@/components/portal/auth'; import { AuthFlow } from '@/components/portal/authflow';
import { usePortalNav } from '@/components/portal/nav'; import { usePortalNav } from '@/components/portal/nav';
export default function LoginPage() { export default function LoginPage() {
@@ -9,7 +9,7 @@ export default function LoginPage() {
return ( return (
<> <>
<Header /> <Header />
<LoginSelect go={go} /> <AuthFlow go={go} />
</> </>
); );
} }
+124
View File
@@ -0,0 +1,124 @@
import { NextResponse } from "next/server";
// Lightweight backend-for-frontend over free, keyless geo services.
// GET /api/geo?pin=203201 → India Post: { ok, pin, city, district, state, localities }
// GET /api/geo?q=alpha → India Post locality search: { ok, results }
// GET /api/geo?addr=350 5th&country=us → OpenStreetMap/Nominatim house-number autosuggest:
// { ok, results: [{ label, line1, line2, city, state, postal }] }
// If an upstream service is unreachable we fall back to a small built-in dataset
// so the registration flow keeps working offline / in the demo.
type Locality = { city: string; district: string; state: string; localities: string[] };
const FALLBACK_PINS: Record<string, Locality> = {
"203201": { city: "Greater Noida", district: "Gautam Buddh Nagar", state: "Uttar Pradesh", localities: ["Dankaur", "Dhanouri Kalan", "Jaganpur Afjalpur"] },
"201301": { city: "Noida", district: "Gautam Buddh Nagar", state: "Uttar Pradesh", localities: ["Noida Sector 1", "Noida Sector 27", "Hoshiarpur"] },
"110001": { city: "New Delhi", district: "Central Delhi", state: "Delhi", localities: ["Connaught Place", "Janpath", "Sansad Marg"] },
"122001": { city: "Gurugram", district: "Gurugram", state: "Haryana", localities: ["Gurgaon", "Civil Lines", "Gurgaon Sector 14"] },
"226001": { city: "Lucknow", district: "Lucknow", state: "Uttar Pradesh", localities: ["Lucknow GPO", "Hazratganj", "Lalbagh"] },
"302001": { city: "Jaipur", district: "Jaipur", state: "Rajasthan", localities: ["Jaipur GPO", "Kishanpole Bazar", "Mi Road"] },
"456010": { city: "Ujjain", district: "Ujjain", state: "Madhya Pradesh", localities: ["Ujjain GPO", "Freeganj", "Mahakal"] },
};
type IndiaPostOffice = { Name: string; District: string; State: string; Pincode: string; BranchType?: string };
type IndiaPostResp = Array<{ Status: string; PostOffice: IndiaPostOffice[] | null }>;
async function fetchUpstream(url: string): Promise<IndiaPostResp> {
const res = await fetch(url, {
headers: { "User-Agent": "YEIDA-Portal/1.0 (address autofill)" },
// The PIN → locality mapping is effectively static; cache for a day.
next: { revalidate: 86400 },
});
if (!res.ok) throw new Error(`upstream ${res.status}`);
return (await res.json()) as IndiaPostResp;
}
const clean = (s: string) => s.replace(/\s+/g, " ").trim();
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const pin = (searchParams.get("pin") || "").replace(/\D/g, "");
const q = clean(searchParams.get("q") || "");
const addr = clean(searchParams.get("addr") || "");
const country = (searchParams.get("country") || "").toLowerCase().replace(/[^a-z]/g, "");
// --- PIN lookup → city / district / state + locality options ---
if (pin) {
if (pin.length !== 6) {
return NextResponse.json({ ok: false, error: "PIN must be 6 digits" }, { status: 400 });
}
try {
const data = await fetchUpstream(`https://api.postalpincode.in/pincode/${pin}`);
const offices = data?.[0]?.Status === "Success" ? data[0].PostOffice ?? [] : [];
if (offices.length) {
const first = offices[0];
const localities = [...new Set(offices.map((o) => clean(o.Name)))];
return NextResponse.json({
ok: true, pin, city: first.District, district: first.District, state: first.State, localities,
});
}
} catch {
/* fall through to the built-in fallback below */
}
const fb = FALLBACK_PINS[pin];
if (fb) {
return NextResponse.json({ ok: true, pin, ...fb, source: "fallback" });
}
return NextResponse.json({ ok: false, error: "PIN not found" }, { status: 404 });
}
// --- Address / locality typeahead by name ---
if (q) {
if (q.length < 3) return NextResponse.json({ ok: true, results: [] });
try {
const data = await fetchUpstream(`https://api.postalpincode.in/postoffice/${encodeURIComponent(q)}`);
const offices = data?.[0]?.Status === "Success" ? data[0].PostOffice ?? [] : [];
const results = offices.slice(0, 8).map((o) => ({
label: `${clean(o.Name)} · ${o.District}, ${o.State}${o.Pincode}`,
line2: clean(o.Name), city: o.District, state: o.State, pin: o.Pincode,
}));
return NextResponse.json({ ok: true, results });
} catch {
const ql = q.toLowerCase();
const results = Object.entries(FALLBACK_PINS)
.filter(([, v]) => v.city.toLowerCase().includes(ql) || v.localities.some((l) => l.toLowerCase().includes(ql)))
.slice(0, 8)
.map(([p, v]) => ({ label: `${v.city} · ${v.district}, ${v.state}${p}`, line2: v.city, city: v.city, state: v.state, pin: p }));
return NextResponse.json({ ok: true, results, source: "fallback" });
}
}
// --- Worldwide house-number / street autosuggest (non-India) via OpenStreetMap ---
if (addr) {
if (addr.length < 3) return NextResponse.json({ ok: true, results: [] });
try {
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(addr)}` +
`${country ? `&countrycodes=${country}` : ""}&format=jsonv2&addressdetails=1&limit=6`;
const res = await fetch(url, {
headers: { "User-Agent": "YEIDA-Portal/1.0 (address autofill)" },
next: { revalidate: 3600 },
});
if (!res.ok) throw new Error(`upstream ${res.status}`);
type NomRow = { display_name: string; name?: string; address?: Record<string, string> };
const rows = (await res.json()) as NomRow[];
const results = rows.map((r) => {
const a = r.address || {};
const line1 = clean([a.house_number, a.road || a.pedestrian || a.name || r.name].filter(Boolean).join(" "));
const city = a.city || a.town || a.village || a.municipality || a.suburb || a.county || "";
return {
label: r.display_name,
line1,
line2: a.neighbourhood || a.suburb || a.quarter || "",
city,
state: a.state || a.region || "",
postal: a.postcode || "",
};
}).filter((r) => r.line1);
return NextResponse.json({ ok: true, results });
} catch {
return NextResponse.json({ ok: true, results: [], source: "fallback" });
}
}
return NextResponse.json({ ok: false, error: "Provide a ?pin=, ?q= or ?addr= parameter" }, { status: 400 });
}
+77 -81
View File
@@ -7,10 +7,27 @@ import YEIDA from './data';
const D = YEIDA; const D = YEIDA;
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/* ---- Social / SSO sign-in button (Google · Apple · Microsoft) with brand marks ---- */
const BRAND_SVG = {
google: <svg viewBox="0 0 18 18" width="18" height="18"><path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62Z"/><path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.8.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18Z"/><path fill="#FBBC05" d="M3.97 10.72A5.4 5.4 0 0 1 3.68 9c0-.6.1-1.18.29-1.72V4.95H.96A9 9 0 0 0 0 9c0 1.45.35 2.83.96 4.05l3.01-2.33Z"/><path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.59C13.46.89 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58Z"/></svg>,
apple: <svg viewBox="0 0 18 18" width="16" height="16"><path fill="#000" d="M13.34 9.54c-.02-1.85 1.51-2.74 1.58-2.78-.86-1.26-2.2-1.43-2.68-1.45-1.14-.12-2.23.67-2.8.67-.58 0-1.47-.65-2.42-.64-1.24.02-2.4.72-3.04 1.84-1.3 2.25-.33 5.58.93 7.4.62.9 1.35 1.9 2.31 1.86.93-.04 1.28-.6 2.4-.6 1.12 0 1.43.6 2.41.58 1-.02 1.63-.91 2.24-1.81.7-1.04.99-2.05 1.01-2.1-.02-.01-1.94-.74-1.96-2.97ZM11.5 4.06c.51-.62.86-1.48.76-2.34-.74.03-1.64.49-2.17 1.11-.47.55-.89 1.43-.78 2.27.83.07 1.67-.42 2.19-1.04Z"/></svg>,
microsoft: <svg viewBox="0 0 18 18" width="16" height="16"><path fill="#F25022" d="M0 0h8.5v8.5H0z"/><path fill="#7FBA00" d="M9.5 0H18v8.5H9.5z"/><path fill="#00A4EF" d="M0 9.5h8.5V18H0z"/><path fill="#FFB900" d="M9.5 9.5H18V18H9.5z"/></svg>,
amazon: <svg viewBox="0 0 18 18" width="18" height="15"><path fill="#FF9900" d="M11.7 12.6c-2.1 1.3-5.1 1.9-7.7 1.9-3.6 0-6.9-1.4-9.3-3.6-.2-.2 0-.4.2-.3 2.7 1.6 6 2.5 9.5 2.5 2.3 0 4.9-.5 7.2-1.5.4-.1.7.2.1.9z" transform="translate(2.4 0)"/><path fill="#FF9900" d="M12.6 11.6c-.3-.4-2-.2-2.7-.1-.2 0-.2-.2-.1-.3 1.3-.9 3.5-.7 3.8-.4.3.4-.1 2.6-1.3 3.7-.2.2-.4.1-.3-.2.3-.8 1-2.4.6-2.7z" transform="translate(2.4 0)"/></svg>,
};
function SocialButton({ provider, label, onClick }) {
return (
<button type="button" onClick={onClick} className="btn btn-outline btn-block" style={{ justifyContent: 'center', gap: 11, fontWeight: 600, padding: '12px 16px' }}>
<span style={{ display: 'inline-flex', width: 18, justifyContent: 'center' }}>{BRAND_SVG[provider]}</span>{label}
</button>
);
}
/* ---- Brand panel (navy decorative side) ---- */ /* ---- Brand panel (navy decorative side) ---- */
function BrandPanel({ compact }) { function BrandPanel({ compact }) {
return ( return (
<div style={{ position: 'relative', overflow: 'hidden', color: '#fff', padding: compact ? '38px 40px' : '48px 46px', background: 'radial-gradient(120% 90% at 100% 0%, #16407e 0%, #0b2548 45%, #081a39 100%)', display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}> <div style={{ position: 'relative', overflow: 'hidden', color: '#fff', padding: compact ? '38px 40px' : '48px 46px', background: 'radial-gradient(120% 90% at 100% 0%, #266b41 0%, #15331f 45%, #0f2417 100%)', display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<div style={{ position: 'absolute', inset: 0, opacity: .5, backgroundImage: 'linear-gradient(rgba(255,255,255,.05) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.05) 1px,transparent 1px)', backgroundSize: '46px 46px', maskImage: 'radial-gradient(80% 80% at 70% 30%,#000,transparent)' }} /> <div style={{ position: 'absolute', inset: 0, opacity: .5, backgroundImage: 'linear-gradient(rgba(255,255,255,.05) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.05) 1px,transparent 1px)', backgroundSize: '46px 46px', maskImage: 'radial-gradient(80% 80% at 70% 30%,#000,transparent)' }} />
<div style={{ position: 'absolute', top: -120, right: -100, width: 380, height: 380, borderRadius: '50%', background: 'radial-gradient(circle, rgba(22,182,126,.32), transparent 65%)', filter: 'blur(10px)' }} /> <div style={{ position: 'absolute', top: -120, right: -100, width: 380, height: 380, borderRadius: '50%', background: 'radial-gradient(circle, rgba(22,182,126,.32), transparent 65%)', filter: 'blur(10px)' }} />
<div style={{ position: 'relative', zIndex: 2 }}><Logo light size={42} /></div> <div style={{ position: 'relative', zIndex: 2 }}><Logo light size={42} /></div>
@@ -51,10 +68,10 @@ const D = YEIDA;
/* ---------------- WELCOME ---------------- */ /* ---------------- WELCOME ---------------- */
function Welcome({ go }) { function Welcome({ go }) {
return ( return (
<div style={{ minHeight: '100vh', position: 'relative', overflow: 'hidden', background: 'radial-gradient(130% 100% at 80% -10%, #16407e 0%, #0b2548 42%, #06122b 100%)', color: '#fff' }}> <div style={{ minHeight: '100vh', position: 'relative', overflow: 'hidden', background: 'radial-gradient(130% 100% at 80% -10%, #266b41 0%, #15331f 42%, #0c1c12 100%)', color: '#fff' }}>
<div style={{ position: 'absolute', inset: 0, opacity: .55, backgroundImage: 'linear-gradient(rgba(255,255,255,.045) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.045) 1px,transparent 1px)', backgroundSize: '54px 54px', maskImage: 'radial-gradient(90% 70% at 60% 20%,#000,transparent)' }} /> <div style={{ position: 'absolute', inset: 0, opacity: .55, backgroundImage: 'linear-gradient(rgba(255,255,255,.045) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.045) 1px,transparent 1px)', backgroundSize: '54px 54px', maskImage: 'radial-gradient(90% 70% at 60% 20%,#000,transparent)' }} />
<div style={{ position: 'absolute', top: -160, right: -120, width: 520, height: 520, borderRadius: '50%', background: 'radial-gradient(circle, rgba(22,182,126,.26), transparent 62%)' }} /> <div style={{ position: 'absolute', top: -160, right: -120, width: 520, height: 520, borderRadius: '50%', background: 'radial-gradient(circle, rgba(22,182,126,.26), transparent 62%)' }} />
<div style={{ position: 'absolute', bottom: -200, left: -120, width: 520, height: 520, borderRadius: '50%', background: 'radial-gradient(circle, rgba(38,112,230,.28), transparent 62%)' }} /> <div style={{ position: 'absolute', bottom: -200, left: -120, width: 520, height: 520, borderRadius: '50%', background: 'radial-gradient(circle, rgba(31,81,50,.28), transparent 62%)' }} />
<header style={{ position: 'relative', zIndex: 3, maxWidth: 1180, margin: '0 auto', padding: '26px 40px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <header style={{ position: 'relative', zIndex: 3, maxWidth: 1180, margin: '0 auto', padding: '26px 40px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Logo light size={40} /> <Logo light size={40} />
<div className="row gap-3"> <div className="row gap-3">
@@ -70,7 +87,7 @@ const D = YEIDA;
<h1 style={{ color: '#fff', fontSize: 54, lineHeight: 1.05, fontWeight: 800, letterSpacing: '-.025em' }}>Your Complete <span style={{ background: 'linear-gradient(100deg,#5ad6a6,#7fb3ff)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>Yamuna Expressway</span> Property Management Portal</h1> <h1 style={{ color: '#fff', fontSize: 54, lineHeight: 1.05, fontWeight: 800, letterSpacing: '-.025em' }}>Your Complete <span style={{ background: 'linear-gradient(100deg,#5ad6a6,#7fb3ff)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>Yamuna Expressway</span> Property Management Portal</h1>
<p style={{ color: 'rgba(214,228,247,.85)', fontSize: 17.5, lineHeight: 1.55, maxWidth: 520, marginTop: 22 }}>Verify your allotment, view official authority payment records, track construction approvals and manage documents all in one secure place.</p> <p style={{ color: 'rgba(214,228,247,.85)', fontSize: 17.5, lineHeight: 1.55, maxWidth: 520, marginTop: 22 }}>Verify your allotment, view official authority payment records, track construction approvals and manage documents all in one secure place.</p>
<div className="row gap-3" style={{ marginTop: 34, flexWrap: 'wrap' }}> <div className="row gap-3" style={{ marginTop: 34, flexWrap: 'wrap' }}>
<button className="btn btn-lg" style={{ background: '#0077FF', color: '#fff', borderRadius: 999, boxShadow: '0 16px 40px -12px rgba(0,119,255,.7)' }} onClick={() => go('login')}>Login to Portal<Icon name="arrowR" size={19} /></button> <button className="btn btn-lg" style={{ background: '#1f5132', color: '#fff', borderRadius: 999, boxShadow: '0 16px 40px -12px rgba(31,81,50,.7)' }} onClick={() => go('login')}>Login to Portal<Icon name="arrowR" size={19} /></button>
<button className="btn btn-lg" style={{ background: 'rgba(255,255,255,.1)', border: '1px solid rgba(255,255,255,.18)', color: '#fff', borderRadius: 999 }} onClick={() => go('sectormaps-public')}><Icon name="map" size={19} />Explore Sector Maps</button> <button className="btn btn-lg" style={{ background: 'rgba(255,255,255,.1)', border: '1px solid rgba(255,255,255,.18)', color: '#fff', borderRadius: 999 }} onClick={() => go('sectormaps-public')}><Icon name="map" size={19} />Explore Sector Maps</button>
</div> </div>
<div className="row gap-4" style={{ marginTop: 40, color: 'rgba(200,216,240,.7)', fontSize: 13.5, fontWeight: 500, flexWrap: 'wrap' }}> <div className="row gap-4" style={{ marginTop: 40, color: 'rgba(200,216,240,.7)', fontSize: 13.5, fontWeight: 500, flexWrap: 'wrap' }}>
@@ -95,7 +112,7 @@ const D = YEIDA;
<div className="row gap-3"> <div className="row gap-3">
<div style={{ width: 40, height: 40, borderRadius: 11, background: 'linear-gradient(150deg,var(--blue-500),var(--navy-800))', display: 'grid', placeItems: 'center', color: '#fff' }}><Icon name="building" size={21} /></div> <div style={{ width: 40, height: 40, borderRadius: 11, background: 'linear-gradient(150deg,var(--blue-500),var(--navy-800))', display: 'grid', placeItems: 'center', color: '#fff' }}><Icon name="building" size={21} /></div>
<div> <div>
<div style={{ fontWeight: 700, fontSize: 14.5 }}>Plot B-47, Sector 18</div> <div style={{ fontWeight: 700, fontSize: 14.5 }}>Plot 181, Sector 18</div>
<div className="muted" style={{ fontSize: 12.5 }}>Residential · 300 sq.m</div> <div className="muted" style={{ fontSize: 12.5 }}>Residential · 300 sq.m</div>
</div> </div>
</div> </div>
@@ -111,7 +128,7 @@ const D = YEIDA;
{plots.map((p, i) => <rect key={i} x={p[0]} y={p[1]} width="34" height="44" rx="3" fill="#fff" stroke="#c9d6e6" strokeWidth="1" />)} {plots.map((p, i) => <rect key={i} x={p[0]} y={p[1]} width="34" height="44" rx="3" fill="#fff" stroke="#c9d6e6" strokeWidth="1" />)}
<rect x="108" y="116" width="34" height="44" rx="3" fill="var(--emerald-500)" stroke="#fff" strokeWidth="2" /> <rect x="108" y="116" width="34" height="44" rx="3" fill="var(--emerald-500)" stroke="#fff" strokeWidth="2" />
<circle cx="125" cy="138" r="14" fill="none" stroke="var(--emerald-500)" strokeWidth="2" opacity=".5" style={{ animation: 'ping 2s infinite' }} /> <circle cx="125" cy="138" r="14" fill="none" stroke="var(--emerald-500)" strokeWidth="2" opacity=".5" style={{ animation: 'ping 2s infinite' }} />
<text x="125" y="141" textAnchor="middle" fontSize="9" fontWeight="700" fill="#fff">B-47</text> <text x="125" y="141" textAnchor="middle" fontSize="9" fontWeight="700" fill="#fff">181</text>
</svg> </svg>
</div> </div>
</div> </div>
@@ -121,90 +138,38 @@ const D = YEIDA;
/* ---------------- LOGIN SELECTION ---------------- */ /* ---------------- LOGIN SELECTION ---------------- */
function LoginSelect({ go }) { function LoginSelect({ go }) {
const [mode, setMode] = useState('allotment'); const [showEmail, setShowEmail] = useState(false); // email sign-in
// Either an allotment number OR a plot number may be used — only one is required. const [email, setEmail] = useState(D.user.email);
const [allotment, setAllotment] = useState('YEA-654321');
const [invite, setInvite] = useState('');
const [plot, setPlot] = useState({ sector: D.sectors[0].name, pocket: 'Pocket B', plot: '' });
const [plotState, setPlotState] = useState('idle'); // idle | checking | verified | notfound
const opts = [
{ k: 'allotment', icon: 'building', title: 'Allotment Number', desc: 'Login with your YEIDA allotment number' },
{ k: 'plot', icon: 'plot', title: 'Plot Number', desc: 'No allotment number? Use your sector, pocket & plot' },
{ k: 'invite', icon: 'key', title: 'Invitation Code', desc: 'Access via authority-issued invite code' },
];
const setPlotField = (kk, vv) => { setPlot(p => ({ ...p, [kk]: vv })); setPlotState('idle'); };
const verifyPlot = () => {
if (!plot.plot.trim()) return;
setPlotState('checking');
setTimeout(() => setPlotState('verified'), 800); // demo lookup → resolves to an allotment
};
// Continue gate: allotment filled, OR plot verified, OR invite filled.
const canContinue = mode === 'allotment' ? allotment.trim().length > 0
: mode === 'plot' ? plotState === 'verified'
: invite.trim().length > 0;
return ( return (
<AuthShell> <AuthShell>
<div className="anim-fade-up"> <div className="anim-fade-up">
<div className="kicker" style={{ marginBottom: 10 }}>Welcome back</div> <div className="kicker" style={{ marginBottom: 10 }}>Welcome back</div>
<h1 style={{ fontSize: 28, fontWeight: 800 }}>Sign in to your portal</h1> <h1 style={{ fontSize: 28, fontWeight: 800 }}>Sign in to your portal</h1>
<p className="muted" style={{ fontSize: 14.5, marginTop: 6 }}>Verify yourself using your allotment number, your plot number, or an invitation code.</p> <p className="muted" style={{ fontSize: 14.5, marginTop: 6 }}>Continue with Google, Microsoft or Apple, or sign in with the email you registered with. Your allotment details are already on file.</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 11, margin: '26px 0 20px' }}>
{opts.map(o => ( {/* Social / SSO sign-in — no allotment/plot needed at login, it's stored from sign-up */}
<button key={o.k} onClick={() => setMode(o.k)} style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '15px 16px', borderRadius: 14, textAlign: 'left', border: '1.5px solid ' + (mode === o.k ? 'var(--blue-400)' : 'var(--line)'), background: mode === o.k ? 'var(--blue-50)' : '#fff', boxShadow: mode === o.k ? '0 0 0 4px rgba(38,112,230,.1)' : 'none', transition: 'all .15s', cursor: 'pointer' }}> <div style={{ display: 'grid', gap: 10, margin: '24px 0 4px' }}>
<span style={{ width: 42, height: 42, borderRadius: 11, flex: 'none', display: 'grid', placeItems: 'center', background: mode === o.k ? 'var(--blue-500)' : 'var(--bg-2)', color: mode === o.k ? '#fff' : 'var(--muted)' }}><Icon name={o.icon} size={21} /></span> <SocialButton provider="google" label="Continue with Google" onClick={() => go('verify')} />
<div style={{ flex: 1 }}> <SocialButton provider="microsoft" label="Continue with Microsoft / Outlook" onClick={() => go('verify')} />
<div style={{ fontWeight: 700, fontSize: 15 }}>{o.title}</div> <SocialButton provider="apple" label="Continue with Apple" onClick={() => go('verify')} />
<div className="muted" style={{ fontSize: 12.8, marginTop: 1 }}>{o.desc}</div>
</div>
<span style={{ width: 20, height: 20, borderRadius: 99, border: '2px solid ' + (mode === o.k ? 'var(--blue-500)' : '#cdd7e4'), display: 'grid', placeItems: 'center' }}>{mode === o.k && <span style={{ width: 9, height: 9, borderRadius: 99, background: 'var(--blue-500)' }} />}</span>
</button>
))}
</div> </div>
{mode === 'allotment' && ( {/* Email sign-in */}
<div className="field" style={{ marginBottom: 16 }}> <div className="row gap-3" style={{ margin: '16px 0', color: 'var(--faint)', fontSize: 12.5, fontWeight: 600 }}>
<label>Allotment Number</label> <span style={{ flex: 1, height: 1, background: 'var(--line)' }} />or<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
<div className="input-icon"><Icon name="building" /><input className="input" maxLength={10} placeholder="e.g. YEA-654321" value={allotment} onChange={e => setAllotment(e.target.value.toUpperCase())} /></div>
<button onClick={() => setMode('plot')} style={{ alignSelf: 'flex-start', background: 'none', border: 'none', color: 'var(--blue-600)', fontWeight: 600, fontSize: 12.5, cursor: 'pointer', padding: 0, marginTop: 2 }}>I don't remember my allotment number — use plot number</button>
</div>
)}
{mode === 'invite' && (
<div className="field" style={{ marginBottom: 16 }}>
<label>Invitation Code</label>
<div className="input-icon"><Icon name="key" /><input className="input" placeholder="Enter your code" value={invite} onChange={e => setInvite(e.target.value)} /></div>
</div>
)}
{mode === 'plot' && (
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div className="field"><label>Sector</label><select className="select" value={plot.sector} onChange={e => setPlotField('sector', e.target.value)}>{D.sectors.map(s => <option key={s.id} value={s.name}>{s.name}</option>)}</select></div>
<div className="field"><label>Pocket</label><select className="select" value={plot.pocket} onChange={e => setPlotField('pocket', e.target.value)}>{['Pocket A', 'Pocket B', 'Pocket C', 'Pocket D'].map(p => <option key={p}>{p}</option>)}</select></div>
</div> </div>
{!showEmail ? (
<button type="button" className="btn btn-outline btn-block" onClick={() => setShowEmail(true)}><Icon name="mail" size={17} />Continue with email</button>
) : (
<div className="field"> <div className="field">
<label>Plot Number</label> <label>Email</label>
<div className="row gap-2"> <div className="input-icon"><Icon name="mail" /><input className="input" value={email} onChange={e => setEmail(e.target.value)} placeholder="name@company.com" inputMode="email" /></div>
<div className="input-icon" style={{ flex: 1 }}><Icon name="plot" /><input className="input" value={plot.plot} onChange={e => setPlotField('plot', e.target.value)} placeholder="e.g. B-47 or 047" /> <span className="faint" style={{ fontSize: 11.5 }}>Use your registered primary email (any provider e.g. company, Gmail or Outlook).</span>
{plotState === 'verified' && <Icon name="checkCircle" size={16} style={{ position: 'absolute', left: 'auto', right: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--emerald-600)' }} />} <button type="button" className="btn btn-primary btn-block" style={{ marginTop: 10, opacity: emailRe.test(email) ? 1 : .55 }} disabled={!emailRe.test(email)} onClick={() => go('verify')}>Continue with email<Icon name="arrowR" size={18} /></button>
</div>
{plotState !== 'verified' && <button type="button" className="btn btn-outline btn-sm" style={{ flex: 'none' }} disabled={!plot.plot.trim() || plotState === 'checking'} onClick={verifyPlot}>
{plotState === 'checking' ? <span style={{ width: 14, height: 14, border: '2px solid rgba(38,112,230,.3)', borderTopColor: 'var(--blue-600)', borderRadius: 99, animation: 'spin .7s linear infinite' }} /> : <Icon name="search" size={15} />}Verify
</button>}
</div>
{plotState === 'verified'
? <span className="row gap-2" style={{ fontSize: 12, color: 'var(--emerald-700)', marginTop: 6 }}><Icon name="check" size={13} />Plot verified · matched to allotment YEA-654321</span>
: plotState === 'notfound'
? <span className="row gap-2" style={{ fontSize: 12, color: 'var(--amber-600)', marginTop: 6 }}><Icon name="alert" size={13} />Plot not found — check sector & pocket, or use your allotment number</span>
: <span className="faint" style={{ fontSize: 11.5, marginTop: 6, display: 'block' }}>Unverified — verify your plot to continue (no allotment number needed).</span>}
</div>
</div> </div>
)} )}
<button className="btn btn-primary btn-block btn-lg" style={{ opacity: canContinue ? 1 : .55 }} disabled={!canContinue} onClick={() => go('verify')}>Continue<Icon name="arrowR" size={19} /></button>
<div style={{ textAlign: 'center', marginTop: 22, fontSize: 14, color: 'var(--muted)' }}> <div style={{ textAlign: 'center', marginTop: 22, fontSize: 14, color: 'var(--muted)' }}>
New allottee or representative?{' '} New allottee or representative?{' '}
<button onClick={() => go('register')} style={{ background: 'none', border: 'none', color: 'var(--blue-600)', fontWeight: 700, cursor: 'pointer', fontSize: 14 }}>Register here</button> <button onClick={() => go('register')} style={{ background: 'none', border: 'none', color: 'var(--blue-600)', fontWeight: 700, cursor: 'pointer', fontSize: 14 }}>Register here</button>
@@ -219,10 +184,17 @@ const D = YEIDA;
function VerifyPanel({ email, phone, onChange }) { function VerifyPanel({ email, phone, onChange }) {
const [cc, setCc] = useState(D.countryCodes[0]); const [cc, setCc] = useState(D.countryCodes[0]);
const [state, setState] = useState({ email: false, phone: false }); const [state, setState] = useState({ email: false, phone: false });
const [remember, setRemember] = useState(true); // "remember this device" — skip re-verification
return ( return (
<div className="col gap-3"> <div className="col gap-3">
<VerifyChannelLifted kind="email" value={email} onVerified={v => setState(s => { const n = { ...s, email: v }; onChange && onChange(n.email && n.phone); return n; })} /> <VerifyChannelLifted kind="email" value={email} onVerified={v => setState(s => { const n = { ...s, email: v }; onChange && onChange(n.email && n.phone); return n; })} />
<VerifyChannelLifted kind="phone" value={phone} cc={cc} setCc={setCc} onVerified={v => setState(s => { const n = { ...s, phone: v }; onChange && onChange(n.email && n.phone); return n; })} /> <VerifyChannelLifted kind="phone" value={phone} cc={cc} setCc={setCc} onVerified={v => setState(s => { const n = { ...s, phone: v }; onChange && onChange(n.email && n.phone); return n; })} />
{/* Remember this device — skip OTP / re-verification for 90 days on this device */}
<label className="row gap-3" style={{ fontSize: 13, color: 'var(--muted)', cursor: 'pointer', padding: '12px 14px', borderRadius: 12, border: '1px solid var(--line)', background: '#fff' }}>
<input type="checkbox" checked={remember} onChange={e => setRemember(e.target.checked)} style={{ width: 17, height: 17, accentColor: 'var(--blue-600)', flex: 'none', marginTop: 1 }} />
<span><span style={{ fontWeight: 700, color: 'var(--ink-2)' }}>Remember this device for 90 days</span><br />
<span style={{ fontSize: 11.5 }}>Skip OTP &amp; re-verification on this device for 90 days. Leave unchecked on shared or public devices.</span></span>
</label>
</div> </div>
); );
} }
@@ -237,6 +209,7 @@ const D = YEIDA;
const [api, setApi] = useState('idle'); // idle|checking|ok|invalid_email|mailbox_full|bounce_risk|invalid_mobile const [api, setApi] = useState('idle'); // idle|checking|ok|invalid_email|mailbox_full|bounce_risk|invalid_mobile
const [otpErr, setOtpErr] = useState(false); const [otpErr, setOtpErr] = useState(false);
const [field, setField] = useState(value || ''); const [field, setField] = useState(value || '');
const [channel, setChannel] = useState(kind === 'email' ? 'email' : 'sms'); // OTP delivery: email/sms/whatsapp
const refs = useRef([]); const refs = useRef([]);
useEffect(() => { if (cooldown <= 0) return; const t = setInterval(() => setCooldown(s => s - 1), 1000); return () => clearInterval(t); }, [cooldown]); useEffect(() => { if (cooldown <= 0) return; const t = setInterval(() => setCooldown(s => s - 1), 1000); return () => clearInterval(t); }, [cooldown]);
useEffect(() => { onVerified && onVerified(verified); }, [verified]); useEffect(() => { onVerified && onVerified(verified); }, [verified]);
@@ -264,9 +237,14 @@ const D = YEIDA;
}, 950); }, 950);
}; };
// Delivery channel label + the toggle options (email channel can also fall back to WhatsApp).
const chName = channel === 'whatsapp' ? 'WhatsApp' : (kind === 'email' ? 'Email' : 'SMS');
const chOpts = kind === 'email'
? [['email', 'Email', 'mail', 'var(--blue-700)'], ['whatsapp', 'WhatsApp', 'messages', 'var(--emerald-700)']]
: [['sms', 'SMS', 'phone', 'var(--blue-700)'], ['whatsapp', 'WhatsApp', 'messages', 'var(--emerald-700)']];
const API_MSG = { const API_MSG = {
checking: { tone: 'blue', icon: 'clock', text: kind === 'email' ? 'Validating email via API…' : 'Validating mobile via API…' }, checking: { tone: 'blue', icon: 'clock', text: kind === 'email' ? 'Validating email via API…' : 'Validating mobile via API…' },
ok: { tone: 'green', icon: 'checkCircle', text: kind === 'email' ? 'Email deliverable · OTP sent' : 'Mobile valid · OTP sent' }, ok: { tone: 'green', icon: 'checkCircle', text: `OTP sent via ${chName}` },
invalid_email: { tone: 'red', icon: 'alert', text: 'Invalid email address' }, invalid_email: { tone: 'red', icon: 'alert', text: 'Invalid email address' },
mailbox_full: { tone: 'amber', icon: 'alert', text: 'Mailbox full — try another email' }, mailbox_full: { tone: 'amber', icon: 'alert', text: 'Mailbox full — try another email' },
bounce_risk: { tone: 'amber', icon: 'alert', text: 'High bounce risk — verify spelling' }, bounce_risk: { tone: 'amber', icon: 'alert', text: 'High bounce risk — verify spelling' },
@@ -312,17 +290,35 @@ const D = YEIDA;
</div> </div>
)} )}
{/* Choose how the OTP is delivered. Phone → SMS / WhatsApp. Email → Email / WhatsApp
(so users who can't receive it by email can still get the code on WhatsApp). */}
<div className="row gap-3" style={{ marginTop: 11, flexWrap: 'wrap' }}>
<span className="faint" style={{ fontSize: 12, fontWeight: 600 }}>Receive OTP via</span>
<div className="row gap-1" style={{ background: 'var(--bg-2)', padding: 3, borderRadius: 9 }}>
{chOpts.map(([k, l, ic, col]) => {
const on = channel === k;
return (
<button key={k} type="button" disabled={sent} onClick={() => { setChannel(k); setApi('idle'); }} className="row gap-2"
style={{ padding: '6px 12px', borderRadius: 7, border: 'none', fontSize: 12.5, fontWeight: 700, cursor: sent ? 'not-allowed' : 'pointer', background: on ? '#fff' : 'transparent', color: on ? col : 'var(--muted)', boxShadow: on ? 'var(--sh-xs)' : 'none', opacity: sent ? .6 : 1 }}>
<Icon name={ic} size={14} />{l}
</button>
);
})}
</div>
{kind === 'email' && channel === 'whatsapp' && <span className="faint" style={{ fontSize: 11 }}>Dont want it on email? Well send the code to your WhatsApp.</span>}
</div>
{/* API validation status banner */} {/* API validation status banner */}
{apiMsg && ( {apiMsg && (
<div className="row gap-2" style={{ fontSize: 12, color: toneColor[apiMsg.tone], background: toneBg[apiMsg.tone], borderRadius: 9, padding: '7px 11px', marginTop: 9, fontWeight: 600 }}> <div className="row gap-2" style={{ fontSize: 12, color: toneColor[apiMsg.tone], background: toneBg[apiMsg.tone], borderRadius: 9, padding: '7px 11px', marginTop: 9, fontWeight: 600 }}>
{api === 'checking' ? <span style={{ width: 13, height: 13, border: '2px solid rgba(38,112,230,.3)', borderTopColor: 'var(--blue-600)', borderRadius: 99, animation: 'spin .7s linear infinite' }} /> : <Icon name={apiMsg.icon} size={14} />} {api === 'checking' ? <span style={{ width: 13, height: 13, border: '2px solid rgba(31,81,50,.3)', borderTopColor: 'var(--blue-600)', borderRadius: 99, animation: 'spin .7s linear infinite' }} /> : <Icon name={apiMsg.icon} size={14} />}
{apiMsg.text} {apiMsg.text}
</div> </div>
)} )}
{!sent ? ( {!sent ? (
<button className="btn btn-outline btn-sm btn-block" style={{ marginTop: 12 }} disabled={checking} onClick={runApi}> <button className="btn btn-outline btn-sm btn-block" style={{ marginTop: 12 }} disabled={checking} onClick={runApi}>
{checking ? <><span style={{ width: 15, height: 15, border: '2px solid rgba(38,112,230,.3)', borderTopColor: 'var(--blue-600)', borderRadius: 99, animation: 'spin .7s linear infinite' }} />{kind === 'email' ? 'Checking email' : 'Sending OTP'}</> : <><Icon name="send" size={15} />Send OTP</>} {checking ? <><span style={{ width: 15, height: 15, border: '2px solid rgba(31,81,50,.3)', borderTopColor: 'var(--blue-600)', borderRadius: 99, animation: 'spin .7s linear infinite' }} />Sending OTP via {chName}</> : <><Icon name={channel === 'whatsapp' ? 'messages' : 'send'} size={15} />Send OTP via {chName}</>}
</button> </button>
) : ( ) : (
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
@@ -369,4 +365,4 @@ const D = YEIDA;
); );
} }
export { Welcome, LoginSelect, Verify, AuthShell, VerifyPanel, BrandPanel }; export { Welcome, LoginSelect, Verify, AuthShell, VerifyPanel, BrandPanel, SocialButton };
+889
View File
@@ -0,0 +1,889 @@
'use client';
/* =============================================================================
YEIDA Allottee Portal — first-party authentication flow (Google-style).
One client-side state machine drives every screen:
welcome → identify → primary → { passkey | password | otp } → [2fa] → terms → success
↘ another-way ↘ { totp | push | otp | passkey }
↘ forgot (own mini machine)
↘ recovery (dead-end)
• Social buttons ("Continue with Google/Microsoft/Apple") trigger REAL OAuth:
we build the provider's authorize URL and redirect. When no client id is
configured (NEXT_PUBLIC_*_CLIENT_ID) we fall back to a clearly-labelled demo
continue — we never clone the providers' own sign-in screens.
• The email path is YEIDA's first-party auth, styled like Google for familiarity.
• Verification (OTP / TOTP / passkey) is mocked client-side so the whole journey
is clickable end-to-end for demos; the real backend contract lives in the spec
(Section 12). Passkey attempts the genuine WebAuthn browser prompt.
========================================================================== */
import React, { useState, useEffect, useRef, useCallback } from 'react';
import Icon from './icons';
import { Avatar } from './ui';
import { AuthShell, SocialButton } from './auth';
import YEIDA from './data';
const D = YEIDA;
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/* ----------------------------- helpers ----------------------------------- */
function maskEmail(e) {
if (!e || !e.includes('@')) return e || '';
const [u, dom] = e.split('@');
const head = u.slice(0, 1);
const tail = u.length > 2 ? u.slice(-1) : '';
return `${head}${'•'.repeat(Math.max(3, u.length - 2))}${tail}@${dom}`;
}
const MASKED_PHONE = '(•••) •••-••12';
const MASKED_WA = '+91 ••••• ••012';
/* In a real build this is a server lookup. Here: the seeded allottee is "on file";
anything else is treated as not-registered so the not-found path is demoable. */
function lookupAccount(email) {
const e = (email || '').trim().toLowerCase();
if (e === D.user.email.toLowerCase()) {
return {
email: D.user.email,
firstName: D.user.firstName,
name: D.user.name,
initials: D.user.initials,
hasPasskey: true,
hasTotp: true,
hasPush: true,
twoStep: true, // password sign-in requires a second factor
maskedEmail: maskEmail(D.user.email),
maskedPhone: MASKED_PHONE,
maskedWa: MASKED_WA,
};
}
return null;
}
/* OAuth provider config — client ids come from env at build time. */
const OAUTH = {
google: {
label: 'Google',
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
scope: 'openid email profile',
},
microsoft: {
label: 'Microsoft',
authUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
clientId: process.env.NEXT_PUBLIC_MS_CLIENT_ID,
scope: 'openid email profile',
},
apple: {
label: 'Apple',
authUrl: 'https://appleid.apple.com/auth/authorize',
clientId: process.env.NEXT_PUBLIC_APPLE_CLIENT_ID,
scope: 'name email',
},
};
function randToken(n = 24) {
const a = new Uint8Array(n);
(typeof crypto !== 'undefined' ? crypto : { getRandomValues: () => a }).getRandomValues(a);
return Array.from(a, b => b.toString(16).padStart(2, '0')).join('');
}
/* ------------------------- small shared UI ------------------------------- */
function StepBack({ onClick, label = 'Back' }) {
return (
<button className="btn btn-ghost btn-sm" style={{ marginBottom: 14, marginLeft: -8 }} onClick={onClick}>
<Icon name="chevL" size={16} />{label}
</button>
);
}
/* Account pill — avatar + email + a Change link back to the identify step. */
function AccountPill({ account, onChange }) {
return (
<div className="row gap-3" style={{ padding: '7px 7px 7px 9px', border: '1px solid var(--line)', borderRadius: 999, background: '#fff', boxShadow: 'var(--sh-xs)', alignSelf: 'flex-start', maxWidth: '100%' }}>
<Avatar initials={account.initials} size={26} />
<span style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{account.email}</span>
{onChange && (
<button onClick={onChange} style={{ background: 'none', border: 'none', color: 'var(--blue-600)', fontWeight: 700, cursor: 'pointer', fontSize: 12.5, padding: '2px 8px', borderLeft: '1px solid var(--line)' }}>Change</button>
)}
</div>
);
}
/* Non-blocking inline note shown when a method is cancelled / falls back. */
function FlashNote({ flash, onClose }) {
if (!flash) return null;
return (
<div role="status" aria-live="polite" className="row gap-2 anim-pop" style={{ fontSize: 12.5, color: 'var(--amber-600)', background: 'var(--amber-50)', border: '1px solid #ecdcb4', borderRadius: 10, padding: '9px 12px', marginBottom: 16, fontWeight: 600 }}>
<Icon name="info" size={15} style={{ flex: 'none' }} />
<span style={{ flex: 1 }}>{flash}</span>
<button onClick={onClose} aria-label="Dismiss" style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--amber-600)', display: 'flex' }}><Icon name="x" size={14} /></button>
</div>
);
}
/* 6-box OTP input — auto-advance, paste, backspace nav, error + aria. */
function OtpBoxes({ onComplete, error, disabled }) {
const [otp, setOtp] = useState(['', '', '', '', '', '']);
const refs = useRef([]);
useEffect(() => { if (error) { setOtp(['', '', '', '', '', '']); refs.current[0] && refs.current[0].focus(); } }, [error]);
useEffect(() => { const t = setTimeout(() => refs.current[0] && refs.current[0].focus(), 60); return () => clearTimeout(t); }, []);
const set = (i, v) => {
if (!/^\d?$/.test(v)) return;
const d = otp.slice(); d[i] = v; setOtp(d);
if (v && i < 5) refs.current[i + 1] && refs.current[i + 1].focus();
if (d.every(x => x !== '')) onComplete(d.join(''));
};
const onKey = (i, e) => { if (e.key === 'Backspace' && !otp[i] && i > 0) refs.current[i - 1] && refs.current[i - 1].focus(); };
const onPaste = (e) => {
const txt = (e.clipboardData.getData('text') || '').replace(/\D/g, '').slice(0, 6);
if (!txt) return; e.preventDefault();
const d = txt.split(''); while (d.length < 6) d.push('');
setOtp(d); const last = Math.min(txt.length, 5); refs.current[last] && refs.current[last].focus();
if (txt.length === 6) onComplete(txt);
};
return (
<div>
<div className="row gap-2" style={{ justifyContent: 'space-between' }} role="group" aria-label="6 digit code">
{otp.map((d, i) => (
<input key={i} ref={el => refs.current[i] = el} value={d} inputMode="numeric" autoComplete="one-time-code" maxLength={1} disabled={disabled}
aria-label={`Digit ${i + 1}`} aria-invalid={!!error}
onChange={e => set(i, e.target.value)} onKeyDown={e => onKey(i, e)} onPaste={onPaste}
style={{ width: 46, height: 54, textAlign: 'center', fontSize: 21, fontWeight: 700, fontFamily: 'var(--font-display)', border: '1.5px solid ' + (error ? 'var(--red-500)' : d ? 'var(--blue-400)' : 'var(--line)'), borderRadius: 12, color: 'var(--ink)', outline: 'none', background: error ? 'var(--red-50)' : d ? 'var(--blue-50)' : '#fff', transition: 'all .15s' }} />
))}
</div>
{error && <div role="alert" className="row gap-2" style={{ fontSize: 12.5, color: 'var(--red-600)', marginTop: 10, fontWeight: 600 }}><Icon name="alert" size={14} />{error}</div>}
</div>
);
}
/* Resend countdown link with cooldown. */
function ResendLink({ onResend, seconds = 30, label = 'Resend code' }) {
const [cd, setCd] = useState(seconds);
useEffect(() => { if (cd <= 0) return; const t = setInterval(() => setCd(s => s - 1), 1000); return () => clearInterval(t); }, [cd]);
if (cd > 0) return <span className="muted" style={{ fontSize: 12.5 }}>Resend in <b className="num" style={{ color: 'var(--ink-2)' }}>0:{String(cd).padStart(2, '0')}</b></span>;
return <button onClick={() => { setCd(seconds); onResend && onResend(); }} style={{ background: 'none', border: 'none', color: 'var(--blue-600)', fontWeight: 700, cursor: 'pointer', fontSize: 12.5 }}>{label}</button>;
}
/* A selectable method row (used on primary + another-way). */
function MethodRow({ icon, title, sub, badge, onClick }) {
return (
<button type="button" onClick={onClick} className="row gap-3" style={{ width: '100%', textAlign: 'left', padding: '14px 16px', borderRadius: 14, border: '1.5px solid var(--line)', background: '#fff', cursor: 'pointer', transition: 'all .15s' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--blue-400)'; e.currentTarget.style.background = 'var(--blue-50)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--line)'; e.currentTarget.style.background = '#fff'; }}>
<span style={{ width: 40, height: 40, flex: 'none', borderRadius: 11, display: 'grid', placeItems: 'center', background: 'var(--blue-50)', color: 'var(--blue-600)' }}><Icon name={icon} size={20} /></span>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: 'block', fontWeight: 700, fontSize: 14.5, color: 'var(--ink)' }}>{title}</span>
{sub && <span style={{ display: 'block', fontSize: 12.5, color: 'var(--muted)', marginTop: 1 }}>{sub}</span>}
</span>
{badge && <span className="badge badge-blue" style={{ flex: 'none' }}>{badge}</span>}
<Icon name="chevR" size={18} style={{ color: 'var(--faint)', flex: 'none' }} />
</button>
);
}
/* --- Trusted-device ("remember this device for 90 days") ------------------ */
const TRUST_KEY = 'yeida.trustedDevice';
const DAY = 86400000;
function rememberDevice(email) {
try { localStorage.setItem(TRUST_KEY, JSON.stringify({ email, exp: Date.now() + 90 * DAY })); } catch (e) {}
}
function trustedDevice() {
try { const v = JSON.parse(localStorage.getItem(TRUST_KEY) || 'null'); if (v && v.exp > Date.now()) return v; } catch (e) {}
return null;
}
/* The "Remember this device for 90 days" choice — shown on the verification
screens. It keeps you signed in longer and prefills this account next time;
2-step verification stays mandatory on every sign-in. */
function RememberDevice({ ctx }) {
return (
<label className="row gap-3" style={{ marginTop: 16, fontSize: 13, color: 'var(--muted)', cursor: 'pointer', padding: '12px 14px', borderRadius: 12, border: '1px solid var(--line)', background: '#fff' }}>
<input type="checkbox" checked={ctx.remember} onChange={e => ctx.setRemember(e.target.checked)} style={{ width: 17, height: 17, accentColor: 'var(--blue-600)', flex: 'none', marginTop: 1 }} />
<span>
<span style={{ fontWeight: 700, color: 'var(--ink-2)' }}>Remember this device for 90 days</span><br />
<span style={{ fontSize: 11.5 }}>Stay signed in longer and recognise this device next time. 2-step verification is still required at every sign-in. Leave unchecked on shared or public devices.</span>
</span>
</label>
);
}
/* Consistent reassurance footer at the bottom of each card. */
function SecureFooter() {
return (
<div className="row gap-4" style={{ marginTop: 22, paddingTop: 16, borderTop: '1px solid var(--line-2)', color: 'var(--faint)', fontSize: 11.5, fontWeight: 600, flexWrap: 'wrap', justifyContent: 'center' }}>
<span className="row gap-2"><Icon name="lock" size={13} style={{ color: 'var(--emerald-600)' }} />256-bit SSL</span>
<span className="row gap-2"><Icon name="shield" size={13} style={{ color: 'var(--emerald-600)' }} />Govt. of Uttar Pradesh</span>
<span className="row gap-2"><Icon name="check" size={13} style={{ color: 'var(--emerald-600)' }} />Official Portal</span>
</div>
);
}
/* =============================================================================
STEP SCREENS
========================================================================== */
/* --- 2. Welcome / sign-in card (social + Continue with email) -------------- */
function Welcome({ ctx }) {
const trusted = trustedDevice();
const [showEmail, setShowEmail] = useState(false);
const [email, setEmail] = useState((trusted && trusted.email) || ctx.email || '');
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const inputRef = useRef(null);
useEffect(() => { if (showEmail) setTimeout(() => inputRef.current && inputRef.current.focus(), 50); }, [showEmail]);
const submit = () => {
if (!emailRe.test(email)) { setErr('Enter a valid email address.'); return; }
setErr(''); setBusy(true);
setTimeout(() => { // simulate the account-lookup round-trip
const acc = lookupAccount(email);
setBusy(false);
ctx.setEmail(email);
if (acc) { ctx.setAccount(acc); ctx.push('primary'); }
else ctx.push('notfound');
}, 650);
};
return (
<AuthShell>
<div className="anim-fade-up" key="welcome">
<div className="kicker" style={{ marginBottom: 10 }}>Welcome back</div>
<h1 style={{ fontSize: 28, fontWeight: 800 }}>Sign in to your portal</h1>
<p className="muted" style={{ fontSize: 14.5, marginTop: 6 }}>Continue with Google, Microsoft or Apple, or sign in with the email you registered with. Your allotment details are already on file.</p>
<div style={{ display: 'grid', gap: 10, margin: '24px 0 4px' }}>
<SocialButton provider="google" label="Continue with Google" onClick={() => ctx.oauth('google')} />
<SocialButton provider="microsoft" label="Continue with Microsoft / Outlook" onClick={() => ctx.oauth('microsoft')} />
<SocialButton provider="apple" label="Continue with Apple" onClick={() => ctx.oauth('apple')} />
</div>
<div className="row gap-3" style={{ margin: '16px 0', color: 'var(--faint)', fontSize: 12.5, fontWeight: 600 }}>
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />or<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
</div>
{!showEmail ? (
<button type="button" className="btn btn-outline btn-block" onClick={() => setShowEmail(true)}><Icon name="mail" size={17} />Continue with email</button>
) : (
<div className="field">
<label htmlFor="auth-email">Email</label>
<div className="input-icon"><Icon name="mail" />
<input id="auth-email" ref={inputRef} className="input" value={email} aria-invalid={!!err}
onChange={e => { setEmail(e.target.value); setErr(''); }}
onKeyDown={e => e.key === 'Enter' && submit()}
placeholder="name@company.com" inputMode="email" autoComplete="email" />
</div>
{err
? <span role="alert" className="row gap-2" style={{ fontSize: 12, color: 'var(--red-600)', fontWeight: 600 }}><Icon name="alert" size={13} />{err}</span>
: <span className="faint" style={{ fontSize: 11.5 }}>Use your registered primary email (any provider company, Gmail or Outlook).</span>}
<button type="button" className="btn btn-primary btn-block" style={{ marginTop: 10 }} disabled={busy} onClick={submit}>
{busy ? <><span className="spinner" />Looking up your account</> : <>Continue<Icon name="arrowR" size={18} /></>}
</button>
</div>
)}
<div style={{ textAlign: 'center', marginTop: 22, fontSize: 14, color: 'var(--muted)' }}>
New allottee or representative?{' '}
<button onClick={() => ctx.go('register')} style={{ background: 'none', border: 'none', color: 'var(--blue-600)', fontWeight: 700, cursor: 'pointer', fontSize: 14 }}>Register here</button>
</div>
<SecureFooter />
</div>
</AuthShell>
);
}
/* --- 3 (error). Account not found ----------------------------------------- */
function NotFound({ ctx }) {
return (
<AuthShell>
<div className="anim-fade-up" key="notfound">
<StepBack onClick={ctx.back} />
<div style={{ width: 54, height: 54, borderRadius: 14, background: 'var(--red-50)', display: 'grid', placeItems: 'center', color: 'var(--red-600)', marginBottom: 16 }}><Icon name="alert" size={27} /></div>
<h1 style={{ fontSize: 26, fontWeight: 800 }}>No allotment account found</h1>
<p className="muted" style={{ fontSize: 14.5, marginTop: 8 }}>We couldn't find an allotment account for <b style={{ color: 'var(--ink-2)' }}>{ctx.email}</b>. Check the spelling, try a different email, or register if you're new.</p>
<div className="col gap-3" style={{ marginTop: 22 }}>
<button className="btn btn-primary btn-block" onClick={ctx.back}>Try a different email</button>
<button className="btn btn-outline btn-block" onClick={() => ctx.go('register')}>Register a new account<Icon name="arrowR" size={17} /></button>
</div>
</div>
</AuthShell>
);
}
/* --- 4. Primary sign-in (Hi {name} · choose how) -------------------------- */
function Primary({ ctx }) {
const a = ctx.account;
return (
<AuthShell>
<div className="anim-fade-up" key="primary">
<FlashNote flash={ctx.flash} onClose={() => ctx.setFlash(null)} />
<h1 style={{ fontSize: 28, fontWeight: 800 }}>Hi {a.firstName}</h1>
<div style={{ marginTop: 12, marginBottom: 22 }}><AccountPill account={a} onChange={ctx.switchAccount} /></div>
<p style={{ fontSize: 14, fontWeight: 700, color: 'var(--ink-2)', marginBottom: 12 }}>Choose how you want to sign in:</p>
<div className="col gap-3">
{a.hasPasskey && <MethodRow icon="key" title="Use your passkey" sub="Face, fingerprint, PIN or security key" badge="Fastest" onClick={() => ctx.push('passkey')} />}
<MethodRow icon="lock" title="Enter your password" sub="Your YEIDA portal password" onClick={() => ctx.push('password')} />
<MethodRow icon="layers" title="Try another way" sub="Authenticator, OTP, mobile approval" onClick={() => ctx.push('another')} />
</div>
<SecureFooter />
</div>
</AuthShell>
);
}
/* --- 5. Passkey (real WebAuthn attempt) ----------------------------------- */
function Passkey({ ctx }) {
const [state, setState] = useState('idle'); // idle | prompting | failed
const run = useCallback(async () => {
setState('prompting');
try {
if (typeof window === 'undefined' || !window.PublicKeyCredential || !navigator.credentials) throw new Error('unsupported');
const challenge = new Uint8Array(32); crypto.getRandomValues(challenge);
await navigator.credentials.get({
publicKey: { challenge, timeout: 60000, userVerification: 'preferred', rpId: window.location.hostname },
});
ctx.afterAuth('passkey'); // strong factor → no extra 2FA
} catch (e) {
setState('failed');
}
}, [ctx]);
useEffect(() => { run(); }, []); // eslint-disable-line
return (
<AuthShell>
<div className="anim-fade-up" key="passkey">
<StepBack onClick={ctx.back} />
<div style={{ width: 56, height: 56, borderRadius: 15, background: 'var(--blue-50)', display: 'grid', placeItems: 'center', color: 'var(--blue-600)', marginBottom: 16 }}><Icon name="key" size={28} /></div>
<h1 style={{ fontSize: 26, fontWeight: 800 }}>Use your passkey</h1>
<p className="muted" style={{ fontSize: 14.5, marginTop: 8 }}>Your browser will ask you to confirm with the saved passkey for <b style={{ color: 'var(--ink-2)' }}>portal.yeida.in</b> using your device's face, fingerprint, PIN, or a security key.</p>
<div style={{ marginTop: 18 }}><AccountPill account={ctx.account} onChange={ctx.switchAccount} /></div>
{state === 'prompting' && (
<div className="row gap-3" style={{ marginTop: 24, padding: '16px 18px', borderRadius: 14, border: '1.5px solid var(--blue-100)', background: 'var(--blue-50)' }}>
<span className="spinner spinner-lg" />
<div><div style={{ fontWeight: 700, fontSize: 14 }}>Waiting for your device…</div><div className="muted" style={{ fontSize: 12.5 }}>Follow the system prompt to confirm.</div></div>
</div>
)}
{state === 'failed' && (
<div role="alert" className="row gap-3" style={{ marginTop: 24, padding: '16px 18px', borderRadius: 14, border: '1.5px solid #ecc9c2', background: 'var(--red-50)' }}>
<Icon name="alert" size={22} style={{ color: 'var(--red-600)', flex: 'none' }} />
<div><div style={{ fontWeight: 700, fontSize: 14, color: 'var(--red-600)' }}>Couldn't use a passkey</div><div className="muted" style={{ fontSize: 12.5 }}>It was cancelled, timed out, or this device has no passkey saved.</div></div>
</div>
)}
<div className="col gap-3" style={{ marginTop: 22 }}>
{state !== 'prompting' && <button className="btn btn-primary btn-block" onClick={run}><Icon name="key" size={18} />{state === 'failed' ? 'Try passkey again' : 'Continue with passkey'}</button>}
<button className="btn btn-outline btn-block" onClick={() => ctx.replace('password')}><Icon name="lock" size={17} />Use your password instead</button>
<button className="btn btn-ghost btn-block" onClick={() => ctx.replace('another')}>Try another way</button>
</div>
</div>
</AuthShell>
);
}
/* --- 6. Password (Zoho-style light card) ---------------------------------- */
function Password({ ctx }) {
const [pwd, setPwd] = useState('');
const [show, setShow] = useState(false);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const [attempts, setAttempts] = useState(0);
const locked = attempts >= 3;
const inputRef = useRef(null);
useEffect(() => { setTimeout(() => inputRef.current && inputRef.current.focus(), 60); }, []);
const submit = () => {
if (locked || !pwd) return;
setBusy(true); setErr('');
setTimeout(() => {
setBusy(false);
if (pwd.toLowerCase() === 'wrong') { // demo: "wrong" fails, anything else passes
const n = attempts + 1; setAttempts(n);
setErr(n >= 3 ? 'Account temporarily locked after 3 failed attempts. Use an OTP or reset your password.' : `Wrong password. ${3 - n} attempt${3 - n === 1 ? '' : 's'} left.`);
} else {
// Correct password is only the first factor — 2-step verification is
// ALWAYS required (email / SMS / WhatsApp code) before reaching the portal.
ctx.setChannel('email');
ctx.push('otp');
}
}, 700);
};
return (
<AuthShell>
<div className="anim-fade-up" key="password">
<StepBack onClick={ctx.back} />
<FlashNote flash={ctx.flash} onClose={() => ctx.setFlash(null)} />
<h1 style={{ fontSize: 26, fontWeight: 800 }}>Enter your password</h1>
<div style={{ marginTop: 12, marginBottom: 20 }}><AccountPill account={ctx.account} onChange={ctx.switchAccount} /></div>
<div className="field">
<label htmlFor="auth-pwd">Password</label>
<div className="input-icon">
<Icon name="lock" />
<input id="auth-pwd" ref={inputRef} className="input" type={show ? 'text' : 'password'} value={pwd} disabled={locked} aria-invalid={!!err}
style={{ paddingRight: 44 }} autoComplete="current-password"
onChange={e => { setPwd(e.target.value); setErr(''); }} onKeyDown={e => e.key === 'Enter' && submit()} />
<button type="button" onClick={() => setShow(s => !s)} aria-label={show ? 'Hide password' : 'Show password'}
style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--faint)', display: 'flex', padding: 4 }}>
<Icon name="eye" size={18} />
</button>
</div>
{err && <span role="alert" className="row gap-2" style={{ fontSize: 12, color: 'var(--red-600)', fontWeight: 600 }}><Icon name="alert" size={13} />{err}</span>}
</div>
<div className="row spread" style={{ marginTop: 14, flexWrap: 'wrap', gap: 8 }}>
<button onClick={() => { ctx.setChannel('email'); ctx.push('otp'); }} style={{ background: 'none', border: 'none', color: 'var(--blue-600)', fontWeight: 700, cursor: 'pointer', fontSize: 13.5 }}>Sign in using email OTP</button>
<button onClick={() => ctx.push('forgot')} style={{ background: 'none', border: 'none', color: 'var(--blue-600)', fontWeight: 700, cursor: 'pointer', fontSize: 13.5 }}>Forgot password?</button>
</div>
<button className="btn btn-primary btn-block btn-lg" style={{ marginTop: 20 }} disabled={busy || locked || !pwd} onClick={submit}>
{busy ? <><span className="spinner" />Signing in</> : <>Sign in<Icon name="arrowR" size={18} /></>}
</button>
{locked && <button className="btn btn-outline btn-block" style={{ marginTop: 10 }} onClick={() => { ctx.setChannel('sms'); ctx.push('otp'); }}>Get a one-time code instead</button>}
<p className="faint" style={{ fontSize: 11.5, textAlign: 'center', marginTop: 16 }}>Demo: any password signs in · type wrong to see errors &amp; the lockout flow.</p>
<SecureFooter />
</div>
</AuthShell>
);
}
/* --- 7. OTP (multi-channel) ----------------------------------------------- */
const CHANNELS = {
email: { label: 'Email', icon: 'mail', dest: a => a.maskedEmail, blurb: a => `We've sent a 6-digit code to ${a.maskedEmail}.` },
sms: { label: 'SMS', icon: 'phone', dest: () => MASKED_PHONE, blurb: () => `We've sent a code by SMS to ${MASKED_PHONE}.` },
whatsapp: { label: 'WhatsApp', icon: 'messages', dest: a => a.maskedWa, blurb: a => `We've sent a code on WhatsApp to ${a.maskedWa}.` },
};
function OtpStep({ ctx }) {
const [channel, setChannel] = useState(ctx.channel || 'email');
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const ch = CHANNELS[channel];
const verify = (code) => {
setBusy(true); setErr('');
setTimeout(() => {
setBusy(false);
if (code === '000000') setErr('That code is incorrect or expired. Request a new one.');
else ctx.afterAuth('otp'); // OTP already proves possession → done
}, 600);
};
return (
<AuthShell>
<div className="anim-fade-up" key={'otp-' + channel}>
<StepBack onClick={ctx.back} />
<div style={{ width: 54, height: 54, borderRadius: 14, background: 'var(--blue-50)', display: 'grid', placeItems: 'center', color: 'var(--blue-600)', marginBottom: 16 }}><Icon name={ch.icon} size={26} /></div>
<h1 style={{ fontSize: 26, fontWeight: 800 }}>2-step verification</h1>
<p className="muted" style={{ fontSize: 14.5, marginTop: 8 }} aria-live="polite">{ch.blurb(ctx.account)}</p>
<div style={{ marginTop: 16, marginBottom: 18 }}><AccountPill account={ctx.account} onChange={ctx.switchAccount} /></div>
{/* Channel chooser — Email / SMS / WhatsApp all visible together */}
<p className="faint" style={{ fontSize: 12, fontWeight: 700, marginBottom: 9 }}>Where should we send your code?</p>
<div className="row gap-2" style={{ background: 'var(--bg-2)', padding: 4, borderRadius: 12, marginBottom: 18 }} role="tablist" aria-label="OTP delivery channel">
{Object.entries(CHANNELS).map(([k, c]) => {
const on = channel === k;
return (
<button key={k} role="tab" aria-selected={on} onClick={() => { setChannel(k); setErr(''); ctx.setChannel(k); }}
className="col center gap-1" style={{ flex: 1, padding: '10px 6px', borderRadius: 9, border: 'none', cursor: 'pointer', background: on ? '#fff' : 'transparent', color: on ? 'var(--blue-600)' : 'var(--muted)', boxShadow: on ? 'var(--sh-xs)' : 'none', fontWeight: 700, fontSize: 12.5, transition: 'all .15s' }}>
<Icon name={c.icon} size={18} />{c.label}
</button>
);
})}
</div>
<div style={{ fontSize: 12.5, color: 'var(--muted)', marginBottom: 14 }} className="row gap-2">
<Icon name={ch.icon} size={14} style={{ color: 'var(--blue-600)' }} />Code sent to <b style={{ color: 'var(--ink-2)' }}>{ch.dest(ctx.account)}</b>
</div>
<OtpBoxes onComplete={verify} error={err} disabled={busy} />
<div className="row spread" style={{ marginTop: 16, fontSize: 12.5 }}>
<span className="faint">Didn't get it?</span>
<ResendLink onResend={() => setErr('')} seconds={30} label={`Resend ${ch.label} code`} />
</div>
<RememberDevice ctx={ctx} />
<p className="faint" style={{ fontSize: 11.5, textAlign: 'center', marginTop: 14 }}>Demo: enter any 6 digits to verify · type “000000” for an incorrect-code state.</p>
<SecureFooter />
</div>
</AuthShell>
);
}
/* --- 8. Forgot password / reset (own mini machine) ------------------------ */
function strength(pw) {
let s = 0;
if (pw.length >= 8) s++;
if (/[A-Z]/.test(pw) && /[a-z]/.test(pw)) s++;
if (/\d/.test(pw)) s++;
if (/[^A-Za-z0-9]/.test(pw)) s++;
return s; // 0..4
}
function Forgot({ ctx }) {
const [phase, setPhase] = useState('confirm'); // confirm | code | reset | done
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const [pw, setPw] = useState(''); const [pw2, setPw2] = useState('');
const [show, setShow] = useState(false);
const sc = strength(pw);
const meter = ['Too weak', 'Weak', 'Fair', 'Good', 'Strong'][sc];
const meterColor = ['var(--red-500)', 'var(--red-500)', 'var(--gold-500)', 'var(--emerald-600)', 'var(--emerald-600)'][sc];
return (
<AuthShell>
<div className="anim-fade-up" key={'forgot-' + phase}>
<StepBack onClick={phase === 'confirm' ? ctx.back : () => setPhase(phase === 'reset' ? 'code' : 'confirm')} />
{phase === 'confirm' && (<>
<div style={{ width: 54, height: 54, borderRadius: 14, background: 'var(--blue-50)', display: 'grid', placeItems: 'center', color: 'var(--blue-600)', marginBottom: 16 }}><Icon name="lock" size={26} /></div>
<h1 style={{ fontSize: 26, fontWeight: 800 }}>Reset your password</h1>
<p className="muted" style={{ fontSize: 14.5, marginTop: 8 }}>We'll send a verification code to confirm it's you, then you can set a new password.</p>
<div style={{ marginTop: 16, marginBottom: 4 }}><AccountPill account={ctx.account} onChange={ctx.switchAccount} /></div>
<button className="btn btn-primary btn-block btn-lg" style={{ marginTop: 18 }} disabled={busy} onClick={() => { setBusy(true); setTimeout(() => { setBusy(false); setPhase('code'); }, 600); }}>
{busy ? <><span className="spinner" />Sending code…</> : <>Send code to {ctx.account.maskedEmail}<Icon name="arrowR" size={18} /></>}
</button>
</>)}
{phase === 'code' && (<>
<h1 style={{ fontSize: 26, fontWeight: 800 }}>Enter the reset code</h1>
<p className="muted" style={{ fontSize: 14.5, marginTop: 8 }}>We've sent a 6-digit code to {ctx.account.maskedEmail}.</p>
<div style={{ marginTop: 18, marginBottom: 18 }}>
<OtpBoxes onComplete={(code) => { if (code === '000000') setErr('That code is incorrect or expired.'); else { setErr(''); setPhase('reset'); } }} error={err} />
</div>
<div className="row spread" style={{ fontSize: 12.5 }}><span className="faint">Didn't get it?</span><ResendLink onResend={() => setErr('')} label="Resend code" /></div>
</>)}
{phase === 'reset' && (<>
<h1 style={{ fontSize: 26, fontWeight: 800 }}>Set a new password</h1>
<p className="muted" style={{ fontSize: 14.5, marginTop: 8 }}>Choose a strong password you don't use elsewhere.</p>
<div className="field" style={{ marginTop: 18 }}>
<label htmlFor="np">New password</label>
<div className="input-icon"><Icon name="lock" />
<input id="np" className="input" type={show ? 'text' : 'password'} value={pw} style={{ paddingRight: 44 }} onChange={e => setPw(e.target.value)} autoComplete="new-password" />
<button type="button" onClick={() => setShow(s => !s)} aria-label="Toggle visibility" style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--faint)', display: 'flex', padding: 4 }}><Icon name="eye" size={18} /></button>
</div>
{pw && (
<div style={{ marginTop: 4 }}>
<div className="row gap-2">{[0, 1, 2, 3].map(i => <span key={i} style={{ flex: 1, height: 5, borderRadius: 99, background: i < sc ? meterColor : 'var(--bg-2)', transition: 'background .2s' }} />)}</div>
<span style={{ fontSize: 11.5, fontWeight: 700, color: meterColor }}>{meter}</span>
</div>
)}
</div>
<div className="field" style={{ marginTop: 14 }}>
<label htmlFor="np2">Confirm password</label>
<div className="input-icon"><Icon name="lock" /><input id="np2" className="input" type={show ? 'text' : 'password'} value={pw2} onChange={e => setPw2(e.target.value)} autoComplete="new-password" /></div>
{pw2 && pw !== pw2 && <span role="alert" style={{ fontSize: 12, color: 'var(--red-600)', fontWeight: 600 }}>Passwords don't match.</span>}
</div>
<button className="btn btn-primary btn-block btn-lg" style={{ marginTop: 18 }} disabled={busy || sc < 2 || pw !== pw2} onClick={() => { setBusy(true); setTimeout(() => { setBusy(false); setPhase('done'); }, 700); }}>
{busy ? <><span className="spinner" />Saving…</> : 'Update password'}
</button>
</>)}
{phase === 'done' && (<>
<div style={{ width: 60, height: 60, borderRadius: 16, background: 'var(--emerald-50)', display: 'grid', placeItems: 'center', color: 'var(--emerald-600)', marginBottom: 16 }}><Icon name="checkCircle" size={30} /></div>
<h1 style={{ fontSize: 26, fontWeight: 800 }}>Password updated</h1>
<p className="muted" style={{ fontSize: 14.5, marginTop: 8 }}>You can now sign in with your new password.</p>
<button className="btn btn-primary btn-block btn-lg" style={{ marginTop: 20 }} onClick={() => ctx.replace('password')}>Back to sign in<Icon name="arrowR" size={18} /></button>
</>)}
</div>
</AuthShell>
);
}
/* --- 9a. Another way (method picker) -------------------------------------- */
function AnotherWay({ ctx }) {
const [more, setMore] = useState(false);
const a = ctx.account;
return (
<AuthShell>
<div className="anim-fade-up" key="another">
<StepBack onClick={ctx.back} />
<FlashNote flash={ctx.flash} onClose={() => ctx.setFlash(null)} />
<h1 style={{ fontSize: 26, fontWeight: 800 }}>Sign in another way</h1>
<div style={{ marginTop: 12, marginBottom: 20 }}><AccountPill account={a} onChange={ctx.switchAccount} /></div>
<div className="col gap-3">
{a.hasPasskey && <MethodRow icon="key" title="Use your face, fingerprint, PIN or security key" sub="Passkey on this device" onClick={() => ctx.push('passkey')} />}
{a.hasPush && <MethodRow icon="device" title="Approve sign-in with a mobile app" sub="Send a request to your authenticator app" onClick={() => ctx.push('push')} />}
<MethodRow icon="lock" title="Use your password" onClick={() => ctx.push('password')} />
{a.hasTotp && <MethodRow icon="clock" title="Get a code from your authenticator app" sub="Time-based code (TOTP)" onClick={() => ctx.push('totp')} />}
<MethodRow icon="mail" title={`Send a code to ${a.maskedEmail}`} sub="Email OTP" onClick={() => { ctx.setChannel('email'); ctx.push('otp'); }} />
{more && (<>
<MethodRow icon="phone" title={`Send a code to your phone ${a.maskedPhone}`} sub="SMS · standard rates apply" onClick={() => { ctx.setChannel('sms'); ctx.push('otp'); }} />
<MethodRow icon="messages" title={`Send a code on WhatsApp to ${a.maskedWa}`} sub="WhatsApp OTP" onClick={() => { ctx.setChannel('whatsapp'); ctx.push('otp'); }} />
</>)}
{!more
? <button className="btn btn-ghost btn-block" onClick={() => setMore(true)}><Icon name="chevD" size={16} />Show more options</button>
: <button className="btn btn-ghost btn-block" onClick={() => ctx.push('recovery')}>None of these work →</button>}
</div>
</div>
</AuthShell>
);
}
/* --- 9b. Authenticator TOTP ----------------------------------------------- */
function Totp({ ctx }) {
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const [backup, setBackup] = useState(false);
const [bc, setBc] = useState('');
const verify = (code) => {
setBusy(true); setErr('');
setTimeout(() => { setBusy(false); if (code === '000000') setErr('That code didn\'t match. Check your authenticator app.'); else ctx.afterAuth('totp'); }, 550);
};
return (
<AuthShell>
<div className="anim-fade-up" key="totp">
<StepBack onClick={ctx.back} />
<div style={{ width: 54, height: 54, borderRadius: 14, background: 'var(--blue-50)', display: 'grid', placeItems: 'center', color: 'var(--blue-600)', marginBottom: 16 }}><Icon name="clock" size={26} /></div>
<h1 style={{ fontSize: 26, fontWeight: 800 }}>Verify your identity</h1>
<p className="muted" style={{ fontSize: 14.5, marginTop: 8 }}>{backup ? 'Enter one of your one-time backup codes.' : 'Enter the 6-digit code generated by your authenticator app (Google Authenticator, Microsoft Authenticator, Authy…).'}</p>
<div style={{ marginTop: 16, marginBottom: 20 }}><AccountPill account={ctx.account} onChange={ctx.switchAccount} /></div>
{!backup ? (
<OtpBoxes onComplete={verify} error={err} disabled={busy} />
) : (
<div className="field">
<label htmlFor="bc">Backup code</label>
<input id="bc" className="input" value={bc} onChange={e => setBc(e.target.value.replace(/[^a-zA-Z0-9-]/g, ''))} placeholder="xxxx-xxxx" style={{ letterSpacing: 2 }} />
{err && <span role="alert" style={{ fontSize: 12, color: 'var(--red-600)', fontWeight: 600 }}>{err}</span>}
<button className="btn btn-primary btn-block" style={{ marginTop: 10 }} disabled={busy || bc.length < 8} onClick={() => verify(bc === '000000' ? '000000' : 'ok')}>Verify backup code</button>
</div>
)}
<div className="col gap-2" style={{ marginTop: 18 }}>
<button onClick={() => { setBackup(b => !b); setErr(''); }} style={{ background: 'none', border: 'none', color: 'var(--blue-600)', fontWeight: 700, cursor: 'pointer', fontSize: 13.5, textAlign: 'left' }}>{backup ? 'Use authenticator code instead' : 'Enter a backup code instead'}</button>
<button onClick={() => ctx.replace('another')} style={{ background: 'none', border: 'none', color: 'var(--blue-600)', fontWeight: 700, cursor: 'pointer', fontSize: 13.5, textAlign: 'left' }}>Use a different verification option</button>
</div>
<RememberDevice ctx={ctx} />
<div className="row gap-3" style={{ marginTop: 16 }}>
<button className="btn btn-outline" style={{ flex: 1 }} onClick={ctx.back}>Cancel</button>
</div>
<SecureFooter />
</div>
</AuthShell>
);
}
/* --- 9 (push). Mobile-app approval ---------------------------------------- */
function Push({ ctx }) {
const [state, setState] = useState('waiting'); // waiting | approved | timeout
useEffect(() => {
const t = setTimeout(() => setState('approved'), 3200); // demo auto-approve
return () => clearTimeout(t);
}, []);
useEffect(() => { if (state === 'approved') { const t = setTimeout(() => ctx.afterAuth('push'), 900); return () => clearTimeout(t); } }, [state]); // eslint-disable-line
return (
<AuthShell>
<div className="anim-fade-up" key="push">
<StepBack onClick={ctx.back} />
<div style={{ width: 56, height: 56, borderRadius: 15, background: 'var(--blue-50)', display: 'grid', placeItems: 'center', color: 'var(--blue-600)', marginBottom: 16 }}><Icon name="device" size={28} /></div>
<h1 style={{ fontSize: 26, fontWeight: 800 }}>Check your phone</h1>
<p className="muted" style={{ fontSize: 14.5, marginTop: 8 }}>Open your YEIDA authenticator app and approve the sign-in request.</p>
<div style={{ marginTop: 18, marginBottom: 4 }}><AccountPill account={ctx.account} onChange={ctx.switchAccount} /></div>
<div className="row gap-3" style={{ marginTop: 22, padding: '18px', borderRadius: 14, border: '1.5px solid ' + (state === 'approved' ? '#bfe6d3' : 'var(--blue-100)'), background: state === 'approved' ? 'var(--emerald-50)' : 'var(--blue-50)' }}>
{state === 'approved' ? <Icon name="checkCircle" size={26} style={{ color: 'var(--emerald-600)', flex: 'none' }} /> : <span className="spinner spinner-lg" />}
<div>
<div style={{ fontWeight: 700, fontSize: 14 }}>{state === 'approved' ? 'Approved — signing you in…' : 'Waiting for approval…'}</div>
<div className="muted" style={{ fontSize: 12.5 }}>{state === 'approved' ? 'Request confirmed on your device.' : 'This request expires in a couple of minutes.'}</div>
</div>
</div>
{state !== 'approved' && (<>
<RememberDevice ctx={ctx} />
<div className="col gap-2" style={{ marginTop: 16 }}>
<button className="btn btn-outline btn-block" onClick={() => ctx.replace('totp')}>Enter a code manually instead</button>
<button className="btn btn-ghost btn-block" onClick={ctx.back}>Cancel</button>
</div>
</>)}
</div>
</AuthShell>
);
}
/* --- 9c. Recovery dead-end ------------------------------------------------ */
function Recovery({ ctx }) {
return (
<AuthShell>
<div className="anim-fade-up" key="recovery">
<StepBack onClick={ctx.back} />
<div style={{ width: 54, height: 54, borderRadius: 14, background: 'var(--amber-50)', display: 'grid', placeItems: 'center', color: 'var(--amber-600)', marginBottom: 16 }}><Icon name="shield" size={26} /></div>
<h1 style={{ fontSize: 26, fontWeight: 800 }}>Couldn't sign you in</h1>
<div style={{ marginTop: 12, marginBottom: 16 }}><AccountPill account={ctx.account} onChange={ctx.switchAccount} /></div>
<p className="muted" style={{ fontSize: 14.5, lineHeight: 1.6 }}>We don't have enough information to confirm this account belongs to you. To help us:</p>
<ul className="muted" style={{ fontSize: 14, lineHeight: 1.7, paddingLeft: 20, marginTop: 8 }}>
<li>Answer as many verification prompts as you can.</li>
<li>Use a device or browser you've signed in on before.</li>
<li>Make sure you're on a trusted network.</li>
</ul>
<button onClick={() => ctx.go('support')} style={{ background: 'none', border: 'none', color: 'var(--blue-600)', fontWeight: 700, cursor: 'pointer', fontSize: 13.5, marginTop: 10 }}>More tips to recover your account</button>
<div className="col gap-3" style={{ marginTop: 22 }}>
<button className="btn btn-primary btn-block" onClick={() => ctx.replace('another')}>Try again</button>
<button className="btn btn-ghost btn-block" onClick={ctx.switchAccount}>Use a different account</button>
</div>
</div>
</AuthShell>
);
}
/* --- 10. Terms acceptance gate -------------------------------------------- */
function Terms({ ctx }) {
const [scrolled, setScrolled] = useState(false);
const [agree, setAgree] = useState(false);
const ref = useRef(null);
const onScroll = () => {
const el = ref.current; if (!el) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 12) setScrolled(true);
};
return (
<AuthShell>
<div className="anim-fade-up" key="terms">
<h1 style={{ fontSize: 26, fontWeight: 800 }}>Before you continue</h1>
<p className="muted" style={{ fontSize: 14.5, marginTop: 8 }}>Please review and accept the YEIDA portal terms and privacy notice to access your allotment records.</p>
<div ref={ref} onScroll={onScroll} style={{ marginTop: 16, maxHeight: 230, overflowY: 'auto', border: '1px solid var(--line)', borderRadius: 14, padding: '16px 18px', background: 'var(--bg)', fontSize: 13, lineHeight: 1.7, color: 'var(--ink-2)' }}>
<p style={{ fontWeight: 700, color: 'var(--ink)' }}>1. Authorised use</p>
<p>This portal provides access to official records of the Yamuna Expressway Industrial Development Authority. Access is restricted to the registered allottee or an authorised representative on record.</p>
<p style={{ fontWeight: 700, color: 'var(--ink)', marginTop: 10 }}>2. Accuracy of records</p>
<p>Authority records (allotment, lease, possession and payment status) are the system of record. Where the portal and an official letter differ, the authority's records prevail.</p>
<p style={{ fontWeight: 700, color: 'var(--ink)', marginTop: 10 }}>3. Data &amp; privacy</p>
<p>Your contact details and documents are processed solely to service your allotment. We never sell your data. OTP and verification events are logged for security and audit as required by the authority.</p>
<p style={{ fontWeight: 700, color: 'var(--ink)', marginTop: 10 }}>4. Security</p>
<p>Keep your password, passkeys and one-time codes confidential. YEIDA officials will never ask you for an OTP. Report suspicious activity to the support cell immediately.</p>
<p style={{ fontWeight: 700, color: 'var(--ink)', marginTop: 10 }}>5. Acceptable conduct</p>
<p>You agree not to misuse the portal, attempt unauthorised access, or upload unlawful content. The authority may suspend access for breaches of these terms.</p>
<p style={{ marginTop: 10 }}>By continuing you confirm you have read and agree to the full Terms of Use and Privacy Notice of the YEIDA Allottee Portal.</p>
</div>
{!scrolled && <p className="faint" style={{ fontSize: 11.5, marginTop: 8 }}>Scroll to the end to enable acceptance.</p>}
<label className="row gap-3" style={{ marginTop: 14, fontSize: 13.5, color: scrolled ? 'var(--ink-2)' : 'var(--faint)', cursor: scrolled ? 'pointer' : 'not-allowed' }}>
<input type="checkbox" disabled={!scrolled} checked={agree} onChange={e => setAgree(e.target.checked)} style={{ width: 18, height: 18, accentColor: 'var(--blue-600)', flex: 'none' }} />
I have read and accept the Terms of Use and Privacy Notice.
</label>
<div className="row gap-3" style={{ marginTop: 20 }}>
<button className="btn btn-outline" style={{ flex: 1 }} onClick={ctx.switchAccount}>Cancel</button>
<button className="btn btn-primary" style={{ flex: 2 }} disabled={!agree} onClick={() => ctx.replace('success')}>I accept<Icon name="arrowR" size={18} /></button>
</div>
</div>
</AuthShell>
);
}
/* --- 11. Success ---------------------------------------------------------- */
function Success({ ctx }) {
useEffect(() => {
if (ctx.remember && ctx.account) rememberDevice(ctx.account.email); // persist trusted device (90 days)
const t = setTimeout(() => ctx.go('app'), 1700); return () => clearTimeout(t);
}, []); // eslint-disable-line
return (
<AuthShell>
<div className="anim-pop" key="success" style={{ textAlign: 'center', padding: '20px 0' }}>
<div style={{ width: 84, height: 84, margin: '0 auto 22px', borderRadius: 999, background: 'var(--emerald-50)', display: 'grid', placeItems: 'center', color: 'var(--emerald-600)', boxShadow: '0 0 0 10px rgba(22,182,126,.10)' }}><Icon name="check" size={44} strokeWidth={2.4} /></div>
<h1 style={{ fontSize: 27, fontWeight: 800 }}>You're signed in</h1>
<p className="muted" style={{ fontSize: 14.5, marginTop: 8 }}>Welcome back, {ctx.account.name}.<br />Taking you to your dashboard</p>
<div className="row gap-3" style={{ justifyContent: 'center', marginTop: 16, fontSize: 13, color: 'var(--muted)' }}>
<Avatar initials={ctx.account.initials} size={26} /><span>{ctx.account.email}</span>
</div>
{ctx.remember && (
<div className="row gap-2" style={{ justifyContent: 'center', marginTop: 12, fontSize: 12, color: 'var(--emerald-700)', fontWeight: 600 }}>
<Icon name="shield" size={14} />We'll remember this device for 90 days
</div>
)}
<button className="btn btn-primary" style={{ marginTop: 24 }} onClick={() => ctx.go('app')}>Go to dashboard now<Icon name="arrowR" size={18} /></button>
</div>
</AuthShell>
);
}
/* --- OAuth redirect / demo-fallback interstitial -------------------------- */
function OAuthRedirect({ ctx }) {
const p = OAUTH[ctx.oauthProvider] || OAUTH.google;
useEffect(() => {
if (p.clientId && typeof window !== 'undefined') {
const params = new URLSearchParams({
client_id: p.clientId,
redirect_uri: window.location.origin + '/portal/login',
response_type: 'code',
scope: p.scope,
state: randToken(16),
nonce: randToken(16),
});
window.location.href = `${p.authUrl}?${params.toString()}`; // REAL provider redirect
return;
}
// No client id configured → continue in clearly-labelled demo mode (no fake provider UI).
const t = setTimeout(() => {
ctx.setAccount(lookupAccount(D.user.email));
ctx.setEmail(D.user.email);
ctx.replace('terms');
}, 1400);
return () => clearTimeout(t);
}, []); // eslint-disable-line
return (
<AuthShell>
<div className="anim-fade-up" key="oauth" style={{ textAlign: 'center', padding: '30px 0' }}>
<span className="spinner spinner-lg" style={{ margin: '0 auto 18px', display: 'block' }} />
<h1 style={{ fontSize: 22, fontWeight: 800 }}>Connecting to {p.label}…</h1>
<p className="muted" style={{ fontSize: 14, marginTop: 8 }}>
{p.clientId ? `Redirecting you to ${p.label} to authorise securely.` : `${p.label} OAuth isn't configured in this demo build continuing in demo mode (your real ${p.label} screen would appear here).`}
</p>
<button className="btn btn-ghost" style={{ marginTop: 18 }} onClick={ctx.back}>Cancel</button>
</div>
</AuthShell>
);
}
/* =============================================================================
CONTROLLER — step stack + browser back/forward (hash sync)
========================================================================== */
const SCREENS = {
welcome: Welcome, notfound: NotFound, primary: Primary, passkey: Passkey,
password: Password, otp: OtpStep, forgot: Forgot, another: AnotherWay,
totp: Totp, push: Push, recovery: Recovery, terms: Terms, success: Success,
oauth: OAuthRedirect,
};
function AuthFlow({ go }) {
const [stack, setStack] = useState(['welcome']);
const [account, setAccount] = useState(null);
const [email, setEmail] = useState(D.user.email);
const [channel, setChannel] = useState('email');
const [flash, setFlash] = useState(null);
const [oauthProvider, setOauthProvider] = useState('google');
const [remember, setRemember] = useState(true); // "remember this device for 90 days"
const step = stack[stack.length - 1];
// browser back/forward → pop our stack (hash-only history, won't trigger Next nav)
useEffect(() => {
const onPop = () => setStack(st => (st.length > 1 ? st.slice(0, -1) : st));
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
const push = useCallback((s) => {
setFlash(null);
setStack(st => [...st, s]);
try { window.history.pushState(null, '', '#' + s); } catch (e) {}
}, []);
const replace = useCallback((s) => {
setStack(st => [...st.slice(0, -1), s]);
try { window.history.replaceState(null, '', '#' + s); } catch (e) {}
}, []);
const back = useCallback(() => { try { window.history.back(); } catch (e) { setStack(st => st.length > 1 ? st.slice(0, -1) : st); } }, []);
const switchAccount = useCallback(() => { setFlash(null); setAccount(null); setStack(['welcome']); try { window.history.replaceState(null, '', '#welcome'); } catch (e) {} }, []);
// central post-authentication routing: first sign-in / updated terms → terms gate.
const afterAuth = useCallback(() => { setStack(st => [...st, 'terms']); try { window.history.pushState(null, '', '#terms'); } catch (e) {} }, []);
const oauth = useCallback((provider) => { setOauthProvider(provider); push('oauth'); }, [push]);
const ctx = {
account, setAccount, email, setEmail, channel, setChannel, flash, setFlash,
push, replace, back, switchAccount, afterAuth, oauth, oauthProvider, go,
remember, setRemember,
};
const Screen = SCREENS[step] || Welcome;
// guard: steps that require a looked-up account fall back to welcome
if (!account && !['welcome', 'notfound', 'oauth'].includes(step)) return <Welcome ctx={ctx} />;
return <Screen ctx={ctx} />;
}
export default AuthFlow;
export { AuthFlow };
+1 -1
View File
@@ -30,7 +30,7 @@ const D = YEIDA;
<div key={s.key} style={{ flex: 1, position: 'relative' }}> <div key={s.key} style={{ flex: 1, position: 'relative' }}>
{i < steps.length - 1 && <div style={{ position: 'absolute', left: '50%', right: '-50%', top: 21, height: 3, background: s.done ? 'var(--emerald-500)' : 'var(--line)' }} />} {i < steps.length - 1 && <div style={{ position: 'absolute', left: '50%', right: '-50%', top: 21, height: 3, background: s.done ? 'var(--emerald-500)' : 'var(--line)' }} />}
<button onClick={() => setActive(i)} style={{ position: 'relative', zIndex: 1, background: 'none', border: 'none', cursor: 'pointer', width: '100%', textAlign: 'center', padding: 0 }}> <button onClick={() => setActive(i)} style={{ position: 'relative', zIndex: 1, background: 'none', border: 'none', cursor: 'pointer', width: '100%', textAlign: 'center', padding: 0 }}>
<span style={{ width: 44, height: 44, borderRadius: 99, display: 'grid', placeItems: 'center', margin: '0 auto', background: state === 'todo' ? '#fff' : cc, border: '2.5px solid ' + (state === 'todo' ? 'var(--line)' : cc), color: state === 'todo' ? 'var(--faint)' : '#fff', boxShadow: state === 'active' ? '0 0 0 5px rgba(38,112,230,.15)' : 'none', transition: 'all .2s' }}> <span style={{ width: 44, height: 44, borderRadius: 99, display: 'grid', placeItems: 'center', margin: '0 auto', background: state === 'todo' ? '#fff' : cc, border: '2.5px solid ' + (state === 'todo' ? 'var(--line)' : cc), color: state === 'todo' ? 'var(--faint)' : '#fff', boxShadow: state === 'active' ? '0 0 0 5px rgba(31,81,50,.15)' : 'none', transition: 'all .2s' }}>
{s.done ? <Icon name="check" size={20} /> : <span style={{ fontWeight: 800, fontFamily: 'var(--font-display)', fontSize: 16 }}>{i + 1}</span>} {s.done ? <Icon name="check" size={20} /> : <span style={{ fontWeight: 800, fontFamily: 'var(--font-display)', fontSize: 16 }}>{i + 1}</span>}
</span> </span>
<div style={{ fontWeight: i === active ? 700 : 600, fontSize: 13, marginTop: 9, color: i === active ? 'var(--ink)' : 'var(--muted)' }}>{s.title}</div> <div style={{ fontWeight: i === active ? 700 : 600, fontSize: 13, marginTop: 9, color: i === active ? 'var(--ink)' : 'var(--muted)' }}>{s.title}</div>
+3 -3
View File
@@ -49,7 +49,7 @@ const D = YEIDA, fmt = D.fmtINR;
return ( return (
<div> <div>
{/* Welcome banner */} {/* Welcome banner */}
<div className="anim-fade-up" style={{ position: 'relative', overflow: 'hidden', borderRadius: 20, padding: '30px 34px', marginBottom: 18, background: 'radial-gradient(130% 170% at 92% -10%, #1b4d8f 0%, #0b2548 52%, #071b38 100%)', color: '#fff', boxShadow: '0 18px 44px -22px rgba(8,26,57,.7)' }}> <div className="anim-fade-up" style={{ position: 'relative', overflow: 'hidden', borderRadius: 20, padding: '30px 34px', marginBottom: 18, background: 'radial-gradient(130% 170% at 92% -10%, #1b4d8f 0%, #15331f 52%, #071b38 100%)', color: '#fff', boxShadow: '0 18px 44px -22px rgba(8,26,57,.7)' }}>
<div style={{ position: 'absolute', inset: 0, opacity: .45, backgroundImage: 'linear-gradient(rgba(255,255,255,.05) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.05) 1px,transparent 1px)', backgroundSize: '38px 38px', maskImage: 'radial-gradient(70% 90% at 85% 0%,#000,transparent)' }} /> <div style={{ position: 'absolute', inset: 0, opacity: .45, backgroundImage: 'linear-gradient(rgba(255,255,255,.05) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.05) 1px,transparent 1px)', backgroundSize: '38px 38px', maskImage: 'radial-gradient(70% 90% at 85% 0%,#000,transparent)' }} />
{/* decorative concentric rings */} {/* decorative concentric rings */}
<svg viewBox="0 0 200 200" style={{ position: 'absolute', right: -30, top: -40, width: 280, height: 280, opacity: .5 }} aria-hidden="true"> <svg viewBox="0 0 200 200" style={{ position: 'absolute', right: -30, top: -40, width: 280, height: 280, opacity: .5 }} aria-hidden="true">
@@ -83,7 +83,7 @@ const D = YEIDA, fmt = D.fmtINR;
].map(m => { ].map(m => {
const on = view === m.k; const on = view === m.k;
return ( return (
<button key={m.k} onClick={() => switchTo(m.k)} className="card mod-switch" style={{ flex: 1, minWidth: 280, textAlign: 'left', cursor: 'pointer', padding: '16px 18px', display: 'flex', alignItems: 'center', gap: 14, border: on ? '1.5px solid var(--blue-400)' : '1px solid var(--line)', background: on ? 'linear-gradient(100deg, var(--blue-50), #fff)' : '#fff', boxShadow: on ? '0 0 0 4px rgba(38,112,230,.1)' : 'var(--sh-sm)' }}> <button key={m.k} onClick={() => switchTo(m.k)} className="card mod-switch" style={{ flex: 1, minWidth: 280, textAlign: 'left', cursor: 'pointer', padding: '16px 18px', display: 'flex', alignItems: 'center', gap: 14, border: on ? '1.5px solid var(--blue-400)' : '1px solid var(--line)', background: on ? 'linear-gradient(100deg, var(--blue-50), #fff)' : '#fff', boxShadow: on ? '0 0 0 4px rgba(31,81,50,.1)' : 'var(--sh-sm)' }}>
<span style={{ width: 48, height: 48, borderRadius: 13, flex: 'none', display: 'grid', placeItems: 'center', background: on ? 'linear-gradient(150deg,var(--blue-500),var(--navy-800))' : 'var(--bg-2)', color: on ? '#fff' : 'var(--muted)' }}><Icon name={m.icon} size={24} /></span> <span style={{ width: 48, height: 48, borderRadius: 13, flex: 'none', display: 'grid', placeItems: 'center', background: on ? 'linear-gradient(150deg,var(--blue-500),var(--navy-800))' : 'var(--bg-2)', color: on ? '#fff' : 'var(--muted)' }}><Icon name={m.icon} size={24} /></span>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div className="row gap-2" style={{ flexWrap: 'wrap' }}> <div className="row gap-2" style={{ flexWrap: 'wrap' }}>
@@ -247,7 +247,7 @@ const D = YEIDA, fmt = D.fmtINR;
</div> </div>
</div> </div>
<div className="card card-pad anim-fade-up" style={{ animationDelay: '.06s', background: 'radial-gradient(130% 130% at 100% 0%,#16407e,#0b2548)', color: '#fff', display: 'flex', flexDirection: 'column' }}> <div className="card card-pad anim-fade-up" style={{ animationDelay: '.06s', background: 'radial-gradient(130% 130% at 100% 0%,#266b41,#15331f)', color: '#fff', display: 'flex', flexDirection: 'column' }}>
<div className="row gap-3" style={{ marginBottom: 12 }}> <div className="row gap-3" style={{ marginBottom: 12 }}>
<span style={{ width: 40, height: 40, borderRadius: 11, display: 'grid', placeItems: 'center', background: 'rgba(255,255,255,.14)' }}><Icon name="sparkles" size={20} /></span> <span style={{ width: 40, height: 40, borderRadius: 11, display: 'grid', placeItems: 'center', background: 'rgba(255,255,255,.14)' }}><Icon name="sparkles" size={20} /></span>
<div><h3 style={{ color: '#fff', fontSize: 16 }}>AI Assistant</h3><div style={{ fontSize: 12, color: '#9ff0cd' }}>Online · ask anything</div></div> <div><h3 style={{ color: '#fff', fontSize: 16 }}>AI Assistant</h3><div style={{ fontSize: 12, color: '#9ff0cd' }}>Online · ask anything</div></div>
+120 -12
View File
@@ -9,7 +9,7 @@ const YEIDA = (function () {
allotment: 'YEA-654321', allotment: 'YEA-654321',
sector: 'Sector 18', sector: 'Sector 18',
pocket: 'Pocket B', pocket: 'Pocket B',
plot: 'B-47', plot: '181',
category: 'Residential', category: 'Residential',
size: '300 sq.m', size: '300 sq.m',
mobile: '+91 98xxxxxx12', mobile: '+91 98xxxxxx12',
@@ -20,7 +20,7 @@ const YEIDA = (function () {
allotteeFirstName: 'Rajeev', // original allottee (may differ from registrant) allotteeFirstName: 'Rajeev', // original allottee (may differ from registrant)
allotteeLastName: 'Malhotra', allotteeLastName: 'Malhotra',
registeredAddress: { registeredAddress: {
line1: 'Plot B-47, Pocket B, Sector 18', line1: 'Plot 181, Pocket B, Sector 18',
line2: 'Yamuna Expressway, Gautam Buddh Nagar', line2: 'Yamuna Expressway, Gautam Buddh Nagar',
city: 'Greater Noida', state: 'Uttar Pradesh', pin: '203201', city: 'Greater Noida', state: 'Uttar Pradesh', pin: '203201',
}, },
@@ -53,7 +53,7 @@ const YEIDA = (function () {
const property = { const property = {
allotmentNo: 'YEA-654321', allotmentNo: 'YEA-654321',
sector: 'Sector 18', sector: 'Sector 18',
plot: 'B-47', plot: '181',
block: 'Block B', block: 'Block B',
size: '300 sq.m', size: '300 sq.m',
category: 'Residential', category: 'Residential',
@@ -161,9 +161,28 @@ const YEIDA = (function () {
]; ];
const tickets = [ const tickets = [
{ id: 'TKT-4821', subject: 'Lease deed correction request', status: 'Open', priority: 'High', updated: '2h ago' }, { id: 'TKT-4821', subject: 'Lease deed correction request', category: 'Records & Documents', status: 'Open', priority: 'High', updated: '2h ago' },
{ id: 'TKT-4790', subject: 'Challan not reflecting after UPI payment', status: 'Resolved', priority: 'Medium', updated: '3d ago' }, { id: 'TKT-4790', subject: 'Challan not reflecting after UPI payment', category: 'Payments & Challan', status: 'Resolved', priority: 'Medium', updated: '3d ago' },
{ id: 'TKT-4755', subject: 'Building plan re-submission query', status: 'In Progress', priority: 'Medium', updated: '5d ago' }, { id: 'TKT-4755', subject: 'Building plan re-submission query', category: 'Construction Approvals', status: 'In Progress', priority: 'Medium', updated: '5d ago' },
];
// Support ticket categories — drive the dropdown-based routing (Amazon/ServiceNow style).
// Each category maps to a department/assignee and its own sub-reason list, so a ticket is
// classified before it is created and routed to the correct cell — no "call for everything".
// `attach: true` means an attachment (screenshot / document) is required to submit.
const ticketCategories = [
{ id: 'technical', label: 'Technical Support', icon: 'device', color: 'blue', dept: 'IT Helpdesk', sla: 'Replies within 4 hrs',
subs: ['Login / OTP not working', 'Portal error or bug', 'Document upload failing', 'Page not loading', 'Other technical issue'] },
{ id: 'payment', label: 'Payments & Challan', icon: 'payments', color: 'emerald', dept: 'Payments Desk', sla: 'Replies within 1 working day',
subs: ['Payment not reflecting', 'Challan / receipt not generated', 'Refund request', 'Dues / interest query', 'Failed online payment'] },
{ id: 'kyc', label: 'KYC & Documents', icon: 'documents', color: 'navy', dept: 'Records Cell', sla: 'Replies within 2 working days', attach: true,
subs: ['KYC document rejected', 'Update / re-upload KYC', 'Lease deed correction', 'Certificate copy request', 'Name / detail correction'] },
{ id: 'construction', label: 'Construction Approvals', icon: 'construction', color: 'amber', dept: 'Construction Cell', sla: 'Replies within 2 working days',
subs: ['Building plan query', 'Plan re-submission', 'Inspection / progress', 'Completion certificate', 'Map / setback / FAR query'] },
{ id: 'allotment', label: 'Allotment & Transfer', icon: 'transfer', color: 'blue', dept: 'Allotment Cell', sla: 'Replies within 2 working days', attach: true,
subs: ['Allotment detail correction', 'Plot transfer / sale', 'Possession query', 'Mutation / succession', 'Address update'] },
{ id: 'general', label: 'General / Other', icon: 'messages', color: 'navy', dept: 'Customer Care', sla: 'Replies within 1 working day',
subs: ['Account question', 'Feedback / suggestion', 'How do I…', 'Something else'] },
]; ];
// Support inbox — secure message threads with authority officers // Support inbox — secure message threads with authority officers
@@ -200,6 +219,58 @@ const YEIDA = (function () {
}, },
]; ];
// Help Center / knowledge base — Amazon-style self-service so users aren't pushed
// to "call for everything". Browse by topic → expandable Q&A articles.
const helpTopics = [
{ id: 'payments', icon: 'payments', color: 'emerald', title: 'Payments & Challan', desc: 'Dues, e-challan, receipts & refunds',
articles: [
{ q: 'How do I generate an e-Challan?', a: 'Open Payments → Dues, pick the pending due and tap "Pay / Generate Challan". The e-Challan is created instantly and saved under Payments → Receipts for download.' },
{ q: "My payment isn't reflecting after UPI", a: 'UPI reconciliations can take up to 24 hours. If it still is not visible after a day, raise a ticket under "Payments & Challan" with your UTR / transaction reference and we will reconcile it.' },
{ q: 'How is the transfer fee calculated?', a: 'Transfer charges are 2.5% of the property value, plus a processing fee, documentation charges and 18% GST on the fees. See Transfer → Fee breakup for your exact figure.' },
{ q: 'Can I get a refund for an overpayment?', a: 'Yes. Raise a ticket under "Payments & Challan" → "Refund request". Refunds are processed to your verified bank account on file within 710 working days.' },
] },
{ id: 'kyc', icon: 'documents', color: 'navy', title: 'KYC & Documents', desc: 'KYC, lease deed & certificates',
articles: [
{ q: 'How do I update my KYC?', a: 'Go to Profile → KYC Verification → Upload document. Pick the document type, then choose the file. Note: your identity proof and address proof must be two different documents.' },
{ q: 'Which documents are accepted?', a: 'Identity proof: Aadhaar, PAN, Passport or Driving Licence. Address proof: Aadhaar, Passport, DL, an electricity/utility bill, house-tax receipt or a bank statement showing your name & address.' },
{ q: 'Where can I find my lease deed?', a: 'Open Documents → Lease Deed (Registered). You can view, download or print it any time.' },
{ q: 'My KYC document was rejected — what now?', a: 'Re-upload a clearer copy from Profile → KYC Verification. If it is rejected again, raise a ticket under "KYC & Documents" and an officer will guide you.' },
] },
{ id: 'construction', icon: 'construction', color: 'amber', title: 'Construction', desc: 'Plans, approvals & progress',
articles: [
{ q: 'What is the construction approval timeline?', a: 'Authority scrutiny typically takes 1530 working days after a complete building-plan submission. You can track the stage live under Construction → Authority Review.' },
{ q: 'My building plan was returned — what next?', a: 'Open Construction → Authority Review to read the remarks (e.g. setback/FAR), revise the architect-stamped plan and re-upload it. No need to call.' },
{ q: 'How do I apply for a completion certificate?', a: 'Once construction is finished, open Construction → Progress Tracker → "Apply for completion". A final site inspection is scheduled before the certificate is issued.' },
] },
{ id: 'account', icon: 'user', color: 'blue', title: 'Account & Profile', desc: 'Login, contacts & security',
articles: [
{ q: 'How do I change my email or mobile?', a: 'Profile → Personal Info → "Change with verification". For your security an OTP is sent to your registered mobile (not the old email) to authorise the change.' },
{ q: 'How do I set my notification preferences?', a: 'Profile → Notifications & Privacy. Tick which email/number receives SMS, WhatsApp, email or OTP, and set your preferred contact time and timezone.' },
{ q: 'How do I log in if I forgot my allotment number?', a: 'On the login screen choose "Plot Number", enter your sector, pocket and plot, then Verify — we match it to your allotment automatically.' },
{ q: 'How do I add a representative / family member?', a: 'During registration choose your relationship to the allottee and enter the invitation code. The allottee can be a different person from the account holder.' },
] },
{ id: 'property', icon: 'building', color: 'navy', title: 'Property Verification', desc: 'Allotment, plot & possession checks',
articles: [
{ q: 'How do I verify my allotment details?', a: 'Open Profile → Personal Info → Allotment Identity and tap "Verify to reveal". Sensitive details (allotment number, plot) stay masked until you verify, then your authority-record data is shown.' },
{ q: 'How do I confirm my plot if I forgot the allotment number?', a: 'On the login screen choose "Plot Number", enter your sector, pocket and plot, then Verify — we match it to your allotment record automatically.' },
{ q: 'Where can I see my possession status?', a: 'Open My Plot → Status. Authority statuses such as Lease Deed Completed, Possession Granted and Authority Verified are shown with their dates.' },
{ q: 'My plot/sector details look wrong — how do I correct them?', a: 'Allotment identity fields are locked to authority records. Raise a ticket under "Allotment & Transfer → Allotment detail correction" with supporting proof and the Allotment Cell will review it.' },
] },
{ id: 'technical', icon: 'device', color: 'blue', title: 'Technical Support', desc: 'Login, OTP, errors & the portal',
articles: [
{ q: 'I am not receiving my OTP', a: 'OTPs are sent by SMS and WhatsApp to your registered mobile. Check your network and spam filters, wait 30 seconds and tap Resend. If it still fails, raise a ticket under "Technical Support → Login / OTP not working".' },
{ q: 'The portal shows an error or a page wont load', a: 'Refresh the page and clear your browser cache. If the problem continues, note the error and raise a ticket under "Technical Support → Portal error or bug" with a screenshot.' },
{ q: 'My document upload keeps failing', a: 'Use a JPG, PNG or PDF under 10 MB and a stable connection. If it still fails, raise a ticket under "Technical Support → Document upload failing" and attach the file.' },
] },
{ id: 'documents', icon: 'documents', color: 'amber', title: 'Documents & Uploads', desc: 'Uploading, formats & retrieving files',
articles: [
{ q: 'Which file formats and sizes are accepted?', a: 'JPG, PNG and PDF are accepted, up to 10 MB per file. For KYC, scans should be clear and show all four corners of the document.' },
{ q: 'Where do I find my uploaded documents?', a: 'Open Documents to view, download or print your allotment letter, possession letter, lease deed, approvals and NOCs any time.' },
{ q: 'My identity and address proof cant be the same file', a: 'Correct — identity proof and address proof must be two different documents. For example, use a Passport for identity and an Aadhaar or utility bill for address.' },
{ q: 'How do I re-upload a rejected document?', a: 'Open Profile → KYC Verification, pick the document type and choose a clearer file. The new upload replaces the rejected one for review.' },
] },
];
// Support team — shown in the "Your support team" section (AI + human officers) // Support team — shown in the "Your support team" section (AI + human officers)
const supportTeam = [ const supportTeam = [
{ name: 'Aarav', role: 'Chief AI Support', initials: 'AI', online: true, ai: true, desc: 'Instant answers, available 24/7' }, { name: 'Aarav', role: 'Chief AI Support', initials: 'AI', online: true, ai: true, desc: 'Instant answers, available 24/7' },
@@ -285,7 +356,7 @@ const YEIDA = (function () {
{ key: 'goal', title: 'Goal Selection', desc: 'What you want to achieve', icon: 'flag', { key: 'goal', title: 'Goal Selection', desc: 'What you want to achieve', icon: 'flag',
summary: 'Start construction soon', done: true }, summary: 'Start construction soon', done: true },
{ key: 'plot', title: 'Plot Verification', desc: 'Confirm your allotment & plot', icon: 'building', { key: 'plot', title: 'Plot Verification', desc: 'Confirm your allotment & plot', icon: 'building',
summary: 'YEA-654321 · Plot B-47, Sector 18', done: true }, summary: 'YEA-654321 · Plot 181, Sector 18', done: true },
{ key: 'design', title: 'Design Selection', desc: 'Choose a house design', icon: 'grid', { key: 'design', title: 'Design Selection', desc: 'Choose a house design', icon: 'grid',
summary: 'Aangan — Courtyard Villa (G+1)', done: true }, summary: 'Aangan — Courtyard Villa (G+1)', done: true },
{ key: 'planning', title: 'Construction Planning', desc: 'Scope, timeline & permissions', icon: 'calendar', { key: 'planning', title: 'Construction Planning', desc: 'Scope, timeline & permissions', icon: 'calendar',
@@ -348,9 +419,9 @@ const YEIDA = (function () {
contractSigned: true, // KYC is collected only after the contract is signed contractSigned: true, // KYC is collected only after the contract is signed
viaPOA: false, // contract signed by one person, KYC completed for another via Power of Attorney viaPOA: false, // contract signed by one person, KYC completed for another via Power of Attorney
documents: [ documents: [
{ type: 'aadhaar', number: 'xxxx xxxx 4821', proof: 'identity+address', status: 'verified', expiry: null }, { type: 'aadhaar', number: '•••• 4821', proof: 'identity+address', status: 'verified', expiry: null },
{ type: 'pan', number: 'ABCDE****F', proof: 'identity', status: 'verified', expiry: null }, { type: 'pan', number: '•••• 234F', proof: 'identity', status: 'verified', expiry: null },
{ type: 'passport', number: 'M12****9', proof: 'identity+address', status: 'verified', expiry: '14 Aug 2028' }, { type: 'passport', number: '•••• 6789', proof: 'identity+address', status: 'verified', expiry: '14 Aug 2028' },
], ],
}; };
@@ -388,8 +459,45 @@ const YEIDA = (function () {
post: { on: false }, post: { on: false },
}, },
marketing: { newsletter: false, promotions: false, productUpdates: true }, marketing: { newsletter: false, promotions: false, productUpdates: true },
// Preferred window for calls / proactive contact (quiet hours respected outside this).
// The allottee may live abroad (NRI) — the window is interpreted in `preferredTz`.
preferredTime: 'anytime', // morning | afternoon | evening | anytime
preferredTz: 'IST', // timezone the window above is measured in
// Privacy & consent — these defaults are seeded from authority backend records.
// `dataProcessing` is statutory and stays locked-on; the rest are user-controllable.
consent: {
dataProcessing: true, // required to administer the allotment (locked)
profileVisibility: true, // show name/plot to authority officers handling your case
shareWithFamily: false, // let linked family / representatives view profile & allotment
kycSharing: true, // share KYC documents with authority cells for verification
transferDisclosure: false, // reveal allotment identity to a buyer during a transfer
authorizedReps: false, // allow an authorised representative / POA holder to act for you
analyticsCookies: false, // preference & analytics cookies
thirdPartySharing: false, // share with empanelled service partners
crossBorder: false, // process data outside India for an overseas account holder
platformUpdates: true, // product updates, announcements & service notices
marketingConsent: false, // I agree to receive marketing communications
},
}; };
// Preferred contact-time options (segmented control in Notifications & Privacy)
const contactTimes = [
{ id: 'morning', label: 'Morning', hint: '9 AM 12 PM', icon: 'sun' },
{ id: 'afternoon', label: 'Afternoon', hint: '12 PM 4 PM', icon: 'sun' },
{ id: 'evening', label: 'Evening', hint: '4 PM 8 PM', icon: 'moon' },
{ id: 'anytime', label: 'Anytime', hint: 'No preference', icon: 'clock' },
];
// Timezones the preferred-contact window can be measured in (allottee may be an NRI).
const contactTimezones = [
{ id: 'IST', abbr: 'IST', label: 'India — IST', utc: 'UTC+5:30' },
{ id: 'GST', abbr: 'GST', label: 'UAE / Gulf — GST', utc: 'UTC+4:00' },
{ id: 'GMT', abbr: 'GMT', label: 'UK — GMT', utc: 'UTC+0:00' },
{ id: 'ET', abbr: 'ET', label: 'US Eastern — ET', utc: 'UTC5:00' },
{ id: 'PT', abbr: 'PT', label: 'US Pacific — PT', utc: 'UTC8:00' },
{ id: 'SGT', abbr: 'SGT', label: 'Singapore — SGT', utc: 'UTC+8:00' },
];
// Recent login / security activity log (device · IP · location) // Recent login / security activity log (device · IP · location)
const loginActivity = [ const loginActivity = [
{ action: 'Successful login', device: 'MacBook Pro · Chrome', ip: '49.36.x.x', location: 'Greater Noida, IN', time: 'Today, 09:14', ok: true }, { action: 'Successful login', device: 'MacBook Pro · Chrome', ip: '49.36.x.x', location: 'Greater Noida, IN', time: 'Today, 09:14', ok: true },
@@ -508,8 +616,8 @@ const YEIDA = (function () {
journeyGoals, journeySteps, projectTeam, teamUpdates, progressStages, journeyGoals, journeySteps, projectTeam, teamUpdates, progressStages,
property, dues, payments, constructionSteps, requiredDocs, property, dues, payments, constructionSteps, requiredDocs,
constructionMilestones, documents, notices, activity, sectors, transferFees, constructionMilestones, documents, notices, activity, sectors, transferFees,
assistantSuggestions, tickets, supportThreads, supportTeam, assistantSuggestions, tickets, ticketCategories, helpTopics, supportThreads, supportTeam,
kycDocTypes, kyc, bankAccount, contactPrefs, loginActivity, elevations, galleryFolders, kycDocTypes, kyc, bankAccount, contactPrefs, contactTimes, contactTimezones, loginActivity, elevations, galleryFolders,
}; };
})(); })();
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -39,7 +39,7 @@ import Icon from './icons';
{open && ( {open && (
<div className="anim-pop card" style={{ width: 360, maxWidth: 'calc(100vw - 52px)', height: 480, maxHeight: 'calc(100vh - 120px)', display: 'flex', flexDirection: 'column', overflow: 'hidden', boxShadow: 'var(--sh-lg)' }}> <div className="anim-pop card" style={{ width: 360, maxWidth: 'calc(100vw - 52px)', height: 480, maxHeight: 'calc(100vh - 120px)', display: 'flex', flexDirection: 'column', overflow: 'hidden', boxShadow: 'var(--sh-lg)' }}>
{/* header */} {/* header */}
<div className="row spread" style={{ padding: '14px 16px', background: 'linear-gradient(120deg,#103263,#0b2548)', color: '#fff' }}> <div className="row spread" style={{ padding: '14px 16px', background: 'linear-gradient(120deg,#1f5132,#15331f)', color: '#fff' }}>
<div className="row gap-3"> <div className="row gap-3">
<span style={{ width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center', background: 'rgba(255,255,255,.14)' }}><Icon name="sparkles" size={19} /></span> <span style={{ width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center', background: 'rgba(255,255,255,.14)' }}><Icon name="sparkles" size={19} /></span>
<div><div style={{ fontWeight: 700, fontSize: 14.5 }}>YEIDA Assistant</div><div className="row gap-2" style={{ fontSize: 11.5, color: '#9ff0cd' }}><span style={{ width: 6, height: 6, borderRadius: 99, background: 'var(--emerald-500)' }} />Online</div></div> <div><div style={{ fontWeight: 700, fontSize: 14.5 }}>YEIDA Assistant</div><div className="row gap-2" style={{ fontSize: 11.5, color: '#9ff0cd' }}><span style={{ width: 6, height: 6, borderRadius: 99, background: 'var(--emerald-500)' }} />Online</div></div>
+2 -2
View File
@@ -90,8 +90,8 @@ import Icon from './icons';
{/* master plan grid overlay */} {/* master plan grid overlay */}
{layers.master && Array.from({ length: 20 }).map((_, i) => ( {layers.master && Array.from({ length: 20 }).map((_, i) => (
<g key={i}> <g key={i}>
<line x1={i * 40} y1={0} x2={i * 40} y2={H} stroke="rgba(38,112,230,.18)" strokeWidth={1} /> <line x1={i * 40} y1={0} x2={i * 40} y2={H} stroke="rgba(31,81,50,.18)" strokeWidth={1} />
<line x1={0} y1={i * 40} x2={W} y2={i * 40} stroke="rgba(38,112,230,.18)" strokeWidth={1} /> <line x1={0} y1={i * 40} x2={W} y2={i * 40} stroke="rgba(31,81,50,.18)" strokeWidth={1} />
</g> </g>
))} ))}
{/* plots */} {/* plots */}
+3 -3
View File
@@ -70,7 +70,7 @@ const D = YEIDA, fmt = D.fmtINR;
{D.journeyGoals.map(gOpt => { {D.journeyGoals.map(gOpt => {
const on = goal === gOpt.id; const on = goal === gOpt.id;
return ( return (
<button key={gOpt.id} onClick={() => setGoal(gOpt.id)} className="card-hover" style={{ textAlign: 'left', cursor: 'pointer', padding: '14px 15px', borderRadius: 13, border: '1.5px solid ' + (on ? 'var(--blue-400)' : 'var(--line)'), background: on ? 'var(--blue-50)' : '#fff', display: 'flex', gap: 12, alignItems: 'flex-start', boxShadow: on ? '0 0 0 4px rgba(38,112,230,.1)' : 'none' }}> <button key={gOpt.id} onClick={() => setGoal(gOpt.id)} className="card-hover" style={{ textAlign: 'left', cursor: 'pointer', padding: '14px 15px', borderRadius: 13, border: '1.5px solid ' + (on ? 'var(--blue-400)' : 'var(--line)'), background: on ? 'var(--blue-50)' : '#fff', display: 'flex', gap: 12, alignItems: 'flex-start', boxShadow: on ? '0 0 0 4px rgba(31,81,50,.1)' : 'none' }}>
<span style={{ width: 40, height: 40, borderRadius: 11, flex: 'none', display: 'grid', placeItems: 'center', background: on ? 'var(--blue-500)' : 'var(--bg-2)', color: on ? '#fff' : 'var(--muted)' }}><Icon name={gOpt.icon} size={20} /></span> <span style={{ width: 40, height: 40, borderRadius: 11, flex: 'none', display: 'grid', placeItems: 'center', background: on ? 'var(--blue-500)' : 'var(--bg-2)', color: on ? '#fff' : 'var(--muted)' }}><Icon name={gOpt.icon} size={20} /></span>
<div> <div>
<div style={{ fontWeight: 700, fontSize: 13.5, lineHeight: 1.25 }}>{gOpt.title}</div> <div style={{ fontWeight: 700, fontSize: 13.5, lineHeight: 1.25 }}>{gOpt.title}</div>
@@ -237,7 +237,7 @@ const D = YEIDA, fmt = D.fmtINR;
{/* live view + summary */} {/* live view + summary */}
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr', gap: 18, marginBottom: 22 }}> <div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr', gap: 18, marginBottom: 22 }}>
<div className="card anim-fade-up" style={{ overflow: 'hidden' }}> <div className="card anim-fade-up" style={{ overflow: 'hidden' }}>
<div style={{ position: 'relative', height: 280, background: 'radial-gradient(120% 120% at 50% 0%,#16407e,#081a39)', display: 'grid', placeItems: 'center' }}> <div style={{ position: 'relative', height: 280, background: 'radial-gradient(120% 120% at 50% 0%,#266b41,#0f2417)', display: 'grid', placeItems: 'center' }}>
<div style={{ position: 'absolute', inset: 0, opacity: .3, backgroundImage: 'linear-gradient(rgba(255,255,255,.06) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.06) 1px,transparent 1px)', backgroundSize: '36px 36px' }} /> <div style={{ position: 'absolute', inset: 0, opacity: .3, backgroundImage: 'linear-gradient(rgba(255,255,255,.06) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.06) 1px,transparent 1px)', backgroundSize: '36px 36px' }} />
<div style={{ position: 'absolute', top: 14, left: 14, display: 'flex', alignItems: 'center', gap: 7, padding: '6px 12px', borderRadius: 99, background: 'rgba(210,63,63,.92)', color: '#fff', fontSize: 12, fontWeight: 700 }}> <div style={{ position: 'absolute', top: 14, left: 14, display: 'flex', alignItems: 'center', gap: 7, padding: '6px 12px', borderRadius: 99, background: 'rgba(210,63,63,.92)', color: '#fff', fontSize: 12, fontWeight: 700 }}>
<span style={{ width: 8, height: 8, borderRadius: 99, background: '#fff', animation: 'pulseDot 1.4s infinite' }} />LIVE <span style={{ width: 8, height: 8, borderRadius: 99, background: '#fff', animation: 'pulseDot 1.4s infinite' }} />LIVE
@@ -316,7 +316,7 @@ const D = YEIDA, fmt = D.fmtINR;
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(150px,1fr))', gap: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(150px,1fr))', gap: 12 }}>
{[...Array(open.count)].map((_, i) => ( {[...Array(open.count)].map((_, i) => (
<div key={i} className="card-hover" style={{ position: 'relative', borderRadius: 11, overflow: 'hidden', aspectRatio: '4 / 3', cursor: 'pointer', background: `linear-gradient(135deg, ${open.cover}, #0b2548)`, display: 'grid', placeItems: 'center' }}> <div key={i} className="card-hover" style={{ position: 'relative', borderRadius: 11, overflow: 'hidden', aspectRatio: '4 / 3', cursor: 'pointer', background: `linear-gradient(135deg, ${open.cover}, #15331f)`, display: 'grid', placeItems: 'center' }}>
<Icon name="image" size={26} style={{ color: 'rgba(255,255,255,.6)' }} /> <Icon name="image" size={26} style={{ color: 'rgba(255,255,255,.6)' }} />
<span style={{ position: 'absolute', bottom: 6, left: 8, fontSize: 10.5, color: 'rgba(255,255,255,.85)', fontWeight: 600 }}>IMG_{String(i + 1).padStart(3, '0')}</span> <span style={{ position: 'absolute', bottom: 6, left: 8, fontSize: 10.5, color: 'rgba(255,255,255,.85)', fontWeight: 600 }}>IMG_{String(i + 1).padStart(3, '0')}</span>
</div> </div>
+185 -83
View File
@@ -3,7 +3,7 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import Icon from './icons'; import Icon from './icons';
import YEIDA from './data'; import YEIDA from './data';
import { AuthShell, VerifyPanel } from './auth'; import { AuthShell, VerifyPanel, SocialButton } from './auth';
import { Avatar } from './ui'; import { Avatar } from './ui';
import { useUserPhoto, readImageFile, setUserProfile } from './user-store'; import { useUserPhoto, readImageFile, setUserProfile } from './user-store';
@@ -37,17 +37,28 @@ const D = YEIDA;
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const ccOf = (code) => D.countryCodes.find(c => c.code === code) || D.countryCodes[0]; const ccOf = (code) => D.countryCodes.find(c => c.code === code) || D.countryCodes[0];
// Password policy: min 8 chars with upper, lower, a number & a special character
// (weak passwords like 1234 / abcd / abc12 are rejected). e.g. MyHome@2026
const pwCheck = (p) => ({
len: p.length >= 8,
upper: /[A-Z]/.test(p),
lower: /[a-z]/.test(p),
digit: /[0-9]/.test(p),
special: /[^A-Za-z0-9]/.test(p),
});
const pwStrong = (p) => { const c = pwCheck(p); return c.len && c.upper && c.lower && c.digit && c.special; };
function Register({ go }) { function Register({ go }) {
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [legal, setLegal] = useState(null); // 'terms' | 'privacy' | null const [legal, setLegal] = useState(null); // 'terms' | 'privacy' | null
const [form, setForm] = useState({ const [form, setForm] = useState({
firstName: 'Rajeev', lastName: 'Malhotra', email: 'r.malhotra@email.com', firstName: 'Rajeev', lastName: 'Malhotra', email: 'r.malhotra@email.com',
cc: '+91', mobile: '9876500012', password: '', confirm: '', cc: '+91', mobile: '9876500012', password: '', confirm: '',
relationship: 'Self', inviteCode: '', inviteValid: false, relationship: 'Self', inviteCode: '', inviteValid: false, ssoLinked: null, regPhase: 'sso',
terms: false, reviewedTerms: false, reviewedPrivacy: false, cookieChoice: null, acceptTerms: false, acceptPrivacy: false, reviewedTerms: false, reviewedPrivacy: false, cookieChoice: null,
allotteeFirst: 'Rajeev', allotteeLast: 'Malhotra', allotteeFirst: 'Rajeev', allotteeLast: 'Malhotra',
hasAllotment: true, allotment: 'YEA-654321', sector: 'Sector 18', pocket: 'Pocket B', plot: 'B-47', plotVerified: false, hasAllotment: true, allotment: 'YEA-654321', sector: 'Sector 18', pocket: 'Pocket B', plot: '181', plotVerified: false,
reg: { country: 'IN', line1: 'Plot B-47, Pocket B, Sector 18', line2: 'Yamuna Expressway, Gautam Buddh Nagar', city: 'Greater Noida', state: 'Uttar Pradesh', postal: '203201' }, reg: { country: 'IN', line1: 'Plot 181, Pocket B, Sector 18', line2: 'Yamuna Expressway, Gautam Buddh Nagar', city: 'Greater Noida', state: 'Uttar Pradesh', postal: '203201' },
mail: { country: 'IN', line1: 'C-204, Orchid Residency, Sector 50', line2: 'Near City Centre', city: 'Noida', state: 'Uttar Pradesh', postal: '201301' }, mail: { country: 'IN', line1: 'C-204, Orchid Residency, Sector 50', line2: 'Near City Centre', city: 'Noida', state: 'Uttar Pradesh', postal: '201301' },
}); });
const set = (k, v) => setForm(f => ({ ...f, [k]: v })); const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
@@ -59,9 +70,10 @@ const D = YEIDA;
const cc = ccOf(form.cc); const cc = ccOf(form.cc);
const phoneOk = form.mobile.replace(/\D/g, '').length === cc.digits; const phoneOk = form.mobile.replace(/\D/g, '').length === cc.digits;
const emailOk = emailRe.test(form.email); const emailOk = emailRe.test(form.email);
const pwOk = form.password.length >= 8 && form.password === form.confirm; // Signing up with Google/Apple/Microsoft needs no password; otherwise enforce the policy.
const pwOk = form.ssoLinked ? true : (pwStrong(form.password) && form.password === form.confirm);
const inviteOk = isAllottee || (form.inviteValid && !!form.allotteeFirst && !!form.allotteeLast); const inviteOk = isAllottee || (form.inviteValid && !!form.allotteeFirst && !!form.allotteeLast);
const step0Valid = !!form.firstName && !!form.lastName && emailOk && phoneOk && pwOk && form.terms && inviteOk; const step0Valid = !!form.firstName && !!form.lastName && emailOk && phoneOk && pwOk && form.acceptTerms && form.acceptPrivacy && inviteOk;
// Step 1: either the allotment number OR a verified plot number is enough. // Step 1: either the allotment number OR a verified plot number is enough.
const step1Valid = form.hasAllotment ? !!form.allotment.trim() : !!form.plotVerified; const step1Valid = form.hasAllotment ? !!form.allotment.trim() : !!form.plotVerified;
const canContinue = step === 0 ? step0Valid : step === 1 ? step1Valid : true; const canContinue = step === 0 ? step0Valid : step === 1 ? step1Valid : true;
@@ -101,7 +113,7 @@ const D = YEIDA;
{step === 3 && <AddressStep form={form} setAddr={setAddr} />} {step === 3 && <AddressStep form={form} setAddr={setAddr} />}
</div> </div>
{step !== 2 && ( {step !== 2 && !(step === 0 && form.regPhase === 'sso') && (
<> <>
<button className="btn btn-primary btn-block btn-lg" style={{ marginTop: 22, opacity: canContinue ? 1 : .55 }} disabled={!canContinue} <button className="btn btn-primary btn-block btn-lg" style={{ marginTop: 22, opacity: canContinue ? 1 : .55 }} disabled={!canContinue}
onClick={() => step === STEPS.length - 1 ? finish() : next()}> onClick={() => step === STEPS.length - 1 ? finish() : next()}>
@@ -135,6 +147,10 @@ const D = YEIDA;
function AccountStep({ form, set, isAllottee, openLegal }) { function AccountStep({ form, set, isAllottee, openLegal }) {
const [photo, setPhoto] = useUserPhoto(); const [photo, setPhoto] = useUserPhoto();
const photoRef = useRef(null); const photoRef = useRef(null);
const linked = form.ssoLinked; // SSO provider linked (Google/Microsoft/Apple) no password needed
const setLinked = (p) => set('ssoLinked', p);
const phase = form.regPhase || 'sso'; // 'sso' (step 1) 'create' (step 2)
const goCreate = (p) => { if (p) setLinked(p); set('regPhase', 'create'); };
const onPhoto = (e) => { const f = e.target.files && e.target.files[0]; if (f) readImageFile(f, setPhoto); }; const onPhoto = (e) => { const f = e.target.files && e.target.files[0]; if (f) readImageFile(f, setPhoto); };
const cc = ccOf(form.cc); const cc = ccOf(form.cc);
const digits = form.mobile.replace(/\D/g, ''); const digits = form.mobile.replace(/\D/g, '');
@@ -142,7 +158,8 @@ const D = YEIDA;
const phoneTouched = digits.length > 0; const phoneTouched = digits.length > 0;
const emailOk = emailRe.test(form.email); const emailOk = emailRe.test(form.email);
const emailTouched = form.email.length > 0; const emailTouched = form.email.length > 0;
const pwShort = form.password.length > 0 && form.password.length < 8; const pw = pwCheck(form.password);
const pwBad = form.password.length > 0 && !pwStrong(form.password);
const pwMismatch = form.confirm.length > 0 && form.password !== form.confirm; const pwMismatch = form.confirm.length > 0 && form.password !== form.confirm;
const reviewedBoth = form.reviewedTerms && form.reviewedPrivacy; const reviewedBoth = form.reviewedTerms && form.reviewedPrivacy;
@@ -169,8 +186,42 @@ const D = YEIDA;
}, 700); }, 700);
}; };
// STEP 1 Create your account: pick a provider, or enter your email then profile details
if (phase === 'sso') {
const emailValid = emailRe.test(form.email);
return (
<div style={{ display: 'grid', gap: 13 }}>
<div>
<h3 style={{ fontSize: 19, fontWeight: 800 }}>Create your account</h3>
<p className="muted" style={{ fontSize: 13, marginTop: 4 }}>Continue with a provider, or sign up with your email then complete your profile details.</p>
</div>
<SocialButton provider="google" label="Continue with Google" onClick={() => goCreate('Google')} />
<SocialButton provider="microsoft" label="Continue with Microsoft" onClick={() => goCreate('Microsoft')} />
<SocialButton provider="apple" label="Continue with Apple" onClick={() => goCreate('Apple')} />
<div className="row gap-3" style={{ margin: '4px 0', color: 'var(--faint)', fontSize: 12.5, fontWeight: 600 }}>
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />or continue with email<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
</div>
<div className="field"><label>Email address</label>
<div className="input-icon"><Icon name="mail" /><input className="input" value={form.email} onChange={e => set('email', e.target.value)} placeholder="you@email.com" inputMode="email" style={form.email && !emailValid ? { borderColor: 'var(--red-500)' } : undefined} /></div>
{form.email && !emailValid && <span className="row gap-2" style={{ fontSize: 12, color: 'var(--red-600)', marginTop: 4 }}><Icon name="alert" size={13} />Enter a valid email address</span>}
</div>
<button type="button" className="btn btn-primary btn-block" style={{ opacity: emailValid ? 1 : .55 }} disabled={!emailValid} onClick={() => goCreate(null)}>Continue<Icon name="arrowR" size={18} /></button>
</div>
);
}
// STEP 2 "Create an account": Apple / email + the profile details
return ( return (
<div style={{ display: 'grid', gap: 15 }}> <div style={{ display: 'grid', gap: 15 }}>
<button type="button" className="btn btn-ghost btn-sm" style={{ alignSelf: 'flex-start', marginLeft: -8 }} onClick={() => { set('regPhase', 'sso'); setLinked(null); }}><Icon name="chevL" size={15} />Back</button>
<h3 style={{ fontSize: 19, fontWeight: 800, marginTop: -6 }}>Complete your profile</h3>
<div className="row gap-2" style={{ fontSize: 12.5, color: linked ? 'var(--emerald-700)' : 'var(--blue-700)', background: linked ? 'var(--emerald-50)' : 'var(--blue-50)', padding: '10px 12px', borderRadius: 10 }}>
<Icon name={linked ? 'checkCircle' : 'mail'} size={15} style={{ flex: 'none' }} />
{linked
? <span>Connected with <b style={{ margin: '0 3px' }}>{linked}</b> · <span className="num">{form.email}</span></span>
: <span>Creating your account for <b className="num" style={{ marginLeft: 3 }}>{form.email}</b></span>}
</div>
{/* Profile photo (optional) — carries through to the portal */} {/* Profile photo (optional) — carries through to the portal */}
<div className="row gap-3" style={{ alignItems: 'center', padding: '4px 0 2px' }}> <div className="row gap-3" style={{ alignItems: 'center', padding: '4px 0 2px' }}>
<div style={{ position: 'relative', flex: 'none' }}> <div style={{ position: 'relative', flex: 'none' }}>
@@ -179,8 +230,8 @@ const D = YEIDA;
<input ref={photoRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={onPhoto} /> <input ref={photoRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={onPhoto} />
</div> </div>
<div> <div>
<div style={{ fontWeight: 700, fontSize: 14 }}>Profile photo</div> <div style={{ fontWeight: 700, fontSize: 14 }}>Profile photo <span className="badge badge-gray" style={{ fontSize: 10 }}>Optional now</span></div>
<div className="faint" style={{ fontSize: 12 }}>Optional JPG or PNG. Shown across your portal.</div> <div className="faint" style={{ fontSize: 12 }}>JPG or PNG. Optional at sign-up a photograph becomes mandatory later at KYC verification.</div>
{photo && <button type="button" onClick={() => setPhoto(null)} style={{ background: 'none', border: 'none', color: 'var(--red-600)', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', padding: 0, marginTop: 2 }}>Remove photo</button>} {photo && <button type="button" onClick={() => setPhoto(null)} style={{ background: 'none', border: 'none', color: 'var(--red-600)', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', padding: 0, marginTop: 2 }}>Remove photo</button>}
</div> </div>
</div> </div>
@@ -188,10 +239,6 @@ const D = YEIDA;
<div className="field"><label>First Name</label><div className="input-icon"><Icon name="user" /><input className="input" value={form.firstName} onChange={e => set('firstName', e.target.value)} /></div></div> <div className="field"><label>First Name</label><div className="input-icon"><Icon name="user" /><input className="input" value={form.firstName} onChange={e => set('firstName', e.target.value)} /></div></div>
<div className="field"><label>Last Name</label><div className="input-icon"><Icon name="user" /><input className="input" value={form.lastName} onChange={e => set('lastName', e.target.value)} /></div></div> <div className="field"><label>Last Name</label><div className="input-icon"><Icon name="user" /><input className="input" value={form.lastName} onChange={e => set('lastName', e.target.value)} /></div></div>
</div> </div>
<div className="field"><label>Email Address</label>
<div className="input-icon"><Icon name="mail" /><input className="input" value={form.email} onChange={e => set('email', e.target.value)} placeholder="you@email.com" style={emailTouched && !emailOk ? { borderColor: 'var(--red-500)' } : undefined} /></div>
{emailTouched && !emailOk && <span className="row gap-2" style={{ fontSize: 12, color: 'var(--red-600)', marginTop: 4 }}><Icon name="alert" size={13} />Enter a valid email address</span>}
</div>
<div className="field"><label>Mobile Number</label> <div className="field"><label>Mobile Number</label>
<div className="row gap-2"> <div className="row gap-2">
<select className="select" value={cc.code} onChange={onCc} style={{ width: 104, flex: 'none' }}>{D.countryCodes.map(c => <option key={c.code} value={c.code}>{c.label} {c.code}</option>)}</select> <select className="select" value={cc.code} onChange={onCc} style={{ width: 104, flex: 'none' }}>{D.countryCodes.map(c => <option key={c.code} value={c.code}>{c.label} {c.code}</option>)}</select>
@@ -203,12 +250,26 @@ const D = YEIDA;
? <span className="row gap-2" style={{ fontSize: 12, color: 'var(--red-600)', marginTop: 4 }}><Icon name="alert" size={13} />Enter a valid {cc.name} mobile number ({cc.digits} digits)</span> ? <span className="row gap-2" style={{ fontSize: 12, color: 'var(--red-600)', marginTop: 4 }}><Icon name="alert" size={13} />Enter a valid {cc.name} mobile number ({cc.digits} digits)</span>
: <span className="faint" style={{ fontSize: 11.5, marginTop: 4, display: 'block' }}>Validated against {cc.name} numbering rules</span>} : <span className="faint" style={{ fontSize: 11.5, marginTop: 4, display: 'block' }}>Validated against {cc.name} numbering rules</span>}
</div> </div>
{/* SSO sign-up needs no password; otherwise enforce the 812 + complexity policy. */}
{linked ? (
<div className="row gap-2" style={{ fontSize: 12.5, color: 'var(--blue-700)', background: 'var(--blue-50)', padding: '10px 12px', borderRadius: 10 }}>
<Icon name="shield" size={15} style={{ flex: 'none' }} />No password needed you'll sign in securely with <b style={{ margin: '0 3px' }}>{linked}</b>.
</div>
) : (<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<div className="field"><label>Password</label><div className="input-icon"><Icon name="lock" /><input className="input" type="password" value={form.password} onChange={e => set('password', e.target.value)} placeholder="Min 8 characters" style={pwShort ? { borderColor: 'var(--red-500)' } : undefined} /></div></div> <div className="field"><label>Password</label><div className="input-icon"><Icon name="lock" /><input className="input" type="password" value={form.password} onChange={e => set('password', e.target.value)} placeholder="e.g. MyHome@2026" style={pwBad ? { borderColor: 'var(--red-500)' } : undefined} /></div></div>
<div className="field"><label>Confirm Password</label><div className="input-icon"><Icon name="lock" /><input className="input" type="password" value={form.confirm} onChange={e => set('confirm', e.target.value)} placeholder="Re-enter password" style={pwMismatch ? { borderColor: 'var(--red-500)' } : undefined} /></div></div> <div className="field"><label>Confirm Password</label><div className="input-icon"><Icon name="lock" /><input className="input" type="password" value={form.confirm} onChange={e => set('confirm', e.target.value)} placeholder="Re-enter password" style={pwMismatch ? { borderColor: 'var(--red-500)' } : undefined} /></div></div>
</div> </div>
{pwShort && <div className="row gap-2" style={{ fontSize: 12, color: 'var(--red-600)', marginTop: -6 }}><Icon name="alert" size={14} />Password must be at least 8 characters</div>} {/* Live policy checklist */}
{pwMismatch && <div className="row gap-2" style={{ fontSize: 12, color: 'var(--red-600)', marginTop: -6 }}><Icon name="alert" size={14} />Passwords do not match</div>} <div className="row gap-3" style={{ flexWrap: 'wrap', fontSize: 11.5, marginTop: -4 }}>
{[['len', 'At least 8 characters'], ['upper', 'Uppercase'], ['lower', 'Lowercase'], ['digit', 'A number'], ['special', 'A special character']].map(([k, l]) => (
<span key={k} className="row gap-2" style={{ color: pw[k] ? 'var(--emerald-700)' : 'var(--muted)' }}>
<Icon name={pw[k] ? 'checkCircle' : 'info'} size={13} />{l}
</span>
))}
</div>
{pwMismatch && <div className="row gap-2" style={{ fontSize: 12, color: 'var(--red-600)', marginTop: -2 }}><Icon name="alert" size={14} />Passwords do not match</div>}
</>)}
{/* Relationship with Allottee */} {/* Relationship with Allottee */}
<div className="field"> <div className="field">
@@ -236,7 +297,7 @@ const D = YEIDA;
onChange={onInvite} onChange={onInvite}
placeholder="Enter your invitation code" placeholder="Enter your invitation code"
style={inviteState === 'invalid' ? { borderColor: 'var(--red-500)' } : inviteState === 'valid' ? { borderColor: '#7fd3ad' } : undefined} /> style={inviteState === 'invalid' ? { borderColor: 'var(--red-500)' } : inviteState === 'valid' ? { borderColor: '#7fd3ad' } : undefined} />
{inviteState === 'checking' && <span style={{ position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)', width: 14, height: 14, border: '2px solid rgba(38,112,230,.3)', borderTopColor: 'var(--blue-600)', borderRadius: 99, animation: 'spin .7s linear infinite' }} />} {inviteState === 'checking' && <span style={{ position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)', width: 14, height: 14, border: '2px solid rgba(31,81,50,.3)', borderTopColor: 'var(--blue-600)', borderRadius: 99, animation: 'spin .7s linear infinite' }} />}
{inviteState === 'valid' && <Icon name="checkCircle" size={16} style={{ position: 'absolute', left: 'auto', right: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--emerald-600)' }} />} {inviteState === 'valid' && <Icon name="checkCircle" size={16} style={{ position: 'absolute', left: 'auto', right: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--emerald-600)' }} />}
</div> </div>
{inviteState === 'checking' && <span className="row gap-2" style={{ fontSize: 12, color: 'var(--blue-600)', marginTop: 4 }}>Verifying invitation code</span>} {inviteState === 'checking' && <span className="row gap-2" style={{ fontSize: 12, color: 'var(--blue-600)', marginTop: 4 }}>Verifying invitation code</span>}
@@ -252,18 +313,25 @@ const D = YEIDA;
</div> </div>
)} )}
{/* Terms — links open a popup; acceptance enabled only after both are reviewed */} {/* Terms & Privacy two separate acknowledgements; both required to continue.
<label className="row gap-3" style={{ fontSize: 13.5, color: 'var(--muted)', cursor: reviewedBoth ? 'pointer' : 'not-allowed', opacity: reviewedBoth ? 1 : .85 }}> Each checkbox unlocks only after its document has been opened & reviewed. */}
<input type="checkbox" checked={form.terms} disabled={!reviewedBoth} onChange={e => set('terms', e.target.checked)} style={{ width: 17, height: 17, accentColor: 'var(--blue-600)' }} /> <div style={{ display: 'grid', gap: 11 }}>
<span>I have read and accept YEIDA's{' '} <label className="row gap-3" style={{ fontSize: 13.5, color: 'var(--muted)', cursor: form.reviewedTerms ? 'pointer' : 'not-allowed', opacity: form.reviewedTerms ? 1 : .85 }}>
<input type="checkbox" checked={form.acceptTerms} disabled={!form.reviewedTerms} onChange={e => set('acceptTerms', e.target.checked)} style={{ width: 17, height: 17, accentColor: 'var(--blue-600)' }} />
<span>I accept YEIDA's{' '}
<button type="button" onClick={() => openLegal('terms')} style={{ background: 'none', border: 'none', padding: 0, color: 'var(--blue-600)', fontWeight: 700, cursor: 'pointer', textDecoration: 'underline', fontSize: 13.5 }}>Terms &amp; Conditions</button> <button type="button" onClick={() => openLegal('terms')} style={{ background: 'none', border: 'none', padding: 0, color: 'var(--blue-600)', fontWeight: 700, cursor: 'pointer', textDecoration: 'underline', fontSize: 13.5 }}>Terms &amp; Conditions</button>
{form.reviewedTerms && <Icon name="check" size={13} style={{ color: 'var(--emerald-600)', verticalAlign: 'middle', margin: '0 2px' }} />} {form.reviewedTerms && <Icon name="check" size={13} style={{ color: 'var(--emerald-600)', verticalAlign: 'middle', margin: '0 2px' }} />}
{' '}and{' '} </span>
</label>
<label className="row gap-3" style={{ fontSize: 13.5, color: 'var(--muted)', cursor: form.reviewedPrivacy ? 'pointer' : 'not-allowed', opacity: form.reviewedPrivacy ? 1 : .85 }}>
<input type="checkbox" checked={form.acceptPrivacy} disabled={!form.reviewedPrivacy} onChange={e => set('acceptPrivacy', e.target.checked)} style={{ width: 17, height: 17, accentColor: 'var(--blue-600)' }} />
<span>I accept YEIDA's{' '}
<button type="button" onClick={() => openLegal('privacy')} style={{ background: 'none', border: 'none', padding: 0, color: 'var(--blue-600)', fontWeight: 700, cursor: 'pointer', textDecoration: 'underline', fontSize: 13.5 }}>Privacy Policy</button> <button type="button" onClick={() => openLegal('privacy')} style={{ background: 'none', border: 'none', padding: 0, color: 'var(--blue-600)', fontWeight: 700, cursor: 'pointer', textDecoration: 'underline', fontSize: 13.5 }}>Privacy Policy</button>
{form.reviewedPrivacy && <Icon name="check" size={13} style={{ color: 'var(--emerald-600)', verticalAlign: 'middle', margin: '0 2px' }} />} {form.reviewedPrivacy && <Icon name="check" size={13} style={{ color: 'var(--emerald-600)', verticalAlign: 'middle', margin: '0 2px' }} />}
</span> </span>
</label> </label>
{!reviewedBoth && <div className="row gap-2" style={{ fontSize: 11.5, color: 'var(--amber-600)', marginTop: -8 }}><Icon name="info" size={13} />Please open and review both documents to enable acceptance.</div>} </div>
{!reviewedBoth && <div className="row gap-2" style={{ fontSize: 11.5, color: 'var(--amber-600)', marginTop: -8 }}><Icon name="info" size={13} />Please open and review both documents to tick the boxes above.</div>}
</div> </div>
); );
} }
@@ -314,12 +382,20 @@ const D = YEIDA;
</div> </div>
</div> </div>
</div> </div>
<div className="row spread" style={{ marginTop: 10, flexWrap: 'wrap', gap: 8 }}> {/* Matched allotment — kept hidden & masked with *** until the plot is verified */}
<div className="row spread" style={{ marginTop: 14, padding: '12px 14px', borderRadius: 12, border: '1px solid ' + (form.plotVerified ? '#bfe6d3' : 'var(--line)'), background: form.plotVerified ? 'var(--emerald-50)' : 'var(--bg)', flexWrap: 'wrap', gap: 10 }}>
<div className="row gap-3" style={{ minWidth: 0 }}>
<span style={{ width: 36, height: 36, borderRadius: 9, display: 'grid', placeItems: 'center', background: form.plotVerified ? 'var(--emerald-500)' : 'var(--bg-2)', color: form.plotVerified ? '#fff' : 'var(--muted)', flex: 'none' }}><Icon name={form.plotVerified ? 'checkCircle' : 'lock'} size={18} /></span>
<div style={{ minWidth: 0 }}>
<div className="faint" style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.06em' }}>Matched allotment number</div>
<div className="num" style={{ fontWeight: 800, fontSize: 17, letterSpacing: form.plotVerified ? 0 : '.18em', color: form.plotVerified ? 'var(--emerald-700)' : 'var(--muted)' }}>{form.plotVerified ? 'YEA-654321' : 'YEA-******'}</div>
<div className="faint" style={{ fontSize: 11.5 }}>{form.plotVerified ? `Verified · ${form.sector} · ${form.pocket} · Plot ${form.plot}` : 'Sensitive details stay masked until you verify your plot.'}</div>
</div>
</div>
{form.plotVerified {form.plotVerified
? <span className="row gap-2" style={{ fontSize: 12.5, color: 'var(--emerald-700)' }}><Icon name="check" size={14} />Plot verified · matched to allotment YEA-654321</span> ? <span className="badge badge-green" style={{ flex: 'none' }}><Icon name="check" size={13} />Verified</span>
: <span className="row gap-2 faint" style={{ fontSize: 12 }}><Icon name="info" size={13} />Unverified verify your plot to continue.</span>} : <button type="button" className="btn btn-primary btn-sm" style={{ flex: 'none' }} disabled={!form.plot.trim() || checking} onClick={verifyPlot}>
{!form.plotVerified && <button type="button" className="btn btn-outline btn-sm" disabled={!form.plot.trim() || checking} onClick={verifyPlot}> {checking ? <span style={{ width: 14, height: 14, border: '2px solid rgba(255,255,255,.4)', borderTopColor: '#fff', borderRadius: 99, animation: 'spin .7s linear infinite' }} /> : <Icon name="shield" size={15} />}Verify to reveal
{checking ? <span style={{ width: 14, height: 14, border: '2px solid rgba(38,112,230,.3)', borderTopColor: 'var(--blue-600)', borderRadius: 99, animation: 'spin .7s linear infinite' }} /> : <Icon name="search" size={15} />}Verify plot
</button>} </button>}
</div> </div>
</div> </div>
@@ -362,40 +438,54 @@ const D = YEIDA;
function AddressBlock({ title, sub, icon, value, onChange }) { function AddressBlock({ title, sub, icon, value, onChange }) {
const country = D.addressCountries.find(c => c.code === value.country) || D.addressCountries[0]; const country = D.addressCountries.find(c => c.code === value.country) || D.addressCountries[0];
const [pinState, setPinState] = useState('idle'); // idle|checking|filled|notfound (India) const [pinState, setPinState] = useState('idle'); // idle|checking|filled|notfound (India)
const [candidates, setCandidates] = useState(null); // postcode lookup results (US/UK) const [localities, setLocalities] = useState([]); // PIN list of areas to pick from (India)
const [lookupState, setLookupState] = useState('idle'); // idle|checking|done|notfound // Non-India address typeahead type the house number / street, click to auto-fill all fields
const [suggests, setSuggests] = useState(null); // array of results | null
const [searching, setSearching] = useState(false);
const searchTimer = useRef(null);
const isIndia = country.lookup === 'pincode';
const onCountry = (e) => { const onCountry = (e) => {
const c = D.addressCountries.find(x => x.code === e.target.value) || D.addressCountries[0]; const c = D.addressCountries.find(x => x.code === e.target.value) || D.addressCountries[0];
setPinState('idle'); setCandidates(null); setLookupState('idle'); setPinState('idle'); setLocalities([]); setSuggests(null);
onChange({ country: c.code, state: c.states[0] || '', city: '', line1: '', line2: '', postal: '' }); onChange({ country: c.code, state: c.states[0] || '', city: '', line1: '', line2: '', postal: '' });
}; };
// India: auto-fill on a complete 6-digit PIN // India: auto-fill city & state on a complete 6-digit PIN (real API + offline fallback)
const onPin = (e) => { const onPin = (e) => {
const pin = e.target.value.replace(/\D/g, '').slice(0, country.postalLen); const pin = e.target.value.replace(/\D/g, '').slice(0, country.postalLen);
onChange({ postal: pin }); onChange({ postal: pin });
if (country.lookup !== 'pincode') return; if (country.lookup !== 'pincode') return;
if (pin.length < country.postalLen) { setPinState('idle'); return; } if (pin.length < country.postalLen) { setPinState('idle'); setLocalities([]); return; }
setPinState('checking'); setPinState('checking'); setLocalities([]);
setTimeout(() => { fetch(`/api/geo?pin=${pin}`)
const hit = D.pincodeLookup[pin]; .then(r => r.json())
if (hit) { onChange({ postal: pin, city: hit.city, state: hit.state }); setPinState('filled'); } .then(j => {
else setPinState('notfound'); if (j && j.ok) { onChange({ postal: pin, city: j.city, state: j.state }); setLocalities(j.localities || []); setPinState('filled'); }
}, 600); else { setPinState('notfound'); }
})
.catch(() => setPinState('notfound'));
}; };
// US/UK: postcode lookup service returns address candidates // Non-India: type the house number / street live address suggestions that fill every field.
const runLookup = () => { const onAddrSearch = (e) => {
const key = value.postal.replace(/\s/g, '').toUpperCase(); const v = e.target.value;
setLookupState('checking'); setCandidates(null); onChange({ line1: v });
setTimeout(() => { if (searchTimer.current) clearTimeout(searchTimer.current);
const hits = D.postcodeLookup[key]; if (v.trim().length < 3) { setSuggests(null); setSearching(false); return; }
if (hits && hits.length) { setCandidates(hits); setLookupState('done'); } setSearching(true);
else setLookupState('notfound'); searchTimer.current = setTimeout(() => {
}, 700); fetch(`/api/geo?addr=${encodeURIComponent(v.trim())}&country=${country.code.toLowerCase()}`)
.then(r => r.json())
.then(j => setSuggests((j && j.results) || []))
.catch(() => setSuggests([]))
.finally(() => setSearching(false));
}, 400);
};
const pickAddr = (s) => {
onChange({ line1: s.line1, line2: s.line2 || '', city: s.city, state: s.state, postal: s.postal });
setSuggests(null);
}; };
const pickCandidate = (c) => { onChange({ line1: c.line1, city: c.city, state: c.state }); setCandidates(null); setLookupState('done'); };
const stateField = country.states.length ? ( const stateField = country.states.length ? (
<div className="field"><label>State / Region</label> <div className="field"><label>State / Region</label>
@@ -425,49 +515,61 @@ const D = YEIDA;
</div> </div>
</div> </div>
{/* 2. India — PIN drives auto-fill */} {/* Non-India — house number / street first, with live address autosuggest. */}
{country.lookup === 'pincode' && ( {!isIndia && (
<div className="field"><label>{country.postalLabel}</label> <div className="field" style={{ position: 'relative' }}><label>Address Line 1 House No. / Street</label>
<div className="input-icon"><Icon name="mapPin" /><input className="input num" value={value.postal} onChange={onPin} inputMode="numeric" placeholder="6-digit PIN auto-fills your city & state" /> <div className="input-icon"><Icon name="search" /><input className="input" value={value.line1} onChange={onAddrSearch} autoComplete="off" spellCheck={false} placeholder="Start typing your house number &amp; street…" />
{pinState === 'checking' && <span style={{ position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)', width: 14, height: 14, border: '2px solid rgba(38,112,230,.3)', borderTopColor: 'var(--blue-600)', borderRadius: 99, animation: 'spin .7s linear infinite' }} />} {searching && <span style={{ position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)', width: 14, height: 14, border: '2px solid rgba(31,81,50,.3)', borderTopColor: 'var(--blue-600)', borderRadius: 99, animation: 'spin .7s linear infinite' }} />}
</div> </div>
{pinState === 'filled' && <span className="row gap-2" style={{ fontSize: 12, color: 'var(--emerald-700)', marginTop: 4 }}><Icon name="check" size={13} />Auto-filled city &amp; state from PIN</span>} {suggests && suggests.length > 0 && (
{pinState === 'notfound' && <span className="row gap-2" style={{ fontSize: 12, color: 'var(--amber-600)', marginTop: 4 }}><Icon name="alert" size={13} />PIN not found please fill the fields manually</span>} <div style={{ position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 20, marginTop: 4, border: '1px solid var(--line)', borderRadius: 10, overflow: 'hidden', background: '#fff', boxShadow: 'var(--sh-md)' }}>
</div> {suggests.map((s, i) => (
)} <button key={i} type="button" onClick={() => pickAddr(s)} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, width: '100%', textAlign: 'left', padding: '9px 12px', border: 'none', borderTop: i ? '1px solid var(--line-2)' : 'none', background: '#fff', cursor: 'pointer', fontSize: 13 }}>
<Icon name="mapPin" size={14} style={{ color: 'var(--blue-600)', flex: 'none', marginTop: 2 }} /><span>{s.label}</span>
{/* 2. US/UK — postcode lookup service */}
{country.lookup === 'postcode' && (
<div className="field"><label>{country.postalLabel}</label>
<div className="row gap-2">
<div className="input-icon" style={{ flex: 1 }}><Icon name="mapPin" /><input className="input" value={value.postal} onChange={e => { onChange({ postal: e.target.value.toUpperCase() }); setLookupState('idle'); }} placeholder={`Enter ${country.postalLabel}`} /></div>
<button type="button" className="btn btn-outline btn-sm" style={{ flex: 'none' }} disabled={!value.postal || lookupState === 'checking'} onClick={runLookup}>
{lookupState === 'checking' ? <span style={{ width: 14, height: 14, border: '2px solid rgba(38,112,230,.3)', borderTopColor: 'var(--blue-600)', borderRadius: 99, animation: 'spin .7s linear infinite' }} /> : <Icon name="search" size={15} />}
Find address
</button>
</div>
{lookupState === 'notfound' && <span className="row gap-2" style={{ fontSize: 12, color: 'var(--amber-600)', marginTop: 4 }}><Icon name="alert" size={13} />No match please fill the fields manually</span>}
{candidates && (
<div style={{ marginTop: 8, border: '1px solid var(--line)', borderRadius: 10, overflow: 'hidden' }}>
{candidates.map((c, i) => (
<button key={i} type="button" onClick={() => pickCandidate(c)} style={{ display: 'block', width: '100%', textAlign: 'left', padding: '9px 12px', border: 'none', borderTop: i ? '1px solid var(--line)' : 'none', background: '#fff', cursor: 'pointer', fontSize: 13 }}>
<b>{c.line1}</b><span className="muted"> · {c.city}, {c.state}</span>
</button> </button>
))} ))}
</div> </div>
)} )}
{suggests && suggests.length === 0 && !searching && (value.line1 || '').trim().length >= 3 && <span className="row gap-2" style={{ fontSize: 12, color: 'var(--muted)', marginTop: 4 }}><Icon name="info" size={13} />No matches keep typing or fill the fields below manually</span>}
{!suggests && <span className="faint" style={{ fontSize: 11.5, marginTop: 4, display: 'block' }}>Pick a suggestion to auto-fill city, state &amp; {country.postalLabel} all editable after.</span>}
</div> </div>
)} )}
{/* 3. State (dependent dropdown) */} {/* India — 6-digit PIN drives city/state auto-fill (real lookup, no free-text search). */}
{isIndia && (
<>
<div className="field"><label>{country.postalLabel}</label>
<div className="input-icon"><Icon name="mapPin" /><input className="input num" value={value.postal} onChange={onPin} inputMode="numeric" placeholder="6-digit PIN auto-fills your city & state" />
{pinState === 'checking' && <span style={{ position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)', width: 14, height: 14, border: '2px solid rgba(31,81,50,.3)', borderTopColor: 'var(--blue-600)', borderRadius: 99, animation: 'spin .7s linear infinite' }} />}
</div>
{pinState === 'filled' && <span className="row gap-2" style={{ fontSize: 12, color: 'var(--emerald-700)', marginTop: 4 }}><Icon name="check" size={13} />Auto-filled city &amp; state from PIN</span>}
{pinState === 'notfound' && <span className="row gap-2" style={{ fontSize: 12, color: 'var(--amber-600)', marginTop: 4 }}><Icon name="alert" size={13} />PIN not found please fill the fields manually</span>}
</div>
{/* Locality picker — select your area from the PIN's post offices, fills Address Line 2 */}
{localities.length > 0 && (
<div className="field"><label>Area / Locality</label>
<select className="select" value={localities.includes(value.line2) ? value.line2 : ''} onChange={e => onChange({ line2: e.target.value })}>
<option value="">Select your area</option>
{localities.map(l => <option key={l} value={l}>{l}</option>)}
</select>
<span className="faint" style={{ fontSize: 11.5, marginTop: 4, display: 'block' }}>Choose your locality it fills Address Line 2 (editable).</span>
</div>
)}
{/* India keeps an explicit house/flat field at the top of the manual details */}
<div className="field"><label>Address Line 1 House / Flat, Street</label><input className="input" value={value.line1} onChange={e => onChange({ line1: e.target.value })} placeholder="House / flat, street" /></div>
</>
)}
{/* State (dependent dropdown) */}
{stateField} {stateField}
{/* 4. Remaining address fields */} {/* Remaining address fields */}
<div className="field"><label>Address Line 1</label><input className="input" value={value.line1} onChange={e => onChange({ line1: e.target.value })} placeholder="House / flat, street" /></div>
<div className="field"><label>Address Line 2</label><input className="input" value={value.line2} onChange={e => onChange({ line2: e.target.value })} placeholder="Area / landmark (optional)" /></div> <div className="field"><label>Address Line 2</label><input className="input" value={value.line2} onChange={e => onChange({ line2: e.target.value })} placeholder="Area / landmark (optional)" /></div>
<div style={{ display: 'grid', gridTemplateColumns: country.lookup === 'manual' ? '1.4fr 1fr' : '1fr', gap: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: isIndia ? '1fr' : '1.4fr 1fr', gap: 12 }}>
<div className="field"><label>City</label><input className="input" value={value.city} onChange={e => onChange({ city: e.target.value })} placeholder="City" /></div> <div className="field"><label>City</label><input className="input" value={value.city} onChange={e => onChange({ city: e.target.value })} placeholder="City" /></div>
{country.lookup === 'manual' && <div className="field"><label>{country.postalLabel}</label><input className="input" value={value.postal} onChange={e => onChange({ postal: e.target.value })} placeholder={country.postalLabel} /></div>} {!isIndia && <div className="field"><label>{country.postalLabel}</label><input className="input" value={value.postal} onChange={e => onChange({ postal: e.target.value })} placeholder={country.postalLabel} /></div>}
</div> </div>
</div> </div>
</div> </div>
@@ -543,7 +645,7 @@ const D = YEIDA;
/* Standalone allotment lookup (login path — "I don't remember my allotment number") */ /* Standalone allotment lookup (login path — "I don't remember my allotment number") */
function AllotmentLookup({ go }) { function AllotmentLookup({ go }) {
const [form, setForm] = useState({ sector: 'Sector 18', pocket: 'Pocket B', plot: 'B-47' }); const [form, setForm] = useState({ sector: 'Sector 18', pocket: 'Pocket B', plot: '181' });
const [found, setFound] = useState(false); const [found, setFound] = useState(false);
const set = (k, v) => setForm(f => ({ ...f, [k]: v })); const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
return ( return (
+1 -1
View File
@@ -122,7 +122,7 @@ const D = YEIDA, fmt = D.fmtINR, P = D.property;
))} ))}
</div> </div>
</div> </div>
<div style={{ background: 'radial-gradient(120% 120% at 100% 0%,#16407e,#0b2548)', color: '#fff', padding: 26, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}> <div style={{ background: 'radial-gradient(120% 120% at 100% 0%,#266b41,#15331f)', color: '#fff', padding: 26, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<div style={{ fontSize: 12.5, color: 'rgba(200,216,240,.8)', fontWeight: 600 }}>Total Premium Value</div> <div style={{ fontSize: 12.5, color: 'rgba(200,216,240,.8)', fontWeight: 600 }}>Total Premium Value</div>
<div className="num" style={{ fontFamily: 'var(--font-display)', fontWeight: 800, fontSize: 32, marginTop: 4 }}>{fmt(P.totalValue)}</div> <div className="num" style={{ fontFamily: 'var(--font-display)', fontWeight: 800, fontSize: 32, marginTop: 4 }}>{fmt(P.totalValue)}</div>
<div className="progress" style={{ margin: '16px 0 8px', background: 'rgba(255,255,255,.14)' }}><span style={{ width: Math.round(P.paid / P.totalValue * 100) + '%' }} /></div> <div className="progress" style={{ margin: '16px 0 8px', background: 'rgba(255,255,255,.14)' }}><span style={{ width: Math.round(P.paid / P.totalValue * 100) + '%' }} /></div>
+99 -54
View File
@@ -7,44 +7,57 @@
@import url("https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Heebo:wght@300;400;500;600;700&family=Parkinsans:wght@300..800&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Philosopher:wght@400;700&family=Gloock&display=swap"); @import url("https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Heebo:wght@300;400;500;600;700&family=Parkinsans:wght@300..800&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Philosopher:wght@400;700&family=Gloock&display=swap");
:root { :root {
/* Brand */ /* ============================================================
--navy-900: #081a39; Brand "Heritage Green & Gold": deep forest green is the
--navy-800: #0b2548; primary (remapped onto the old --blue-* / --navy-* slots so the
--navy-700: #103263; whole portal shifts), antique brass/gold is the accent (--amber-*),
--navy-600: #16407e; emerald stays for success, on a warm ivory canvas.
--blue-600: #1d5fbf; ============================================================ */
--blue-700: #16407e; /* Deep forest (dark brand surfaces — sidebar, brand panels) */
--blue-500: #2670e6; --navy-900: #0f2417;
--blue-400: #5a93f0; --navy-800: #15331f;
--blue-100: #d8e6fb; --navy-700: #1f5132;
--blue-50: #eef4ff; --navy-600: #266b41;
--emerald-700: #0a7d56; /* Forest green = PRIMARY action / links / active / icons */
--blue-600: #1f5132;
--blue-700: #15331f;
--blue-500: #2a7048;
--blue-400: #5e9f77;
--blue-100: #cfe3d6;
--blue-50: #eef5f0;
/* Emerald = success / verified */
--emerald-700: #0a6b46;
--emerald-600: #0f9d6b; --emerald-600: #0f9d6b;
--emerald-500: #16b67e; --emerald-500: #16b67e;
--emerald-100: #cdeede; --emerald-100: #cdeede;
--emerald-50: #e8f7f0; --emerald-50: #e8f7f0;
--amber-600: #c77a08; /* Antique brass / gold = ACCENT, pending, highlights */
--amber-500: #e8930c; --gold-700: #8a6526;
--amber-50: #fdf3e2; --gold-600: #a8782f;
--red-600: #d23f3f; --gold-500: #b8893b;
--red-500: #e85656; --gold-400: #cda85f;
--red-50: #fdedee; --amber-600: #9a6b1f;
--amber-500: #b8893b;
--amber-50: #f6edd9;
/* Classic muted red for errors */
--red-600: #b0402f;
--red-500: #cb5a47;
--red-50: #f7ebe7;
/* Neutrals */ /* Neutrals — warm ivory / parchment */
--ink: #0e1b2e; --ink: #20271f;
--ink-2: #2b3a4f; --ink-2: #38463a;
--muted: #5d6f87; --muted: #6b7567;
--faint: #8a99 ac; --faint: #98a394;
--faint: #8a99ac; --line: #e7e0d0;
--line: #e4eaf1; --line-2: #f0eadc;
--line-2: #eef2f7; --bg: #f7f4ec;
--bg: #f4f7fb; --bg-2: #efe8d9;
--bg-2: #eef3f9; --card: #fffdf8;
--card: #ffffff; --ink-on-navy: #eef5ee;
--ink-on-navy: #eaf1fb;
/* Type — matched to the main website */ /* Type — classic editorial: serif display headings + clean sans body */
--font-display: 'Parkinsans', 'Heebo', system-ui, sans-serif; --font-display: 'Libre Baskerville', 'Philosopher', Georgia, 'Times New Roman', serif;
--font-ui: 'Plus Jakarta Sans', 'Heebo', system-ui, -apple-system, sans-serif; --font-ui: 'Plus Jakarta Sans', 'Heebo', system-ui, -apple-system, sans-serif;
/* Radii */ /* Radii */
@@ -54,11 +67,11 @@
--r-xl: 22px; --r-xl: 22px;
--r-pill: 999px; --r-pill: 999px;
/* Shadows */ /* Shadows — slightly richer for more depth (less "flat / plain") */
--sh-xs: 0 1px 2px rgba(13,38,76,.05); --sh-xs: 0 1px 2px rgba(13,38,76,.06);
--sh-sm: 0 1px 2px rgba(13,38,76,.05), 0 4px 12px -4px rgba(13,38,76,.06); --sh-sm: 0 1px 2px rgba(13,38,76,.05), 0 6px 16px -6px rgba(13,38,76,.10);
--sh-md: 0 8px 24px -8px rgba(13,38,76,.13), 0 2px 6px -2px rgba(13,38,76,.05); --sh-md: 0 10px 28px -10px rgba(13,38,76,.18), 0 3px 8px -3px rgba(13,38,76,.07);
--sh-lg: 0 24px 48px -16px rgba(11,37,72,.26), 0 6px 16px -8px rgba(11,37,72,.16); --sh-lg: 0 28px 56px -18px rgba(11,37,72,.30), 0 8px 18px -10px rgba(11,37,72,.18);
--sh-glow: 0 10px 28px -8px rgba(29,95,191,.42); --sh-glow: 0 10px 28px -8px rgba(29,95,191,.42);
--sidebar-w: 252px; --sidebar-w: 252px;
@@ -70,13 +83,18 @@ html, body { margin: 0; padding: 0; }
body { body {
font-family: var(--font-ui); font-family: var(--font-ui);
color: var(--ink); color: var(--ink);
background: var(--bg); /* Warm parchment backdrop with faint gold & green corners (classic, not flat) */
background:
radial-gradient(1100px 560px at 100% -8%, #efe6cf 0%, transparent 55%),
radial-gradient(960px 520px at -8% 108%, #e4ede0 0%, transparent 52%),
var(--bg);
background-attachment: fixed;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
} }
#root { min-height: 100vh; } #root { min-height: 100vh; }
h1,h2,h3,h4,h5 { font-family: var(--font-display); margin: 0; color: var(--ink); letter-spacing: -.015em; } h1,h2,h3,h4,h5 { font-family: var(--font-display); margin: 0; color: var(--ink); letter-spacing: -.005em; line-height: 1.18; }
p { margin: 0; } p { margin: 0; }
button { font-family: inherit; cursor: pointer; } button { font-family: inherit; cursor: pointer; }
a { color: inherit; text-decoration: none; } a { color: inherit; text-decoration: none; }
@@ -87,8 +105,8 @@ input, select, textarea { font-family: inherit; }
/* Scrollbars */ /* Scrollbars */
*::-webkit-scrollbar { width: 10px; height: 10px; } *::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-thumb { background: #cdd7e4; border-radius: 99px; border: 2px solid transparent; background-clip: padding-box; } *::-webkit-scrollbar-thumb { background: #d6cdb6; border-radius: 99px; border: 2px solid transparent; background-clip: padding-box; }
*::-webkit-scrollbar-thumb:hover { background: #b4c2d4; background-clip: padding-box; } *::-webkit-scrollbar-thumb:hover { background: #c2b89c; background-clip: padding-box; }
*::-webkit-scrollbar-track { background: transparent; } *::-webkit-scrollbar-track { background: transparent; }
/* ---------- Buttons ---------- */ /* ---------- Buttons ---------- */
@@ -104,11 +122,16 @@ input, select, textarea { font-family: inherit; }
.btn svg { width: 18px; height: 18px; flex: none; } .btn svg { width: 18px; height: 18px; flex: none; }
.btn-primary { .btn-primary {
background: #0077FF; background: linear-gradient(180deg, #266b41, #1f5132);
color: #fff; box-shadow: 0 10px 24px -8px rgba(0,119,255,.5); color: #fff; box-shadow: 0 10px 24px -8px rgba(31,81,50,.46);
border-radius: var(--r-pill) !important; border-radius: var(--r-pill) !important;
border: 1px solid rgba(184,137,59,0);
} }
.btn-primary:hover { box-shadow: 0 14px 32px -8px rgba(0,119,255,.6); filter: brightness(1.05); } .btn-primary:hover { box-shadow: 0 14px 32px -8px rgba(31,81,50,.55), 0 0 0 1px rgba(184,137,59,.5) inset; filter: brightness(1.06); border-color: rgba(184,137,59,.55); }
/* Gold / brass accent button (classic CTA accent) */
.btn-gold { background: linear-gradient(180deg, #c69540, #b8893b); color: #fff; box-shadow: 0 10px 24px -10px rgba(184,137,59,.55); }
.btn-gold:hover { filter: brightness(1.05); }
.btn-navy { background: linear-gradient(180deg, var(--navy-700), var(--navy-800)); color: #fff; box-shadow: 0 10px 24px -10px rgba(8,26,57,.6); } .btn-navy { background: linear-gradient(180deg, var(--navy-700), var(--navy-800)); color: #fff; box-shadow: 0 10px 24px -10px rgba(8,26,57,.6); }
.btn-navy:hover { filter: brightness(1.1); } .btn-navy:hover { filter: brightness(1.1); }
@@ -134,7 +157,12 @@ input, select, textarea { font-family: inherit; }
} }
.card-pad { padding: 22px; } .card-pad { padding: 22px; }
.card-hover { transition: box-shadow .2s ease, transform .2s ease, border-color .2s ease; } .card-hover { transition: box-shadow .2s ease, transform .2s ease, border-color .2s ease; }
.card-hover:hover { box-shadow: var(--sh-md); border-color: #d6e0ee; transform: translateY(-2px); } .card-hover:hover { box-shadow: var(--sh-md); border-color: #cdd9ea; transform: translateY(-2px); }
/* Subtle gradient sheen so cards feel less flat (opt-in, e.g. headers/feature cards) */
.card-grad { background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%); }
.card-accent { position: relative; overflow: hidden; }
.card-accent::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--accent, var(--blue-500)); }
/* ---------- Badges / chips ---------- */ /* ---------- Badges / chips ---------- */
.badge { .badge {
@@ -148,7 +176,8 @@ input, select, textarea { font-family: inherit; }
.badge-amber { background: var(--amber-50); color: var(--amber-600); } .badge-amber { background: var(--amber-50); color: var(--amber-600); }
.badge-red { background: var(--red-50); color: var(--red-600); } .badge-red { background: var(--red-50); color: var(--red-600); }
.badge-gray { background: var(--bg-2); color: var(--muted); } .badge-gray { background: var(--bg-2); color: var(--muted); }
.badge-navy { background: #e9eefb; color: var(--navy-700); } .badge-navy { background: #e5ede4; color: var(--navy-700); }
.badge-gold { background: var(--amber-50); color: var(--gold-700); }
.chip { .chip {
display: inline-flex; align-items: center; gap: 7px; cursor: pointer; display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
@@ -165,10 +194,11 @@ input, select, textarea { font-family: inherit; }
.input, .select { .input, .select {
width: 100%; padding: 12px 14px; font-size: 14.5px; color: var(--ink); width: 100%; padding: 12px 14px; font-size: 14.5px; color: var(--ink);
background: #fff; border: 1.5px solid var(--line); border-radius: var(--r-md); background: #fff; border: 1.5px solid var(--line); border-radius: var(--r-md);
transition: border-color .15s ease, box-shadow .15s ease; outline: none; transition: border-color .15s ease, box-shadow .15s ease, background .15s ease; outline: none;
} }
.input::placeholder { color: #9aa9bc; } .input::placeholder { color: #9aa9bc; }
.input:focus, .select:focus { border-color: var(--blue-400); box-shadow: 0 0 0 4px rgba(38,112,230,.12); } .input:hover, .select:hover { border-color: #cdd9ea; }
.input:focus, .select:focus { border-color: var(--blue-400); box-shadow: 0 0 0 4px rgba(38,112,230,.12); background: #fff; }
.input-icon { position: relative; } .input-icon { position: relative; }
.input-icon svg { position: absolute; left: 13px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: #9aa9bc; } .input-icon svg { position: absolute; left: 13px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: #9aa9bc; }
.input-icon .input { padding-left: 42px; } .input-icon .input { padding-left: 42px; }
@@ -185,9 +215,9 @@ input, select, textarea { font-family: inherit; }
.nav-item.active { background: rgba(255,255,255,.1); color: #fff; font-weight: 600; } .nav-item.active { background: rgba(255,255,255,.1); color: #fff; font-weight: 600; }
.nav-item.active::before { .nav-item.active::before {
content: ''; position: absolute; left: -12px; top: 50%; transform: translateY(-50%); content: ''; position: absolute; left: -12px; top: 50%; transform: translateY(-50%);
width: 3.5px; height: 22px; border-radius: 99px; background: var(--emerald-500); width: 3.5px; height: 22px; border-radius: 99px; background: var(--gold-500);
} }
.nav-badge { margin-left: auto; font-size: 11px; font-weight: 700; background: var(--emerald-500); color: #042e1f; padding: 1px 7px; border-radius: 99px; } .nav-badge { margin-left: auto; font-size: 11px; font-weight: 700; background: var(--gold-500); color: #2a1c05; padding: 1px 7px; border-radius: 99px; }
/* ---------- Misc ---------- */ /* ---------- Misc ---------- */
.divider { height: 1px; background: var(--line); border: none; } .divider { height: 1px; background: var(--line); border: none; }
@@ -221,12 +251,15 @@ body.anim-on .stagger > * { animation: fadeUp .5s cubic-bezier(.2,.7,.3,1) both;
.stagger > *:nth-child(7){animation-delay:.33s} .stagger > *:nth-child(7){animation-delay:.33s}
.stagger > *:nth-child(8){animation-delay:.38s} .stagger > *:nth-child(8){animation-delay:.38s}
/* Tables */ /* Tables — zebra striping + stronger header + clearer hover (less plain) */
.tbl { width: 100%; border-collapse: collapse; } .tbl { width: 100%; border-collapse: collapse; }
.tbl th { text-align: left; font-size: 12px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--faint); padding: 12px 16px; border-bottom: 1px solid var(--line); } .tbl th { text-align: left; font-size: 12px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--gold-700); padding: 12px 16px; border-bottom: 1px solid var(--line); background: linear-gradient(180deg, #f4eedd, #ece3cf); }
.tbl th:first-child { border-top-left-radius: var(--r-md); }
.tbl th:last-child { border-top-right-radius: var(--r-md); }
.tbl td { padding: 14px 16px; border-bottom: 1px solid var(--line-2); font-size: 14px; color: var(--ink-2); } .tbl td { padding: 14px 16px; border-bottom: 1px solid var(--line-2); font-size: 14px; color: var(--ink-2); }
.tbl tbody tr { transition: background .12s ease; } .tbl tbody tr { transition: background .12s ease, box-shadow .12s ease; }
.tbl tbody tr:hover { background: #fafcfe; } .tbl tbody tr:nth-child(even) { background: #faf6ec; }
.tbl tbody tr:hover { background: var(--blue-50); box-shadow: inset 3px 0 0 var(--gold-500); }
.tbl tbody tr:last-child td { border-bottom: none; } .tbl tbody tr:last-child td { border-bottom: none; }
/* Utility layout */ /* Utility layout */
@@ -268,6 +301,18 @@ body.anim-on .stagger > * { animation: fadeUp .5s cubic-bezier(.2,.7,.3,1) both;
/* table refinement on dashboard */ /* table refinement on dashboard */
.tbl.tbl-clean th { background: var(--bg); } .tbl.tbl-clean th { background: var(--bg); }
/* notification matrix table */
.notif-tbl thead th { background: #f7faff; padding-top: 12px; padding-bottom: 12px; vertical-align: middle; }
.notif-tbl tbody td { vertical-align: middle; }
.notif-tbl .chk-cell:hover { border-color: #aebfd6; }
.notif-tbl .chk-cell:active { transform: scale(.92); }
/* Inline spinner (auth flow buttons + waiting states) */
.spinner { width: 16px; height: 16px; flex: none; border: 2px solid rgba(255,255,255,.45); border-top-color: #fff; border-radius: 99px; animation: spin .7s linear infinite; display: inline-block; }
/* On light surfaces the white ring is invisible — tint it green instead */
.btn-outline .spinner, .spinner-lg { border-color: rgba(31,81,50,.25); border-top-color: var(--blue-600); }
.spinner-lg { width: 26px; height: 26px; border-width: 3px; }
/* keyframes migrated from ui.jsx (bar chart) and auth.jsx (verify ping) */ /* keyframes migrated from ui.jsx (bar chart) and auth.jsx (verify ping) */
@keyframes growBar { from { transform: scaleY(0); transform-origin: bottom; } to { transform: scaleY(1); transform-origin: bottom; } } @keyframes growBar { from { transform: scaleY(0); transform-origin: bottom; } to { transform: scaleY(1); transform-origin: bottom; } }
+3 -3
View File
@@ -82,7 +82,7 @@ const D = YEIDA;
))} ))}
</div> </div>
</div> </div>
<div style={{ background: 'radial-gradient(120% 120% at 100% 0%,#16407e,#0b2548)', display: 'grid', placeItems: 'center', padding: 24 }}> <div style={{ background: 'radial-gradient(120% 120% at 100% 0%,#266b41,#15331f)', display: 'grid', placeItems: 'center', padding: 24 }}>
<div style={{ textAlign: 'center', color: '#fff' }}> <div style={{ textAlign: 'center', color: '#fff' }}>
<div style={{ width: 64, height: 64, borderRadius: 16, margin: '0 auto 12px', display: 'grid', placeItems: 'center', background: 'rgba(255,255,255,.12)', border: '1px solid rgba(255,255,255,.2)' }}><Icon name="eye" size={30} /></div> <div style={{ width: 64, height: 64, borderRadius: 16, margin: '0 auto 12px', display: 'grid', placeItems: 'center', background: 'rgba(255,255,255,.12)', border: '1px solid rgba(255,255,255,.2)' }}><Icon name="eye" size={30} /></div>
<div style={{ fontWeight: 700, fontSize: 16 }}>Immersive 3D Walkthroughs</div> <div style={{ fontWeight: 700, fontSize: 16 }}>Immersive 3D Walkthroughs</div>
@@ -137,7 +137,7 @@ const D = YEIDA;
const on = elev === e.id; const on = elev === e.id;
const fits = e.suitsSize === mySize || e.suitsFacing === myFacing; const fits = e.suitsSize === mySize || e.suitsFacing === myFacing;
return ( return (
<button key={e.id} onClick={() => setElev(e.id)} className="card-hover" style={{ textAlign: 'left', cursor: 'pointer', padding: 0, overflow: 'hidden', borderRadius: 14, border: '1.5px solid ' + (on ? 'var(--blue-400)' : 'var(--line)'), background: '#fff', boxShadow: on ? '0 0 0 4px rgba(38,112,230,.1)' : 'none' }}> <button key={e.id} onClick={() => setElev(e.id)} className="card-hover" style={{ textAlign: 'left', cursor: 'pointer', padding: 0, overflow: 'hidden', borderRadius: 14, border: '1.5px solid ' + (on ? 'var(--blue-400)' : 'var(--line)'), background: '#fff', boxShadow: on ? '0 0 0 4px rgba(31,81,50,.1)' : 'none' }}>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<ElevationThumb accent={e.accent} floors={e.floors} /> <ElevationThumb accent={e.accent} floors={e.floors} />
{fits && <span style={{ position: 'absolute', top: 10, left: 10, fontSize: 10.5, fontWeight: 700, padding: '3px 8px', borderRadius: 99, background: 'var(--emerald-500)', color: '#fff' }}>Recommended</span>} {fits && <span style={{ position: 'absolute', top: 10, left: 10, fontSize: 10.5, fontWeight: 700, padding: '3px 8px', borderRadius: 99, background: 'var(--emerald-500)', color: '#fff' }}>Recommended</span>}
@@ -171,7 +171,7 @@ const D = YEIDA;
<div><div style={{ fontWeight: 700, fontSize: 16 }}>{plan.name}</div><div className="faint" style={{ fontSize: 12.5 }}>{plan.style} · {plan.size} · {plan.facing} facing</div></div> <div><div style={{ fontWeight: 700, fontSize: 16 }}>{plan.name}</div><div className="faint" style={{ fontSize: 12.5 }}>{plan.style} · {plan.size} · {plan.facing} facing</div></div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button> <button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
</div> </div>
<div style={{ position: 'relative', height: 380, background: 'radial-gradient(120% 120% at 50% 0%,#16407e,#081a39)', display: 'grid', placeItems: 'center' }}> <div style={{ position: 'relative', height: 380, background: 'radial-gradient(120% 120% at 50% 0%,#266b41,#0f2417)', display: 'grid', placeItems: 'center' }}>
<div style={{ position: 'absolute', inset: 0, opacity: .3, backgroundImage: 'linear-gradient(rgba(255,255,255,.06) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.06) 1px,transparent 1px)', backgroundSize: '40px 40px' }} /> <div style={{ position: 'absolute', inset: 0, opacity: .3, backgroundImage: 'linear-gradient(rgba(255,255,255,.06) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.06) 1px,transparent 1px)', backgroundSize: '40px 40px' }} />
<div style={{ textAlign: 'center', color: '#fff', position: 'relative' }}> <div style={{ textAlign: 'center', color: '#fff', position: 'relative' }}>
<div style={{ width: 80, height: 80, borderRadius: 99, margin: '0 auto 16px', display: 'grid', placeItems: 'center', background: 'rgba(255,255,255,.12)', border: '1px solid rgba(255,255,255,.25)', cursor: 'pointer' }}><Icon name="arrowR" size={36} /></div> <div style={{ width: 80, height: 80, borderRadius: 99, margin: '0 auto 16px', display: 'grid', placeItems: 'center', background: 'rgba(255,255,255,.12)', border: '1px solid rgba(255,255,255,.25)', cursor: 'pointer' }}><Icon name="arrowR" size={36} /></div>
+1 -1
View File
@@ -45,7 +45,7 @@ const D = YEIDA;
{/* ---- Sidebar ---- */} {/* ---- Sidebar ---- */}
<aside style={{ <aside style={{
width: 'var(--sidebar-w)', flex: 'none', position: 'sticky', top: 0, height: '100vh', width: 'var(--sidebar-w)', flex: 'none', position: 'sticky', top: 0, height: '100vh',
background: 'linear-gradient(185deg,#0b2548,#081a39)', display: 'flex', flexDirection: 'column', background: 'linear-gradient(185deg,#15331f,#0f2417)', display: 'flex', flexDirection: 'column',
padding: '20px 14px', borderRight: '1px solid rgba(255,255,255,.06)', padding: '20px 14px', borderRight: '1px solid rgba(255,255,255,.06)',
}}> }}>
<div style={{ padding: '4px 8px 18px' }}><Logo light size={36} /></div> <div style={{ padding: '4px 8px 18px' }}><Logo light size={36} /></div>
+1 -1
View File
@@ -71,7 +71,7 @@ const D = YEIDA, fmt = D.fmtINR;
))} ))}
</div> </div>
</div> </div>
<div className="card card-pad" style={{ background: 'linear-gradient(170deg,#0b2548,#081a39)', color: '#fff', alignSelf: 'start' }}> <div className="card card-pad" style={{ background: 'linear-gradient(170deg,#15331f,#0f2417)', color: '#fff', alignSelf: 'start' }}>
<span style={{ width: 56, height: 56, borderRadius: 14, display: 'grid', placeItems: 'center', background: 'var(--emerald-500)', color: '#fff', marginBottom: 16 }}><Icon name="check" size={28} /></span> <span style={{ width: 56, height: 56, borderRadius: 14, display: 'grid', placeItems: 'center', background: 'var(--emerald-500)', color: '#fff', marginBottom: 16 }}><Icon name="check" size={28} /></span>
<h3 style={{ color: '#fff', fontSize: 20 }}>You're eligible to transfer</h3> <h3 style={{ color: '#fff', fontSize: 20 }}>You're eligible to transfer</h3>
<p style={{ fontSize: 13.5, color: 'rgba(200,216,240,.8)', marginTop: 8, lineHeight: 1.55 }}>Your plot meets the core eligibility criteria. Clear pending dues to proceed to fee payment and application.</p> <p style={{ fontSize: 13.5, color: 'rgba(200,216,240,.8)', marginTop: 8, lineHeight: 1.55 }}>Your plot meets the core eligibility criteria. Clear pending dues to proceed to fee payment and application.</p>
+1 -1
View File
@@ -38,7 +38,7 @@ import { useLang } from '../i18n';
} }
/* Line chart with gradient fill + animated draw */ /* Line chart with gradient fill + animated draw */
function LineChart({ data, w = 520, h = 150, color = 'var(--blue-500)', fill = 'rgba(38,112,230,.12)', pad = 8 }) { function LineChart({ data, w = 520, h = 150, color = 'var(--blue-500)', fill = 'rgba(31,81,50,.12)', pad = 8 }) {
const max = Math.max(...data) * 1.12, min = Math.min(...data) * 0.85; const max = Math.max(...data) * 1.12, min = Math.min(...data) * 0.85;
const range = max - min || 1; const range = max - min || 1;
const stepX = (w - pad * 2) / (data.length - 1); const stepX = (w - pad * 2) / (data.length - 1);