EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
PurchaseLicenseModal.jsx
Go to the documentation of this file.
1import { useState, useEffect } from 'react';
2import { apiFetch } from '../lib/api';
3
4export default function PurchaseLicenseModal({ onClose, onSuccess }) {
5 const [step, setStep] = useState(1);
6 const [loading, setLoading] = useState(false);
7 const [error, setError] = useState(null);
8
9 // Step 1: Customer selection
10 const [customers, setCustomers] = useState([]);
11 const [selectedCustomer, setSelectedCustomer] = useState(null);
12 const [syncing, setSyncing] = useState(false);
13
14 // Step 2: Product selection
15 const [products, setProducts] = useState([]);
16 const [selectedProduct, setSelectedProduct] = useState(null);
17 const [productSearch, setProductSearch] = useState('');
18 const [productFilter, setProductFilter] = useState('all'); // all, business, enterprise
19
20 // Step 3-4: Configuration
21 const [quantity, setQuantity] = useState(1);
22 const [billingTerm, setBillingTerm] = useState('monthly');
23
24 // Step 5: Processing
25 const [purchasing, setPurchasing] = useState(false);
26
27 useEffect(() => {
28 if (step === 1) {
29 fetchCustomers();
30 } else if (step === 2 && !products.length) {
31 fetchProducts();
32 }
33 }, [step]);
34
35 const fetchCustomers = async () => {
36 setLoading(true);
37 setError(null);
38 try {
39 const token = localStorage.getItem('token');
40 const res = await apiFetch('/customers', {
41 headers: { Authorization: `Bearer ${token}` }
42 });
43
44 if (res.ok) {
45 const data = await res.json();
46 setCustomers(data.customers || []);
47 } else {
48 throw new Error('Failed to fetch customers');
49 }
50 } catch (err) {
51 setError(err.message);
52 } finally {
53 setLoading(false);
54 }
55 };
56
57 const fetchProducts = async () => {
58 setLoading(true);
59 setError(null);
60 try {
61 const token = localStorage.getItem('token');
62 const res = await apiFetch('/office365/products', {
63 headers: { Authorization: `Bearer ${token}` }
64 });
65
66 if (res.ok) {
67 const data = await res.json();
68 setProducts(data.products || []);
69 } else {
70 throw new Error('Failed to fetch product catalog');
71 }
72 } catch (err) {
73 setError('Unable to load product catalog. Please ensure Pax8 is configured in Settings.');
74 } finally {
75 setLoading(false);
76 }
77 };
78
79 const syncCustomer = async (customerId) => {
80 setSyncing(true);
81 setError(null);
82 try {
83 const token = localStorage.getItem('token');
84 const res = await apiFetch(`/office365/customers/${customerId}/sync`, {
85 method: 'POST',
86 headers: { Authorization: `Bearer ${token}` }
87 });
88
89 if (res.ok) {
90 // Refresh customer list to update sync status
91 await fetchCustomers();
92 const customer = customers.find(c => c.customer_id === customerId);
93 setSelectedCustomer({ ...customer, synced: true });
94 } else {
95 throw new Error('Failed to sync customer with Pax8');
96 }
97 } catch (err) {
98 setError(err.message);
99 } finally {
100 setSyncing(false);
101 }
102 };
103
104 const handleCustomerSelect = (customer) => {
105 setSelectedCustomer(customer);
106 };
107
108 const handleProductSelect = (product) => {
109 setSelectedProduct(product);
110 };
111
112 const handlePurchase = async () => {
113 setPurchasing(true);
114 setError(null);
115
116 try {
117 const token = localStorage.getItem('token');
118 const res = await apiFetch('/office365/subscriptions', {
119 method: 'POST',
120 headers: {
121 'Authorization': `Bearer ${token}`,
122 'Content-Type': 'application/json'
123 },
124 body: JSON.stringify({
125 customer_id: selectedCustomer.customer_id,
126 product_id: selectedProduct.pax8_product_id,
127 quantity: quantity,
128 billing_term: billingTerm
129 })
130 });
131
132 if (res.ok) {
133 onSuccess && onSuccess();
134 onClose();
135 } else {
136 const data = await res.json();
137 throw new Error(data.error || 'Failed to create subscription');
138 }
139 } catch (err) {
140 setError(err.message);
141 } finally {
142 setPurchasing(false);
143 }
144 };
145
146 const canProceedToStep2 = selectedCustomer && selectedCustomer.synced;
147 const canProceedToStep3 = selectedProduct;
148 const canProceedToStep4 = quantity > 0;
149
150 const formatCurrency = (amount) => {
151 return new Intl.NumberFormat('en-AU', {
152 style: 'currency',
153 currency: 'AUD'
154 }).format(amount || 0);
155 };
156
157 const calculateTotal = () => {
158 if (!selectedProduct) return 0;
159 const monthlyPrice = selectedProduct.unit_price || 0;
160 const total = monthlyPrice * quantity * (billingTerm === 'annual' ? 12 : 1);
161 return total;
162 };
163
164 const filteredProducts = products.filter(product => {
165 const matchesSearch = !productSearch ||
166 product.name.toLowerCase().includes(productSearch.toLowerCase()) ||
167 (product.sku && product.sku.toLowerCase().includes(productSearch.toLowerCase()));
168
169 const matchesFilter = productFilter === 'all' ||
170 (productFilter === 'business' && product.category?.toLowerCase().includes('business')) ||
171 (productFilter === 'enterprise' && product.category?.toLowerCase().includes('enterprise'));
172
173 return matchesSearch && matchesFilter;
174 });
175
176 return (
177 <div className="modal-overlay" onClick={onClose}>
178 <div className="modal modal-lg" onClick={(e) => e.stopPropagation()}>
179 <div className="modal-header">
180 <h2>Purchase Microsoft 365 License</h2>
181 <button className="modal-close" onClick={onClose}>×</button>
182 </div>
183
184 {/* Progress Steps */}
185 <div style={{ padding: '20px', borderBottom: '1px solid var(--border)' }}>
186 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
187 {[1, 2, 3, 4, 5].map((s) => (
188 <div key={s} style={{ flex: 1, textAlign: 'center' }}>
189 <div style={{
190 width: '32px',
191 height: '32px',
192 borderRadius: '50%',
193 background: step >= s ? 'var(--primary)' : '#ddd',
194 color: step >= s ? 'white' : '#888',
195 display: 'flex',
196 alignItems: 'center',
197 justifyContent: 'center',
198 margin: '0 auto 8px',
199 fontWeight: 'bold'
200 }}>
201 {s}
202 </div>
203 <div style={{ fontSize: '12px', color: step >= s ? 'var(--text)' : '#888' }}>
204 {['Customer', 'Product', 'Quantity', 'Term', 'Review'][s - 1]}
205 </div>
206 </div>
207 ))}
208 </div>
209 </div>
210
211 <div className="modal-body" style={{ minHeight: '400px', maxHeight: '500px', overflow: 'auto' }}>
212 {error && (
213 <div style={{
214 padding: '12px',
215 background: '#fee',
216 border: '1px solid #fcc',
217 borderRadius: '4px',
218 color: '#c33',
219 marginBottom: '20px'
220 }}>
221 {error}
222 </div>
223 )}
224
225 {/* Step 1: Select Customer */}
226 {step === 1 && (
227 <div>
228 <h3 style={{ marginBottom: '16px' }}>Select Customer</h3>
229 <p style={{ color: '#888', marginBottom: '20px' }}>
230 Choose the customer who will receive the Microsoft 365 licenses
231 </p>
232
233 {loading ? (
234 <div style={{ textAlign: 'center', padding: '40px' }}>
235 <div className="spinner"></div>
236 <p style={{ marginTop: '16px', color: '#888' }}>Loading customers...</p>
237 </div>
238 ) : customers.length === 0 ? (
239 <div style={{ textAlign: 'center', padding: '40px', color: '#888' }}>
240 <p>No customers found. Please create a customer first.</p>
241 </div>
242 ) : (
243 <div style={{ display: 'grid', gap: '12px' }}>
244 {customers.map((customer) => (
245 <div
246 key={customer.customer_id}
247 onClick={() => handleCustomerSelect(customer)}
248 style={{
249 padding: '16px',
250 border: `2px solid ${selectedCustomer?.customer_id === customer.customer_id ? 'var(--primary)' : '#ddd'}`,
251 borderRadius: '8px',
252 cursor: 'pointer',
253 transition: 'all 0.2s',
254 background: selectedCustomer?.customer_id === customer.customer_id ? '#f0f8ff' : 'white'
255 }}
256 >
257 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
258 <div>
259 <strong>{customer.customer_name}</strong>
260 {customer.contact_email && (
261 <div style={{ fontSize: '14px', color: '#888', marginTop: '4px' }}>
262 {customer.contact_email}
263 </div>
264 )}
265 </div>
266 {customer.synced ? (
267 <span style={{
268 padding: '4px 12px',
269 background: '#d4edda',
270 color: '#155724',
271 borderRadius: '4px',
272 fontSize: '12px',
273 fontWeight: 'bold'
274 }}>
275 ✓ Synced
276 </span>
277 ) : (
278 <button
279 className="btn btn-sm"
280 onClick={(e) => {
281 e.stopPropagation();
282 syncCustomer(customer.customer_id);
283 }}
284 disabled={syncing}
285 >
286 {syncing ? 'Syncing...' : 'Sync to Pax8'}
287 </button>
288 )}
289 </div>
290 </div>
291 ))}
292 </div>
293 )}
294
295 {selectedCustomer && !selectedCustomer.synced && (
296 <div style={{
297 marginTop: '20px',
298 padding: '12px',
299 background: '#fff3cd',
300 border: '1px solid #ffc107',
301 borderRadius: '4px',
302 color: '#856404'
303 }}>
304 <strong>⚠️ Customer not synced</strong>
305 <p style={{ margin: '4px 0 0 0', fontSize: '14px' }}>
306 Please sync this customer with Pax8 before purchasing licenses.
307 </p>
308 </div>
309 )}
310 </div>
311 )}
312
313 {/* Step 2: Select Product */}
314 {step === 2 && (
315 <div>
316 <h3 style={{ marginBottom: '16px' }}>Select Product</h3>
317 <p style={{ color: '#888', marginBottom: '20px' }}>
318 Choose the Microsoft 365 license type
319 </p>
320
321 <div style={{ marginBottom: '20px' }}>
322 <input
323 type="text"
324 className="form-input"
325 placeholder="Search products..."
326 value={productSearch}
327 onChange={(e) => setProductSearch(e.target.value)}
328 style={{ marginBottom: '12px' }}
329 />
330 <div style={{ display: 'flex', gap: '8px' }}>
331 <button
332 className={`btn ${productFilter === 'all' ? 'btn-primary' : ''}`}
333 onClick={() => setProductFilter('all')}
334 >
335 All
336 </button>
337 <button
338 className={`btn ${productFilter === 'business' ? 'btn-primary' : ''}`}
339 onClick={() => setProductFilter('business')}
340 >
341 Business
342 </button>
343 <button
344 className={`btn ${productFilter === 'enterprise' ? 'btn-primary' : ''}`}
345 onClick={() => setProductFilter('enterprise')}
346 >
347 Enterprise
348 </button>
349 </div>
350 </div>
351
352 {loading ? (
353 <div style={{ textAlign: 'center', padding: '40px' }}>
354 <div className="spinner"></div>
355 <p style={{ marginTop: '16px', color: '#888' }}>Loading products...</p>
356 </div>
357 ) : filteredProducts.length === 0 ? (
358 <div style={{ textAlign: 'center', padding: '40px', color: '#888' }}>
359 <p>No products found matching your criteria.</p>
360 </div>
361 ) : (
362 <div style={{ display: 'grid', gap: '12px' }}>
363 {filteredProducts.map((product) => (
364 <div
365 key={product.pax8_product_id}
366 onClick={() => handleProductSelect(product)}
367 style={{
368 padding: '16px',
369 border: `2px solid ${selectedProduct?.pax8_product_id === product.pax8_product_id ? 'var(--primary)' : '#ddd'}`,
370 borderRadius: '8px',
371 cursor: 'pointer',
372 transition: 'all 0.2s',
373 background: selectedProduct?.pax8_product_id === product.pax8_product_id ? '#f0f8ff' : 'white'
374 }}
375 >
376 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
377 <div style={{ flex: 1 }}>
378 <strong>{product.name}</strong>
379 {product.description && (
380 <div style={{ fontSize: '14px', color: '#666', marginTop: '4px' }}>
381 {product.description}
382 </div>
383 )}
384 {product.sku && (
385 <div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
386 SKU: {product.sku}
387 </div>
388 )}
389 </div>
390 <div style={{ textAlign: 'right', marginLeft: '16px' }}>
391 <div style={{ fontSize: '18px', fontWeight: 'bold', color: 'var(--primary)' }}>
392 {formatCurrency(product.unit_price)}
393 </div>
394 <div style={{ fontSize: '12px', color: '#888' }}>
395 per user/month
396 </div>
397 </div>
398 </div>
399 </div>
400 ))}
401 </div>
402 )}
403 </div>
404 )}
405
406 {/* Step 3: Select Quantity */}
407 {step === 3 && (
408 <div>
409 <h3 style={{ marginBottom: '16px' }}>Select Quantity</h3>
410 <p style={{ color: '#888', marginBottom: '20px' }}>
411 How many licenses do you need?
412 </p>
413
414 <div style={{ marginBottom: '24px' }}>
415 <label style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold' }}>
416 Number of Licenses
417 </label>
418 <input
419 type="number"
420 className="form-input"
421 min="1"
422 value={quantity}
423 onChange={(e) => setQuantity(parseInt(e.target.value) || 1)}
424 style={{ maxWidth: '200px', fontSize: '18px', textAlign: 'center' }}
425 />
426 </div>
427
428 {selectedProduct && (
429 <div style={{
430 padding: '16px',
431 background: '#f8f9fa',
432 borderRadius: '8px',
433 border: '1px solid #ddd'
434 }}>
435 <div style={{ marginBottom: '12px' }}>
436 <strong>{selectedProduct.name}</strong>
437 </div>
438 <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
439 <span>Unit Price:</span>
440 <span>{formatCurrency(selectedProduct.unit_price)}/month</span>
441 </div>
442 <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
443 <span>Quantity:</span>
444 <span>{quantity}</span>
445 </div>
446 <div style={{
447 display: 'flex',
448 justifyContent: 'space-between',
449 paddingTop: '12px',
450 borderTop: '2px solid #ddd',
451 fontSize: '18px',
452 fontWeight: 'bold'
453 }}>
454 <span>Monthly Total:</span>
455 <span style={{ color: 'var(--primary)' }}>
456 {formatCurrency(selectedProduct.unit_price * quantity)}
457 </span>
458 </div>
459 </div>
460 )}
461 </div>
462 )}
463
464 {/* Step 4: Select Billing Term */}
465 {step === 4 && (
466 <div>
467 <h3 style={{ marginBottom: '16px' }}>Select Billing Term</h3>
468 <p style={{ color: '#888', marginBottom: '20px' }}>
469 Choose your billing frequency
470 </p>
471
472 <div style={{ display: 'grid', gap: '12px', marginBottom: '24px' }}>
473 <div
474 onClick={() => setBillingTerm('monthly')}
475 style={{
476 padding: '20px',
477 border: `2px solid ${billingTerm === 'monthly' ? 'var(--primary)' : '#ddd'}`,
478 borderRadius: '8px',
479 cursor: 'pointer',
480 background: billingTerm === 'monthly' ? '#f0f8ff' : 'white'
481 }}
482 >
483 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
484 <div>
485 <strong>Monthly Billing</strong>
486 <div style={{ fontSize: '14px', color: '#888', marginTop: '4px' }}>
487 Pay month-to-month with flexibility to cancel anytime
488 </div>
489 </div>
490 <div style={{ fontSize: '18px', fontWeight: 'bold', color: 'var(--primary)' }}>
491 {selectedProduct && formatCurrency(selectedProduct.unit_price * quantity)}
492 </div>
493 </div>
494 </div>
495
496 <div
497 onClick={() => setBillingTerm('annual')}
498 style={{
499 padding: '20px',
500 border: `2px solid ${billingTerm === 'annual' ? 'var(--primary)' : '#ddd'}`,
501 borderRadius: '8px',
502 cursor: 'pointer',
503 background: billingTerm === 'annual' ? '#f0f8ff' : 'white'
504 }}
505 >
506 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
507 <div>
508 <strong>Annual Billing</strong>
509 <div style={{ fontSize: '14px', color: '#888', marginTop: '4px' }}>
510 Pay annually for the full year upfront
511 </div>
512 </div>
513 <div style={{ fontSize: '18px', fontWeight: 'bold', color: 'var(--primary)' }}>
514 {selectedProduct && formatCurrency(selectedProduct.unit_price * quantity * 12)}
515 </div>
516 </div>
517 </div>
518 </div>
519 </div>
520 )}
521
522 {/* Step 5: Review & Confirm */}
523 {step === 5 && (
524 <div>
525 <h3 style={{ marginBottom: '16px' }}>Review and Confirm</h3>
526 <p style={{ color: '#888', marginBottom: '20px' }}>
527 Please review your order before confirming
528 </p>
529
530 <div style={{
531 padding: '20px',
532 background: '#f8f9fa',
533 borderRadius: '8px',
534 border: '1px solid #ddd',
535 marginBottom: '24px'
536 }}>
537 <div style={{ marginBottom: '16px' }}>
538 <div style={{ color: '#888', fontSize: '14px', marginBottom: '4px' }}>Customer</div>
539 <strong>{selectedCustomer?.customer_name}</strong>
540 </div>
541
542 <div style={{ marginBottom: '16px' }}>
543 <div style={{ color: '#888', fontSize: '14px', marginBottom: '4px' }}>Product</div>
544 <strong>{selectedProduct?.name}</strong>
545 {selectedProduct?.sku && (
546 <div style={{ fontSize: '12px', color: '#888' }}>SKU: {selectedProduct.sku}</div>
547 )}
548 </div>
549
550 <div style={{ marginBottom: '16px' }}>
551 <div style={{ color: '#888', fontSize: '14px', marginBottom: '4px' }}>Quantity</div>
552 <strong>{quantity} licenses</strong>
553 </div>
554
555 <div style={{ marginBottom: '16px' }}>
556 <div style={{ color: '#888', fontSize: '14px', marginBottom: '4px' }}>Billing Term</div>
557 <strong>{billingTerm === 'monthly' ? 'Monthly' : 'Annual'}</strong>
558 </div>
559
560 <div style={{
561 paddingTop: '16px',
562 borderTop: '2px solid #ddd',
563 marginTop: '16px'
564 }}>
565 <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '18px', fontWeight: 'bold' }}>
566 <span>Total {billingTerm === 'annual' ? '(Annual)' : '(Monthly)'}:</span>
567 <span style={{ color: 'var(--primary)' }}>
568 {formatCurrency(calculateTotal())}
569 </span>
570 </div>
571 {billingTerm === 'annual' && (
572 <div style={{ fontSize: '14px', color: '#888', textAlign: 'right', marginTop: '4px' }}>
573 {formatCurrency(calculateTotal() / 12)}/month
574 </div>
575 )}
576 </div>
577 </div>
578
579 <div style={{
580 padding: '12px',
581 background: '#d1ecf1',
582 border: '1px solid #bee5eb',
583 borderRadius: '4px',
584 color: '#0c5460',
585 fontSize: '14px'
586 }}>
587 <strong>ⓘ Note:</strong> This subscription will be created in Pax8 and linked to the selected customer.
588 </div>
589 </div>
590 )}
591 </div>
592
593 <div className="modal-footer" style={{ display: 'flex', justifyContent: 'space-between' }}>
594 <button
595 className="btn"
596 onClick={() => step === 1 ? onClose() : setStep(step - 1)}
597 disabled={purchasing}
598 >
599 {step === 1 ? 'Cancel' : 'Back'}
600 </button>
601
602 {step < 5 ? (
603 <button
604 className="btn btn-primary"
605 onClick={() => setStep(step + 1)}
606 disabled={
607 (step === 1 && !canProceedToStep2) ||
608 (step === 2 && !canProceedToStep3) ||
609 (step === 3 && !canProceedToStep4) ||
610 loading
611 }
612 >
613 Next
614 </button>
615 ) : (
616 <button
617 className="btn btn-primary"
618 onClick={handlePurchase}
619 disabled={purchasing}
620 >
621 {purchasing ? 'Processing...' : 'Confirm Purchase'}
622 </button>
623 )}
624 </div>
625 </div>
626 </div>
627 );
628}