@liiift-studio/sales-portal 2.3.0 → 3.0.0

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.
@@ -0,0 +1,105 @@
1
+ // API endpoint for fetching a full year of sales data in a single request
2
+ import { authenticateDesigner, processSalesData } from './utils/salesDataProcessor';
3
+ import { sendError, requirePost } from './utils/apiResponse';
4
+
5
+ export const config = { maxDuration: 300 };
6
+
7
+ /**
8
+ * Fetches 12 months of sales data, processes each month, and returns
9
+ * the results grouped by month with typeface breakdowns.
10
+ * This avoids 12 separate API calls from the frontend.
11
+ */
12
+ export default async function handler(req, res) {
13
+ if (!requirePost(req, res)) return;
14
+
15
+ try {
16
+ const { user, password, year, admin } = req.body;
17
+
18
+ if (!year) {
19
+ return sendError(res, 400, 'Year is required');
20
+ }
21
+
22
+ const designer = await authenticateDesigner(user, password);
23
+ if (!designer) {
24
+ return sendError(res, 401, 'Looks like there was an issue finding the account.');
25
+ }
26
+
27
+ const months = [];
28
+
29
+ // Process each month sequentially to avoid Stripe rate limiting
30
+ for (let month = 0; month < 12; month++) {
31
+ const monthDate = new Date(Date.UTC(year, month, 1));
32
+
33
+ try {
34
+ const sales = await processSalesData({
35
+ date: monthDate.toISOString(),
36
+ designer,
37
+ admin
38
+ });
39
+
40
+ // Group sales by typeface for the stacked chart
41
+ const typefaceMap = {};
42
+ let monthTotal = 0;
43
+ let shippingTotal = 0;
44
+
45
+ sales.forEach(sale => {
46
+ if (sale.shippingProvision) {
47
+ shippingTotal += sale.total || 0;
48
+ return;
49
+ }
50
+
51
+ const typefaceName = sale.typeface?.title || sale.description?.split(' (')[0] || 'Other';
52
+ if (!typefaceMap[typefaceName]) {
53
+ typefaceMap[typefaceName] = { total: 0, count: 0 };
54
+ }
55
+ typefaceMap[typefaceName].total += sale.total || 0;
56
+ typefaceMap[typefaceName].count += 1;
57
+ monthTotal += sale.total || 0;
58
+ });
59
+
60
+ months.push({
61
+ month,
62
+ year,
63
+ total: monthTotal,
64
+ shippingTotal,
65
+ salesCount: sales.length,
66
+ currency: sales[0]?.currency || 'usd',
67
+ typefaces: Object.entries(typefaceMap)
68
+ .map(([name, data]) => ({ name, ...data }))
69
+ .sort((a, b) => b.total - a.total),
70
+ });
71
+ } catch (monthError) {
72
+ // If a single month fails, include it with zero data rather than failing the whole request
73
+ console.error(`Error processing month ${month + 1}/${year}:`, monthError.message);
74
+ months.push({
75
+ month,
76
+ year,
77
+ total: 0,
78
+ shippingTotal: 0,
79
+ salesCount: 0,
80
+ currency: 'usd',
81
+ typefaces: [],
82
+ error: monthError.message,
83
+ });
84
+ }
85
+ }
86
+
87
+ // Build a list of all typefaces across the year for consistent chart series
88
+ const allTypefaces = new Set();
89
+ months.forEach(m => m.typefaces.forEach(t => allTypefaces.add(t.name)));
90
+
91
+ res.status(200).json({
92
+ success: true,
93
+ data: {
94
+ year,
95
+ months,
96
+ allTypefaces: [...allTypefaces].sort(),
97
+ yearTotal: months.reduce((sum, m) => sum + m.total, 0),
98
+ yearShipping: months.reduce((sum, m) => sum + m.shippingTotal, 0),
99
+ currency: months.find(m => m.currency)?.currency || 'usd',
100
+ }
101
+ });
102
+ } catch (error) {
103
+ return sendError(res, 500, 'Failed to fetch year sales data', error);
104
+ }
105
+ }
@@ -18,6 +18,7 @@ import countryCodes from '../data/countryCode.json';
18
18
  import { getCurrencySymbol, getCurrencyLabel } from '../utils/currencyUtils';
19
19
  import { SalesTable } from './SalesTable';
20
20
  import { DateRangeSalesTable } from './DateRangeSalesTable';
21
+ import YearOverview from './YearOverview';
21
22
  import SalesChart from './SalesChart';
22
23
  import TopPerformers from './TopPerformers';
23
24
  import TypefaceList from './TypefaceList';
