Compare commits

...

8 commits

9 changed files with 700 additions and 131 deletions

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
# Logs
logs
html
*.log
npm-debug.log*
yarn-debug.log*

62
cypress/e2e/api.cy.ts Normal file
View file

@ -0,0 +1,62 @@
describe('Seller Central Django Backend API E2E Tests', () => {
const backendUrl = 'http://localhost:8000';
it('verifies product list', () => {
cy.request(`${backendUrl}/api/products/`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body).to.be.an('array');
});
});
it('creates and deletes a product', () => {
const uniqueSku = `SKU-CY-${Date.now()}`;
cy.request('POST', `${backendUrl}/api/products/`, {
title: 'Cypress Test Silk Scarf',
category: 'Apparel',
price: '55.00',
stock: 20,
sku: uniqueSku
}).then((response) => {
expect(response.status).to.eq(201);
expect(response.body.sku).to.eq(uniqueSku);
const prodId = response.body.id;
// Delete the created product
cy.request('DELETE', `${backendUrl}/api/products/${prodId}/`).then((delRes) => {
expect(delRes.status).to.eq(204); // django rest framework delete returns 204 No Content
});
});
});
it('verifies order list and acceptance', () => {
cy.request(`${backendUrl}/api/orders/`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body).to.be.an('array');
expect(response.body.length).to.be.greaterThan(0);
const pendingOrder = response.body.find((o: any) => o.status === 'Pending Acceptance');
if (pendingOrder) {
cy.request('POST', `${backendUrl}/api/orders/${pendingOrder.id}/accept/`).then((acceptRes) => {
expect(acceptRes.status).to.eq(200);
expect(acceptRes.body.status).to.eq('Ready to Ship');
});
}
});
});
it('verifies wallet summary and payout withdrawal', () => {
cy.request(`${backendUrl}/api/wallet/`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body).to.have.property('outstanding');
expect(response.body).to.have.property('withdrawn');
const currentOutstanding = parseFloat(response.body.outstanding);
if (currentOutstanding > 10) {
cy.request('POST', `${backendUrl}/api/wallet/withdraw/`, { amount: '10.00' }).then((withdrawRes) => {
expect(withdrawRes.status).to.eq(200);
expect(parseFloat(withdrawRes.body.outstanding)).to.eq(currentOutstanding - 10);
});
}
});
});
});

View file

