Files
pwap/web/src/pages/Identity.tsx
T
pezkuwichain e39a1f192a feat: add mobile layout with native app UX, identity page with ID card & passport
Mobile users (<768px) now see a native app-style home page with:
- Green gradient header with avatar, greeting, language/wallet/notification
- Horizontal scrollable score cards (auth-aware: login prompt for guests)
- App grid sections (Finance, Governance, Social, Education) with 4-col layout
- Bottom tab bar (Home / Citizen / Referral)
- MobileShell wrapper for consistent mobile navigation across pages

BeCitizen page redesigned for mobile with full-viewport hero screen,
scroll-to-reveal content, and compact benefits/process cards.

New Identity page (/identity) with realistic Kurdistan Republic ID card
and passport design. Users can fill personal info, upload photo from
camera/gallery, and save to device (localStorage only).

Desktop layout completely untouched. i18n keys added for all 6 languages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:39:21 +03:00

484 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '@/hooks/use-mobile';
import MobileShell from '@/components/MobileShell';
import { useAuth } from '@/contexts/AuthContext';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
import { Save, CreditCard, BookOpen, Camera } from 'lucide-react';
// ── Types ──
interface IdentityData {
fullName: string;
fatherName: string;
motherName: string;
dateOfBirth: string;
placeOfBirth: string;
gender: 'M' | 'F' | '';
bloodType: string;
citizenNumber: string;
passportNumber: string;
photo: string; // base64 data URL
}
const DEFAULT_DATA: IdentityData = {
fullName: '',
fatherName: '',
motherName: '',
dateOfBirth: '',
placeOfBirth: '',
gender: '',
bloodType: '',
citizenNumber: '',
passportNumber: '',
photo: '',
};
const STORAGE_KEY = 'pezkuwi_identity_data';
// ── Helpers ──
function generatePassportNo(citizenNo: string): string {
if (!citizenNo) return 'KRD-000000';
return `KRD-${citizenNo.replace(/\D/g, '').slice(0, 6).padStart(6, '0')}`;
}
function formatMRZ(data: IdentityData): [string, string] {
const name = data.fullName.toUpperCase().replace(/[^A-Z ]/g, '').replace(/ /g, '<') || 'SURNAME<<NAME';
const dob = data.dateOfBirth.replace(/-/g, '').slice(2) || '000000';
const gender = data.gender || '<';
const pno = data.passportNumber.replace(/[^A-Z0-9]/g, '').padEnd(9, '<');
const line1 = `P<KRD${name}${'<'.repeat(Math.max(0, 44 - 5 - name.length))}`.slice(0, 44);
const line2 = `${pno}0KRD${dob}0${gender}${'<'.repeat(14)}00`.slice(0, 44);
return [line1, line2];
}
const today = new Date();
const issueDate = today.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
const expiryDate = new Date(today.getFullYear() + 10, today.getMonth(), today.getDate())
.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
// Avatar from profile
const AVATAR_POOL: Record<string, string> = {
avatar1: '👨🏻', avatar2: '👨🏼', avatar3: '👨🏽', avatar4: '👨🏾',
avatar5: '👩🏻', avatar6: '👩🏼', avatar7: '👩🏽', avatar8: '👩🏾',
avatar9: '🧔🏻', avatar10: '🧔🏼', avatar11: '🧔🏽', avatar12: '🧔🏾',
avatar13: '👳🏻‍♂️', avatar14: '👳🏼‍♂️', avatar15: '👳🏽‍♂️', avatar16: '🧕🏻',
avatar17: '🧕🏼', avatar18: '🧕🏽',
};
// ── Main Component ──
export default function Identity() {
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useIsMobile();
const { user } = useAuth();
const { selectedAccount } = usePezkuwi();
const [tab, setTab] = useState<'id' | 'passport'>('id');
const [data, setData] = useState<IdentityData>(DEFAULT_DATA);
const [saved, setSaved] = useState(false);
// Load from localStorage
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) setData(JSON.parse(raw));
} catch { /* ignore */ }
}, []);
// Auto-fill citizen number from wallet
useEffect(() => {
if (selectedAccount && !data.citizenNumber) {
const short = selectedAccount.address.slice(-8).toUpperCase();
setData(prev => ({
...prev,
citizenNumber: short,
passportNumber: generatePassportNo(short),
}));
}
}, [selectedAccount, data.citizenNumber]);
const photoInputRef = React.useRef<HTMLInputElement>(null);
const handlePhotoSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Resize to max 300px and compress
const reader = new FileReader();
reader.onload = () => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const maxSize = 300;
let w = img.width, h = img.height;
if (w > h) { h = (h / w) * maxSize; w = maxSize; }
else { w = (w / h) * maxSize; h = maxSize; }
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0, w, h);
const dataUrl = canvas.toDataURL('image/jpeg', 0.8);
setData(prev => ({ ...prev, photo: dataUrl }));
setSaved(false);
};
img.src = reader.result as string;
};
reader.readAsDataURL(file);
};
const handleChange = (field: keyof IdentityData, value: string) => {
setData(prev => {
const next = { ...prev, [field]: value };
if (field === 'citizenNumber') {
next.passportNumber = generatePassportNo(value);
}
return next;
});
setSaved(false);
};
const handleSave = () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
const [mrzLine1, mrzLine2] = formatMRZ(data);
const content = (
<div className="bg-gray-950 min-h-full">
{/* Tab switcher */}
<div className="flex bg-gray-900 border-b border-gray-800">
<button
onClick={() => setTab('id')}
className={`flex-1 py-3 text-sm font-semibold flex items-center justify-center gap-2 transition-colors
${tab === 'id' ? 'text-green-400 border-b-2 border-green-400 bg-gray-900' : 'text-gray-500'}`}
>
<CreditCard className="w-4 h-4" />
{t('identity.idCard', 'ID Card')}
</button>
<button
onClick={() => setTab('passport')}
className={`flex-1 py-3 text-sm font-semibold flex items-center justify-center gap-2 transition-colors
${tab === 'passport' ? 'text-green-400 border-b-2 border-green-400 bg-gray-900' : 'text-gray-500'}`}
>
<BookOpen className="w-4 h-4" />
{t('identity.passport', 'Passport')}
</button>
</div>
<div className="px-4 py-4 space-y-4">
{/* Hidden file input for photo */}
<input
ref={photoInputRef}
type="file"
accept="image/*"
capture="user"
className="hidden"
onChange={handlePhotoSelect}
/>
{/* ── CARD PREVIEW ── */}
{tab === 'id' ? (
<IDCardPreview data={data} onPhotoClick={() => photoInputRef.current?.click()} />
) : (
<PassportPreview data={data} mrzLine1={mrzLine1} mrzLine2={mrzLine2} onPhotoClick={() => photoInputRef.current?.click()} />
)}
{/* ── FORM ── */}
<div className="bg-gray-900/80 rounded-xl border border-gray-800 p-4 space-y-3">
<h3 className="text-sm font-bold text-white mb-2">
{t('identity.personalInfo', 'Personal Information')}
</h3>
<FormField label={t('identity.fullName', 'Full Name')} value={data.fullName}
onChange={v => handleChange('fullName', v)} placeholder="Azad Kurdistanî" />
<FormField label={t('identity.fatherName', "Father's Name")} value={data.fatherName}
onChange={v => handleChange('fatherName', v)} placeholder="Rêber" />
<FormField label={t('identity.motherName', "Mother's Name")} value={data.motherName}
onChange={v => handleChange('motherName', v)} placeholder="Jîn" />
<div className="grid grid-cols-2 gap-3">
<FormField label={t('identity.dateOfBirth', 'Date of Birth')} value={data.dateOfBirth}
onChange={v => handleChange('dateOfBirth', v)} type="date" />
<div>
<label className="text-[10px] text-gray-400 font-medium block mb-1">
{t('identity.gender', 'Gender')}
</label>
<select
value={data.gender}
onChange={e => handleChange('gender', e.target.value as 'M' | 'F' | '')}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white"
>
<option value=""></option>
<option value="M">{t('identity.male', 'Male')}</option>
<option value="F">{t('identity.female', 'Female')}</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField label={t('identity.placeOfBirth', 'Place of Birth')} value={data.placeOfBirth}
onChange={v => handleChange('placeOfBirth', v)} placeholder="Hewlêr" />
<div>
<label className="text-[10px] text-gray-400 font-medium block mb-1">
{t('identity.bloodType', 'Blood Type')}
</label>
<select
value={data.bloodType}
onChange={e => handleChange('bloodType', e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white"
>
<option value=""></option>
{['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-'].map(bt => (
<option key={bt} value={bt}>{bt}</option>
))}
</select>
</div>
</div>
<FormField label={t('identity.citizenNo', 'Citizen Number')} value={data.citizenNumber}
onChange={v => handleChange('citizenNumber', v)} placeholder="KRD-000000" />
{/* Save button */}
<button
onClick={handleSave}
className={`w-full py-3 rounded-xl font-bold text-sm flex items-center justify-center gap-2 transition-all active:scale-95
${saved
? 'bg-green-600 text-white'
: 'bg-gradient-to-r from-green-600 to-yellow-500 text-white shadow-lg'}`}
>
<Save className="w-4 h-4" />
{saved ? t('identity.saved', 'Saved!') : t('identity.save', 'Save to Device')}
</button>
<p className="text-[10px] text-gray-500 text-center">
{t('identity.localOnly', 'Data is stored only on your device. Never uploaded.')}
</p>
</div>
<div className="h-4" />
</div>
</div>
);
if (isMobile) {
return (
<MobileShell title={`🆔 ${t('identity.title', 'Identity')}`}>
{content}
</MobileShell>
);
}
// Desktop: simple centered layout
return (
<div className="min-h-screen bg-gray-950 text-white">
<div className="max-w-lg mx-auto py-8">
<button onClick={() => navigate('/')} className="mb-4 text-sm text-gray-400 hover:text-white">
{t('common.backToHome', 'Back to Home')}
</button>
{content}
</div>
</div>
);
}
// ── ID Card Preview ──
function IDCardPreview({ data, onPhotoClick }: { data: IdentityData; onPhotoClick: () => void }) {
return (
<div className="relative w-full aspect-[1.586/1] rounded-xl overflow-hidden shadow-2xl border-2 border-yellow-600/50"
style={{ background: 'linear-gradient(135deg, #f5f0e8 0%, #e8dcc8 50%, #f0e8d8 100%)' }}>
{/* Top stripe - Kurdistan flag colors */}
<div className="absolute top-0 left-0 right-0 flex h-2">
<div className="flex-1 bg-red-600" />
<div className="flex-1 bg-white" />
<div className="flex-1 bg-green-600" />
</div>
{/* Header */}
<div className="px-4 pt-4 pb-1 flex items-center justify-between">
<div>
<p className="text-[8px] font-bold text-gray-600 tracking-wider">KOMARA KURDISTANÊ</p>
<p className="text-[7px] text-gray-500">KURDISTAN REPUBLIC</p>
</div>
{/* Sun emblem */}
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-yellow-400 to-yellow-600 flex items-center justify-center shadow-md">
<span className="text-sm"></span>
</div>
<div className="text-right">
<p className="text-[8px] font-bold text-gray-600 tracking-wider">کۆماری کوردستان</p>
<p className="text-[7px] text-gray-500">NASNAMA / ID CARD</p>
</div>
</div>
{/* Divider */}
<div className="mx-4 h-px bg-gradient-to-r from-red-400 via-yellow-400 to-green-400" />
{/* Body */}
<div className="px-4 pt-2 flex gap-3">
{/* Photo area - clickable */}
<button onClick={onPhotoClick}
className="w-16 h-20 rounded-md bg-gray-200 border border-gray-300 flex items-center justify-center flex-shrink-0 overflow-hidden relative group">
{data.photo ? (
<img src={data.photo} alt="photo" className="w-full h-full object-cover" />
) : (
<span className="text-3xl">👤</span>
)}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 group-active:opacity-100 transition-opacity flex items-center justify-center">
<Camera className="w-4 h-4 text-white" />
</div>
</button>
{/* Info */}
<div className="flex-1 min-w-0 space-y-0.5">
<IDField label="Nav / Name" value={data.fullName || '—'} bold />
<IDField label="Navê bav / Father" value={data.fatherName || '—'} />
<IDField label="Navê dayik / Mother" value={data.motherName || '—'} />
<div className="flex gap-3">
<IDField label="Zayîn / DOB" value={data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-GB') : '—'} />
<IDField label="Zayî / Sex" value={data.gender || '—'} />
</div>
<IDField label="Cih / Place" value={data.placeOfBirth || '—'} />
</div>
</div>
{/* Bottom bar */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-r from-green-800 via-green-700 to-green-800 px-4 py-1.5 flex items-center justify-between">
<div>
<p className="text-[7px] text-green-200">JIM / NO</p>
<p className="text-[9px] font-mono font-bold text-white tracking-widest">
{data.citizenNumber || 'KRD-000000'}
</p>
</div>
<div className="text-center">
<p className="text-[7px] text-green-200">XWÎNê / Blood</p>
<p className="text-[9px] font-bold text-white">{data.bloodType || '—'}</p>
</div>
<div className="text-right">
<p className="text-[7px] text-green-200">DERBASDAR / Expiry</p>
<p className="text-[9px] font-mono text-white">{expiryDate}</p>
</div>
</div>
</div>
);
}
// ── Passport Preview ──
function PassportPreview({ data, mrzLine1, mrzLine2, onPhotoClick }: {
data: IdentityData; mrzLine1: string; mrzLine2: string; onPhotoClick: () => void;
}) {
return (
<div className="relative w-full aspect-[0.71/1] rounded-xl overflow-hidden shadow-2xl border-2 border-green-800/70"
style={{ background: 'linear-gradient(180deg, #1a472a 0%, #0d2818 100%)' }}>
{/* Top ornament */}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-red-500 via-yellow-400 to-green-500" />
{/* Header */}
<div className="text-center pt-4 pb-2">
<p className="text-[9px] font-bold text-yellow-400 tracking-[0.2em]">KOMARA KURDISTANÊ</p>
<p className="text-[8px] text-yellow-400/70 tracking-wider">KURDISTAN REPUBLIC</p>
{/* Emblem */}
<div className="w-14 h-14 mx-auto my-2 rounded-full bg-gradient-to-br from-yellow-300 to-yellow-600 flex items-center justify-center shadow-lg border-2 border-yellow-300/50">
<span className="text-2xl"></span>
</div>
<p className="text-[9px] font-bold text-yellow-400/80 tracking-[0.3em]">پاسپۆرت</p>
<p className="text-[8px] text-yellow-400/60 tracking-widest">PASSPORT</p>
</div>
{/* Divider */}
<div className="mx-6 h-px bg-yellow-400/30" />
{/* Data page */}
<div className="mx-4 mt-2 bg-white/95 rounded-lg p-3 space-y-1.5"
style={{ background: 'linear-gradient(135deg, #fefdf8 0%, #f5f0e0 100%)' }}>
<div className="flex gap-3">
{/* Photo - clickable */}
<button onClick={onPhotoClick}
className="w-14 h-18 rounded bg-gray-200 border border-gray-300 flex items-center justify-center flex-shrink-0 overflow-hidden relative group">
{data.photo ? (
<img src={data.photo} alt="photo" className="w-full h-full object-cover" />
) : (
<span className="text-2xl">👤</span>
)}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 group-active:opacity-100 transition-opacity flex items-center justify-center">
<Camera className="w-3 h-3 text-white" />
</div>
</button>
<div className="flex-1 space-y-0.5">
<PassField label="Type / Cure" value="P" />
<PassField label="Code / Kod" value="KRD" />
<PassField label="Passport No" value={data.passportNumber || 'KRD-000000'} mono />
</div>
</div>
<PassField label="Surname, Name / Nav û paşnav" value={data.fullName?.toUpperCase() || '—'} bold />
<div className="flex gap-3">
<PassField label="Nationality / Netewe" value="KURDISTANÎ" />
<PassField label="Sex / Zayî" value={data.gender || '—'} />
</div>
<div className="flex gap-3">
<PassField label="Date of Birth / Zayîn" value={data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-GB') : '—'} />
<PassField label="Place / Cih" value={data.placeOfBirth?.toUpperCase() || '—'} />
</div>
<div className="flex gap-3">
<PassField label="Issue / Dest pê" value={issueDate} />
<PassField label="Expiry / Dawî" value={expiryDate} />
</div>
</div>
{/* MRZ Zone */}
<div className="mx-4 mt-2 bg-white/90 rounded-b-lg px-2 py-1.5 font-mono">
<p className="text-[7px] text-gray-700 tracking-[0.15em] leading-relaxed break-all">{mrzLine1}</p>
<p className="text-[7px] text-gray-700 tracking-[0.15em] leading-relaxed break-all">{mrzLine2}</p>
</div>
</div>
);
}
// ── Shared sub-components ──
function IDField({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
return (
<div>
<p className="text-[6px] text-gray-500 leading-none">{label}</p>
<p className={`text-[9px] text-gray-900 leading-tight truncate ${bold ? 'font-bold' : 'font-medium'}`}>{value}</p>
</div>
);
}
function PassField({ label, value, bold, mono }: { label: string; value: string; bold?: boolean; mono?: boolean }) {
return (
<div className="flex-1 min-w-0">
<p className="text-[5px] text-gray-500 leading-none">{label}</p>
<p className={`text-[8px] text-gray-900 leading-tight truncate
${bold ? 'font-bold' : 'font-medium'}
${mono ? 'font-mono tracking-wider' : ''}`}>{value}</p>
</div>
);
}
function FormField({ label, value, onChange, placeholder, type = 'text' }: {
label: string; value: string; onChange: (v: string) => void; placeholder?: string; type?: string;
}) {
return (
<div>
<label className="text-[10px] text-gray-400 font-medium block mb-1">{label}</label>
<input
type={type}
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:border-green-500 focus:outline-none transition-colors"
/>
</div>
);
}