@liiift-studio/sales-portal 3.1.0 → 3.1.2

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,9 +1,34 @@
1
1
  /**
2
- * Utility functions for fetching data from Stripe API
2
+ * Utility functions for fetching data from Stripe API with batched secondary lookups
3
3
  */
4
4
 
5
5
  import { stripe } from './clients';
6
6
 
7
+ /** Maximum number of concurrent secondary API calls (disputes, refunds) */
8
+ const CONCURRENCY_LIMIT = 10;
9
+
10
+ /**
11
+ * Runs an array of async tasks with a concurrency limit
12
+ * @param {Array} items - Items to process
13
+ * @param {Function} fn - Async function to call for each item
14
+ * @param {number} limit - Max concurrent tasks
15
+ * @returns {Promise<Array>} Results in original order
16
+ */
17
+ async function mapWithConcurrency(items, fn, limit = CONCURRENCY_LIMIT) {
18
+ const results = [];
19
+ let index = 0;
20
+
21
+ async function next() {
22
+ const i = index++;
23
+ if (i >= items.length) return;
24
+ results[i] = await fn(items[i], i);
25
+ await next();
26
+ }
27
+
28
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => next()));
29
+ return results;
30
+ }
31
+
7
32
  /**
8
33
  * Fetches dispute details including all balance transactions
9
34
  * @param {Object} dispute - Stripe dispute object
@@ -12,28 +37,24 @@ import { stripe } from './clients';
12
37
  async function fetchDisputeDetails(dispute) {
13
38
  if (!dispute) return null;
14
39
 
15
- // Handle dispute as either a string ID or an object with .id
16
40
  const disputeId = typeof dispute === 'string' ? dispute : dispute.id;
17
41
  if (!disputeId) return null;
18
42
 
19
- // Get dispute with balance transactions
20
43
  const fullDispute = await stripe.disputes.retrieve(disputeId, {
21
44
  expand: ['balance_transactions']
22
45
  });
23
46
 
24
- // If there's a dispute charge, get its balance transaction
25
47
  if (fullDispute.charge) {
26
- const disputeCharge = await stripe.charges.retrieve(fullDispute.charge, {
27
- expand: ['balance_transaction']
28
- });
29
-
30
- // Get all balance transactions for this dispute
31
- const balanceTransactions = await stripe.balanceTransactions.list({
32
- type: 'dispute_fee',
33
- source: disputeCharge.id
34
- });
48
+ const [disputeCharge, balanceTransactions] = await Promise.all([
49
+ stripe.charges.retrieve(fullDispute.charge, {
50
+ expand: ['balance_transaction']
51
+ }),
52
+ stripe.balanceTransactions.list({
53
+ type: 'dispute_fee',
54
+ source: fullDispute.charge
55
+ })
56
+ ]);
35
57
 
36
- // Add balance transactions to the dispute object
37
58
  fullDispute.charge = disputeCharge;
38
59
  fullDispute.balance_transactions = [
39
60
  ...(fullDispute.balance_transactions || []),
@@ -44,6 +65,90 @@ async function fetchDisputeDetails(dispute) {
44
65
  return fullDispute;
45
66
  }
46
67
 
68
+ /**
69
+ * Enriches a batch of invoices with dispute and refund balance transaction details.
70
+ * Uses concurrency-limited parallel calls instead of sequential per-invoice awaits.
71
+ * @param {Array} invoices - Raw invoices from Stripe list
72
+ * @returns {Promise<Array>} Enriched invoices
73
+ */
74
+ async function enrichInvoices(invoices) {
75
+ return mapWithConcurrency(invoices, async (invoice) => {
76
+ const tasks = [];
77
+
78
+ // Dispute details
79
+ if (invoice.charge?.disputed) {
80
+ tasks.push(
81
+ fetchDisputeDetails(invoice.charge.dispute).then(d => {
82
+ invoice.charge.dispute = d;
83
+ })
84
+ );
85
+ }
86
+
87
+ // Refund balance transactions — batch all refunds for this invoice into one Promise.all
88
+ if (invoice.charge?.refunds?.data?.length) {
89
+ tasks.push(
90
+ Promise.all(
91
+ invoice.charge.refunds.data.map(refund =>
92
+ stripe.refunds.retrieve(refund.id, { expand: ['balance_transaction'] })
93
+ )
94
+ ).then(fullRefunds => {
95
+ invoice.charge.refunds.data = fullRefunds;
96
+ })
97
+ );
98
+ }
99
+
100
+ // Only re-fetch payment_intent if Stripe returned a bare string ID (not expanded)
101
+ if (typeof invoice.payment_intent === 'string') {
102
+ tasks.push(
103
+ stripe.paymentIntents.retrieve(invoice.payment_intent, {
104
+ expand: ['payment_method']
105
+ }).then(pi => {
106
+ invoice.payment_intent = pi;
107
+ }).catch(err => {
108
+ console.warn(`Could not expand payment intent for invoice ${invoice.id}:`, err.message);
109
+ })
110
+ );
111
+ }
112
+
113
+ if (tasks.length) await Promise.all(tasks);
114
+ return invoice;
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Enriches a batch of payment intents with dispute and refund balance transaction details.
120
+ * @param {Array} payments - Raw payment intents from Stripe list
121
+ * @returns {Promise<Array>} Enriched payment intents
122
+ */
123
+ async function enrichPaymentIntents(payments) {
124
+ return mapWithConcurrency(payments, async (payment) => {
125
+ const tasks = [];
126
+
127
+ if (payment.latest_charge?.disputed) {
128
+ tasks.push(
129
+ fetchDisputeDetails(payment.latest_charge.dispute).then(d => {
130
+ payment.latest_charge.dispute = d;
131
+ })
132
+ );
133
+ }
134
+
135
+ if (payment.latest_charge?.refunds?.data?.length) {
136
+ tasks.push(
137
+ Promise.all(
138
+ payment.latest_charge.refunds.data.map(refund =>
139
+ stripe.refunds.retrieve(refund.id, { expand: ['balance_transaction'] })
140
+ )
141
+ ).then(fullRefunds => {
142
+ payment.latest_charge.refunds.data = fullRefunds;
143
+ })
144
+ );
145
+ }
146
+
147
+ if (tasks.length) await Promise.all(tasks);
148
+ return payment;
149
+ });
150
+ }
151
+
47
152
  /**
48
153
  * Fetches all pages of Stripe invoices for a given date range
49
154
  * @param {Object} timeRange - Stripe-compatible date range
@@ -88,45 +193,13 @@ export async function fetchAllInvoices(timeRange, options = {}) {
88
193
  ? response.data.filter(invoice => invoice?.metadata?.authors?.includes(options.designerId))
89
194
  : response.data;
90
195
 
91
- // For each invoice with a dispute, fetch additional details
92
- const processedInvoices = await Promise.all(invoices.map(async invoice => {
93
- if (invoice.charge?.disputed) {
94
- invoice.charge.dispute = await fetchDisputeDetails(invoice.charge.dispute);
95
- }
96
-
97
- // For each refund, fetch its balance transaction
98
- if (invoice.charge?.refunds?.data?.length) {
99
- const refundsWithTransactions = await Promise.all(
100
- invoice.charge.refunds.data.map(async refund => {
101
- const fullRefund = await stripe.refunds.retrieve(refund.id, {
102
- expand: ['balance_transaction']
103
- });
104
- return fullRefund;
105
- })
106
- );
107
- invoice.charge.refunds.data = refundsWithTransactions;
108
- }
109
-
110
- // If payment_intent is a string, try to fetch the full object
111
- if (invoice.payment_intent && typeof invoice.payment_intent === 'string') {
112
- try {
113
- const paymentIntent = await stripe.paymentIntents.retrieve(invoice.payment_intent, {
114
- expand: ['payment_method']
115
- });
116
- invoice.payment_intent = paymentIntent;
117
- } catch (error) {
118
- console.warn(`Could not expand payment intent for invoice ${invoice.id}:`, error.message);
119
- }
120
- }
121
-
122
- return invoice;
123
- }));
124
-
125
- allInvoices = allInvoices.concat(processedInvoices);
196
+ // Enrich invoices with dispute/refund details (batched, concurrency-limited)
197
+ const enriched = await enrichInvoices(invoices);
198
+ allInvoices = allInvoices.concat(enriched);
199
+
126
200
  lastId = response.data[response.data.length - 1].id;
127
201
  hasMore = response.has_more;
128
202
  pageCount++;
129
-
130
203
  } else {
131
204
  hasMore = false;
132
205
  }
@@ -179,36 +252,16 @@ export async function fetchAllPaymentIntents(timeRange, options = {}) {
179
252
  // Filter succeeded payments and by designer if needed
180
253
  const payments = response.data
181
254
  .filter(pi => pi.status === 'succeeded')
182
- .filter(pi => !options.filterByDesigner || !options.designerId ||
255
+ .filter(pi => !options.filterByDesigner || !options.designerId ||
183
256
  pi?.metadata?.authors?.includes(options.designerId));
184
257
 
185
- // For each payment with a dispute, fetch additional details
186
- const processedPayments = await Promise.all(payments.map(async payment => {
187
- if (payment.latest_charge?.disputed) {
188
- payment.latest_charge.dispute = await fetchDisputeDetails(payment.latest_charge.dispute);
189
- }
190
-
191
- // For each refund, fetch its balance transaction
192
- if (payment.latest_charge?.refunds?.data?.length) {
193
- const refundsWithTransactions = await Promise.all(
194
- payment.latest_charge.refunds.data.map(async refund => {
195
- const fullRefund = await stripe.refunds.retrieve(refund.id, {
196
- expand: ['balance_transaction']
197
- });
198
- return fullRefund;
199
- })
200
- );
201
- payment.latest_charge.refunds.data = refundsWithTransactions;
202
- }
203
-
204
- return payment;
205
- }));
206
-
207
- allPayments = allPayments.concat(processedPayments);
258
+ // Enrich payments with dispute/refund details (batched, concurrency-limited)
259
+ const enriched = await enrichPaymentIntents(payments);
260
+ allPayments = allPayments.concat(enriched);
261
+
208
262
  lastId = response.data[response.data.length - 1].id;
209
263
  hasMore = response.has_more;
210
264
  pageCount++;
211
-
212
265
  } else {
213
266
  hasMore = false;
214
267
  }
@@ -29,7 +29,7 @@ dayjs.extend(utc);
29
29
  import styles from '../styles/sales-portal.module.scss';
30
30
  import { getCellValue } from './table-row-cells';
31
31
  import { COLUMNS } from './table-columns';
32
- import SummaryCards from './SummaryCards';
32
+ import Insights from './Insights';
33
33
  import TopPerformers from './TopPerformers';
34
34
  import TypefaceList from './TypefaceList';
35
35
  import LicenseTypeList from './LicenseTypeList';
@@ -379,7 +379,7 @@ export function DateRangeSalesTable({ designer, admin, loading, updateLoadingSta
379
379
  </Box>
380
380
 
381
381
  {/* Date Range Pickers */}
382
- <Box sx={{ width: { xs: '100%', sm: '50%', md: '33.33%' }, display: 'flex', gap: 2, mb: 8, justifyContent: 'flex-end', alignItems: 'center' }}>
382
+ <Box sx={{ width: { xs: '100%', sm: '50%', md: '33.33%' }, display: 'flex', flexWrap: 'wrap', gap: { xs: 1, sm: 2 }, mb: { xs: 4, sm: 8 }, justifyContent: { xs: 'flex-start', sm: 'flex-end' }, alignItems: 'center' }}>
383
383
  {!startDate && !endDate && (
384
384
  <>
385
385
  <Button
@@ -507,9 +507,11 @@ export function DateRangeSalesTable({ designer, admin, loading, updateLoadingSta
507
507
  <Box sx={{ width: '100%', mb: 2 }}>
508
508
  <Box sx={{
509
509
  display: 'inline-block',
510
- p: 2,
511
- ml: -2,
510
+ p: { xs: 1.5, sm: 2 },
511
+ ml: { xs: 0, sm: -2 },
512
512
  borderRadius: '4px',
513
+ maxWidth: '100%',
514
+ boxSizing: 'border-box',
513
515
  border: reconciliationData.isReconciled
514
516
  ? '2px solid var(--green, green)'
515
517
  : '2px solid var(--red, red)',
@@ -618,8 +620,8 @@ export function DateRangeSalesTable({ designer, admin, loading, updateLoadingSta
618
620
  {/* Summary Dashboard and Analysis Components - only show when data is loaded */}
619
621
  {!!dateRangeSales.length && (
620
622
  <>
621
- {/* Summary Cards */}
622
- <SummaryCards
623
+ {/* Insights */}
624
+ <Insights
623
625
  sales={dateRangeSales}
624
626
  previousSales={[]} // No previous data in date range view
625
627
  loading={loading}
@@ -1,4 +1,4 @@
1
- // Summary dashboard cards displaying key sales metrics at a glance with country code tooltips
1
+ // Insights cards displaying key sales metrics at a glance with country code tooltips
2
2
  import React from 'react';
3
3
  import {
4
4
  Box,
@@ -33,7 +33,7 @@ const getCountryName = (code) => {
33
33
  };
34
34
 
35
35
  /**
36
- * Summary dashboard component showing key sales metrics with period-over-period comparison
36
+ * Insights component showing key sales metrics with period-over-period comparison
37
37
  * @param {Object} props - Component props
38
38
  * @param {Array} props.sales - Current period sales data
39
39
  * @param {Array} props.previousSales - Previous period sales data for comparison
@@ -41,9 +41,9 @@ const getCountryName = (code) => {
41
41
  * @param {Object} props.chartState - Chart data state
42
42
  * @param {Date} props.date - Current date being displayed
43
43
  * @param {Object} props.locationData - Data about sales by location
44
- * @returns {JSX.Element} Summary cards component
44
+ * @returns {JSX.Element} Insights component
45
45
  */
46
- export default function SummaryCards({
46
+ export default function Insights({
47
47
  sales = [],
48
48
  previousSales = [],
49
49
  loading = false,
@@ -151,34 +151,34 @@ export default function SummaryCards({
151
151
  value: formatCurrency(grossRevenue),
152
152
  change: revenueChange,
153
153
  tooltip: hasPreviousData ? `Previous: ${formatCurrency(previousRevenue)}` : 'No prior data available',
154
- bgcolor: 'var(--black, black)',
154
+ bgcolor: '#000000',
155
155
  },
156
156
  {
157
157
  title: 'Orders',
158
158
  value: orderCount,
159
159
  change: orderCountChange,
160
160
  tooltip: hasPreviousData ? `Previous: ${previousOrderCount}` : 'No prior data available',
161
- bgcolor: 'var(--black, black)',
161
+ bgcolor: '#000000',
162
162
  },
163
163
  {
164
164
  title: 'Avg. Order Value',
165
165
  value: formatCurrency(averageOrderValue),
166
166
  change: avgOrderValueChange,
167
167
  tooltip: hasPreviousData ? `Previous: ${formatCurrency(previousAvgOrderValue)}` : 'No prior data available',
168
- bgcolor: 'var(--black, black)',
168
+ bgcolor: '#000000',
169
169
  },
170
170
  {
171
171
  title: 'Discount Rate',
172
172
  value: `${discountRate.toFixed(1)}%`,
173
173
  tooltip: `Total discounts: ${formatCurrency(discountTotal)}`,
174
- bgcolor: 'var(--black, black)',
174
+ bgcolor: '#000000',
175
175
  },
176
176
  {
177
177
  title: 'Refund Rate',
178
178
  value: `${refundRate.toFixed(1)}%`,
179
179
  change: refundChange,
180
180
  tooltip: `Total refunds: ${formatCurrency(refundTotal)}`,
181
- bgcolor: 'var(--black, black)',
181
+ bgcolor: '#000000',
182
182
  },
183
183
  {
184
184
  title: 'Top Location',
@@ -187,7 +187,7 @@ export default function SummaryCards({
187
187
  tooltip: previousTopLocation ?
188
188
  `Previous top location: ${previousTopLocation} (${previousTopLocationPercentage.toFixed(1)}%)` :
189
189
  'No prior data available',
190
- bgcolor: 'var(--black, black)',
190
+ bgcolor: '#000000',
191
191
  isLocationCard: true,
192
192
  locationCode: topLocation,
193
193
  }
@@ -221,7 +221,7 @@ export default function SummaryCards({
221
221
  placement="top"
222
222
  arrow
223
223
  >
224
- <span style={{ color: "var(--white, white)", borderBottom: '2px solid rgba(255,255,255,.45)', cursor: 'help' }}>{code}</span>
224
+ <span style={{ color: "var(--white, white)", borderBottom: '2px solid rgba(var(--whiteRGB, 255, 255, 255), .45)', cursor: 'help' }}>{code}</span>
225
225
  </Tooltip>
226
226
  </>
227
227
  );
@@ -235,7 +235,7 @@ export default function SummaryCards({
235
235
  sx={{
236
236
  display: 'flex',
237
237
  flexWrap: 'wrap',
238
- gap: isMobile ? 1 : 2,
238
+ gap: isMobile ? 1.5 : 2,
239
239
  mt: 2,
240
240
  position: 'relative',
241
241
  '&[data-loading="true"]': {
@@ -259,77 +259,62 @@ export default function SummaryCards({
259
259
 
260
260
  {/* Metric cards */}
261
261
  {metricCards.map((card, index) => (
262
- <Box key={`metric-${index}`} sx={{ width: { xs: 'calc(50% - 8px)', md: 'calc(33.33% - 11px)' } }}>
262
+ <Box key={`metric-${index}`} sx={{ width: { xs: '100%', sm: 'calc(50% - 8px)', md: 'calc(33.33% - 11px)' } }}>
263
263
  <Tooltip
264
264
  title={card.tooltip}
265
265
  placement="top"
266
266
  arrow
267
267
  >
268
- <Paper
269
- elevation={0}
270
- sx={{
271
- p: 4,
272
- textAlign: 'center',
268
+ <Paper
269
+ elevation={0}
270
+ sx={{
271
+ p: { xs: 1.5, sm: 4 },
272
+ textAlign: { xs: 'left', sm: 'center' },
273
273
  borderRadius: '4px',
274
- border: '1px solid rgba(255, 255, 255, 0.12)',
274
+ border: '1px solid rgba(var(--whiteRGB, 255, 255, 255), 0.12)',
275
275
  backgroundColor: card.bgcolor,
276
+ display: { xs: 'flex', sm: 'block' },
277
+ alignItems: 'center',
278
+ justifyContent: 'space-between',
279
+ gap: 1,
276
280
  '&:hover': {
277
- boxShadow: '0 4px 8px rgba(0, 0, 0, 0.3)',
278
- transform: 'translateY(-2px)',
281
+ boxShadow: '0 4px 8px rgba(var(--blackRGB, 0, 0, 0), 0.3)',
282
+ transform: { sm: 'translateY(-2px)' },
279
283
  transition: 'all 0.3s ease'
280
284
  }
281
285
  }}
282
286
  >
283
- <Box sx={{
284
- display: 'flex',
285
- alignItems: 'center',
286
- justifyContent: 'center',
287
- }}>
288
- <Typography
289
- variant="h6" color="rgba(255, 255, 255, 0.7)"
290
- sx={{
291
- whiteSpace: 'nowrap',
292
- textOverflow: 'ellipsis',
293
- overflow: 'hidden',
294
- }}
295
- >
296
- {card.title}
297
- </Typography>
298
- </Box>
299
- <Box sx={{
300
- display: 'flex',
301
- alignItems: 'center',
302
- justifyContent: 'center',
303
- }}>
304
- <Typography
305
- variant={isMobile ? "h6" : "h5"}
287
+ <Typography
288
+ variant="body2" color="rgba(var(--whiteRGB, 255, 255, 255), 0.7)"
289
+ sx={{
290
+ whiteSpace: 'nowrap',
291
+ textOverflow: 'ellipsis',
292
+ overflow: 'hidden',
293
+ fontSize: { xs: '0.8rem', sm: '1rem' },
294
+ }}
295
+ >
296
+ {card.title}
297
+ </Typography>
298
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, justifyContent: { xs: 'flex-end', sm: 'center' } }}>
299
+ <Typography
300
+ variant="body1"
306
301
  fontWeight="bold"
307
302
  sx={{
308
- fontSize: {
309
- xs: '1.1rem',
310
- sm: '1.3rem',
311
- md: '1.5rem'
312
- },
313
- color: 'rgba(255, 255, 255, 0.95)'
303
+ fontSize: { xs: '0.95rem', sm: '1.3rem', md: '1.5rem' },
304
+ color: 'rgba(var(--whiteRGB, 255, 255, 255), 0.95)',
305
+ whiteSpace: 'nowrap',
314
306
  }}
315
307
  >
316
308
  {card.isLocationCard ? renderLocationValue(card.locationCode, card.value) : card.value}
317
309
  </Typography>
318
- </Box>
319
-
320
- <Box sx={{
321
- display: 'flex',
322
- alignItems: 'center',
323
- justifyContent: 'center',
324
- }}>
325
- <Typography
326
- variant="caption"
327
- sx={{
328
- mt: 0.5,
310
+ <Typography
311
+ variant="caption"
312
+ sx={{
313
+ display: { xs: 'none', sm: 'block' },
329
314
  opacity: (card.change !== null && card.change !== undefined) ? 1 : 0.25,
330
- color: card.change > 0 ? 'var(--green, #4caf50)' :
331
- card.change < 0 ? 'var(--red, #f44336)' :
332
- 'rgba(255, 255, 255, 0.6)'
315
+ color: card.change > 0 ? 'var(--green, #4caf50)' :
316
+ card.change < 0 ? 'var(--red, #f44336)' :
317
+ 'rgba(var(--whiteRGB, 255, 255, 255), 0.6)'
333
318
  }}
334
319
  >
335
320
  {(card.change !== null && card.change !== undefined) ? `${formatPercentage(card.change)} vs prior` : <span>&nbsp;</span>}
@@ -76,17 +76,19 @@ export default function LicenseTypeList({ licenseTypeData, sales, loading, admin
76
76
  <Box sx={{
77
77
  background: "rgba(var(--blackRGB, 0,0,0), 1)",
78
78
  color: "var(--white, white)",
79
- padding: "5px 10px 10px",
79
+ padding: { xs: "8px 12px", sm: "5px 10px 10px" },
80
80
  display: "flex",
81
81
  justifyContent: "space-between",
82
- alignItems: "center"
82
+ alignItems: "center",
83
+ flexWrap: { xs: 'wrap', sm: 'nowrap' },
84
+ gap: 1,
83
85
  }}>
84
- <Typography variant='h5'>
86
+ <Typography variant='h5' sx={{ fontSize: { xs: '1rem', sm: '1.25rem' } }}>
85
87
  <strong>License Type</strong>
86
88
  </Typography>
87
- <Stack direction="row" spacing={2} alignItems="center">
88
- <FormControl variant="filled" size="small" sx={{
89
- minWidth: 120,
89
+ <Stack direction="row" spacing={1} alignItems="center">
90
+ <FormControl variant="filled" size="small" sx={{
91
+ minWidth: { xs: 90, sm: 120 },
90
92
  '& .MuiFilledInput-root': { color: "var(--white, white)"},
91
93
  '& .MuiFormLabel-root': { color: 'rgba(255,255,255,0.7)' },
92
94
  '& .MuiSelect-icon': { display: 'none' },
@@ -137,10 +139,11 @@ export default function LicenseTypeList({ licenseTypeData, sales, loading, admin
137
139
  key={`license-total-${i}`}
138
140
  variant='h5'
139
141
  sx={{
140
- color: 'var(--black, black)',
141
- whiteSpace: "nowrap",
142
+ color: 'var(--black, black)',
143
+ whiteSpace: { xs: "normal", md: "nowrap" },
142
144
  overflow: "hidden",
143
- textOverflow: "ellipsis",
145
+ textOverflow: "ellipsis",
146
+ fontSize: { xs: '0.85rem', sm: '1rem', md: '1.25rem' },
144
147
  paddingTop: licenseTypeData[i-1] && license.name[0] !== licenseTypeData[i-1].name[0] ? "0.5em" : "",
145
148
  }}
146
149
  >
@@ -104,7 +104,7 @@ export default function LoginForm({
104
104
  <Box sx={{
105
105
  width: '100%',
106
106
  maxWidth: '860px',
107
- borderRadius: '12px',
107
+ borderRadius: { xs: 0, sm: '12px' },
108
108
  overflow: 'hidden',
109
109
  boxShadow: '0 8px 32px rgba(0, 0, 0, 0.12)',
110
110
  bgcolor: 'var(--white, white)',
@@ -207,9 +207,9 @@ export default function LoginForm({
207
207
  justifyContent: 'center',
208
208
  alignItems: 'center',
209
209
  bgcolor: 'var(--green)',
210
- minHeight: '64px',
210
+ minHeight: { xs: '52px', sm: '64px' },
211
211
  maxWidth: 'initial',
212
- borderRadius: '0 0 0 12px',
212
+ borderRadius: { xs: '0 0 0 0', sm: '0 0 0 12px' },
213
213
  opacity: 1,
214
214
  flexShrink: 0,
215
215
  '&:hover': {
@@ -22,7 +22,7 @@ import YearOverview from './YearOverview';
22
22
  import SalesChart from './SalesChart';
23
23
  import TopPerformers from './TopPerformers';
24
24
  import TypefaceList from './TypefaceList';
25
- import SummaryCards from './SummaryCards';
25
+ import Insights from './Insights';
26
26
  import LicenseTypeList from './LicenseTypeList';
27
27
 
28
28
  dayjs.extend(utc);
@@ -216,8 +216,11 @@ export default function Sales(props) {
216
216
  setMessage('Error retrieving sales data');
217
217
  }
218
218
  } catch (err) {
219
- setMessage('Error retrieving sales data');
220
- console.error(err);
219
+ // Ignore aborted requests (e.g. from component unmount during resize)
220
+ if (err.name !== 'AbortError' && err.message !== 'Failed to fetch') {
221
+ setMessage('Error retrieving sales data');
222
+ console.error(err);
223
+ }
221
224
  } finally {
222
225
  updateLoadingState('salesData', false);
223
226
  }
@@ -277,6 +280,8 @@ export default function Sales(props) {
277
280
  setPreviousSales([]); // Clear any stale data
278
281
  }
279
282
  } catch (err) {
283
+ // Ignore aborted requests (e.g. from component unmount during resize)
284
+ if (err.name === 'AbortError' || err.message === 'Failed to fetch') return;
280
285
  const errorMsg = `Error retrieving previous sales data: ${err.message}`;
281
286
  console.error(errorMsg, err);
282
287
  setPreviousSalesError(errorMsg);
@@ -534,7 +539,7 @@ export default function Sales(props) {
534
539
  {/* Header Section */}
535
540
  <Box data-disabled={loading} data-loading={loading}
536
541
  sx={{
537
- width: { xs: '66.67%', md: '66.67%' },
542
+ width: { xs: '100%', sm: '66.67%' },
538
543
  ".show-hover": { display: "none" },
539
544
  "&:hover .show-hover": { display: "inline", opacity: "0.5" },
540
545
  }}
@@ -588,13 +593,14 @@ export default function Sales(props) {
588
593
  {/* Date Picker and View Controls */}
589
594
  <Box
590
595
  sx={{
591
- width: { xs: '33.33%', md: '33.33%' },
592
- justifyContent: "end",
596
+ width: { xs: '100%', sm: '33.33%' },
597
+ justifyContent: { xs: 'flex-start', sm: 'flex-end' },
593
598
  display: "flex",
599
+ mt: { xs: 2, sm: 0 },
594
600
  }}
595
601
  data-disabled={loading}
596
602
  >
597
- <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
603
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0.5, sm: 1 }, flexWrap: { xs: 'wrap', sm: 'nowrap' }, justifyContent: { xs: 'flex-start', sm: 'flex-end' } }}>
598
604
  <IconButton
599
605
  onClick={() => {
600
606
  const newDate = new Date(date);
@@ -606,7 +612,7 @@ export default function Sales(props) {
606
612
  setDate(newDate);
607
613
  }}
608
614
  size="small"
609
- sx={{ display: { xs: 'none', md: 'inherit' } }}
615
+ sx={{ minWidth: '44px', minHeight: '44px', display: { xs: 'none', sm: 'flex' } }}
610
616
  >
611
617
  <ChevronLeftIcon />
612
618
  </IconButton>
@@ -615,8 +621,8 @@ export default function Sales(props) {
615
621
  views={viewMode === 'year' ? ['year'] : ['month', 'year']}
616
622
  format={viewMode === 'year' ? 'YYYY' : 'MMM YYYY'}
617
623
  formatDensity="dense"
618
- slotProps={{ textField: { variant: "filled" } }}
619
- sx={{ "& *": { borderRadius: "4px" } }}
624
+ slotProps={{ textField: { variant: "filled", size: "small" } }}
625
+ sx={{ "& *": { borderRadius: "4px" }, maxWidth: { xs: '160px', sm: 'none' }, flex: { xs: 1, sm: 'none' } }}
620
626
  value={dayjs.utc(date)}
621
627
  onChange={(newValue) => setDate(newValue.toDate())}
622
628
  />
@@ -631,15 +637,15 @@ export default function Sales(props) {
631
637
  setDate(newDate);
632
638
  }}
633
639
  size="small"
634
- sx={{ display: { xs: 'none', md: 'inherit' } }}
640
+ sx={{ minWidth: '44px', minHeight: '44px', display: { xs: 'none', sm: 'flex' } }}
635
641
  >
636
642
  <ChevronRightIcon />
637
643
  </IconButton>
638
644
  {!!sales.length && (
639
- <Tooltip
645
+ <Tooltip
640
646
  title="Print to PDF"
641
- sx={{ display: { xs: 'none', md: 'inherit' } }}
642
647
  >
648
+ <Box sx={{ display: { xs: 'none', md: 'flex' } }}>
643
649
  <IconButton
644
650
  className='print-button'
645
651
  onClick={() => {
@@ -658,36 +664,50 @@ export default function Sales(props) {
658
664
  size: letter portrait;
659
665
  margin: 0.5in;
660
666
  }
661
- header, #navBar, footer, #footer, #titleContainer, .exportSection,
667
+ /* Hide everything except this sales portal */
668
+ header, nav, footer, #navBar, #footer, #titleContainer,
669
+ .exportSection, .date-range-sales-table-wrapper,
662
670
  .salesPortal:not(:nth-child(${portalIndex})) {
663
- display: none!important;
671
+ display: none !important;
664
672
  }
665
- .salesPortal, .salesPortalWrap{
666
- background: white;
673
+ .salesPortal, .salesPortalWrap {
674
+ background: white !important;
667
675
  }
668
676
  body {
669
677
  -webkit-print-color-adjust: exact !important;
670
678
  print-color-adjust: exact !important;
671
679
  }
680
+ /* Clean up layout */
672
681
  .salesPortal {
673
682
  padding: 0 !important;
683
+ width: 100% !important;
674
684
  }
675
685
  .salesPortal > * {
676
686
  page-break-inside: avoid;
677
687
  }
678
- /* Hide non-printable elements */
679
- button, .MuiIconButton-root, .DatePicker {
688
+ /* Hide interactive elements */
689
+ button, .MuiIconButton-root, .MuiInput-root,
690
+ .MuiDateCalendar-root, .print-button,
691
+ .year-overview-wrapper, .sales-chart-wrapper {
680
692
  display: none !important;
681
693
  }
682
- /* Ensure charts and tables fit on page */
683
- .salesSection {
694
+ /* Full width sections */
695
+ .sales-header-section, .sales-data-section,
696
+ .insights-wrapper, .sales-table-wrapper {
684
697
  width: 100% !important;
685
- margin: 20px 0 !important;
698
+ padding: 0 !important;
699
+ margin: 10px 0 !important;
686
700
  }
687
- /* Ensure text is readable */
701
+ /* Readable text */
688
702
  .MuiTypography-root {
689
703
  color: black !important;
690
704
  }
705
+ /* Compact cards for print */
706
+ .MuiPaper-root {
707
+ box-shadow: none !important;
708
+ border: 1px solid #ddd !important;
709
+ page-break-inside: avoid;
710
+ }
691
711
  }
692
712
  `;
693
713
  document.head.appendChild(style);
@@ -707,6 +727,7 @@ export default function Sales(props) {
707
727
  >
708
728
  <PrintIcon />
709
729
  </IconButton>
730
+ </Box>
710
731
  </Tooltip>
711
732
  )}
712
733
  {/* Month/Year toggle */}
@@ -722,10 +743,10 @@ export default function Sales(props) {
722
743
  key={mode}
723
744
  onClick={() => setViewMode(mode)}
724
745
  sx={{
725
- px: 1.5,
726
- py: 0.5,
746
+ px: { xs: 2, sm: 1.5 },
747
+ py: { xs: 1, sm: 0.5 },
727
748
  cursor: 'pointer',
728
- fontSize: '0.75rem',
749
+ fontSize: { xs: '0.85rem', sm: '0.75rem' },
729
750
  fontWeight: viewMode === mode ? 'bold' : 'normal',
730
751
  bgcolor: viewMode === mode ? 'var(--black, #1a1a1a)' : 'transparent',
731
752
  color: viewMode === mode ? 'var(--white, white)' : 'inherit',
@@ -860,9 +881,9 @@ export default function Sales(props) {
860
881
  </Box>
861
882
  )}
862
883
 
863
- {/* Summary Dashboard */}
864
- <Box className="summary-cards-wrapper">
865
- <SummaryCards
884
+ {/* Insights */}
885
+ <Box className="insights-wrapper">
886
+ <Insights
866
887
  sales={sales}
867
888
  previousSales={previousSales}
868
889
  loading={loading}
@@ -166,9 +166,11 @@ export function SalesTable({ sales = [], designer = {}, admin = false, loading =
166
166
  <Box sx={{ width: '100%', mb: 2 }}>
167
167
  <Box sx={{
168
168
  display: 'inline-block',
169
- p: 2,
169
+ p: { xs: 1.5, sm: 2 },
170
170
  mt: '20px',
171
171
  borderRadius: '4px',
172
+ maxWidth: '100%',
173
+ boxSizing: 'border-box',
172
174
  border: reconciliationData.isReconciled
173
175
  ? '2px solid var(--green, green)'
174
176
  : '2px solid var(--red, red)',
@@ -110,18 +110,20 @@ export default function TopPerformers({
110
110
  <Box sx={{
111
111
  background: "rgba(var(--blackRGB, 0,0,0), 1)",
112
112
  color: "var(--white, white)",
113
- padding: "5px 10px 10px",
113
+ padding: { xs: "8px 12px", sm: "5px 10px 10px" },
114
114
  display: "flex",
115
115
  justifyContent: "space-between",
116
- alignItems: "center"
116
+ alignItems: "center",
117
+ flexWrap: { xs: 'wrap', sm: 'nowrap' },
118
+ gap: 1,
117
119
  }}>
118
- <Typography variant='h5' sx={{color: "var(--white, white)"}}>
120
+ <Typography variant='h5' sx={{ color: "var(--white, white)", fontSize: { xs: '1rem', sm: '1.25rem' } }}>
119
121
  <strong>Designers</strong>
120
122
  </Typography>
121
- <Stack direction="row" spacing={2} alignItems="center">
122
-
123
- <FormControl variant="filled" size="small" sx={{
124
- minWidth: 120,
123
+ <Stack direction="row" spacing={1} alignItems="center">
124
+
125
+ <FormControl variant="filled" size="small" sx={{
126
+ minWidth: { xs: 90, sm: 120 },
125
127
  '& .MuiFilledInput-root': { color: "var(--white, white)" },
126
128
  '& .MuiFormLabel-root': { color: 'rgba(255,255,255,0.7)' },
127
129
  '& .MuiSelect-icon': { display: 'none' }
@@ -159,7 +161,7 @@ export default function TopPerformers({
159
161
  </Box>
160
162
  <Box sx={{
161
163
  background: "rgba(var(--blackRGB, 0,0,0), 0.06)",
162
- padding: "10px 10px 20px",
164
+ padding: { xs: "8px 12px 16px", sm: "10px 10px 20px" },
163
165
  }}>
164
166
  {
165
167
  sortedDesigners.map((designer, i) => {
@@ -173,10 +175,11 @@ export default function TopPerformers({
173
175
  <Typography
174
176
  variant='h5'
175
177
  sx={{
176
- whiteSpace: "nowrap",
178
+ whiteSpace: { xs: "normal", md: "nowrap" },
177
179
  overflow: "hidden",
178
180
  textOverflow: "ellipsis",
179
- paddingBottom: "0.5em"
181
+ paddingBottom: "0.5em",
182
+ fontSize: { xs: '0.85rem', sm: '1rem', md: '1.25rem' },
180
183
  }}>
181
184
  {designer?.firstName && designer?.lastName ?
182
185
  `${designer.firstName} ${designer.lastName}`
@@ -199,9 +202,10 @@ export default function TopPerformers({
199
202
  key={`designer-typeface-${i}-${j}`}
200
203
  variant='h6'
201
204
  sx={{
202
- whiteSpace: "nowrap",
205
+ whiteSpace: { xs: "normal", md: "nowrap" },
203
206
  overflow: "hidden",
204
207
  textOverflow: "ellipsis",
208
+ fontSize: { xs: '0.8rem', sm: '0.9rem', md: '1rem' },
205
209
  }}>
206
210
  {typeface.title}
207
211
  <span className={styles.earningContainer}>
@@ -240,17 +244,19 @@ export default function TopPerformers({
240
244
  <Box sx={{
241
245
  background: "rgba(var(--blackRGB, 0,0,0), 1)",
242
246
  color: "var(--white, white)",
243
- padding: "5px 10px 10px",
247
+ padding: { xs: "8px 12px", sm: "5px 10px 10px" },
244
248
  display: "flex",
245
249
  justifyContent: "space-between",
246
- alignItems: "center"
250
+ alignItems: "center",
251
+ flexWrap: { xs: 'wrap', sm: 'nowrap' },
252
+ gap: 1,
247
253
  }}>
248
- <Typography variant='h5'>
254
+ <Typography variant='h5' sx={{ fontSize: { xs: '1rem', sm: '1.25rem' } }}>
249
255
  <strong>Top Performers</strong>
250
256
  </Typography>
251
- <Stack direction="row" spacing={2} alignItems="center">
252
-
253
- <FormControl
257
+ <Stack direction="row" spacing={1} alignItems="center">
258
+
259
+ <FormControl
254
260
  variant="filled"
255
261
  size="small"
256
262
  sx={{
@@ -64,17 +64,19 @@ export default function TypefaceList({ typefaceData, sales, loading, admin }) {
64
64
  <Box sx={{
65
65
  background: "rgba(var(--blackRGB, 0,0,0), 1)",
66
66
  color: "var(--white, white)",
67
- padding: "5px 10px 10px",
67
+ padding: { xs: "8px 12px", sm: "5px 10px 10px" },
68
68
  display: "flex",
69
69
  justifyContent: "space-between",
70
- alignItems: "center"
70
+ alignItems: "center",
71
+ flexWrap: { xs: 'wrap', sm: 'nowrap' },
72
+ gap: 1,
71
73
  }}>
72
- <Typography variant='h5'>
74
+ <Typography variant='h5' sx={{ fontSize: { xs: '1rem', sm: '1.25rem' } }}>
73
75
  <strong>Products</strong>
74
76
  </Typography>
75
- <Stack direction="row" spacing={2} alignItems="center">
76
- <FormControl variant="filled" size="small" sx={{
77
- minWidth: 120,
77
+ <Stack direction="row" spacing={1} alignItems="center">
78
+ <FormControl variant="filled" size="small" sx={{
79
+ minWidth: { xs: 90, sm: 120 },
78
80
  '& .MuiFilledInput-root': { color: "var(--white, white)"},
79
81
  '& .MuiFormLabel-root': { color: 'rgba(255,255,255,0.7)' },
80
82
  '& .MuiSelect-icon': { display: 'none' },
@@ -118,10 +120,11 @@ export default function TypefaceList({ typefaceData, sales, loading, admin }) {
118
120
  key={`typeface-total-${i}`}
119
121
  variant='h5'
120
122
  sx={{
121
- color: 'var(--black, black)',
122
- whiteSpace: "nowrap",
123
+ color: 'var(--black, black)',
124
+ whiteSpace: { xs: "normal", md: "nowrap" },
123
125
  overflow: "hidden",
124
- textOverflow: "ellipsis",
126
+ textOverflow: "ellipsis",
127
+ fontSize: { xs: '0.85rem', sm: '1rem', md: '1.25rem' },
125
128
  paddingTop: typefaceData[i-1] && typeface.title[0] !== typefaceData[i-1].title[0] ? "0.5em" : "",
126
129
  "& .designer": {display: "none"},
127
130
  "&:hover .designer": {display: "inline"}
@@ -155,7 +155,7 @@ export default function YearOverview({ designer, year, onMonthClick }) {
155
155
 
156
156
  {/* Stacked bar chart */}
157
157
  {series.length > 0 ? (
158
- <Box sx={{ width: '100%', height: { xs: 250, sm: 350 } }}>
158
+ <Box sx={{ width: '100%', height: { xs: 200, sm: 300, md: 350 } }}>
159
159
  <ResponsiveChartContainer
160
160
  series={series}
161
161
  xAxis={[{
@@ -215,8 +215,8 @@ export default function YearOverview({ designer, year, onMonthClick }) {
215
215
  sx={{
216
216
  display: 'flex',
217
217
  justifyContent: 'space-between',
218
- py: 0.75,
219
- px: 1,
218
+ py: { xs: 1.5, sm: 0.75 },
219
+ px: { xs: 1.5, sm: 1 },
220
220
  borderBottom: '1px solid rgba(0,0,0,0.06)',
221
221
  cursor: 'pointer',
222
222
  '&:hover': { bgcolor: 'rgba(0,0,0,0.03)' },
@@ -227,7 +227,7 @@ export default function YearOverview({ designer, year, onMonthClick }) {
227
227
  {MONTH_LABELS[i]} {year}
228
228
  {m.error && <span style={{ color: 'var(--red, red)', marginLeft: 8 }}>error</span>}
229
229
  </Typography>
230
- <Box sx={{ display: 'flex', gap: 3 }}>
230
+ <Box sx={{ display: 'flex', gap: { xs: 1.5, sm: 3 } }}>
231
231
  <Typography variant="body2" sx={{ opacity: 0.5 }}>
232
232
  {m.salesCount} sale{m.salesCount !== 1 ? 's' : ''}
233
233
  </Typography>
@@ -12,7 +12,7 @@ export { calculateGrossSales };
12
12
  */
13
13
  function buildChecks({ sales, diagnostics, grossSales, totalBalanceChange, difference, flaggedTransactions }) {
14
14
  const checks = [];
15
- const uniqueChargeIds = new Set(sales.map(s => s.id)).size;
15
+ const uniqueChargeIds = new Set(sales.filter(s => (s.chargeAmount || s.invoiceTotalWithTax || 0) > 0).map(s => s.id)).size;
16
16
  const salesRefundIds = new Set();
17
17
  sales.forEach(s => s.refunds?.forEach(r => r.id && salesRefundIds.add(r.id)));
18
18
  const testSales = sales.filter(s => s.testSale);
@@ -20,7 +20,7 @@ function buildChecks({ sales, diagnostics, grossSales, totalBalanceChange, diffe
20
20
  // 1. Transaction count match
21
21
  const countMatch = uniqueChargeIds === diagnostics.chargeCount;
22
22
  checks.push({
23
- label: 'Transaction count',
23
+ label: 'Transaction count (>$0)',
24
24
  status: countMatch ? 'pass' : 'fail',
25
25
  detail: `${uniqueChargeIds} sales vs ${diagnostics.chargeCount} Stripe charges`,
26
26
  tip: !countMatch ? 'Mismatch may indicate missing orders in Sanity or charges outside this period.' : null,
@@ -184,7 +184,9 @@ export function useReconciliation({ designer, admin, sales, date, dateRange }) {
184
184
  };
185
185
 
186
186
  fetchBalanceTransactions();
187
- }, [designer?.user, designer?.password, designer?.admin, date, dateRange, admin, sales]);
187
+ // Use sales.length instead of sales array to avoid infinite re-render loops
188
+ // when the array reference changes but content is the same
189
+ }, [designer?.user, designer?.password, designer?.admin, date, dateRange, admin, sales?.length]);
188
190
 
189
191
  return reconciliationData;
190
192
  }
package/index.js CHANGED
@@ -11,7 +11,7 @@ export { SalesTable } from './components/SalesTable.js';
11
11
  export { DateRangeSalesTable } from './components/DateRangeSalesTable.js';
12
12
  export { default as SalesChart } from './components/SalesChart.js';
13
13
  export { default as YearOverview } from './components/YearOverview.js';
14
- export { default as SummaryCards } from './components/SummaryCards.js';
14
+ export { default as SummaryCards } from './components/Insights.js';
15
15
  export { default as TopPerformers } from './components/TopPerformers.js';
16
16
  export { default as TypefaceList } from './components/TypefaceList.js';
17
17
  export { default as LicenseTypeList } from './components/LicenseTypeList.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liiift-studio/sales-portal",
3
- "version": "3.1.0",
3
+ "version": "3.1.2",
4
4
  "description": "Centralized sales portal package for Liiift Studio projects",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -76,7 +76,7 @@
76
76
  }
77
77
 
78
78
  .earningContainer {
79
- border-bottom: 1px solid rgba(0, 0, 0, .25);
79
+ border-bottom: 1px solid rgba(var(--blackRGB, 0, 0, 0), .25);
80
80
  }
81
81
 
82
82
  .displayLossContainer{
@@ -115,8 +115,11 @@
115
115
  .salesSection{
116
116
  padding-left: var(--marginXMobile, 15px);
117
117
  padding-right: var(--marginXMobile, 15px);
118
- margin-bottom: 28px;
118
+ margin-bottom: 16px;
119
119
  transition: all 0.3s ease;
120
+ @media screen and (min-width: 600px) {
121
+ margin-bottom: 28px;
122
+ }
120
123
  @media screen and (min-width: 900px) {
121
124
  padding-left: var(--marginX, 30px);
122
125
  padding-right: var(--marginX, 30px);
@@ -129,10 +132,14 @@
129
132
  pointer-events: none;
130
133
  position: absolute;
131
134
  top: 0;
132
- left: -50%;
133
- width: 200%;
135
+ left: -10%;
136
+ width: 120%;
137
+ @media screen and (min-width: 600px) {
138
+ left: -50%;
139
+ width: 200%;
140
+ }
134
141
  height: 100%;
135
- box-shadow: inset 0 10px 10px 0px rgba(0, 0, 0, 1);
142
+ box-shadow: inset 0 10px 10px 0px rgba(var(--blackRGB, 0, 0, 0), 1);
136
143
  }
137
144
  }
138
145
  .globeWrap {
@@ -162,7 +169,7 @@
162
169
  width: 90%;
163
170
  height: 90%;
164
171
  border-radius: 100%;
165
- background: radial-gradient(circle at top, white, rgba(255, 255, 255, 0) 58%);
172
+ background: radial-gradient(circle at top, var(--white, white), rgba(var(--whiteRGB, 255, 255, 255), 0) 58%);
166
173
  filter: blur(5px);
167
174
  z-index: 2;
168
175
  }
@@ -223,14 +230,14 @@
223
230
 
224
231
  /* New Dashboard Styles */
225
232
 
226
- /* Summary Cards Styling */
227
- .summaryCardsContainer {
233
+ /* Insights Styling */
234
+ .insightsContainer {
228
235
  display: grid;
229
236
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
230
237
  gap: 16px;
231
238
  margin-bottom: 24px;
232
239
 
233
- .summaryCard {
240
+ .insightCard {
234
241
  background-color: rgba(var(--blackRGB, 0, 0, 0), 0.04);
235
242
  padding: 16px;
236
243
  border-radius: 4px;
@@ -239,7 +246,7 @@
239
246
 
240
247
  &:hover {
241
248
  transform: translateY(-2px);
242
- box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
249
+ box-shadow: 0 4px 8px rgba(var(--blackRGB, 0, 0, 0), 0.1);
243
250
  }
244
251
 
245
252
  .cardTitle {
@@ -320,7 +327,7 @@
320
327
  .enhancedTable {
321
328
  border-radius: 4px;
322
329
  overflow: hidden;
323
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
330
+ box-shadow: 0 2px 4px rgba(var(--blackRGB, 0, 0, 0), 0.05);
324
331
 
325
332
  .tableHeader {
326
333
  background-color: rgba(var(--blackRGB, 0, 0, 0), 0.06);