@@ -47,6 +48,7 @@ export default function Sales(props) {
47
48
  const [previousSalesError, setPreviousSalesError] = useState('');
48
49
  const [retryInfo, setRetryInfo] = useState({ retrying: false, attempt: 0, label: '' });
49
50
  const [date, setDate] = useState(null);
51
+ const [viewMode, setViewMode] = useState('month'); // 'month' | 'year'
50
52
  const [displayLosses, setDisplayLosses] = useState(false);
51
53
 
52
54
  // Max retry attempts for failed API calls
@@ -671,6 +673,37 @@ export default function Sales(props) {
671
673
  </IconButton>
672
674
  </Tooltip>
673
675
  )}
676
+ {/* Month/Year toggle */}
677
+ <Box sx={{
678
+ display: 'flex',
679
+ ml: 1,
680
+ border: '1px solid var(--black, #1a1a1a)',
681
+ borderRadius: '4px',
682
+ overflow: 'hidden',
683
+ }}>
684
+ {['month', 'year'].map(mode => (
685
+ <Box
686
+ key={mode}
687
+ onClick={() => setViewMode(mode)}
688
+ sx={{
689
+ px: 1.5,
690
+ py: 0.5,
691
+ cursor: 'pointer',
692
+ fontSize: '0.75rem',
693
+ fontWeight: viewMode === mode ? 'bold' : 'normal',
694
+ bgcolor: viewMode === mode ? 'var(--black, #1a1a1a)' : 'transparent',
695
+ color: viewMode === mode ? 'var(--white, white)' : 'inherit',
696
+ textTransform: 'uppercase',
697
+ letterSpacing: '0.05em',
698
+ '&:hover': {
699
+ bgcolor: viewMode === mode ? 'var(--black, #1a1a1a)' : 'rgba(0,0,0,0.05)',
700
+ },
701
+ }}
702
+ >
703
+ {mode === 'month' ? 'Mo' : 'Yr'}
704
+ </Box>
705
+ ))}
706
+ </Box>
674
707
  </Box>
675
708
  </Box>
676
709
 
@@ -724,7 +757,7 @@ export default function Sales(props) {
724
757
  </Box>
725
758
  }
726
759
  </Box>
727
- ), [loading, sales, total, designer, date, message, revenueChangePercent, previousSales, retryInfo]);
760
+ ), [loading, sales, total, designer, date, message, revenueChangePercent, previousSales, retryInfo, viewMode]);
728
761
 
