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:
2025-09-23 20:45:29 +05:30
parent 9575db91e5
commit df2a2b9412
15 changed files with 556 additions and 20 deletions

View File

@@ -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>
);
}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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',
@@ -104,6 +109,12 @@ const WaitlistForm: React.FC<WaitlistFormProps> = ({ isVisible, onClose }) => {
if (!formData.companyName.trim()) newErrors.companyName = 'Company name is required';
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()) {
@@ -133,14 +144,51 @@ const WaitlistForm: React.FC<WaitlistFormProps> = ({ isVisible, onClose }) => {
if (!validateForm()) return;
setIsLoading(true);
// Simulate form submission
await new Promise(resolve => setTimeout(resolve, 2000));
setIsLoading(false);
console.log('Waitlist form submitted:', formData);
// Handle success - maybe show success message or close form
onClose();
setServerError(null);
setServerInfo(null);
// 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',
};
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
View 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';

View 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;
};

View File

@@ -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
View 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 }),
};

View 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
View 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
View 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
View 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
View 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;
}