@liiift-studio/sales-portal 1.3.0 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,6 @@
1
1
  // Login form modal component for sales portal authentication
2
2
  import React, { useEffect, useState } from 'react';
3
- import { Typography, Input, Button, Box, Dialog, DialogContent, IconButton } from '@mui/material';
4
- import CloseIcon from '@mui/icons-material/Close';
3
+ import { Typography, Input, Button, Box, Dialog, DialogContent } from '@mui/material';
5
4
  import packageJson from '../package.json';
6
5
 
7
6
  const { version } = packageJson;
@@ -89,14 +88,14 @@ export default function LoginForm({
89
88
  <Dialog
90
89
  open={open}
91
90
  onClose={onClose}
92
- maxWidth="sm"
91
+ maxWidth="md"
93
92
  fullWidth
94
93
  PaperProps={{
95
94
  sx: {
96
- borderRadius: 0,
97
- p: { xs: 4, sm: 6 },
98
- position: 'relative',
99
- overflow: 'visible',
95
+ borderRadius: '12px',
96
+ overflow: 'hidden',
97
+ m: { xs: 2, sm: 4 },
98
+ maxHeight: 'calc(100vh - 64px)',
100
99
  }
101
100
  }}
102
101
  slotProps={{
@@ -111,131 +110,144 @@ export default function LoginForm({
111
110
  {loading && renderLoader && renderLoader({ fill: true, position: 'fixed', height: '100%' })}
112
111
 
113
112
  <DialogContent sx={{ p: 0 }}>
114
- {onClose && (
115
- <IconButton
116
- onClick={onClose}
117
- aria-label="Close login"
118
- sx={{
119
- position: 'absolute',
120
- top: 12,
121
- right: 12,
122
- color: 'var(--grey800)',
123
- }}
124
- >
125
- <CloseIcon />
126
- </IconButton>
127
- )}
113
+ <Box sx={{
114
+ display: 'flex',
115
+ flexDirection: { xs: 'column', sm: 'row' },
116
+ minHeight: { sm: '420px' },
117
+ }}>
118
+ {/* Left column - Login form */}
119
+ <Box sx={{
120
+ flex: 1,
121
+ p: { xs: 4, sm: 6 },
122
+ display: 'flex',
123
+ flexDirection: 'column',
124
+ justifyContent: 'center',
125
+ }}>
126
+ <Typography component="span" variant="h2" sx={{ mb: 5, display: 'block' }}>
127
+ {title}
128
+ </Typography>
128
129
 
129
- <Typography component="span" variant="h2" sx={{ mb: 4, display: 'block' }}>
130
- {title}
131
- </Typography>
130
+ <Box>
131
+ <Input
132
+ placeholder="Email"
133
+ onChange={(e) => setUser(e.target.value)}
134
+ value={user}
135
+ type="email"
136
+ autoComplete="username"
137
+ onKeyPress={handleKeyPress}
138
+ sx={{
139
+ m: 0,
140
+ border: 'none',
141
+ typography: 'body1',
142
+ color: 'var(--grey800)',
143
+ display: 'flex',
144
+ bgcolor: 'white',
145
+ borderBottom: '2px solid var(--black)',
146
+ width: '100%',
147
+ minHeight: '52px',
148
+ '& input': {
149
+ pl: '12px',
150
+ height: '52px',
151
+ },
152
+ '& ::placeholder': {
153
+ color: 'var(--grey800)',
154
+ opacity: 1,
155
+ }
156
+ }}
157
+ />
158
+ <Input
159
+ placeholder="Password"
160
+ onChange={(e) => setPassword(e.target.value)}
161
+ value={password}
162
+ type="password"
163
+ autoComplete="current-password"
164
+ onKeyPress={handleKeyPress}
165
+ sx={{
166
+ m: 0,
167
+ border: 'none',
168
+ typography: 'body1',
169
+ color: 'var(--grey800)',
170
+ display: 'flex',
171
+ bgcolor: 'white',
172
+ borderBottom: '2px solid var(--black)',
173
+ width: '100%',
174
+ minHeight: '52px',
175
+ '& input': {
176
+ pl: '12px',
177
+ height: '52px',
178
+ },
179
+ '& ::placeholder': {
180
+ color: 'var(--grey800)',
181
+ opacity: 1,
182
+ }
183
+ }}
184
+ />
185
+ <Button
186
+ variant="contained"
187
+ disableElevation
188
+ className="bold"
189
+ onClick={handleLogin}
190
+ disabled={loading}
191
+ aria-label={buttonLabel}
192
+ fullWidth
193
+ sx={{
194
+ pl: '12px',
195
+ m: 0,
196
+ mt: 3,
197
+ border: 'none',
198
+ typography: 'h4',
199
+ color: 'var(--black)',
200
+ display: 'flex',
201
+ justifyContent: 'flex-start',
202
+ bgcolor: 'var(--green)',
203
+ height: '56px',
204
+ borderRadius: '8px',
205
+ opacity: 1,
206
+ '&:hover': {
207
+ color: 'var(--green)',
208
+ bgcolor: 'var(--green)',
209
+ },
210
+ boxShadow: 'none',
211
+ '&:active': { boxShadow: 'none' },
212
+ '&:focus': { boxShadow: 'none' },
213
+ }}
214
+ >
215
+ {buttonLabel}
216
+ </Button>
132
217
 
133
- <Box sx={{ mb: 4 }}>
134
- <Input
135
- placeholder="Email"
136
- onChange={(e) => setUser(e.target.value)}
137
- value={user}
138
- type="email"
139
- autoComplete="username"
140
- onKeyPress={handleKeyPress}
141
- sx={{
142
- m: 0,
143
- mt: 1,
144
- border: 'none',
145
- typography: 'body1',
146
- color: 'var(--grey800)',
147
- display: 'flex',
148
- justifyContent: 'flex-start',
149
- bgcolor: 'white',
150
- borderBottom: '2px solid var(--black)',
151
- width: '100%',
152
- minHeight: '60px',
153
- '& input': {
154
- pl: '15px',
155
- height: '60px',
156
- },
157
- '& ::placeholder': {
158
- color: 'var(--grey800)',
159
- opacity: 1,
160
- }
161
- }}
162
- />
163
- <Input
164
- placeholder="Password"
165
- onChange={(e) => setPassword(e.target.value)}
166
- value={password}
167
- type="password"
168
- autoComplete="current-password"
169
- onKeyPress={handleKeyPress}
170
- sx={{
171
- m: 0,
172
- border: 'none',
173
- typography: 'body1',
174
- color: 'var(--grey800)',
175
- display: 'flex',
176
- justifyContent: 'flex-start',
177
- bgcolor: 'white',
178
- borderBottom: '2px solid var(--black)',
179
- width: '100%',
180
- minHeight: '60px',
181
- '& input': {
182
- pl: '15px',
183
- height: '60px',
184
- },
185
- '& ::placeholder': {
186
- color: 'var(--grey800)',
187
- opacity: 1,
188
- }
189
- }}
190
- />
191
- <Button
192
- variant="contained"
193
- className="bold"
194
- onClick={handleLogin}
195
- disabled={loading}
196
- aria-label={buttonLabel}
197
- sx={{
198
- pl: '15px',
199
- m: 0,
200
- border: 'none',
201
- typography: 'h4',
202
- color: 'var(--black)',
203
- display: 'flex',
204
- justifyContent: 'flex-start',
205
- bgcolor: 'var(--green)',
206
- height: '71px',
207
- width: '100%',
208
- borderRadius: '0px',
209
- opacity: 1,
210
- minHeight: '60px',
211
- '&:hover': {
212
- color: 'var(--green)',
213
- },
214
- boxShadow: 'none',
215
- }}
216
- >
217
- {buttonLabel}
218
- </Button>
218
+ {message !== '' && (
219
+ <Typography variant="body1" sx={{ color: 'var(--red)', mt: 2 }}>
220
+ {message}
221
+ </Typography>
222
+ )}
223
+ </Box>
224
+ </Box>
219
225
 
220
- {message !== '' && (
221
- <Typography variant="body1" sx={{ color: 'var(--red)', mt: 2 }}>
222
- {message}
226
+ {/* Right column - Info */}
227
+ <Box sx={{
228
+ flex: 1,
229
+ p: { xs: 4, sm: 6 },
230
+ bgcolor: 'rgba(0, 0, 0, 0.04)',
231
+ display: 'flex',
232
+ flexDirection: 'column',
233
+ justifyContent: 'center',
234
+ borderLeft: { sm: '1px solid rgba(0, 0, 0, 0.08)' },
235
+ borderTop: { xs: '1px solid rgba(0, 0, 0, 0.08)', sm: 'none' },
236
+ }}>
237
+ <Typography variant="body1" sx={{ textWrap: 'pretty', mb: 3, lineHeight: 1.7 }}>
238
+ {description}
239
+ </Typography>
240
+ <Typography variant="body2" sx={{ textWrap: 'pretty', opacity: 0.7, lineHeight: 1.6 }}>
241
+ {subtitle}
223
242
  </Typography>
224
- )}
225
- </Box>
226
-
227
- <Typography variant="body1" sx={{ textWrap: 'pretty', mb: 1 }}>
228
- {description}
229
- </Typography>
230
- <Typography variant="body2" sx={{ textWrap: 'pretty' }}>
231
- {subtitle}
232
- </Typography>
233
243
 
234
- {/* Version footer */}
235
- <Box sx={{ mt: 4, opacity: 0.5 }}>
236
- <Typography variant="body2" sx={{ fontSize: '0.75rem' }}>
237
- @liiift-studio/sales-portal v{version}
238
- </Typography>
244
+ {/* Version footer */}
245
+ <Box sx={{ mt: 'auto', pt: 4, opacity: 0.35 }}>
246
+ <Typography variant="body2" sx={{ fontSize: '0.7rem' }}>
247
+ v{version}
248
+ </Typography>
249
+ </Box>
250
+ </Box>
239
251
  </Box>
240
252
  </DialogContent>
241
253
  </Dialog>
@@ -29,7 +29,7 @@ dayjs.extend(utc);
29
29
  * Sales dashboard component
30
30
  */
31
31
  export default function Sales(props) {
32
- const { month, year, designer, updateDate, categories = false, admin = false } = props;
32
+ const { month, year, designer, updateDate, categories = false, admin = false, fetchDelay = 0 } = props;
33
33
 
34
34
  // UI State
35
35
  const [loadingStates, setLoadingStates] = useState({
@@ -44,9 +44,13 @@ export default function Sales(props) {
44
44
  });
45
45
  const [message, setMessage] = useState('');
46
46
  const [previousSalesError, setPreviousSalesError] = useState('');
47
+ const [retryInfo, setRetryInfo] = useState({ retrying: false, attempt: 0, label: '' });
47
48
  const [date, setDate] = useState(null);
48
49
  const [displayLosses, setDisplayLosses] = useState(false);
49
50
 
51
+ // Max retry attempts for failed API calls
52
+ const MAX_RETRIES = 3;
53
+
50
54
  // Helper to update individual loading states
51
55
  const updateLoadingState = useCallback((key, value) => {
52
56
  setLoadingStates(prev => ({
@@ -147,21 +151,40 @@ export default function Sales(props) {
147
151
  }
148
152
  }, [date, designer?.admin, month, year, updateDate]);
149
153
 
154
+ // Fetch with retry logic for rate-limited requests
155
+ async function fetchWithRetry(url, options, label, attempt = 0) {
156
+ const response = await fetch(url, options);
157
+
158
+ if (!response.ok && attempt < MAX_RETRIES) {
159
+ const delay = Math.pow(2, attempt) * 1000 + Math.random() * 500;
160
+ setRetryInfo({ retrying: true, attempt: attempt + 1, label });
161
+ await new Promise(resolve => setTimeout(resolve, delay));
162
+ return fetchWithRetry(url, options, label, attempt + 1);
163
+ }
164
+
165
+ setRetryInfo({ retrying: false, attempt: 0, label: '' });
166
+ return response;
167
+ }
168
+
150
169
  // Fetch current period sales data
151
170
  async function fetchSales(){
152
171
  if (!designer?.user || !designer?.password || !date) return;
153
172
  updateLoadingState('salesData', true);
154
173
  try {
155
- const response = await fetch('/api/sales-portal/getSales', {
156
- method: 'POST',
157
- headers: { 'Content-Type': 'application/json' },
158
- body: JSON.stringify({
159
- user: designer?.user,
160
- password: designer?.password,
161
- date,
162
- admin: designer?.admin
163
- }),
164
- });
174
+ const response = await fetchWithRetry(
175
+ '/api/sales-portal/getSales',
176
+ {
177
+ method: 'POST',
178
+ headers: { 'Content-Type': 'application/json' },
179
+ body: JSON.stringify({
180
+ user: designer?.user,
181
+ password: designer?.password,
182
+ date,
183
+ admin: designer?.admin
184
+ }),
185
+ },
186
+ 'Sales data'
187
+ );
165
188
  const data = await response.json();
166
189
 
167
190
  if (data.success) {
@@ -182,15 +205,15 @@ export default function Sales(props) {
182
205
  if (!designer?.user || !designer?.password || !date) {
183
206
  return;
184
207
  }
185
-
208
+
186
209
  updateLoadingState('previousSalesData', true);
187
210
  setPreviousSalesError(''); // Clear any previous errors
188
-
211
+
189
212
  // Calculate previous month date with more robust handling
190
213
  const previousDate = new Date(date.getTime()); // Create a copy
191
214
  const currentMonth = previousDate.getUTCMonth();
192
215
  const currentYear = previousDate.getUTCFullYear();
193
-
216
+
194
217
  // Handle edge cases for month boundaries
195
218
  if (currentMonth === 0) {
196
219
  // January -> December of previous year
@@ -199,23 +222,23 @@ export default function Sales(props) {
199
222
  } else {
200
223
  previousDate.setUTCMonth(currentMonth - 1);
201
224
  }
202
-
225
+
203
226
  try {
204
- const response = await fetch('/api/sales-portal/getSales', {
205
- method: 'POST',
206
- headers: { 'Content-Type': 'application/json' },
207
- body: JSON.stringify({
208
- user: designer?.user,
209
- password: designer?.password,
210
- date: previousDate,
211
- admin: designer?.admin
212
- }),
213
- });
214
-
215
- if (!response.ok) {
216
- throw new Error(`HTTP error! status: ${response.status}`);
217
- }
218
-
227
+ const response = await fetchWithRetry(
228
+ '/api/sales-portal/getSales',
229
+ {
230
+ method: 'POST',
231
+ headers: { 'Content-Type': 'application/json' },
232
+ body: JSON.stringify({
233
+ user: designer?.user,
234
+ password: designer?.password,
235
+ date: previousDate,
236
+ admin: designer?.admin
237
+ }),
238
+ },
239
+ 'Previous sales data'
240
+ );
241
+
219
242
  const data = await response.json();
220
243
 
221
244
  if (data.success) {
@@ -237,6 +260,13 @@ export default function Sales(props) {
237
260
  }
238
261
 
239
262
  useEffect(() => {
263
+ if (fetchDelay > 0) {
264
+ const timer = setTimeout(() => {
265
+ fetchSales();
266
+ fetchPreviousSales();
267
+ }, fetchDelay);
268
+ return () => clearTimeout(timer);
269
+ }
240
270
  fetchSales();
241
271
  fetchPreviousSales();
242
272
  }, [designer?.user, designer?.password, designer?.admin, date]);
@@ -623,6 +653,42 @@ export default function Sales(props) {
623
653
  </Box>
624
654
  </Box>
625
655
 
656
+ {/* Retry Message */}
657
+ {retryInfo.retrying && (
658
+ <Box sx={{ width: '100%', pb: 4 }}>
659
+ <Typography variant='body2' sx={{
660
+ color: 'var(--grey800, #666)',
661
+ mt: 2,
662
+ display: 'flex',
663
+ alignItems: 'center',
664
+ gap: 1,
665
+ }}>
666
+ <Box component="span" sx={{
667
+ '@keyframes dotPulse': {
668
+ '0%, 20%': { opacity: 0 },
669
+ '50%': { opacity: 1 },
670
+ '100%': { opacity: 0 },
671
+ },
672
+ display: 'inline-flex',
673
+ gap: '2px',
674
+ '& span': {
675
+ width: '4px',
676
+ height: '4px',
677
+ borderRadius: '50%',
678
+ bgcolor: 'var(--grey800, #666)',
679
+ display: 'inline-block',
680
+ animation: 'dotPulse 1.4s infinite',
681
+ },
682
+ '& span:nth-of-type(2)': { animationDelay: '0.2s' },
683
+ '& span:nth-of-type(3)': { animationDelay: '0.4s' },
684
+ }}>
685
+ <span /><span /><span />
686
+ </Box>
687
+ {retryInfo.label} — retrying (attempt {retryInfo.attempt} of {MAX_RETRIES})
688
+ </Typography>
689
+ </Box>
690
+ )}
691
+
626
692
  {/* Error Messages */}
627
693
  {!!(message !== '') &&
628
694
  <Box sx={{ width: '100%', pb: 8 }}>
@@ -637,7 +703,7 @@ export default function Sales(props) {
637
703
  </Box>
638
704
  }
639
705
  </Box>
640
- ), [loading, sales, total, designer, date, message, revenueChangePercent, previousSales]);
706
+ ), [loading, sales, total, designer, date, message, revenueChangePercent, previousSales, retryInfo]);
641
707
 
642
708
  return (
643
709
  <LocalizationProvider dateAdapter={AdapterDayjs}>
@@ -57,7 +57,7 @@ export default function SalesPortalPage({
57
57
  const isLoggedIn = !!designers;
58
58
 
59
59
  const content = (
60
- <>
60
+ <Box sx={{ minHeight: '100vh' }}>
61
61
  {/* Login Modal */}
62
62
  <LoginForm
63
63
  open={!isLoggedIn}
@@ -130,11 +130,12 @@ export default function SalesPortalPage({
130
130
  month={month}
131
131
  year={year}
132
132
  updateDate={updateDate}
133
+ fetchDelay={admin ? i * 2000 : 0}
133
134
  />
134
135
  ))}
135
136
  </Box>
136
137
  )}
137
- </>
138
+ </Box>
138
139
  );
139
140
 
140
141
  // Optionally wrap with theme provider
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liiift-studio/sales-portal",
3
- "version": "1.3.0",
3
+ "version": "1.3.4",
4
4
  "description": "Centralized sales portal package for Liiift Studio projects",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",