@liiift-studio/sales-portal 2.3.0 → 3.1.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.
package/README.md CHANGED
@@ -72,7 +72,7 @@ export default function SalesPortal() {
72
72
  <SalesPortalPage
73
73
  texts={{
74
74
  title: 'Sales Portal',
75
- dashboardTitle: 'Monthly Sales',
75
+ dashboardTitle: ' Sales',
76
76
  dashboardSubtitle: 'by designer'
77
77
  }}
78
78
  />
package/SETUP.md CHANGED
@@ -39,7 +39,7 @@ export default function SalesPortal() {
39
39
  title: 'Sales Portal',
40
40
  description: 'Track your typeface sales performance and revenue.',
41
41
  subtitle: 'All sales are reviewed internally before payouts.',
42
- dashboardTitle: 'Monthly Sales',
42
+ dashboardTitle: 'Sales',
43
43
  dashboardSubtitle: 'by designer'
44
44
  }}
45
45
  />
@@ -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
+ }
@@ -17,8 +17,8 @@ import { isTestSale } from '../clients';
17
17
  function findMatches({ invoice, line = {}, sanitySales = [] }) {
18
18
  // Find matching order
19
19
  const associatedOrder =
20
- (invoice?.id && sanitySales.find(order => order.orderStatus.invoiceId === invoice.id)) ||
21
- (invoice?.payment_intent && sanitySales.find(order => order.orderStatus.paymentIntentId === invoice.payment_intent)) ||
20
+ (invoice?.id && sanitySales.find(order => order?.orderStatus?.invoiceId === invoice.id)) ||
21
+ (invoice?.payment_intent && sanitySales.find(order => order?.orderStatus?.paymentIntentId === invoice.payment_intent)) ||
22
22
  (invoice?.customer?.email && sanitySales.find(order => {
23
23
  const orderDate = new Date(order._createdAt);
24
24
  const invoiceDate = new Date(invoice.created * 1000);
@@ -514,7 +514,7 @@ export function DateRangeSalesTable({ designer, admin, loading, updateLoadingSta
514
514
  ? '2px solid var(--green, green)'
515
515
  : '2px solid var(--red, red)',
516
516
  color: reconciliationData.isReconciled
517
- ? 'var(--green, green)'
517
+ ? 'var(--black, #1a1a1a)'
518
518
  : 'var(--red, red)',
519
519
  backgroundColor: 'rgba(255, 255, 255, 0.9)',
520
520
  filter: 'invert(0) !important',
@@ -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
@@ -201,6 +203,11 @@ export default function Sales(props) {
201
203
  },
202
204
  'Sales data'
203
205
  );
206
+
207
+ if (!response.ok) {
208
+ throw new Error(`HTTP ${response.status}`);
209
+ }
210
+
204
211
  const data = await response.json();
205
212
 
206
213
  if (data.success) {
@@ -255,6 +262,10 @@ export default function Sales(props) {
255
262
  'Previous sales data'
256
263
  );
257
264
 
265
+ if (!response.ok) {
266
+ throw new Error(`HTTP ${response.status}`);
267
+ }
268
+
258
269
  const data = await response.json();
259
270
 
260
271
  if (data.success) {
@@ -312,8 +323,27 @@ export default function Sales(props) {
312
323
  const processedLicenseTypeData = processLicenseTypeData(sales);
313
324
  setLicenseTypeData(processedLicenseTypeData);
314
325
 
315
- // Set total sales (sum of all sales minus tax and shipping)
316
- const totalRevenue = (processedData.salesMax - processedData.taxData.at(-1) - processedData.shippingData.at(-1)) || 0;
326
+ // Set total sales from unique invoice amounts (accounts for discounts correctly)
327
+ const seenInvoices = new Set();
328
+ let totalRevenue = 0;
329
+ sales.forEach(s => {
330
+ if (s.shippingProvision) return;
331
+ const sid = s.id;
332
+ if (sid && !seenInvoices.has(sid)) {
333
+ seenInvoices.add(sid);
334
+ totalRevenue += (s.invoiceTotal || s.total || 0) / 100;
335
+ }
336
+ });
337
+ // Subtract refunds
338
+ const seenRefunds = new Set();
339
+ sales.forEach(s => {
340
+ s.refunds?.forEach(r => {
341
+ if (r.id && !seenRefunds.has(r.id)) {
342
+ seenRefunds.add(r.id);
343
+ totalRevenue -= (r.total || 0) / 100;
344
+ }
345
+ });
346
+ });
317
347
  setTotal(totalRevenue);
318
348
 
319
349
  // Calculate revenue change percentage compared to previous period
@@ -565,10 +595,14 @@ export default function Sales(props) {
565
595
  data-disabled={loading}
566
596
  >
567
597
  <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
568
- <IconButton
598
+ <IconButton
569
599
  onClick={() => {
570
600
  const newDate = new Date(date);
571
- newDate.setUTCMonth(date.getUTCMonth() - 1);
601
+ if (viewMode === 'year') {
602
+ newDate.setUTCFullYear(date.getUTCFullYear() - 1);
603
+ } else {
604
+ newDate.setUTCMonth(date.getUTCMonth() - 1);
605
+ }
572
606
  setDate(newDate);
573
607
  }}
574
608
  size="small"
@@ -577,19 +611,23 @@ export default function Sales(props) {
577
611
  <ChevronLeftIcon />
578
612
  </IconButton>
579
613
  <DatePicker
580
- label='Month/Year (UTC)'
581
- views={['month', 'year']}
582
- format="MMM YYYY"
614
+ label={viewMode === 'year' ? 'Year (UTC)' : 'Month/Year (UTC)'}
615
+ views={viewMode === 'year' ? ['year'] : ['month', 'year']}
616
+ format={viewMode === 'year' ? 'YYYY' : 'MMM YYYY'}
583
617
  formatDensity="dense"
584
618
  slotProps={{ textField: { variant: "filled" } }}
585
619
  sx={{ "& *": { borderRadius: "4px" } }}
586
620
  value={dayjs.utc(date)}
587
621
  onChange={(newValue) => setDate(newValue.toDate())}
588
622
  />
589
- <IconButton
623
+ <IconButton
590
624
  onClick={() => {
591
625
  const newDate = new Date(date);
592
- newDate.setUTCMonth(date.getUTCMonth() + 1);
626
+ if (viewMode === 'year') {
627
+ newDate.setUTCFullYear(date.getUTCFullYear() + 1);
628
+ } else {
629
+ newDate.setUTCMonth(date.getUTCMonth() + 1);
630
+ }
593
631
  setDate(newDate);
594
632
  }}
595
633
  size="small"
@@ -671,6 +709,37 @@ export default function Sales(props) {
671
709
  </IconButton>
672
710
  </Tooltip>
673
711
  )}
712
+ {/* Month/Year toggle */}
713
+ <Box sx={{
714
+ display: 'flex',
715
+ ml: 1,
716
+ border: '1px solid var(--black, #1a1a1a)',
717
+ borderRadius: '4px',
718
+ overflow: 'hidden',
719
+ }}>
720
+ {['month', 'year'].map(mode => (
721
+ <Box
722
+ key={mode}
723
+ onClick={() => setViewMode(mode)}
724
+ sx={{
725
+ px: 1.5,
726
+ py: 0.5,
727
+ cursor: 'pointer',
728
+ fontSize: '0.75rem',
729
+ fontWeight: viewMode === mode ? 'bold' : 'normal',
730
+ bgcolor: viewMode === mode ? 'var(--black, #1a1a1a)' : 'transparent',
731
+ color: viewMode === mode ? 'var(--white, white)' : 'inherit',
732
+ textTransform: 'uppercase',
733
+ letterSpacing: '0.05em',
734
+ '&:hover': {
735
+ bgcolor: viewMode === mode ? 'var(--black, #1a1a1a)' : 'rgba(0,0,0,0.05)',
736
+ },
737
+ }}
738
+ >
739
+ {mode === 'month' ? 'Mo' : 'Yr'}
740
+ </Box>
741
+ ))}
742
+ </Box>
674
743
  </Box>
675
744
  </Box>
676
745
 
@@ -724,7 +793,7 @@ export default function Sales(props) {
724
793
  </Box>
725
794
  }
726
795
  </Box>
727
- ), [loading, sales, total, designer, date, message, revenueChangePercent, previousSales, retryInfo]);
796
+ ), [loading, sales, total, designer, date, message, revenueChangePercent, previousSales, retryInfo, viewMode]);
728
797
 
729
798
  return (
730
799
  <LocalizationProvider dateAdapter={AdapterDayjs}>
@@ -753,7 +822,6 @@ export default function Sales(props) {
753
822
  {/* Sales Data Display */}
754
823
  {!!sales.length && (
755
824
  <>
756
-
757
825
  <Box
758
826
  data-disabled={loading}
759
827
  className={`sales-data-section ${styles.salesSection}`}
@@ -765,18 +833,32 @@ export default function Sales(props) {
765
833
  }}
766
834
  >
767
835
 
768
- {/* Chart Section */}
769
- <Box className="sales-chart-wrapper">
770
- <SalesChart
771
- sales={sales}
772
- chartState={chartState}
773
- seriesData={seriesData}
774
- displayLosses={displayLosses}
775
- setDisplayLosses={setDisplayLosses}
776
- date={date}
777
- loading={loading}
778
- />
779
- </Box>
836
+ {/* Chart Section — Year or Month */}
837
+ {viewMode === 'year' && date ? (
838
+ <Box className="year-overview-wrapper">
839
+ <YearOverview
840
+ designer={designer}
841
+ year={date.getUTCFullYear()}
842
+ onMonthClick={(monthIndex) => {
843
+ const newDate = new Date(Date.UTC(date.getUTCFullYear(), monthIndex, 1));
844
+ setDate(newDate);
845
+ setViewMode('month');
846
+ }}
847
+ />
848
+ </Box>
849
+ ) : (
850
+ <Box className="sales-chart-wrapper">
851
+ <SalesChart
852
+ sales={sales}
853
+ chartState={chartState}
854
+ seriesData={seriesData}
855
+ displayLosses={displayLosses}
856
+ setDisplayLosses={setDisplayLosses}
857
+ date={date}
858
+ loading={loading}
859
+ />
860
+ </Box>
861
+ )}
780
862
 
781
863
  {/* Summary Dashboard */}
782
864
  <Box className="summary-cards-wrapper">
@@ -828,7 +910,7 @@ export default function Sales(props) {
828
910
  {/* Sales Table Section */}
829
911
  {designer && (
830
912
  <Box className="sales-table-wrapper">
831
- <SalesTable
913
+ <SalesTable
832
914
  sales={sales}
833
915
  designer={designer}
834
916
  admin={admin}
@@ -841,12 +923,12 @@ export default function Sales(props) {
841
923
  </Box>
842
924
 
843
925
  {/* Date Range Sales Table Section */}
844
- <DateRangeSalesTable
845
- designer={designer}
846
- admin={admin}
847
- loading={loadingStates.dateRangeSalesData}
848
- updateLoadingState={updateLoadingState}
849
- />
926
+ <DateRangeSalesTable
927
+ designer={designer}
928
+ admin={admin}
929
+ loading={loadingStates.dateRangeSalesData}
930
+ updateLoadingState={updateLoadingState}
931
+ />
850
932
  </>
851
933
  )}
852
934
 
@@ -60,7 +60,7 @@ export default function SalesPortalPage({
60
60
 
61
61
  // Default page title text
62
62
  const {
63
- dashboardTitle = 'Monthly Sales',
63
+ dashboardTitle = 'Sales',
64
64
  dashboardSubtitle = 'by designer'
65
65
  } = texts;
66
66
 
@@ -173,7 +173,7 @@ export function SalesTable({ sales = [], designer = {}, admin = false, loading =
173
173
  ? '2px solid var(--green, green)'
174
174
  : '2px solid var(--red, red)',
175
175
  color: reconciliationData.isReconciled
176
- ? 'var(--green, green)'
176
+ ? 'var(--black, #1a1a1a)'
177
177
  : 'var(--red, red)',
178
178
  }}>
179
179
  {reconciliationData.isLoading ? (
@@ -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.1.0",
4
4
  "description": "Centralized sales portal package for Liiift Studio projects",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -47,7 +47,7 @@
47
47
  align-items: center;
48
48
  white-space: nowrap;
49
49
  :global(.MuiTypography-root){
50
- font-family: 'Tomato Grotesk', sans-serif;
50
+ font-family: inherit;
51
51
  }
52
52
  }
53
53
 
@@ -27,7 +27,7 @@ let size = createTheme({
27
27
  let typography = createTheme({
28
28
 
29
29
  typography: {
30
- fontFamily: '"Ordinary", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
30
+ fontFamily: 'inherit, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
31
31
  h1: {
32
32
  maxWidth: '95%',
33
33
  fontStyle: 'normal',