feat: auth provider, route guards, forgot password, API client

- Add AuthProvider context, RequireAuth guard
- Create API client with endpoint helpers
- Add forgot-password route wired to backend
- Remove dummy login button, add forgot-password link
- Dashboard: RequireAuth, auth context integration
- DashboardDesignPreview: replace Beeceptor with internal payments API
- DashboardDesignPreview: add coupon state, validation, apply
- DashboardDesignPreview: credit wallet on verify, ledger entry
- DashboardDesignPreview: adjust total with discount
- misc: getToken import, API constant
- fix: token storage (use getToken)
This commit is contained in:
Ashwin Kumar 2026-04-06 08:24:22 +02:00
parent e5406a0061
commit 3703d66df5

View file

@ -13,6 +13,7 @@ import {
UserCircle2, Users, UserCircle2, Users,
} from 'lucide-solid'; } from 'lucide-solid';
import { RuntimeKBConfig, RuntimeKBArticle } from '../../lib/runtime/types'; import { RuntimeKBConfig, RuntimeKBArticle } from '../../lib/runtime/types';
import { getToken } from '~/lib/auth';
function titleCase(value: string) { function titleCase(value: string) {
return String(value || '') return String(value || '')
@ -22,6 +23,7 @@ function titleCase(value: string) {
const ORANGE_ICON_FILTER = 'invert(51%) sepia(86%) saturate(2445%) hue-rotate(353deg) brightness(101%) contrast(103%)'; const ORANGE_ICON_FILTER = 'invert(51%) sepia(86%) saturate(2445%) hue-rotate(353deg) brightness(101%) contrast(103%)';
const BLUE_ICON_FILTER = 'invert(11%) sepia(85%) saturate(2462%) hue-rotate(233deg) brightness(91%) contrast(101%)'; const BLUE_ICON_FILTER = 'invert(11%) sepia(85%) saturate(2462%) hue-rotate(233deg) brightness(91%) contrast(101%)';
const API = '/api/gateway';
function StatusBadge(props: { status: 'ACTIVE' | 'INACTIVE' }) { function StatusBadge(props: { status: 'ACTIVE' | 'INACTIVE' }) {
const active = () => props.status === 'ACTIVE'; const active = () => props.status === 'ACTIVE';
@ -1195,6 +1197,11 @@ export default function DashboardDesignPreview(props: {
const [paymentStep, setPaymentStep] = createSignal<'idle' | 'processing' | 'verifying' | 'success' | 'error'>('idle'); const [paymentStep, setPaymentStep] = createSignal<'idle' | 'processing' | 'verifying' | 'success' | 'error'>('idle');
const [paymentRef, setPaymentRef] = createSignal<string | null>(null); const [paymentRef, setPaymentRef] = createSignal<string | null>(null);
const [paymentResult, setPaymentResult] = createSignal<any>(null); const [paymentResult, setPaymentResult] = createSignal<any>(null);
// Coupon state
const [couponCode, setCouponCode] = createSignal('');
const [appliedCoupon, setAppliedCoupon] = createSignal<{code: string; discount_type: string; discount_value: number; final_price_inr: number} | null>(null);
const [couponError, setCouponError] = createSignal('');
const [couponLoading, setCouponLoading] = createSignal(false);
const [creditManageView, setCreditManageView] = createSignal(false); const [creditManageView, setCreditManageView] = createSignal(false);
const [txRows, setTxRows] = createSignal<Array<[string, string, string, string, string, string]>>([ const [txRows, setTxRows] = createSignal<Array<[string, string, string, string, string, string]>>([
['#INV-2023-089', 'Enterprise Growth', '5,000', '₹1,20,000', 'Completed', 'Oct 24, 2023'], ['#INV-2023-089', 'Enterprise Growth', '5,000', '₹1,20,000', 'Completed', 'Oct 24, 2023'],
@ -1440,23 +1447,76 @@ export default function DashboardDesignPreview(props: {
})); }));
}); });
const BEEP = 'https://nxtgauge.free.beeceptor.com'; const applyCoupon = async () => {
const code = couponCode().trim();
if (!code) { setCouponError('Enter a coupon code'); return; }
setCouponLoading(true);
setCouponError('');
try {
const token = getToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
};
const res = await fetch(`${API}/coupons/validate`, {
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify({
coupon_code: code,
role_key: normalizeRoleKey(props.roleKey || ''),
package_price_inr: Number(checkoutPackage()?.price_paise || 0)
})
});
const data = await res.json();
if (res.ok && data.valid) {
setAppliedCoupon({
code,
discount_type: data.discount_type,
discount_value: data.discount_value,
final_price_inr: data.final_price_inr
});
} else {
setCouponError(data.message || 'Invalid coupon');
setAppliedCoupon(null);
}
} catch (e) {
setCouponError('Network error');
setAppliedCoupon(null);
} finally {
setCouponLoading(false);
}
};
const startPayment = async (pkg: any) => { const startPayment = async (pkg: any) => {
setPaymentStep('processing'); setPaymentStep('processing');
try { try {
const res = await fetch(`${BEEP}/payments/create-order`, { const token = getToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
};
const ac = appliedCoupon();
const body: any = {
package_id: pkg.id,
amount: ac ? ac.final_price_inr : pkg.price_paise,
};
if (ac) body.coupon_code = ac.code;
const res = await fetch(`${API}/payments/create-order`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers,
body: JSON.stringify({ package_id: pkg.id, amount: pkg.price_paise }), credentials: 'include',
body: JSON.stringify(body),
}); });
const data = await (res.ok ? res.json() : { order_id: `ORD-${Date.now()}` }); const data = await res.json().catch(() => ({}));
if (!res.ok) {
setPaymentStep('error');
setCouponError('Payment initiation failed');
return;
}
setPaymentRef(data.order_id || `ORD-${Date.now()}`); setPaymentRef(data.order_id || `ORD-${Date.now()}`);
// Simulate network latency for UX
setTimeout(() => verifyPayment(pkg), 1200); setTimeout(() => verifyPayment(pkg), 1200);
} catch (e) { } catch (e) {
// Fallback for mock environment
setPaymentRef(`ORD-MOCK-${Date.now()}`); setPaymentRef(`ORD-MOCK-${Date.now()}`);
setTimeout(() => verifyPayment(pkg), 1200); setTimeout(() => verifyPayment(pkg), 1200);
} }
@ -1465,25 +1525,36 @@ export default function DashboardDesignPreview(props: {
const verifyPayment = async (pkg: any) => { const verifyPayment = async (pkg: any) => {
setPaymentStep('verifying'); setPaymentStep('verifying');
try { try {
const res = await fetch(`${BEEP}/payments/verify`, { const token = getToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
};
const res = await fetch(`${API}/payments/verify`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers,
body: JSON.stringify({ order_id: paymentRef(), status: 'success' }), credentials: 'include',
body: JSON.stringify({ order_id: paymentRef(), payment_id: `PAY-${Date.now()}` }),
}); });
if (res.ok || true) { // Always succeed in mock mode if user requested if (res.ok) {
setPaymentStep('success'); setPaymentStep('success');
const creditsToAdd = Number(pkg.credits) + Number(pkg.bonus_credits || 0); const creditsToAdd = Number(pkg.credits) + Number(pkg.bonus_credits || 0);
setLeadCredits((prev) => prev + creditsToAdd); setLeadCredits((prev) => prev + creditsToAdd);
const ac = appliedCoupon();
const amountPaid = ac ? ac.final_price_inr : pkg.price_paise;
const newRow: [string, string, string, string, string, string] = [ const newRow: [string, string, string, string, string, string] = [
paymentRef() || `#INV-${Date.now()}`, paymentRef() || `#INV-${Date.now()}`,
pkg.display_name || pkg.name, pkg.display_name || pkg.name,
Number(creditsToAdd).toLocaleString(), Number(creditsToAdd).toLocaleString(),
`${(pkg.price_paise / 100).toLocaleString('en-IN')}`, `${(amountPaid / 100).toLocaleString('en-IN')}`,
'Completed', 'Completed',
new Date().toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }), new Date().toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }),
]; ];
setTxRows((prev) => [newRow, ...prev]); setTxRows((prev) => [newRow, ...prev]);
setAppliedCoupon(null);
setCouponCode('');
} else {
setPaymentStep('error');
} }
} catch (e) { } catch (e) {
setPaymentStep('error'); setPaymentStep('error');
@ -5294,22 +5365,57 @@ export default function DashboardDesignPreview(props: {
</div> </div>
</div> </div>
<div style="margin-top:16px;padding-top:16px;border-top:1px solid #F3F4F6"> <div style="margin-top:16px;padding-top:16px;border-top:1px solid #F3F4F6">
<div style="display:flex;justify-content:space-between;margin-bottom:8px"> <div style="display:flex;justify-content:space-between;margin-bottom:8px">
<span style="font-size:13px;color:#6B7280">Base Credits</span> <span style="font-size:13px;color:#6B7280">Base Credits</span>
<span style="font-size:13px;font-weight:700;color:#111827">{Number(pkg.credits).toLocaleString()}</span> <span style="font-size:13px;font-weight:700;color:#111827">{Number(pkg.credits).toLocaleString()}</span>
</div> </div>
<Show when={pkg.bonus_credits > 0}> <Show when={pkg.bonus_credits > 0}>
<div style="display:flex;justify-content:space-between;margin-bottom:8px"> <div style="display:flex;justify-content:space-between;margin-bottom:8px">
<span style="font-size:13px;color:#6B7280">Bonus Credits</span> <span style="font-size:13px;color:#6B7280">Bonus Credits</span>
<span style="font-size:13px;font-weight:700;color:#FF5E13">+{Number(pkg.bonus_credits).toLocaleString()}</span> <span style="font-size:13px;font-weight:700;color:#FF5E13">+{Number(pkg.bonus_credits).toLocaleString()}</span>
</div> </div>
</Show> </Show>
<div style="display:flex;justify-content:space-between;margin-top:12px;padding-top:12px;border-top:2px solid #F3F4F6">
<span style="font-size:15px;font-weight:800;color:#111827">Total Amount</span> {/* Coupon section */}
<span style="font-size:18px;font-weight:800;color:#FF5E13">{(Number(pkg.price_paise) / 100).toLocaleString('en-IN')}</span> <Show when={!appliedCoupon()}>
</div> <div style="margin-top:12px;padding:10px;border:1px dashed #D1D5DB;border-radius:10px;background:#F9FAFB">
</div> <p style="margin:0 0 6px;font-size:11px;font-weight:700;color:#374151">Coupon Code</p>
</div> <div style="display:flex;gap:6px">
<input
type="text"
placeholder="Enter code"
value={couponCode()}
onInput={(e) => setCouponCode(e.currentTarget.value)}
style="flex:1;height:32px;border:1px solid #E5E7EB;border-radius:6px;padding:0 8px;font-size:12px"
disabled={couponLoading()}
/>
<button
type="button"
onClick={() => void applyCoupon()}
disabled={couponLoading()}
style="height:32px;border:none;border-radius:6px;background:#03004E;color:white;padding:0 12px;font-size:12px;font-weight:700;cursor:pointer;opacity:${couponLoading() ? 0.6 : 1}"
>
{couponLoading() ? 'Applying...' : 'Apply'}
</button>
</div>
<Show when={couponError()}>
<p style="margin:4px 0 0;font-size:11px;color:#DC2626">{couponError()}</p>
</Show>
</div>
</Show>
<Show when={appliedCoupon()}>
<div style="margin-top:8px;display:flex;justify-content:space-between;font-size:13px;color:#15803D">
<span>Coupon ({appliedCoupon()!.code})</span>
<span>-{appliedCoupon()!.discount_type === 'PERCENT' ? `${appliedCoupon()!.discount_value}%` : `${appliedCoupon()!.discount_value}`}</span>
</div>
</Show>
<div style="display:flex;justify-content:space-between;margin-top:12px;padding-top:12px;border-top:2px solid #F3F4F6">
<span style="font-size:15px;font-weight:800;color:#111827">Total Amount</span>
<span style="font-size:18px;font-weight:800;color:#FF5E13">{((appliedCoupon() ? appliedCoupon().final_price_inr : Number(pkg.price_paise)) / 100).toLocaleString('en-IN')}</span>
</div>
</div>
</div>
<div style="border:1px solid #D9D8F9;background:#F5F5FF;border-radius:16px;padding:20px;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;box-shadow:0 1px 4px rgba(0,0,0,0.06)"> <div style="border:1px solid #D9D8F9;background:#F5F5FF;border-radius:16px;padding:20px;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;box-shadow:0 1px 4px rgba(0,0,0,0.06)">
<Show when={step === 'idle'}> <Show when={step === 'idle'}>