@liiift-studio/sales-portal 3.1.1 → 3.1.3

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.
package/api/getSales.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { authenticateDesigner, processSalesData } from './utils/salesDataProcessor';
3
3
  import { sendError, requirePost } from './utils/apiResponse';
4
4
 
5
- export const config = { maxDuration: 300 };
5
+ export const config = { maxDuration: 800 };
6
6
 
7
7
  export default async function handler(req, res) {
8
8
  if (!requirePost(req, res)) return;
@@ -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
  }
@@ -259,7 +259,7 @@ export default function Insights({
259
259
 
260
260
  {/* Metric cards */}
261
261
  {metricCards.map((card, index) => (
262
- <Box key={`metric-${index}`} sx={{ width: { xs: '100%', sm: 'calc(50% - 8px)', md: 'calc(33.33% - 11px)' } }}>
262
+ <Box key={`metric-${index}`} sx={{ flex: { xs: '1 1 100%', sm: '1 1 calc(50% - 8px)', md: '1 1 calc(33.33% - 11px)' } }}>
263
263
  <Tooltip
264
264
  title={card.tooltip}
265
265
  placement="top"
@@ -1,6 +1,8 @@
1
1
  // Login form component for sales portal authentication
2
2
  import React, { useEffect, useState } from 'react';
3
- import { Typography, Input, Button, Box } from '@mui/material';
3
+ import { Typography, Input, Button, Box, IconButton, InputAdornment } from '@mui/material';
4
+ import VisibilityIcon from '@mui/icons-material/Visibility';
5
+ import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
4
6
  import packageJson from '../package.json';
5
7
 
6
8
  const { version } = packageJson;
@@ -27,6 +29,7 @@ export default function LoginForm({
27
29
  const [password, setPassword] = useState('');
28
30
  const [message, setMessage] = useState('');
29
31
  const [loading, setLoading] = useState(false);
32
+ const [showPassword, setShowPassword] = useState(false);
30
33
 
31
34
  const {
32
35
  title = 'Sales Portal',
@@ -128,7 +131,7 @@ export default function LoginForm({
128
131
  {title}
129
132
  </Typography>
130
133
 
131
- <Box sx={{ px: { xs: 4, sm: 6 } }}>
134
+ <Box sx={{ px: { xs: 0, sm: 6 } }}>
132
135
  <Input
133
136
  placeholder="Email"
134
137
  onChange={(e) => setUser(e.target.value)}
@@ -160,9 +163,21 @@ export default function LoginForm({
160
163
  placeholder="Password"
161
164
  onChange={(e) => setPassword(e.target.value)}
162
165
  value={password}
163
- type="password"
166
+ type={showPassword ? 'text' : 'password'}
164
167
  autoComplete="current-password"
165
168
  onKeyPress={handleKeyPress}
169
+ endAdornment={
170
+ <InputAdornment position="end">
171
+ <IconButton
172
+ onClick={() => setShowPassword(!showPassword)}
173
+ edge="end"
174
+ sx={{ color: 'var(--grey800, #666)', mr: 0.5 }}
175
+ aria-label={showPassword ? 'Hide password' : 'Show password'}
176
+ >
177
+ {showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
178
+ </IconButton>
179
+ </InputAdornment>
180
+ }
166
181
  sx={{
167
182
  m: 0,
168
183
  border: 'none',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liiift-studio/sales-portal",
3
- "version": "3.1.1",
3
+ "version": "3.1.3",
4
4
  "description": "Centralized sales portal package for Liiift Studio projects",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",