From df2a2b941287aee681f4defa55a49911f5e52023 Mon Sep 17 00:00:00 2001 From: ectasy663 Date: Tue, 23 Sep 2025 20:45:29 +0530 Subject: [PATCH] feat(api): add typed API client, auth context, and integrate registration/login flows; docs: add backend integration guide and .env.example --- .env.example | 2 + README.md | 38 +++++++++ src/App.tsx | 7 +- src/components/LiveCounter.tsx | 8 +- src/components/LoginPage.tsx | 54 ++++++++++-- src/components/WaitlistForm.tsx | 92 ++++++++++++++++++-- src/config.ts | 3 + src/context/AuthContext.tsx | 73 ++++++++++++++++ src/index.tsx | 5 +- src/lib/api/auth.ts | 41 +++++++++ src/lib/api/registration.ts | 10 +++ src/lib/api/system.ts | 7 ++ src/lib/api/users.ts | 20 +++++ src/lib/http.ts | 73 ++++++++++++++++ src/types/api.ts | 143 ++++++++++++++++++++++++++++++++ 15 files changed, 556 insertions(+), 20 deletions(-) create mode 100644 .env.example create mode 100644 src/config.ts create mode 100644 src/context/AuthContext.tsx create mode 100644 src/lib/api/auth.ts create mode 100644 src/lib/api/registration.ts create mode 100644 src/lib/api/system.ts create mode 100644 src/lib/api/users.ts create mode 100644 src/lib/http.ts create mode 100644 src/types/api.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4de672b --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# API base URL (override default production URL if needed) +# VITE_API_BASE_URL=https://api.pennyzo.com/api/v1/general diff --git a/README.md b/README.md index 7842b89..b683ffb 100644 --- a/README.md +++ b/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. diff --git a/src/App.tsx b/src/App.tsx index bb7904f..2b34399 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 */}
-
- +
+ setShowLogin(true)} />
@@ -44,6 +46,7 @@ function App() { isVisible={showWaitlistForm} onClose={closeWaitlistForm} /> + setShowLogin(false)} />
); } diff --git a/src/components/LiveCounter.tsx b/src/components/LiveCounter.tsx index 4331c6b..a9989c8 100644 --- a/src/components/LiveCounter.tsx +++ b/src/components/LiveCounter.tsx @@ -2,9 +2,10 @@ import React, { useState, useEffect } from 'react'; interface LiveCounterProps { onJoinWaitlist: () => void; + onOpenLogin?: () => void; } -const LiveCounter: React.FC = ({ onJoinWaitlist }) => { +const LiveCounter: React.FC = ({ onJoinWaitlist, onOpenLogin }) => { const [remainingSpots, setRemainingSpots] = useState(892); const [isLive, setIsLive] = useState(true); @@ -44,7 +45,10 @@ const LiveCounter: React.FC = ({ onJoinWaitlist }) => { diff --git a/src/components/WaitlistForm.tsx b/src/components/WaitlistForm.tsx index a6194dd..ca8acb8 100644 --- a/src/components/WaitlistForm.tsx +++ b/src/components/WaitlistForm.tsx @@ -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 = ({ isVisible, onClose }) => { firstName: '', lastName: '', email: '', + phoneNumber: '', companyName: '', companyType: '', gstRegistered: '', @@ -33,6 +36,8 @@ const WaitlistForm: React.FC = ({ isVisible, onClose }) => { }); const [isLoading, setIsLoading] = useState(false); const [errors, setErrors] = useState>({}); + const [serverError, setServerError] = useState(null); + const [serverInfo, setServerInfo] = useState(null); const companyTypes = [ 'Pvt Ltd', @@ -104,6 +109,12 @@ const WaitlistForm: React.FC = ({ 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 = ({ 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 = { + '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 = ({ isVisible, onClose }) => { {/* Form */}
+ {serverError && ( +
+ {serverError} +
+ )} + {serverInfo && ( +
+ {serverInfo} +
+ )} {/* Personal Information Section */}

@@ -250,6 +308,24 @@ const WaitlistForm: React.FC = ({ isVisible, onClose }) => {

{errors.email}

)}

+ + {/* Phone Number */} +
+ + 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 && ( +

{errors.phoneNumber}

+ )} +
{/* Company Information Section */} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..36fff69 --- /dev/null +++ b/src/config.ts @@ -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'; diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx new file mode 100644 index 0000000..bad1250 --- /dev/null +++ b/src/context/AuthContext.tsx @@ -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; + logout: () => void; + refreshUser: () => Promise; +}; + +const AuthContext = createContext(undefined); + +const TOKEN_KEY = 'pennyzo_token'; + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY)); + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(!!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 {children}; +}; + +export const useAuth = () => { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +}; diff --git a/src/index.tsx b/src/index.tsx index 8edcba1..6a434ac 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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( - + + + ); \ No newline at end of file diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts new file mode 100644 index 0000000..7c048a8 --- /dev/null +++ b/src/lib/api/auth.ts @@ -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('/auth/verify-email', payload), + + resendOtp: (payload: ResendOtpRequest) => + http.post('/auth/resend-otp', payload), + + login: (username: string, password: string) => + http.post( + '/auth/login', + { username, password }, + { contentType: 'form' } + ), + + me: () => http.get('/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 }), +}; diff --git a/src/lib/api/registration.ts b/src/lib/api/registration.ts new file mode 100644 index 0000000..6d5ba68 --- /dev/null +++ b/src/lib/api/registration.ts @@ -0,0 +1,10 @@ +import { http } from '../http'; +import type { RegisterRequest, RegisterResponse } from '../../types/api'; + +export const registrationApi = { + register: (payload: RegisterRequest) => + http.post('/registration/register', payload), + + companyIdentityRules: () => http.get('/registration/company/identity-rules'), + health: () => http.get('/registration/health'), +}; diff --git a/src/lib/api/system.ts b/src/lib/api/system.ts new file mode 100644 index 0000000..4989c6a --- /dev/null +++ b/src/lib/api/system.ts @@ -0,0 +1,7 @@ +import { http } from '../http'; +import type { HealthResponse } from '../../types/api'; + +export const systemApi = { + health: () => http.get('/system/health'), + root: () => http.get('/system/'), +}; diff --git a/src/lib/api/users.ts b/src/lib/api/users.ts new file mode 100644 index 0000000..7638cf5 --- /dev/null +++ b/src/lib/api/users.ts @@ -0,0 +1,20 @@ +import { http } from '../http'; +import type { + UpdateCompanyRequest, + UpdateGstRegistrationRequest, + UpdateUserProfileRequest, + UpgradeMembershipRequest, + UserProfile, +} from '../../types/api'; + +export const usersApi = { + profile: () => http.get('/users/profile'), + updateProfile: (payload: UpdateUserProfileRequest) => http.put('/users/profile', payload), + + updateCompany: (payload: UpdateCompanyRequest) => http.put('/users/company', payload), + updateGst: (payload: UpdateGstRegistrationRequest) => http.put('/users/gst-registration', payload), + upgradeMembership: (payload: UpgradeMembershipRequest) => http.post('/users/upgrade-membership', payload), + + stats: () => http.get('/users/stats'), + membershipInfo: () => http.get('/users/membership-info'), +}; diff --git a/src/lib/http.ts b/src/lib/http.ts new file mode 100644 index 0000000..e4031f4 --- /dev/null +++ b/src/lib/http.ts @@ -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( + path: string, + options: { + method?: HttpMethod; + body?: any; + headers?: Record; + contentType?: 'json' | 'form' | 'none'; + signal?: AbortSignal; + } = {} +): Promise { + const { method = 'GET', body, headers = {}, contentType = 'json', signal } = options; + + const finalHeaders: Record = { ...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: (path: string, options: { headers?: Record; signal?: AbortSignal } = {}) => + request(path, { method: 'GET', ...options }), + post: ( + path: string, + body?: any, + options: { headers?: Record; contentType?: 'json' | 'form' | 'none'; signal?: AbortSignal } = {} + ) => request(path, { method: 'POST', body, ...options }), + put: (path: string, body?: any, options: { headers?: Record; signal?: AbortSignal } = {}) => + request(path, { method: 'PUT', body, ...options }), + patch: (path: string, body?: any, options: { headers?: Record; signal?: AbortSignal } = {}) => + request(path, { method: 'PATCH', body, ...options }), + delete: (path: string, options: { headers?: Record; signal?: AbortSignal } = {}) => + request(path, { method: 'DELETE', ...options }), +}; diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..a41f1cd --- /dev/null +++ b/src/types/api.ts @@ -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 { + 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; +}