@ -6,9 +6,12 @@ describe('Supplier Portal Onboarding & Feature E2E Flow', () => {
// 2. Registration Page
cy.contains('Get Started Today').click();
cy.get('input#reg-email').type('test_seller@example.com');
const randUser = `test_seller_${Date.now()}@example.com`;
cy.get('input#reg-email').type(randUser);
cy.get('input#reg-phone').type('9876543210');
cy.get('input#reg-pass').type('super_secure_pass_123');
cy.get('input#reg-confirm-pass').type('super_secure_pass_123');
cy.get('input#reg-policy').check();
cy.get('button').contains('Register Business').click();
// 3. Step 1: Contact Verification
@ -27,8 +30,8 @@ describe('Supplier Portal Onboarding & Feature E2E Flow', () => {
// 4. Step 2: Tax & Identity Verification
cy.contains('Step 2 of 3: Tax & Identity Verification').should('be.visible');
cy.get('input#verify-gst').clear().type('29AAAAA1111A1Z1');
cy.contains('Verify GSTIN').click();
cy.contains('GSTIN successfully verified with government registry.').should('be.visible');
cy.contains('Submit GSTIN').click();
cy.contains('verification will be completed within next 24 hrs').should('be.visible');
// Upload mock Aadhaar & PAN files
cy.get('input#aadhar-upload').selectFile({
@ -63,8 +66,8 @@ describe('Supplier Portal Onboarding & Feature E2E Flow', () => {
cy.contains('Submit Supplier Profile').click();
// 6. Setup Welcome Tour Stepper
cy.contains('Welcome to Global Artisans Hub! 🎉').should('be.visible');
cy.contains('Start Tour').click();
cy.contains('Supplier Account Under Review ⏳').should('be.visible');
cy.contains('Start Guided Tour 🎬').click();
cy.contains('📦 Products & Inventory Management').should('be.visible');
cy.contains('Next: Order Management').click();
cy.contains('🚚 Order Acceptance Control').should('be.visible');
@ -72,7 +75,7 @@ describe('Supplier Portal Onboarding & Feature E2E Flow', () => {
cy.contains('🏷️ Barcode Identification & Tracking').should('be.visible');
cy.contains('Next: Earnings & Wallet').click();
cy.contains('💼 Wallet & Payout Withdrawals').should('be.visible');
cy.contains('Launch Dashboard 🚀').click();
cy.contains('Explore Dashboard 🚀').click();
// 7. Check Active Dashboard Tab
cy.contains('Performance Analytics').should('be.visible');
@ -98,4 +101,54 @@ describe('Supplier Portal Onboarding & Feature E2E Flow', () => {
cy.contains('Print Label').should('be.visible');
cy.contains('Download SVG').should('be.visible');
});
it('enforces security constraints and validation rules (negative test cases)', () => {
// 1. Visit Home and verify dashboard components are not in DOM (route guarding)
cy.visit('/');
cy.get('.dashboard-container').should('not.exist');
cy.get('.sidebar-menu').should('not.exist');
// 2. Go to Registration
cy.contains('Get Started Today').click();
const randSecurityUser = `security_test_${Date.now()}@example.com`;
cy.get('input#reg-email').type(randSecurityUser);
cy.get('input#reg-phone').type('9000000000');
cy.get('input#reg-pass').type('password123');
cy.get('input#reg-confirm-pass').type('password123');
cy.get('input#reg-policy').check();
cy.get('button').contains('Register Business').click();
// 3. Step 1: Negative OTP validations
cy.contains('Step 1 of 3: Contact Verification').should('be.visible');
// Proceed button must be disabled initially
cy.get('button').contains('Next Step: Identity Verification').should('be.disabled');
// Submit invalid WhatsApp OTP
cy.contains('Send WhatsApp OTP').click();
cy.get('input[placeholder="Enter 123456"]').first().type('000000');
// Capture alert for incorrect OTP
const alertStub = cy.stub();
cy.on('window:alert', alertStub);
cy.contains('Verify Code').first().click().then(() => {
expect(alertStub).to.have.been.calledWith('Incorrect code. Enter 123456');
});
cy.contains('✓ Mobile & WhatsApp Verified').should('not.exist');
// Correct the code to enable proceed
cy.get('input[placeholder="Enter 123456"]').first().clear().type('123456');
cy.contains('Verify Code').first().click();
cy.contains('✓ Mobile & WhatsApp Verified').should('be.visible');
// Submit invalid Email OTP
cy.contains('Send Email OTP').click();
cy.get('input[placeholder="Enter 123456"]').last().type('999999');
cy.contains('Verify Code').last().click().then(() => {
expect(alertStub).to.have.been.calledWith('Incorrect code. Enter 123456');
});
cy.contains('✓ Email Verified').should('not.exist');
cy.get('button').contains('Next Step: Identity Verification').should('be.disabled');
});
});

79
package-lock.json generated
View file

@ -19,6 +19,7 @@
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"@vitest/ui": "^4.1.10",
"cypress": "^15.20.0",
"jsdom": "^30.0.1",
"oxlint": "^1.75.0",
@ -696,6 +697,13 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"dev": true,
"license": "MIT"
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
@ -1233,6 +1241,28 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/ui": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.10.tgz",
"integrity": "sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"fflate": "^0.8.2",
"flatted": "^3.4.2",
"pathe": "^2.0.3",
"sirv": "^3.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "4.1.10"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
@ -2172,6 +2202,20 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/flatted": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
"integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
"dev": true,
"license": "ISC"
},
"node_modules/forever-agent": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
@ -3177,6 +3221,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -3819,6 +3873,21 @@
"dev": true,
"license": "ISC"
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/slice-ansi": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz",
@ -4123,6 +4192,16 @@
"node": ">=14.14"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",

View file

@ -24,6 +24,7 @@
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"@vitest/ui": "^4.1.10",
"cypress": "^15.20.0",
"jsdom": "^30.0.1",
"oxlint": "^1.75.0",

View file

@ -2,9 +2,27 @@ import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeAll } from 'vitest'
import App from './App'
// Mock window.scrollTo since jsdom does not implement it
// Mock window.scrollTo and fetch since jsdom does not implement them
beforeAll(() => {
window.scrollTo = vi.fn()
global.fetch = vi.fn((url) => {
let responseData: any = {};
if (url.includes('/api/auth/register') || url.includes('/api/auth/login')) {
responseData = { id: 1, username: 'test_seller', email: 'test@example.com' };
} else if (url.includes('/api/products')) {
responseData = [];
} else if (url.includes('/api/orders')) {
responseData = [];
} else if (url.includes('/api/returns')) {
responseData = [];
} else if (url.includes('/api/wallet')) {
responseData = { outstanding: 850.00, withdrawn: 1250.00, transactions: [] };
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve(responseData)
} as Response);
})
})
describe('Supplier Portal Onboarding & Active Dashboard Tests', () => {
@ -49,10 +67,14 @@ describe('Supplier Portal Onboarding & Active Dashboard Tests', () => {
const emailInput = screen.getByLabelText(/Business Email \*/i)
const phoneInput = screen.getByLabelText(/Mobile Number \*/i)
const passInput = screen.getByLabelText(/Create Password \*/i)
const confirmPassInput = screen.getByLabelText(/Confirm Password \*/i)
const policyCheckbox = screen.getByLabelText(/I accept the/i)
fireEvent.change(emailInput, { target: { value: 'seller@example.com' } })
fireEvent.change(phoneInput, { target: { value: '9876543210' } })
fireEvent.change(passInput, { target: { value: 'password123' } })
fireEvent.change(confirmPassInput, { target: { value: 'password123' } })
fireEvent.click(policyCheckbox)
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {})
@ -60,7 +82,7 @@ describe('Supplier Portal Onboarding & Active Dashboard Tests', () => {
fireEvent.click(registerBtn)
// Step 1: Contact Verification
expect(screen.getByText(/Step 1 of 3: Contact Verification/i)).toBeInTheDocument()
expect(await screen.findByText(/Step 1 of 3: Contact Verification/i)).toBeInTheDocument()
const sendWhatsappBtn = screen.getByText(/Send WhatsApp OTP/i)
fireEvent.click(sendWhatsappBtn)
@ -87,7 +109,7 @@ describe('Supplier Portal Onboarding & Active Dashboard Tests', () => {
const taxInput = screen.getByLabelText(/GSTIN Number \*/i)
fireEvent.change(taxInput, { target: { value: '29AAAAA1111A1Z1' } })
const verifyGstinBtn = screen.getByRole('button', { name: /Verify GSTIN/i })
const verifyGstinBtn = screen.getByRole('button', { name: /Submit GSTIN/i })
fireEvent.click(verifyGstinBtn)
// Upload files
@ -121,11 +143,11 @@ describe('Supplier Portal Onboarding & Active Dashboard Tests', () => {
const submitBtn = screen.getByRole('button', { name: /Submit Supplier Profile/i })
fireEvent.click(submitBtn)
// Welcome Page & Setup Tour
expect(screen.getByText(/Welcome to Global Artisans Hub!/i)).toBeInTheDocument()
// Welcome Page & Setup Tour (Account Under Review)
expect(screen.getByText(/Supplier Account Under Review/i)).toBeInTheDocument()
// Complete tour steps
const startTourBtn = screen.getByRole('button', { name: /Start Tour/i })
const startTourBtn = screen.getByRole('button', { name: /Start Guided Tour/i })
fireEvent.click(startTourBtn)
const next1 = screen.getByRole('button', { name: /Next: Order Management/i })
@ -137,7 +159,7 @@ describe('Supplier Portal Onboarding & Active Dashboard Tests', () => {
const next3 = screen.getByRole('button', { name: /Next: Earnings & Wallet/i })
fireEvent.click(next3)
const finishBtn = screen.getByRole('button', { name: /Launch Dashboard 🚀/i })
const finishBtn = screen.getByRole('button', { name: /Explore Dashboard 🚀/i })
fireEvent.click(finishBtn)
// Redirects to Dashboard Page
@ -147,7 +169,7 @@ describe('Supplier Portal Onboarding & Active Dashboard Tests', () => {
alertMock.mockRestore()
})
it('logs in successfully and navigates dashboard tools', () => {
it('logs in successfully and navigates dashboard tools', async () => {
render(<App />)
const loginBtn = screen.getByRole('button', { name: /^Login$/i })
@ -163,7 +185,7 @@ describe('Supplier Portal Onboarding & Active Dashboard Tests', () => {
fireEvent.click(submitBtn)
// Check dashboard rendering
expect(screen.getByText('Performance Analytics')).toBeInTheDocument()
expect(await screen.findByText('Performance Analytics')).toBeInTheDocument()
// Check Sidebar Tab click: Manage Products
const productsTabBtn = screen.getByText(/Manage Products/i)

View file

@ -1,5 +1,5 @@
import { useState } from 'react'
import { CONFIG } from './config'
import { useState, useEffect } from 'react'
import { CONFIG, apiFetch } from './config'
type Page = 'home' | 'about' | 'contact' | 'login' | 'signup' | 'profile-completion' | 'confirmation' | 'dashboard' | 'forgot-password' | 'login-otp' | 'welcome-tour'
type DashboardTab = 'overview' | 'products' | 'orders' | 'returns' | 'wallet' | 'settings' | 'barcode-generator'
@ -78,6 +78,13 @@ export default function App() {
})
const [mapCoordinates, setMapCoordinates] = useState({ lat: 12.9716, lng: 77.5946 })
// --- Password visibility, confirm password and policy states ---
const [showLoginPass, setShowLoginPass] = useState(false)
const [showSignupPass, setShowSignupPass] = useState(false)
const [showSignupConfirmPass, setShowSignupConfirmPass] = useState(false)
const [confirmPassword, setConfirmPassword] = useState('')
const [policyAccepted, setPolicyAccepted] = useState(false)
// --- Password Reset Page States ---
const [resetEmail, setResetEmail] = useState('')
const [resetOtpSent, setResetOtpSent] = useState(false)
@ -132,6 +139,67 @@ export default function App() {
})
const [withdrawAmount, setWithdrawAmount] = useState('')
// Load data from backend on mount or when profile is complete / logged in
useEffect(() => {
if (isProfileComplete || currentPage === 'dashboard') {
// Fetch Products
apiFetch(`${CONFIG.apiBaseUrl}/api/products/`)
.then(res => res.json())
.then(data => {
if (Array.isArray(data)) setProducts(data);
})
.catch(err => console.error('Error fetching products:', err));
// Fetch Orders
apiFetch(`${CONFIG.apiBaseUrl}/api/orders/`)
.then(res => res.json())
.then(data => {
if (Array.isArray(data)) setOrders(data);
})
.catch(err => console.error('Error fetching orders:', err));
// Fetch Returns
apiFetch(`${CONFIG.apiBaseUrl}/api/returns/`)
.then(res => res.json())
.then(data => {
if (Array.isArray(data)) setReturns(data);
})
.catch(err => console.error('Error fetching returns:', err));
// Fetch Wallet
apiFetch(`${CONFIG.apiBaseUrl}/api/wallet/`)
.then(res => res.json())
.then(data => {
if (data) setWallet(data);
})
.catch(err => console.error('Error fetching wallet:', err));
}
}, [isProfileComplete, currentPage]);
const handleAcceptOrder = (id: string) => {
apiFetch(`${CONFIG.apiBaseUrl}/api/orders/${id}/accept/`, {
method: 'POST'
})
.then(res => res.json())
.then(updatedOrder => {
setOrders(orders.map(o => o.id === updatedOrder.id ? updatedOrder : o))
alert(`Order accepted successfully!`)
})
.catch(err => console.error(err))
}
const handleRejectOrder = (id: string) => {
apiFetch(`${CONFIG.apiBaseUrl}/api/orders/${id}/reject/`, {
method: 'POST'
})
.then(res => res.json())
.then(updatedOrder => {
setOrders(orders.map(o => o.id === updatedOrder.id ? updatedOrder : o))
alert(`Order rejected.`)
})
.catch(err => console.error(err))
}
// Bulk Upload simulations
const [bulkLog, setBulkLog] = useState<string[]>([])
const [isParsingBulk, setIsParsingBulk] = useState(false)
@ -170,17 +238,100 @@ export default function App() {
const handleRegisterSubmit = (e: React.FormEvent) => {
e.preventDefault()
navigateTo('profile-completion')
// Email Validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
alert('Please enter a valid email address.');
return;
}
// Phone Validation
const phoneRegex = /^[6-9]\d{9}$/;
if (!phoneRegex.test(phone)) {
alert('Please enter a valid 10-digit mobile number starting with 6, 7, 8, or 9.');
return;
}
// Password Validation
if (password.length < 8) {
alert('Password must be at least 8 characters long.');
return;
}
if (password !== confirmPassword) {
alert('Passwords do not match.');
return;
}
// Policy Validation
if (!policyAccepted) {
alert('You must accept the Terms of Service and Privacy Policy.');
return;
}
apiFetch(`${CONFIG.apiBaseUrl}/api/auth/register/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: email,
email: email,
phone: phone,
password: password
})
})
.then(res => {
if (!res.ok) throw new Error('Registration failed. Username/email might already be taken.');
return res.json();
})
.then(() => {
// Auto login after signup
apiFetch(`${CONFIG.apiBaseUrl}/api/auth/login/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: email, password: password })
})
.then(() => {
navigateTo('profile-completion')
})
})
.catch(err => {
alert(err.message);
});
}
const handleLoginSubmit = (e: React.FormEvent) => {
e.preventDefault()
setIsProfileComplete(true)
navigateTo('dashboard', true)
// Email Validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
alert('Please enter a valid email address.');
return;
}
apiFetch(`${CONFIG.apiBaseUrl}/api/auth/login/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: email, password: password })
})
.then(res => {
if (!res.ok) throw new Error('Invalid credentials.');
return res.json();
})
.then(() => {
setIsProfileComplete(true)
navigateTo('dashboard', true)
})
.catch(err => {
alert(err.message);
});
}
const handleProfileSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (profileStep === 2) {
setIsGstinVerified(true)
}
if (profileStep < 3) {
setProfileStep(prev => prev + 1)
} else {
@ -194,21 +345,34 @@ export default function App() {
// Product creation/modification
const handleSaveProduct = (e: React.FormEvent) => {
e.preventDefault()
if (isEditingProduct) {
setProducts(products.map(p => p.id === productForm.id ? { ...productForm, price: Number(productForm.price), stock: Number(productForm.stock) } : p))
setIsEditingProduct(false)
alert('Product modified successfully!')
} else {
const newProd: Product = {
...productForm,
id: String(products.length + 1),
price: Number(productForm.price),
const url = isEditingProduct
? `${CONFIG.apiBaseUrl}/api/products/${productForm.id}/`
: `${CONFIG.apiBaseUrl}/api/products/`
const method = isEditingProduct ? 'PUT' : 'POST'
apiFetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: productForm.title,
category: productForm.category,
price: String(productForm.price),
stock: Number(productForm.stock),
sku: productForm.sku || `PROD-${Date.now().toString().slice(-6)}`
}
setProducts([...products, newProd])
alert('Product uploaded successfully!')
}
sku: productForm.sku || `PROD-${Date.now().toString().slice(-6)}`,
image: productForm.image
})
})
.then(res => res.json())
.then(() => {
// Refresh products from backend
apiFetch(`${CONFIG.apiBaseUrl}/api/products/`)
.then(r => r.json())
.then(prods => setProducts(prods))
setIsEditingProduct(false)
alert(isEditingProduct ? 'Product modified successfully!' : 'Product uploaded successfully!')
})
.catch(err => console.error(err))
// reset form
setProductForm({ id: '', title: '', category: 'Apparel', price: 0, stock: 0, sku: '', image: 'https://images.unsplash.com/photo-1544022613-e87ca75a784a?auto=format&fit=crop&q=80&w=100' })
}
@ -220,7 +384,13 @@ export default function App() {
const handleDeleteProduct = (id: string) => {
if (confirm('Are you sure you want to delete this listing?')) {
setProducts(products.filter(p => p.id !== id))
apiFetch(`${CONFIG.apiBaseUrl}/api/products/${id}/`, {
method: 'DELETE'
})
.then(() => {
setProducts(products.filter(p => p.id !== id))
})
.catch(err => console.error(err))
}
}
@ -230,19 +400,37 @@ export default function App() {
setIsParsingBulk(true)
setBulkLog(['Parsing CSV file template...', 'Validating rows against inventory Schema...'])
const mockBulkProducts = [
{ title: 'Hand-Carved Walnut Bowl', category: 'Kitchenware', price: '65.00', stock: 15, sku: 'BOWL-WAL-12' },
{ title: 'Organic Cotton Tablecloth', category: 'Linens', price: '48.00', stock: 30, sku: 'LINE-COT-15' }
]
setTimeout(() => {
const parsedProducts: Product[] = [
{ id: String(products.length + 1), title: 'Hand-Carved Walnut Bowl', category: 'Kitchenware', price: 65.00, stock: 15, sku: 'BOWL-WAL-12', image: 'https://images.unsplash.com/photo-1610701596007-11502861dcfa?auto=format&fit=crop&q=80&w=100' },
{ id: String(products.length + 2), title: 'Organic Cotton Tablecloth', category: 'Linens', price: 48.00, stock: 30, sku: 'LINE-COT-15', image: 'https://images.unsplash.com/photo-1603006905003-be475563bc59?auto=format&fit=crop&q=80&w=100' }
]
setProducts(prev => [...prev, ...parsedProducts])
setBulkLog(prev => [
...prev,
'Row 1: Verified SKU BOWL-WAL-12 - Added',
'Row 2: Verified SKU LINE-COT-15 - Added',
'Successfully added 2 new listings bulk!'
])
setIsParsingBulk(false)
apiFetch(`${CONFIG.apiBaseUrl}/api/products/bulk-upload/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ products: mockBulkProducts })
})
.then(res => res.json())
.then(() => {
setBulkLog(prev => [
...prev,
'Row 1: Verified SKU BOWL-WAL-12 - Added',
'Row 2: Verified SKU LINE-COT-15 - Added',
'Successfully added 2 new listings bulk!'
])
// Refresh products
apiFetch(`${CONFIG.apiBaseUrl}/api/products/`)
.then(r => r.json())
.then(prods => {
setProducts(prods)
setIsParsingBulk(false)
})
})
.catch(err => {
setBulkLog(prev => [...prev, 'Error during upload to server.'])
setIsParsingBulk(false)
})
}, 1500)
}
@ -254,43 +442,75 @@ export default function App() {
alert('Please enter a valid amount')
return
}
if (amount > wallet.outstanding) {
alert('Amount exceeds outstanding ready payout balance')
return
}
const txId = `TX-${Math.floor(1000 + Math.random() * 9000)}`
const newTx = {
id: txId,
date: new Date().toISOString().split('T')[0],
amount: amount,
status: 'Transferred'
}
setWallet({
outstanding: wallet.outstanding - amount,
withdrawn: wallet.withdrawn + amount,
history: [newTx, ...wallet.history]
apiFetch(`${CONFIG.apiBaseUrl}/api/wallet/withdraw/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: String(amount) })
})
.then(res => {
if (!res.ok) throw new Error('Insufficient funds or invalid request');
return res.json();
})
.then(updatedWallet => {
setWallet(updatedWallet)
setWithdrawAmount('')
alert(`Payout of $${amount} successfully transferred!`)
})
.catch(err => {
alert(err.message)
})
setWithdrawAmount('')
alert(`Payout of $${amount} successfully transferred!`)
}
// Returns actions
const handleReturnAction = (id: string, action: 'Approved' | 'Rejected') => {
setReturns(returns.map(ret => {
if (ret.id === id) {
return {
...ret,
status: action === 'Approved' ? 'In Transit' : 'Rejected'
}
}
return ret
}))
alert(`Return request ${action.toLowerCase()}!`)
apiFetch(`${CONFIG.apiBaseUrl}/api/returns/${id}/action/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: action })
})
.then(res => res.json())
.then(updatedReturn => {
setReturns(returns.map(ret => ret.id === id ? updatedReturn : ret))
alert(`Return request ${action.toLowerCase()}!`)
})
.catch(err => console.error(err))
}
const selectedMetrics = CONFIG.dashboardData.filters[dateFilter]
// Compute realtime metrics based on state loaded from backend
const computeRealtimeMetrics = () => {
const totalSales = orders
.filter(o => o.status !== 'Cancelled' && o.status !== 'Rejected')
.reduce((sum, o) => sum + Number(o.total), 0)
const totalEarned = Number(wallet.outstanding) + Number(wallet.withdrawn)
const totalStock = products.reduce((sum, p) => sum + Number(p.stock), 0)
const totalReturns = returns.length
const isLoggedIn = currentPage === 'dashboard' || currentPage === 'profile-completion' || currentPage === 'welcome-tour' || isProfileComplete
if (isLoggedIn) {
return {
totalSales,
totalEarned,
stockDetails: totalStock,
returnedItems: totalReturns,
chartValues: orders.length > 0
? orders.map(o => Number(o.total))
: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
}
}
return {
totalSales: 45280.00,
totalEarned: 38488.00,
stockDetails: 342,
returnedItems: 12,
chartValues: CONFIG.dashboardData.filters[dateFilter]?.chartValues || [30, 45, 35, 60, 50, 75, 65, 80, 70, 95, 90, 110]
}
}
const selectedMetrics = computeRealtimeMetrics()
return (
<>
@ -518,15 +738,35 @@ export default function App() {
</div>
<div className="form-group">
<label htmlFor="login-password">Password</label>
<input
id="login-password"
type="password"
className="form-control"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<div style={{ position: 'relative' }}>
<input
id="login-password"
type={showLoginPass ? 'text' : 'password'}
className="form-control"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={{ paddingRight: '45px' }}
/>
<button
type="button"
onClick={() => setShowLoginPass(!showLoginPass)}
style={{
position: 'absolute',
right: '12px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '1.1rem',
color: 'var(--text-muted)'
}}
>
{showLoginPass ? '👁️' : '🙈'}
</button>
</div>
</div>
<div style={{ textAlign: 'right', marginBottom: '1.5rem' }}>
<button type="button" className="nav-link" style={{ color: 'var(--text-muted)', fontSize: '0.85rem', textDecoration: 'none', background: 'none', border: 'none', cursor: 'pointer' }} onClick={(e) => { e.preventDefault(); navigateTo('forgot-password'); }}>Forgot Password?</button>
@ -567,17 +807,84 @@ export default function App() {
</div>
<div className="form-group">
<label htmlFor="reg-pass">Create Password *</label>
<div style={{ position: 'relative' }}>
<input
id="reg-pass"
type={showSignupPass ? 'text' : 'password'}
className="form-control"
placeholder="Minimum 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={{ paddingRight: '45px' }}
/>
<button
type="button"
onClick={() => setShowSignupPass(!showSignupPass)}
style={{
position: 'absolute',
right: '12px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '1.1rem',
color: 'var(--text-muted)'
}}
>
{showSignupPass ? '👁️' : '🙈'}
</button>
</div>
</div>
<div className="form-group">
<label htmlFor="reg-confirm-pass">Confirm Password *</label>
<div style={{ position: 'relative' }}>
<input
id="reg-confirm-pass"
type={showSignupConfirmPass ? 'text' : 'password'}
className="form-control"
placeholder="Re-enter password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
style={{ paddingRight: '45px' }}
/>
<button
type="button"
onClick={() => setShowSignupConfirmPass(!showSignupConfirmPass)}
style={{
position: 'absolute',
right: '12px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '1.1rem',
color: 'var(--text-muted)'
}}
>
{showSignupConfirmPass ? '👁️' : '🙈'}
</button>
</div>
</div>
<div className="form-group" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', margin: '1.25rem 0' }}>
<input
id="reg-pass"
type="password"
className="form-control"
placeholder="Minimum 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
id="reg-policy"
type="checkbox"
checked={policyAccepted}
onChange={(e) => setPolicyAccepted(e.target.checked)}
required
/>
<label htmlFor="reg-policy" style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: 0, cursor: 'pointer' }}>
I accept the <a href="#terms" onClick={(e) => e.preventDefault()}>Terms of Service</a> and <a href="#privacy" onClick={(e) => e.preventDefault()}>Privacy Policy</a> *
</label>
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%', padding: '0.75rem', marginTop: '1rem' }}>
<button type="submit" className="btn btn-primary" style={{ width: '100%', padding: '0.75rem', marginTop: '0.5rem' }}>
Register Business
</button>
</form>
@ -869,12 +1176,12 @@ export default function App() {
disabled={isGstinVerified}
style={{ minWidth: '120px' }}
>
{isGstinVerified ? 'Verified ✓' : 'Verify GSTIN'}
{isGstinVerified ? 'Submitted ✓' : 'Submit GSTIN'}
</button>
</div>
{isGstinVerified && (
<p style={{ color: 'var(--success)', fontSize: '0.85rem', marginTop: '0.5rem', fontWeight: 600 }}>
GSTIN successfully verified with government registry.
verification will be completed within next 24 hrs
</p>
)}
</div>
@ -945,7 +1252,7 @@ export default function App() {
type="submit"
className="btn btn-primary"
style={{ flex: 1, backgroundColor: 'var(--accent)', color: 'var(--primary)', fontWeight: 'bold' }}
disabled={!isGstinVerified || !aadharFile || !panFile}
disabled={!gstin || !aadharFile || !panFile}
>
Next Step: Store & Location
</button>
@ -1332,7 +1639,7 @@ export default function App() {
</td>
<td>{p.sku}</td>
<td>{p.category}</td>
<td>${p.price.toFixed(2)}</td>
<td>${Number(p.price).toFixed(2)}</td>
<td style={{ fontWeight: 600, color: p.stock < 15 ? 'var(--error)' : 'inherit' }}>
{p.stock} pcs
</td>
@ -1535,7 +1842,7 @@ export default function App() {
<td>{o.item}</td>
<td>{o.quantity}</td>
<td>{o.customer}</td>
<td style={{ fontWeight: 600 }}>${o.total.toFixed(2)}</td>
<td style={{ fontWeight: 600 }}>${Number(o.total).toFixed(2)}</td>
<td>
<span className={`badge ${
o.status === 'Delivered' ? 'badge-success' :
@ -1552,32 +1859,14 @@ export default function App() {
<button
className="btn btn-primary"
style={{ backgroundColor: 'var(--success)', color: 'white', border: 'none', padding: '0.35rem 0.75rem', fontSize: '0.8rem', cursor: 'pointer' }}
onClick={() => {
setOrders(orders.map(item => item.id === o.id ? {
...item,
status: 'Ready to Ship',
carrier: 'DHL Express',
tracking: `DHL-${Math.floor(100000 + Math.random() * 900000)}`,
eta: '3 Days'
} : item));
alert(`Order ${o.id} accepted successfully!`);
}}
onClick={() => handleAcceptOrder(o.id)}
>
Accept
</button>
<button
className="btn btn-primary"
style={{ backgroundColor: 'var(--error)', color: 'white', border: 'none', padding: '0.35rem 0.75rem', fontSize: '0.8rem', cursor: 'pointer' }}
onClick={() => {
setOrders(orders.map(item => item.id === o.id ? {
...item,
status: 'Rejected',
carrier: 'N/A',
tracking: 'N/A',
eta: 'N/A'
} : item));
alert(`Order ${o.id} rejected.`);
}}
onClick={() => handleRejectOrder(o.id)}
>
Reject
</button>
@ -1832,11 +2121,11 @@ export default function App() {
<div className="metrics-row">
<div className="metric-data-card">
<div className="metric-title">Outstanding Ready Balance</div>
<div className="metric-value" style={{ color: 'var(--success)' }}>${wallet.outstanding.toFixed(2)}</div>
<div className="metric-value" style={{ color: 'var(--success)' }}>${Number(wallet.outstanding).toFixed(2)}</div>
</div>
<div className="metric-data-card">
<div className="metric-title">Total Payouts Withdrawn</div>
<div className="metric-value">${wallet.withdrawn.toFixed(2)}</div>
<div className="metric-value">${Number(wallet.withdrawn).toFixed(2)}</div>
</div>
</div>
@ -1877,11 +2166,11 @@ export default function App() {
</tr>
</thead>
<tbody>
{wallet.history.map(tx => (
<tr key={tx.id}>
<td>{tx.id}</td>
{(wallet.history || (wallet as any).transactions || []).map((tx: any) => (
<tr key={tx.id || tx.tx_id}>
<td>{tx.id || tx.tx_id}</td>
<td>{tx.date}</td>
<td style={{ fontWeight: 600 }}>${tx.amount.toFixed(2)}</td>
<td style={{ fontWeight: 600 }}>${Number(tx.amount).toFixed(2)}</td>
<td><span className="badge badge-success">{tx.status}</span></td>
</tr>
))}
@ -2029,10 +2318,10 @@ function WelcomeTourWizard({ onComplete, storeName }: { onComplete: () => void;
const [tourStep, setTourStep] = useState(1)
const steps = [
{
title: "Welcome to Global Artisans Hub! 🎉",
description: `Congratulations ${storeName}! Your supplier profile is verified and complete. Let's take a 1-minute tour to get you familiar with your active workspace so you can begin selling immediately.`,
icon: "🎉",
action: "Start Tour"
title: "Supplier Account Under Review ⏳",
description: `Thank you for completing your profile! Your GSTIN, PAN, and Aadhaar card details have been submitted. Our compliance team is verifying your documents. This review is typically completed within the next 24 hours. While we verify your credentials, let's take a quick animated tour to get you familiar with your dashboard!`,
icon: "",
action: "Start Guided Tour 🎬"
},
{
title: "📦 Products & Inventory Management",
@ -2056,17 +2345,17 @@ function WelcomeTourWizard({ onComplete, storeName }: { onComplete: () => void;
title: "💼 Wallet & Payout Withdrawals",
description: "Monitor outstanding payouts and withdraw your earnings directly to your bank account anytime. Keep track of transaction receipts directly inside the Wallet tab.",
icon: "💼",
action: "Launch Dashboard 🚀"
action: "Explore Dashboard 🚀"
}
]
const current = steps[tourStep - 1]
return (
<div className="form-card" style={{ maxWidth: '640px', margin: '2rem auto', textAlign: 'center', padding: '2.5rem' }}>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>{current.icon}</div>
<div key={tourStep} className="form-card tour-card-animated" style={{ maxWidth: '640px', margin: '2rem auto', textAlign: 'center', padding: '2.5rem' }}>
<div className="tour-icon-animated" style={{ fontSize: '4.5rem', marginBottom: '1rem' }}>{current.icon}</div>
<h2 className="form-card-title">{current.title}</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '1.05rem', margin: '1.5rem 0 2rem' }}>
<p style={{ color: 'var(--text-muted)', fontSize: '1.05rem', margin: '1.5rem 0 2rem', lineHeight: '1.6' }}>
{current.description}
</p>
@ -2074,6 +2363,7 @@ function WelcomeTourWizard({ onComplete, storeName }: { onComplete: () => void;
{steps.map((_, idx) => (
<div
key={idx}
className={tourStep === idx + 1 ? 'tour-dot-active' : ''}
style={{
width: '12px',
height: '12px',

View file

@ -1,4 +1,12 @@
const getApiBaseUrl = () => {
if (typeof window !== 'undefined' && window.location.hostname === 'betasuppliers.tipro.in') {
return 'http://16.113.57.127:8000';
}
return 'http://localhost:8000';
};
export const CONFIG = {
apiBaseUrl: getApiBaseUrl(),
companyName: 'Global Artisans Hub',
logoLetter: 'A',
supportEmail: 'support@globalartisanshub.com',
@ -114,3 +122,10 @@ export const CONFIG = {
]
}
}
export function apiFetch(url: string, options: RequestInit = {}): Promise<Response> {
return fetch(url, {
...options,
credentials: 'include'
});
}

View file

@ -1093,3 +1093,49 @@ body {
}
}
/* Animated Tour Keyframes */
@keyframes tourCardEntrance {
0% {
opacity: 0;
transform: translateY(20px) scale(0.98);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes tourIconBounce {
0%, 100% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-8px) scale(1.05);
}
}
@keyframes tourDotPulse {
0%, 100% {
transform: scale(1);
opacity: 0.8;
}
50% {
transform: scale(1.25);
opacity: 1;
}
}
.tour-card-animated {
animation: tourCardEntrance 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.tour-icon-animated {
animation: tourIconBounce 2s infinite ease-in-out;
display: inline-block;
}
.tour-dot-active {
animation: tourDotPulse 1.5s infinite ease-in-out;
}