feat(api): add typed API client, auth context, and integrate registration/login flows; docs: add backend integration guide and .env.example
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# API base URL (override default production URL if needed)
|
||||||
|
# VITE_API_BASE_URL=https://api.pennyzo.com/api/v1/general
|
||||||
38
README.md
38
README.md
@@ -55,3 +55,41 @@ src/
|
|||||||
- `npm run dev` - Runs the app in development mode with Vite
|
- `npm run dev` - Runs the app in development mode with Vite
|
||||||
- `npm run build` - Builds the app for production
|
- `npm run build` - Builds the app for production
|
||||||
- `npm run preview` - Preview the production build locally
|
- `npm run preview` - Preview the production build locally
|
||||||
|
|
||||||
|
## Backend Integration Guide
|
||||||
|
|
||||||
|
Base URL: `https://api.pennyzo.com/api/v1/general` (configurable via `VITE_API_BASE_URL`)
|
||||||
|
|
||||||
|
- HTTP client: `src/lib/http.ts` (handles JSON, form, and auth header)
|
||||||
|
- Auth state: `src/context/AuthContext.tsx` (stores JWT token, loads `auth/me`)
|
||||||
|
- Endpoints: `src/lib/api/*`
|
||||||
|
- `system.ts`: `/system/health`, `/system/`
|
||||||
|
- `auth.ts`: `/auth/*` (verify email/resend/login/me/forgot/reset/change)
|
||||||
|
- `registration.ts`: `/registration/*` (register + rules + health)
|
||||||
|
- `users.ts`: `/users/*` (profile/company/gst/upgrade/stats/membership-info)
|
||||||
|
|
||||||
|
### UI flows → endpoints
|
||||||
|
|
||||||
|
- Registration modal `WaitlistForm`: `POST /registration/register`
|
||||||
|
- Maps UI fields to API and enforces rules:
|
||||||
|
- GST Registered → send `gst_number`, `cin_number=null`, `pan_number=null`
|
||||||
|
- Non-GST PRIVATE_LIMITED/LLP → send `cin_number`, `pan_number=null`
|
||||||
|
- Non-GST PARTNERSHIP/PROPRIETORSHIP/SOLE_INDIVIDUAL/OTHERS → send `pan_number`, `cin_number=null`
|
||||||
|
- Login modal `LoginPage`: `POST /auth/login` as `application/x-www-form-urlencoded` with `username`, `password`; then `GET /auth/me`.
|
||||||
|
- Forgot password button calls `POST /auth/forgot-password` with the entered email.
|
||||||
|
|
||||||
|
### Types and config
|
||||||
|
|
||||||
|
- Types: `src/types/api.ts`
|
||||||
|
- Config: set `VITE_API_BASE_URL` in `.env` (see `.env.example`).
|
||||||
|
|
||||||
|
### Quick health check
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -X GET "https://api.pennyzo.com/api/v1/general/system/health"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
|
||||||
|
- Non-2xx responses are surfaced with `error.message` when provided by backend.
|
||||||
|
- Invalid token on `auth/me` clears stored token.
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import WhyPennyzoSection from './components/WhyPennyzoSection';
|
|||||||
import Footer from './components/Footer';
|
import Footer from './components/Footer';
|
||||||
import LiveCounter from './components/LiveCounter';
|
import LiveCounter from './components/LiveCounter';
|
||||||
import WaitlistForm from './components/WaitlistForm';
|
import WaitlistForm from './components/WaitlistForm';
|
||||||
|
import LoginPage from './components/LoginPage';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [showWaitlistForm, setShowWaitlistForm] = useState(false);
|
const [showWaitlistForm, setShowWaitlistForm] = useState(false);
|
||||||
|
const [showLogin, setShowLogin] = useState(false);
|
||||||
|
|
||||||
const openWaitlistForm = () => setShowWaitlistForm(true);
|
const openWaitlistForm = () => setShowWaitlistForm(true);
|
||||||
const closeWaitlistForm = () => setShowWaitlistForm(false);
|
const closeWaitlistForm = () => setShowWaitlistForm(false);
|
||||||
@@ -28,8 +30,8 @@ function App() {
|
|||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="relative z-10 min-h-screen">
|
<div className="relative z-10 min-h-screen">
|
||||||
<Header />
|
<Header />
|
||||||
<LiveCounter onJoinWaitlist={openWaitlistForm} />
|
<LiveCounter onJoinWaitlist={openWaitlistForm} onOpenLogin={() => setShowLogin(true)} />
|
||||||
<main>
|
<main>
|
||||||
<HeroSection onJoinWaitlist={openWaitlistForm} />
|
<HeroSection onJoinWaitlist={openWaitlistForm} />
|
||||||
<FeaturesSection />
|
<FeaturesSection />
|
||||||
@@ -44,6 +46,7 @@ function App() {
|
|||||||
isVisible={showWaitlistForm}
|
isVisible={showWaitlistForm}
|
||||||
onClose={closeWaitlistForm}
|
onClose={closeWaitlistForm}
|
||||||
/>
|
/>
|
||||||
|
<LoginPage isVisible={showLogin} onClose={() => setShowLogin(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import React, { useState, useEffect } from 'react';
|
|||||||
|
|
||||||
interface LiveCounterProps {
|
interface LiveCounterProps {
|
||||||
onJoinWaitlist: () => void;
|
onJoinWaitlist: () => void;
|
||||||
|
onOpenLogin?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LiveCounter: React.FC<LiveCounterProps> = ({ onJoinWaitlist }) => {
|
const LiveCounter: React.FC<LiveCounterProps> = ({ onJoinWaitlist, onOpenLogin }) => {
|
||||||
const [remainingSpots, setRemainingSpots] = useState(892);
|
const [remainingSpots, setRemainingSpots] = useState(892);
|
||||||
const [isLive, setIsLive] = useState(true);
|
const [isLive, setIsLive] = useState(true);
|
||||||
|
|
||||||
@@ -44,7 +45,10 @@ const LiveCounter: React.FC<LiveCounterProps> = ({ onJoinWaitlist }) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.open('https://google.com', '_blank', 'noopener,noreferrer')}
|
onClick={() => {
|
||||||
|
if (onOpenLogin) onOpenLogin();
|
||||||
|
else window.open('http://localhost:5173/login', '_blank', 'noopener,noreferrer');
|
||||||
|
}}
|
||||||
className="bg-luxury-gold/20 hover:bg-luxury-gold/30 px-3 py-1.5 rounded-lg transition-all duration-300"
|
className="bg-luxury-gold/20 hover:bg-luxury-gold/30 px-3 py-1.5 rounded-lg transition-all duration-300"
|
||||||
>
|
>
|
||||||
<span className="text-luxury-gold font-elegant-sans text-xs font-semibold tracking-widest">
|
<span className="text-luxury-gold font-elegant-sans text-xs font-semibold tracking-widest">
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { authApi } from '../lib/api/auth';
|
||||||
|
|
||||||
interface LoginPageProps {
|
interface LoginPageProps {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
@@ -11,17 +13,28 @@ const LoginPage: React.FC<LoginPageProps> = ({ isVisible, onClose }) => {
|
|||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [info, setInfo] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { login } = useAuth();
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setInfo(null);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
// Simulate login process
|
await login(email, password);
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
if (rememberMe) {
|
||||||
|
// token is already persisted by AuthContext
|
||||||
setIsLoading(false);
|
}
|
||||||
// Handle login logic here
|
onClose();
|
||||||
console.log('Login attempt:', { email, password, rememberMe });
|
} catch (e: any) {
|
||||||
|
const msg = e?.data?.error?.message || 'Unable to sign in. Check your credentials.';
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isVisible) return null;
|
if (!isVisible) return null;
|
||||||
@@ -59,6 +72,16 @@ const LoginPage: React.FC<LoginPageProps> = ({ isVisible, onClose }) => {
|
|||||||
{/* Login Form */}
|
{/* Login Form */}
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500/40 text-red-200 text-sm rounded-lg px-4 py-3">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{info && (
|
||||||
|
<div className="bg-emerald-500/10 border border-emerald-500/40 text-emerald-200 text-sm rounded-lg px-4 py-3">
|
||||||
|
{info}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Email Field */}
|
{/* Email Field */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-cream/90 font-elegant-sans text-sm font-medium mb-2">
|
<label className="block text-cream/90 font-elegant-sans text-sm font-medium mb-2">
|
||||||
@@ -121,6 +144,23 @@ const LoginPage: React.FC<LoginPageProps> = ({ isVisible, onClose }) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-luxury-gold hover:text-luxury-gold/80 font-elegant-sans text-sm font-medium transition-colors duration-300"
|
className="text-luxury-gold hover:text-luxury-gold/80 font-elegant-sans text-sm font-medium transition-colors duration-300"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!email) {
|
||||||
|
setError('Enter your email to receive reset link/OTP.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setInfo(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const resp = await authApi.forgotPassword(email);
|
||||||
|
setInfo(resp.message || 'Password reset OTP sent.');
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.data?.error?.message || 'Unable to start password reset.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { registrationApi } from '../lib/api/registration';
|
||||||
|
|
||||||
interface WaitlistFormProps {
|
interface WaitlistFormProps {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
@@ -9,6 +10,7 @@ interface FormData {
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
phoneNumber: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
companyType: string;
|
companyType: string;
|
||||||
gstRegistered: string;
|
gstRegistered: string;
|
||||||
@@ -23,6 +25,7 @@ const WaitlistForm: React.FC<WaitlistFormProps> = ({ isVisible, onClose }) => {
|
|||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
email: '',
|
email: '',
|
||||||
|
phoneNumber: '',
|
||||||
companyName: '',
|
companyName: '',
|
||||||
companyType: '',
|
companyType: '',
|
||||||
gstRegistered: '',
|
gstRegistered: '',
|
||||||
@@ -33,6 +36,8 @@ const WaitlistForm: React.FC<WaitlistFormProps> = ({ isVisible, onClose }) => {
|
|||||||
});
|
});
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [errors, setErrors] = useState<Partial<FormData>>({});
|
const [errors, setErrors] = useState<Partial<FormData>>({});
|
||||||
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
const [serverInfo, setServerInfo] = useState<string | null>(null);
|
||||||
|
|
||||||
const companyTypes = [
|
const companyTypes = [
|
||||||
'Pvt Ltd',
|
'Pvt Ltd',
|
||||||
@@ -105,6 +110,12 @@ const WaitlistForm: React.FC<WaitlistFormProps> = ({ isVisible, onClose }) => {
|
|||||||
if (!formData.companyType) newErrors.companyType = 'Company type is required';
|
if (!formData.companyType) newErrors.companyType = 'Company type is required';
|
||||||
if (!formData.gstRegistered) newErrors.gstRegistered = 'GST registration status is required';
|
if (!formData.gstRegistered) newErrors.gstRegistered = 'GST registration status is required';
|
||||||
|
|
||||||
|
// Optional phone number validation if provided
|
||||||
|
if (formData.phoneNumber.trim()) {
|
||||||
|
const phoneOk = /^[0-9+\-()\s]{7,20}$/.test(formData.phoneNumber.trim());
|
||||||
|
if (!phoneOk) newErrors.phoneNumber = 'Please enter a valid phone number';
|
||||||
|
}
|
||||||
|
|
||||||
// GST Number validation when GST is registered
|
// GST Number validation when GST is registered
|
||||||
if (formData.gstRegistered === 'yes' && !formData.gstNumber.trim()) {
|
if (formData.gstRegistered === 'yes' && !formData.gstNumber.trim()) {
|
||||||
newErrors.gstNumber = 'GST Number is required when registered';
|
newErrors.gstNumber = 'GST Number is required when registered';
|
||||||
@@ -133,14 +144,51 @@ const WaitlistForm: React.FC<WaitlistFormProps> = ({ isVisible, onClose }) => {
|
|||||||
if (!validateForm()) return;
|
if (!validateForm()) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
setServerError(null);
|
||||||
|
setServerInfo(null);
|
||||||
|
|
||||||
// Simulate form submission
|
// Map to API payload shape and business rules
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
const companyTypeMap: Record<string, string> = {
|
||||||
|
'Pvt Ltd': 'PRIVATE_LIMITED',
|
||||||
|
'LLP': 'LLP',
|
||||||
|
'Partnership': 'PARTNERSHIP',
|
||||||
|
'Sole Proprietorship': 'PROPRIETORSHIP',
|
||||||
|
'One Person Company (OPC)': 'SOLE_INDIVIDUAL',
|
||||||
|
'Public Limited Company': 'PUBLIC_LIMITED',
|
||||||
|
'Other': 'OTHERS',
|
||||||
|
};
|
||||||
|
|
||||||
setIsLoading(false);
|
const payload = {
|
||||||
console.log('Waitlist form submitted:', formData);
|
first_name: formData.firstName.trim(),
|
||||||
// Handle success - maybe show success message or close form
|
last_name: formData.lastName.trim(),
|
||||||
onClose();
|
email: formData.email.trim(),
|
||||||
|
mobile: formData.phoneNumber.trim() || undefined,
|
||||||
|
company_name: formData.companyName.trim(),
|
||||||
|
company_type: companyTypeMap[formData.companyType] as any,
|
||||||
|
gst_registered: formData.gstRegistered === 'yes',
|
||||||
|
gst_number: formData.gstRegistered === 'yes' ? formData.gstNumber.trim() : null,
|
||||||
|
industry: formData.industry || undefined,
|
||||||
|
cin_number:
|
||||||
|
formData.gstRegistered === 'no' && ['Pvt Ltd', 'LLP'].includes(formData.companyType)
|
||||||
|
? formData.cin.trim()
|
||||||
|
: null,
|
||||||
|
pan_number:
|
||||||
|
formData.gstRegistered === 'no' && ['Partnership', 'Sole Proprietorship', 'One Person Company (OPC)', 'Other'].includes(formData.companyType)
|
||||||
|
? formData.panNumber.trim()
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await registrationApi.register(payload as any);
|
||||||
|
setServerInfo(res.message || 'Registration successful. Check your email for OTP.');
|
||||||
|
// Optionally close after a short delay
|
||||||
|
setTimeout(() => onClose(), 1200);
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.data?.error?.message || 'Registration failed. Please review your details.';
|
||||||
|
setServerError(msg);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isVisible) return null;
|
if (!isVisible) return null;
|
||||||
@@ -190,6 +238,16 @@ const WaitlistForm: React.FC<WaitlistFormProps> = ({ isVisible, onClose }) => {
|
|||||||
{/* Form */}
|
{/* Form */}
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{serverError && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500/40 text-red-200 text-sm rounded-lg px-4 py-3">
|
||||||
|
{serverError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{serverInfo && (
|
||||||
|
<div className="bg-emerald-500/10 border border-emerald-500/40 text-emerald-200 text-sm rounded-lg px-4 py-3">
|
||||||
|
{serverInfo}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Personal Information Section */}
|
{/* Personal Information Section */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-luxury-gold font-elegant-sans text-lg font-semibold mb-4 flex items-center">
|
<h3 className="text-luxury-gold font-elegant-sans text-lg font-semibold mb-4 flex items-center">
|
||||||
@@ -250,6 +308,24 @@ const WaitlistForm: React.FC<WaitlistFormProps> = ({ isVisible, onClose }) => {
|
|||||||
<p className="text-red-400 text-xs mt-1 font-elegant-sans">{errors.email}</p>
|
<p className="text-red-400 text-xs mt-1 font-elegant-sans">{errors.email}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Phone Number */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<label className="block text-cream/90 font-elegant-sans text-sm font-medium mb-2">
|
||||||
|
Phone Number
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={formData.phoneNumber}
|
||||||
|
onChange={(e) => handleInputChange('phoneNumber', e.target.value)}
|
||||||
|
className={`w-full bg-deep-forest/50 border ${errors.phoneNumber ? 'border-red-400' : 'border-luxury-gold/20'} rounded-lg px-4 py-3 text-cream placeholder-cream/40 focus:outline-none focus:ring-2 focus:ring-luxury-gold/50 focus:border-luxury-gold/50 transition-all duration-300 font-elegant-sans`}
|
||||||
|
placeholder="e.g., +91 98765 43210"
|
||||||
|
inputMode="tel"
|
||||||
|
/>
|
||||||
|
{errors.phoneNumber && (
|
||||||
|
<p className="text-red-400 text-xs mt-1 font-elegant-sans">{errors.phoneNumber}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Company Information Section */}
|
{/* Company Information Section */}
|
||||||
|
|||||||
3
src/config.ts
Normal file
3
src/config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const API_BASE_URL =
|
||||||
|
(import.meta as any)?.env?.VITE_API_BASE_URL ||
|
||||||
|
'https://api.pennyzo.com/api/v1/general';
|
||||||
73
src/context/AuthContext.tsx
Normal file
73
src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { authApi } from '../lib/api/auth';
|
||||||
|
import { setAuthTokenGetter } from '../lib/http';
|
||||||
|
import type { UserMeResponse } from '../types/api';
|
||||||
|
|
||||||
|
type AuthState = {
|
||||||
|
token: string | null;
|
||||||
|
user: UserMeResponse | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
refreshUser: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState | undefined>(undefined);
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'pennyzo_token';
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
|
||||||
|
const [user, setUser] = useState<UserMeResponse | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(!!token);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAuthTokenGetter(() => token);
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const refreshUser = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const me = await authApi.me();
|
||||||
|
setUser(me);
|
||||||
|
} catch (e) {
|
||||||
|
// Token might be invalid
|
||||||
|
setToken(null);
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) refreshUser();
|
||||||
|
}, [token, refreshUser]);
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
const res = await authApi.login(email, password);
|
||||||
|
const access = res.access_token;
|
||||||
|
setToken(access);
|
||||||
|
localStorage.setItem(TOKEN_KEY, access);
|
||||||
|
await refreshUser();
|
||||||
|
}, [refreshUser]);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
setToken(null);
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ token, user, isLoading, login, logout, refreshUser }),
|
||||||
|
[token, user, isLoading, login, logout, refreshUser]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
@@ -2,12 +2,15 @@ import React from 'react';
|
|||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import { AuthProvider } from './context/AuthContext';
|
||||||
|
|
||||||
const container = document.getElementById('root')!;
|
const container = document.getElementById('root')!;
|
||||||
const root = ReactDOM.createRoot(container);
|
const root = ReactDOM.createRoot(container);
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
41
src/lib/api/auth.ts
Normal file
41
src/lib/api/auth.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { http } from '../http';
|
||||||
|
import type {
|
||||||
|
VerifyEmailRequest,
|
||||||
|
VerifyEmailResponse,
|
||||||
|
ResendOtpRequest,
|
||||||
|
ResendOtpResponse,
|
||||||
|
LoginResponse,
|
||||||
|
UserMeResponse,
|
||||||
|
} from '../../types/api';
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
verifyEmail: (payload: VerifyEmailRequest) =>
|
||||||
|
http.post<VerifyEmailResponse>('/auth/verify-email', payload),
|
||||||
|
|
||||||
|
resendOtp: (payload: ResendOtpRequest) =>
|
||||||
|
http.post<ResendOtpResponse>('/auth/resend-otp', payload),
|
||||||
|
|
||||||
|
login: (username: string, password: string) =>
|
||||||
|
http.post<LoginResponse>(
|
||||||
|
'/auth/login',
|
||||||
|
{ username, password },
|
||||||
|
{ contentType: 'form' }
|
||||||
|
),
|
||||||
|
|
||||||
|
me: () => http.get<UserMeResponse>('/auth/me'),
|
||||||
|
|
||||||
|
forgotPassword: (email: string) =>
|
||||||
|
http.post<{ message: string; email: string; expires_in: number; next_step?: string }>(
|
||||||
|
'/auth/forgot-password',
|
||||||
|
{ email }
|
||||||
|
),
|
||||||
|
|
||||||
|
resetPassword: (email: string, otp_code: string, new_password: string) =>
|
||||||
|
http.post<{ message: string }>('/auth/reset-password', { email, otp_code, new_password }),
|
||||||
|
|
||||||
|
resetPasswordWithToken: (token: string, new_password: string) =>
|
||||||
|
http.post<{ message: string }>('/auth/reset-password-token', { token, new_password }),
|
||||||
|
|
||||||
|
changePassword: (current_password: string, new_password: string) =>
|
||||||
|
http.post<{ message: string }>('/auth/change-password', { current_password, new_password }),
|
||||||
|
};
|
||||||
10
src/lib/api/registration.ts
Normal file
10
src/lib/api/registration.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { http } from '../http';
|
||||||
|
import type { RegisterRequest, RegisterResponse } from '../../types/api';
|
||||||
|
|
||||||
|
export const registrationApi = {
|
||||||
|
register: (payload: RegisterRequest) =>
|
||||||
|
http.post<RegisterResponse>('/registration/register', payload),
|
||||||
|
|
||||||
|
companyIdentityRules: () => http.get<any>('/registration/company/identity-rules'),
|
||||||
|
health: () => http.get<any>('/registration/health'),
|
||||||
|
};
|
||||||
7
src/lib/api/system.ts
Normal file
7
src/lib/api/system.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { http } from '../http';
|
||||||
|
import type { HealthResponse } from '../../types/api';
|
||||||
|
|
||||||
|
export const systemApi = {
|
||||||
|
health: () => http.get<HealthResponse>('/system/health'),
|
||||||
|
root: () => http.get<any>('/system/'),
|
||||||
|
};
|
||||||
20
src/lib/api/users.ts
Normal file
20
src/lib/api/users.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { http } from '../http';
|
||||||
|
import type {
|
||||||
|
UpdateCompanyRequest,
|
||||||
|
UpdateGstRegistrationRequest,
|
||||||
|
UpdateUserProfileRequest,
|
||||||
|
UpgradeMembershipRequest,
|
||||||
|
UserProfile,
|
||||||
|
} from '../../types/api';
|
||||||
|
|
||||||
|
export const usersApi = {
|
||||||
|
profile: () => http.get<UserProfile>('/users/profile'),
|
||||||
|
updateProfile: (payload: UpdateUserProfileRequest) => http.put<UserProfile>('/users/profile', payload),
|
||||||
|
|
||||||
|
updateCompany: (payload: UpdateCompanyRequest) => http.put<UserProfile>('/users/company', payload),
|
||||||
|
updateGst: (payload: UpdateGstRegistrationRequest) => http.put<UserProfile>('/users/gst-registration', payload),
|
||||||
|
upgradeMembership: (payload: UpgradeMembershipRequest) => http.post<UserProfile>('/users/upgrade-membership', payload),
|
||||||
|
|
||||||
|
stats: () => http.get<any>('/users/stats'),
|
||||||
|
membershipInfo: () => http.get<any>('/users/membership-info'),
|
||||||
|
};
|
||||||
73
src/lib/http.ts
Normal file
73
src/lib/http.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { API_BASE_URL } from '../config';
|
||||||
|
|
||||||
|
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||||
|
|
||||||
|
let tokenGetter: (() => string | null) | null = null;
|
||||||
|
|
||||||
|
export const setAuthTokenGetter = (getter: () => string | null) => {
|
||||||
|
tokenGetter = getter;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface HttpError extends Error {
|
||||||
|
status: number;
|
||||||
|
data?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
options: {
|
||||||
|
method?: HttpMethod;
|
||||||
|
body?: any;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
contentType?: 'json' | 'form' | 'none';
|
||||||
|
signal?: AbortSignal;
|
||||||
|
} = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const { method = 'GET', body, headers = {}, contentType = 'json', signal } = options;
|
||||||
|
|
||||||
|
const finalHeaders: Record<string, string> = { ...headers };
|
||||||
|
const token = tokenGetter?.() || null;
|
||||||
|
if (token) finalHeaders['Authorization'] = `Bearer ${token}`;
|
||||||
|
if (contentType === 'json') finalHeaders['Content-Type'] = 'application/json';
|
||||||
|
if (contentType === 'form') finalHeaders['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||||
|
|
||||||
|
let payload: BodyInit | undefined;
|
||||||
|
if (body != null) {
|
||||||
|
if (contentType === 'json') payload = JSON.stringify(body);
|
||||||
|
else if (contentType === 'form') payload = new URLSearchParams(body).toString();
|
||||||
|
else payload = body as BodyInit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: finalHeaders,
|
||||||
|
body: payload,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isJson = res.headers.get('content-type')?.includes('application/json');
|
||||||
|
const data = isJson ? await res.json().catch(() => undefined) : await res.text();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err: HttpError = Object.assign(new Error('HTTP Error'), { status: res.status, data });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const http = {
|
||||||
|
get: <T>(path: string, options: { headers?: Record<string, string>; signal?: AbortSignal } = {}) =>
|
||||||
|
request<T>(path, { method: 'GET', ...options }),
|
||||||
|
post: <T>(
|
||||||
|
path: string,
|
||||||
|
body?: any,
|
||||||
|
options: { headers?: Record<string, string>; contentType?: 'json' | 'form' | 'none'; signal?: AbortSignal } = {}
|
||||||
|
) => request<T>(path, { method: 'POST', body, ...options }),
|
||||||
|
put: <T>(path: string, body?: any, options: { headers?: Record<string, string>; signal?: AbortSignal } = {}) =>
|
||||||
|
request<T>(path, { method: 'PUT', body, ...options }),
|
||||||
|
patch: <T>(path: string, body?: any, options: { headers?: Record<string, string>; signal?: AbortSignal } = {}) =>
|
||||||
|
request<T>(path, { method: 'PATCH', body, ...options }),
|
||||||
|
delete: <T>(path: string, options: { headers?: Record<string, string>; signal?: AbortSignal } = {}) =>
|
||||||
|
request<T>(path, { method: 'DELETE', ...options }),
|
||||||
|
};
|
||||||
143
src/types/api.ts
Normal file
143
src/types/api.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
export interface HealthResponse {
|
||||||
|
status: string;
|
||||||
|
service: string;
|
||||||
|
version: string;
|
||||||
|
environment: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
email: string;
|
||||||
|
mobile?: string;
|
||||||
|
company_name: string;
|
||||||
|
company_type:
|
||||||
|
| 'PRIVATE_LIMITED'
|
||||||
|
| 'LLP'
|
||||||
|
| 'PARTNERSHIP'
|
||||||
|
| 'PROPRIETORSHIP'
|
||||||
|
| 'SOLE_INDIVIDUAL'
|
||||||
|
| 'PUBLIC_LIMITED'
|
||||||
|
| 'OTHERS';
|
||||||
|
gst_registered: boolean;
|
||||||
|
gst_number?: string | null;
|
||||||
|
industry?: string | null;
|
||||||
|
cin_number?: string | null;
|
||||||
|
pan_number?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterResponse {
|
||||||
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
email: string;
|
||||||
|
mobile: string | null;
|
||||||
|
company_name: string;
|
||||||
|
company_type: RegisterRequest['company_type'];
|
||||||
|
gst_registered: boolean;
|
||||||
|
gst_number?: string | null;
|
||||||
|
industry?: string | null;
|
||||||
|
membership_type?: string | null;
|
||||||
|
waitlist_position?: number | null;
|
||||||
|
is_active: boolean;
|
||||||
|
email_verified: boolean;
|
||||||
|
created_at: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyEmailRequest {
|
||||||
|
email: string;
|
||||||
|
otp_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyEmailResponse {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResendOtpRequest { email: string }
|
||||||
|
export interface ResendOtpResponse { message: string; email: string; expires_in: number }
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: 'bearer';
|
||||||
|
expires_in?: number;
|
||||||
|
user?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserMeResponse {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
phone?: string | null;
|
||||||
|
role: string;
|
||||||
|
is_verified: boolean;
|
||||||
|
company_id?: string | null;
|
||||||
|
membership_type?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile extends Record<string, any> {
|
||||||
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
full_name?: string;
|
||||||
|
email: string;
|
||||||
|
email_verified: boolean;
|
||||||
|
mobile?: string | null;
|
||||||
|
mobile_verified?: boolean;
|
||||||
|
is_verified: boolean;
|
||||||
|
company_name: string;
|
||||||
|
company_type: RegisterRequest['company_type'];
|
||||||
|
industry?: string | null;
|
||||||
|
gst_registered: boolean;
|
||||||
|
gst_number?: string | null;
|
||||||
|
cin_number?: string | null;
|
||||||
|
pan_number?: string | null;
|
||||||
|
membership_type?: string | null;
|
||||||
|
is_paid?: boolean;
|
||||||
|
plan_type?: string | null;
|
||||||
|
plan_start?: string | null;
|
||||||
|
plan_end?: string | null;
|
||||||
|
waitlist_position?: number | null;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserProfileRequest {
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
mobile?: string;
|
||||||
|
industry?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCompanyRequest {
|
||||||
|
company_name?: string;
|
||||||
|
industry?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateGstRegistrationRequest {
|
||||||
|
gst_registered: boolean;
|
||||||
|
gst_number?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpgradeMembershipRequest {
|
||||||
|
plan_type: 'TRIAL' | 'SUBSCRIPTION' | 'ELITE' | 'ENTERPRISE' | string;
|
||||||
|
membership_type?: 'TRIAL' | 'NORMAL' | 'PAID' | 'ELITE' | string;
|
||||||
|
payment_method_id?: string;
|
||||||
|
billing_cycle?: 'monthly' | 'yearly' | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorSchema {
|
||||||
|
error: {
|
||||||
|
type: string;
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
details?: any;
|
||||||
|
documentation_url?: string;
|
||||||
|
};
|
||||||
|
meta?: any;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user