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 build` - Builds the app for production
|
||||
- `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 LiveCounter from './components/LiveCounter';
|
||||
import WaitlistForm from './components/WaitlistForm';
|
||||
import LoginPage from './components/LoginPage';
|
||||
|
||||
function App() {
|
||||
const [showWaitlistForm, setShowWaitlistForm] = useState(false);
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
|
||||
const openWaitlistForm = () => setShowWaitlistForm(true);
|
||||
const closeWaitlistForm = () => setShowWaitlistForm(false);
|
||||
@@ -28,8 +30,8 @@ function App() {
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="relative z-10 min-h-screen">
|
||||
<Header />
|
||||
<LiveCounter onJoinWaitlist={openWaitlistForm} />
|
||||
<Header />
|
||||
<LiveCounter onJoinWaitlist={openWaitlistForm} onOpenLogin={() => setShowLogin(true)} />
|
||||
<main>
|
||||
<HeroSection onJoinWaitlist={openWaitlistForm} />
|
||||
<FeaturesSection />
|
||||
@@ -44,6 +46,7 @@ function App() {
|
||||
isVisible={showWaitlistForm}
|
||||
onClose={closeWaitlistForm}
|
||||
/>
|
||||
<LoginPage isVisible={showLogin} onClose={() => setShowLogin(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface LiveCounterProps {
|
||||
onJoinWaitlist: () => void;
|
||||
onOpenLogin?: () => void;
|
||||
}
|
||||
|
||||
const LiveCounter: React.FC<LiveCounterProps> = ({ onJoinWaitlist }) => {
|
||||
const LiveCounter: React.FC<LiveCounterProps> = ({ onJoinWaitlist, onOpenLogin }) => {
|
||||
const [remainingSpots, setRemainingSpots] = useState(892);
|
||||
const [isLive, setIsLive] = useState(true);
|
||||
|
||||
@@ -44,7 +45,10 @@ const LiveCounter: React.FC<LiveCounterProps> = ({ onJoinWaitlist }) => {
|
||||
</span>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<span className="text-luxury-gold font-elegant-sans text-xs font-semibold tracking-widest">
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { authApi } from '../lib/api/auth';
|
||||
|
||||
interface LoginPageProps {
|
||||
isVisible: boolean;
|
||||
@@ -11,17 +13,28 @@ const LoginPage: React.FC<LoginPageProps> = ({ isVisible, onClose }) => {
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [isLoading, setIsLoading] = 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) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setIsLoading(true);
|
||||
|
||||
// Simulate login process
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
setIsLoading(false);
|
||||
// Handle login logic here
|
||||
console.log('Login attempt:', { email, password, rememberMe });
|
||||
try {
|
||||
await login(email, password);
|
||||
if (rememberMe) {
|
||||
// token is already persisted by AuthContext
|
||||
}
|
||||
onClose();
|
||||
} 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;
|
||||
@@ -59,6 +72,16 @@ const LoginPage: React.FC<LoginPageProps> = ({ isVisible, onClose }) => {
|
||||
{/* Login Form */}
|
||||
<div className="p-8">
|
||||
<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 */}
|
||||
<div>
|
||||
<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
|
||||
type="button"
|
||||
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?
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { registrationApi } from '../lib/api/registration';
|
||||
|
||||
interface WaitlistFormProps {
|
||||
isVisible: boolean;
|
||||
@@ -9,6 +10,7 @@ interface FormData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
companyName: string;
|
||||
companyType: string;
|
||||
gstRegistered: string;
|
||||
@@ -23,6 +25,7 @@ const WaitlistForm: React.FC<WaitlistFormProps> = ({ isVisible, onClose }) => {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phoneNumber: '',
|
||||
companyName: '',
|
||||
companyType: '',
|
||||
gstRegistered: '',
|
||||
@@ -33,6 +36,8 @@ const WaitlistForm: React.FC<WaitlistFormProps> = ({ isVisible, onClose }) => {
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Partial<FormData>>({});
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
const [serverInfo, setServerInfo] = useState<string | null>(null);
|
||||
|
||||
const companyTypes = [
|
||||
'Pvt Ltd',
|
||||
@@ -105,6 +110,12 @@ const WaitlistForm: React.FC<WaitlistFormProps> = ({ isVisible, onClose }) => {
|
||||
if (!formData.companyType) newErrors.companyType = 'Company type 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
|
||||
if (formData.gstRegistered === 'yes' && !formData.gstNumber.trim()) {
|
||||
newErrors.gstNumber = 'GST Number is required when registered';
|
||||
@@ -133,14 +144,51 @@ const WaitlistForm: React.FC<WaitlistFormProps> = ({ isVisible, onClose }) => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setServerError(null);
|
||||
setServerInfo(null);
|
||||
|
||||
// Simulate form submission
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
// Map to API payload shape and business rules
|
||||
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);
|
||||
console.log('Waitlist form submitted:', formData);
|
||||
// Handle success - maybe show success message or close form
|
||||
onClose();
|
||||
const payload = {
|
||||
first_name: formData.firstName.trim(),
|
||||
last_name: formData.lastName.trim(),
|
||||
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;
|
||||
@@ -190,6 +238,16 @@ const WaitlistForm: React.FC<WaitlistFormProps> = ({ isVisible, onClose }) => {
|
||||
{/* Form */}
|
||||
<div className="p-8">
|
||||
<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 */}
|
||||
<div>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 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 './index.css';
|
||||
import App from './App';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
|
||||
const container = document.getElementById('root')!;
|
||||
const root = ReactDOM.createRoot(container);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</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