729
762
  return (
730
763
  <LocalizationProvider dateAdapter={AdapterDayjs}>
@@ -750,8 +783,23 @@ export default function Sales(props) {
750
783
  <Box sx={{ display: 'flex', flexWrap: 'wrap', width: '100%' }}>
751
784
  {HeaderSection}
752
785
 
753
- {/* Sales Data Display */}
754
- {!!sales.length && (
786
+ {/* Year Overview */}
787
+ {viewMode === 'year' && date && (
788
+ <Box sx={{ width: '100%', px: { xs: 2, md: 0 } }}>
789
+ <YearOverview
790
+ designer={designer}
791
+ year={date.getUTCFullYear()}
792
+ onMonthClick={(monthIndex) => {
793
+ const newDate = new Date(Date.UTC(date.getUTCFullYear(), monthIndex, 1));
794
+ setDate(newDate);
795
+ setViewMode('month');
796
+ }}
797
+ />
798
+ </Box>
799
+ )}
800
+
801
+ {/* Monthly Sales Data Display */}
802
+ {viewMode === 'month' && !!sales.length && (
755
803
  <>
756
804
 
757
805
  <Box
@@ -0,0 +1,243 @@
1
+ // Year overview component showing 12-month stacked bar chart by typeface
2
+ import React, { useState, useEffect, useMemo } from 'react';
3
+ import { Box, Typography, CircularProgress, Tooltip } from '@mui/material';
4
+ import { ResponsiveChartContainer } from '@mui/x-charts/ResponsiveChartContainer';
5
+ import { BarPlot } from '@mui/x-charts/BarChart';
6
+ import { ChartsXAxis } from '@mui/x-charts/ChartsXAxis';
7
+ import { ChartsYAxis } from '@mui/x-charts/ChartsYAxis';
8
+ import { ChartsTooltip } from '@mui/x-charts/ChartsTooltip';
9
+ import { ChartsAxisHighlight } from '@mui/x-charts/ChartsAxisHighlight';
10
+ import { getCurrencySymbol } from '../utils/currencyUtils';
11
+
12
+ const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
13
+
14
+ // Color palette for typeface segments
15
+ const COLORS = [
16
+ 'var(--black, #1a1a1a)',
17
+ 'var(--green, #4caf50)',
18
+ '#2196f3',
19
+ '#ff9800',
20
+ '#9c27b0',
21
+ '#f44336',
22
+ '#00bcd4',
23
+ '#795548',
24
+ '#607d8b',
25
+ '#e91e63',
26
+ '#3f51b5',
27
+ '#cddc39',
28
+ ];
29
+
30
+ /**
31
+ * Year overview component
32
+ * @param {Object} props
33
+ * @param {Object} props.designer - Designer data with user/password
34
+ * @param {number} props.year - Year to display
35
+ * @param {Function} props.onMonthClick - Called when a bar is clicked, receives month index
36
+ */
37
+ export default function YearOverview({ designer, year, onMonthClick }) {
38
+ const [data, setData] = useState(null);
39
+ const [loading, setLoading] = useState(false);
40
+ const [error, setError] = useState('');
41
+
42
+ useEffect(() => {
43
+ if (!designer?.user || !designer?.password || !year) return;
44
+
45
+ setLoading(true);
46
+ setError('');
47
+
48
+ fetch('/api/sales-portal/getYearSales', {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify({
52
+ user: designer.user,
53
+ password: designer.password,
54
+ year,
55
+ admin: designer.admin || false,
56
+ }),
57
+ })
58
+ .then(res => res.json())
59
+ .then(res => {
60
+ if (res.success) {
61
+ setData(res.data);
62
+ } else {
63
+ setError(res.message || 'Failed to load year data');
64
+ }
65
+ })
66
+ .catch(err => setError(err.message))
67
+ .finally(() => setLoading(false));
68
+ }, [designer?.user, designer?.password, designer?.admin, year]);
69
+
70
+ // Build chart series from typeface data
71
+ const { series, xAxisData, topTypefaces } = useMemo(() => {
72
+ if (!data?.months) return { series: [], xAxisData: [], topTypefaces: [] };
73
+
74
+ // Get top typefaces by total revenue across the year, cap at 12 for readability
75
+ const typefaceTotals = {};
76
+ data.months.forEach(m => {
77
+ m.typefaces.forEach(t => {
78
+ typefaceTotals[t.name] = (typefaceTotals[t.name] || 0) + t.total;
79
+ });
80
+ });
81
+
82
+ const sorted = Object.entries(typefaceTotals)
83
+ .sort((a, b) => b[1] - a[1]);
84
+
85
+ // Top N get individual colors, rest grouped as "Other"
86
+ const MAX_SERIES = 10;
87
+ const topNames = sorted.slice(0, MAX_SERIES).map(([name]) => name);
88
+ const hasOther = sorted.length > MAX_SERIES;
89
+
90
+ const chartSeries = topNames.map((name, i) => ({
91
+ type: 'bar',
92
+ data: data.months.map(m => {
93
+ const tf = m.typefaces.find(t => t.name === name);
94
+ return (tf?.total || 0) / 100;
95
+ }),
96
+ label: name,
97
+ stack: 'total',
98
+ color: COLORS[i % COLORS.length],
99
+ }));
100
+
101
+ if (hasOther) {
102
+ chartSeries.push({
103
+ type: 'bar',
104
+ data: data.months.map(m => {
105
+ const otherTotal = m.typefaces
106
+ .filter(t => !topNames.includes(t.name))
107
+ .reduce((sum, t) => sum + t.total, 0);
108
+ return otherTotal / 100;
109
+ }),
110
+ label: 'Other',
111
+ stack: 'total',
112
+ color: '#e0e0e0',
113
+ });
114
+ }
115
+
116
+ return {
117
+ series: chartSeries,
118
+ xAxisData: MONTH_LABELS,
119
+ topTypefaces: sorted.slice(0, MAX_SERIES),
120
+ };
121
+ }, [data]);
122
+
123
+ if (loading) {
124
+ return (
125
+ <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 300, gap: 2 }}>
126
+ <CircularProgress size={24} />
127
+ <Typography variant="body1">Loading {year} sales data...</Typography>
128
+ </Box>
129
+ );
130
+ }
131
+
132
+ if (error) {
133
+ return (
134
+ <Box sx={{ p: 4, textAlign: 'center' }}>
135
+ <Typography variant="body1" sx={{ color: 'var(--red, red)' }}>{error}</Typography>
136
+ </Box>
137
+ );
138
+ }
139
+
140
+ if (!data) return null;
141
+
142
+ const symbol = getCurrencySymbol(data.currency);
143
+
144
+ return (
145
+ <Box sx={{ width: '100%' }}>
146
+ {/* Year total */}
147
+ <Typography variant="h6" sx={{ mb: 2, opacity: 0.7 }}>
148
+ {year} Total: {symbol}{(data.yearTotal / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
149
+ {data.yearShipping > 0 && (
150
+ <span style={{ opacity: 0.5, fontSize: '0.8em' }}>
151
+ {' '}(+ {symbol}{(data.yearShipping / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })} shipping)
152
+ </span>
153
+ )}
154
+ </Typography>
155
+
156
+ {/* Stacked bar chart */}
157
+ {series.length > 0 ? (
158
+ <Box sx={{ width: '100%', height: { xs: 250, sm: 350 } }}>
159
+ <ResponsiveChartContainer
160
+ series={series}
161
+ xAxis={[{
162
+ data: xAxisData,
163
+ scaleType: 'band',
164
+ id: 'months',
165
+ }]}
166
+ yAxis={[{
167
+ id: 'revenue',
168
+ valueFormatter: (v) => `${symbol}${v?.toLocaleString() || 0}`,
169
+ }]}
170
+ >
171
+ <BarPlot
172
+ onItemClick={(event, barData) => {
173
+ if (onMonthClick && barData?.dataIndex !== undefined) {
174
+ onMonthClick(barData.dataIndex);
175
+ }
176
+ }}
177
+ />
178
+ <ChartsXAxis axisId="months" />
179
+ <ChartsYAxis axisId="revenue" />
180
+ <ChartsTooltip />
181
+ <ChartsAxisHighlight x="band" />
182
+ </ResponsiveChartContainer>
183
+ </Box>
184
+ ) : (
185
+ <Typography variant="body2" sx={{ textAlign: 'center', py: 4, opacity: 0.5 }}>
186
+ No sales data for {year}
187
+ </Typography>
188
+ )}
189
+
190
+ {/* Legend */}
191
+ {topTypefaces.length > 0 && (
192
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mt: 2 }}>
193
+ {topTypefaces.map(([name, total], i) => (
194
+ <Tooltip key={name} title={`${symbol}${(total / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`}>
195
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, cursor: 'default' }}>
196
+ <Box sx={{
197
+ width: 12,
198
+ height: 12,
199
+ borderRadius: '2px',
200
+ bgcolor: COLORS[i % COLORS.length],
201
+ }} />
202
+ <Typography variant="caption">{name}</Typography>
203
+ </Box>
204
+ </Tooltip>
205
+ ))}
206
+ </Box>
207
+ )}
208
+
209
+ {/* Monthly breakdown table */}
210
+ <Box sx={{ mt: 4, opacity: 0.8 }}>
211
+ {data.months.map((m, i) => (
212
+ <Box
213
+ key={i}
214
+ onClick={() => onMonthClick && onMonthClick(i)}
215
+ sx={{
216
+ display: 'flex',
217
+ justifyContent: 'space-between',
218
+ py: 0.75,
219
+ px: 1,
220
+ borderBottom: '1px solid rgba(0,0,0,0.06)',
221
+ cursor: 'pointer',
222
+ '&:hover': { bgcolor: 'rgba(0,0,0,0.03)' },
223
+ borderRadius: '4px',
224
+ }}
225
+ >
226
+ <Typography variant="body2">
227
+ {MONTH_LABELS[i]} {year}
228
+ {m.error && <span style={{ color: 'var(--red, red)', marginLeft: 8 }}>error</span>}
229
+ </Typography>
230
+ <Box sx={{ display: 'flex', gap: 3 }}>
231
+ <Typography variant="body2" sx={{ opacity: 0.5 }}>
232
+ {m.salesCount} sale{m.salesCount !== 1 ? 's' : ''}
233
+ </Typography>
234
+ <Typography variant="body2" sx={{ fontWeight: 'bold' }}>
235
+ {symbol}{(m.total / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
236
+ </Typography>
237
+ </Box>
238
+ </Box>
239
+ ))}
240
+ </Box>
241
+ </Box>
242
+ );
243
+ }
package/index.js CHANGED
@@ -10,6 +10,7 @@ export { default as Sales } from './components/Sales.js';
10
10
  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
+ export { default as YearOverview } from './components/YearOverview.js';
13
14
  export { default as SummaryCards } from './components/SummaryCards.js';
14
15
  export { default as TopPerformers } from './components/TopPerformers.js';
15
16
  export { default as TypefaceList } from './components/TypefaceList.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liiift-studio/sales-portal",
3
- "version": "2.3.0",
3
+ "version": "3.0.0",
4
4
  "description": "Centralized sales portal package for Liiift Studio projects",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",