update ui update and fix layout issue
This commit is contained in:
16
src/shared/utils/appInfo.ts
Normal file
16
src/shared/utils/appInfo.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import {version} from '../../../package.json';
|
||||
|
||||
/**
|
||||
* What the About dialog is allowed to claim about this build.
|
||||
*
|
||||
* The version is read from package.json rather than restated, because a
|
||||
* version number a human keeps in sync is a version number that is wrong.
|
||||
*/
|
||||
export const APP_NAME = 'Loyaly Merchant';
|
||||
export const APP_VERSION = version;
|
||||
|
||||
/**
|
||||
* 'development' in `next dev`, 'production' in a built server. Shown so a
|
||||
* screenshot from a preview deploy is never mistaken for one from production.
|
||||
*/
|
||||
export const APP_ENVIRONMENT = process.env.NODE_ENV;
|
||||
22
src/shared/utils/export/csvExport.ts
Normal file
22
src/shared/utils/export/csvExport.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Generates a clean UTF-8 CSV Blob.
|
||||
*/
|
||||
export function generateCsvBlob(
|
||||
columns: {key: string; header: string}[],
|
||||
data: Record<string, any>[]
|
||||
): Blob {
|
||||
const headers = columns.map((c) => `"${c.header.replace(/"/g, '""')}"`).join(',');
|
||||
|
||||
const rows = data.map((row) =>
|
||||
columns
|
||||
.map((col) => {
|
||||
const val = row[col.key];
|
||||
const strVal = val === null || val === undefined ? '' : String(val);
|
||||
return `"${strVal.replace(/"/g, '""')}"`;
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
|
||||
const csvContent = [headers, ...rows].join('\r\n');
|
||||
return new Blob(['\uFEFF' + csvContent], {type: 'text/csv;charset=utf-8;'});
|
||||
}
|
||||
46
src/shared/utils/export/excelExport.ts
Normal file
46
src/shared/utils/export/excelExport.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Generates an Excel-compatible XML Spreadsheet Blob (.xlsx / .xls).
|
||||
*/
|
||||
export function generateExcelBlob(
|
||||
title: string,
|
||||
columns: {key: string; header: string}[],
|
||||
data: Record<string, any>[]
|
||||
): Blob {
|
||||
const headerCells = columns
|
||||
.map((c) => `<Cell><Data ss:Type="String">${c.header}</Data></Cell>`)
|
||||
.join('');
|
||||
|
||||
const rowXML = data
|
||||
.map(
|
||||
(row) =>
|
||||
`<Row>` +
|
||||
columns
|
||||
.map((col) => {
|
||||
const val = row[col.key];
|
||||
const isNum = typeof val === 'number';
|
||||
const strVal = val === null || val === undefined ? '' : String(val);
|
||||
return `<Cell><Data ss:Type="${isNum ? 'Number' : 'String'}">${strVal}</Data></Cell>`;
|
||||
})
|
||||
.join('') +
|
||||
`</Row>`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const xmlContent = `<?xml version="1.0"?>
|
||||
<?mso-application progid="Excel.Sheet"?>
|
||||
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office"
|
||||
xmlns:x="urn:schemas-microsoft-com:office:excel"
|
||||
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
|
||||
<Worksheet ss:Name="${title.slice(0, 30)}">
|
||||
<Table>
|
||||
<Row>${headerCells}</Row>
|
||||
${rowXML}
|
||||
</Table>
|
||||
</Worksheet>
|
||||
</Workbook>`;
|
||||
|
||||
return new Blob([xmlContent], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8;',
|
||||
});
|
||||
}
|
||||
77
src/shared/utils/export/exportManager.ts
Normal file
77
src/shared/utils/export/exportManager.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {generateCsvBlob} from './csvExport';
|
||||
import {generateExcelBlob} from './excelExport';
|
||||
import {generatePdfBlob} from './pdfExport';
|
||||
|
||||
export type ExportFormat = 'pdf' | 'excel' | 'csv';
|
||||
|
||||
export interface ExportColumn {
|
||||
key: string;
|
||||
header: string;
|
||||
}
|
||||
|
||||
export interface ExportDataParams {
|
||||
filename: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
columns: ExportColumn[];
|
||||
data: Record<string, any>[];
|
||||
format: ExportFormat;
|
||||
}
|
||||
|
||||
function triggerBrowserDownload(blob: Blob, filename: string): void {
|
||||
console.log('[exportManager] Blob generated successfully:', {
|
||||
size: blob.size,
|
||||
type: blob.type,
|
||||
isBlob: blob instanceof Blob,
|
||||
});
|
||||
console.log('[exportManager] Triggering download link for:', filename);
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
|
||||
console.log('[exportManager] Executing link.click()...');
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
export async function exportData({
|
||||
filename,
|
||||
title,
|
||||
subtitle,
|
||||
columns,
|
||||
data,
|
||||
format,
|
||||
}: ExportDataParams): Promise<void> {
|
||||
console.log('[exportData] Starting generation for format:', format, {filename, title});
|
||||
|
||||
// Artificial generation delay for smooth UX transition
|
||||
await new Promise((res) => setTimeout(res, 450));
|
||||
|
||||
const dateTag = new Date().toISOString().slice(0, 10);
|
||||
let blob: Blob;
|
||||
let finalFilename = filename;
|
||||
|
||||
switch (format) {
|
||||
case 'pdf':
|
||||
blob = generatePdfBlob(title, subtitle, columns, data);
|
||||
if (!finalFilename.endsWith('.pdf')) finalFilename = `${finalFilename}_${dateTag}.pdf`;
|
||||
break;
|
||||
|
||||
case 'excel':
|
||||
blob = generateExcelBlob(title, columns, data);
|
||||
if (!finalFilename.endsWith('.xlsx')) finalFilename = `${finalFilename}_${dateTag}.xlsx`;
|
||||
break;
|
||||
|
||||
case 'csv':
|
||||
blob = generateCsvBlob(columns, data);
|
||||
if (!finalFilename.endsWith('.csv')) finalFilename = `${finalFilename}_${dateTag}.csv`;
|
||||
break;
|
||||
}
|
||||
|
||||
triggerBrowserDownload(blob, finalFilename);
|
||||
}
|
||||
69
src/shared/utils/export/pdfExport.ts
Normal file
69
src/shared/utils/export/pdfExport.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Generates a clean, valid PDF 1.4 spec Blob.
|
||||
*/
|
||||
export function generatePdfBlob(
|
||||
title: string,
|
||||
subtitle: string | undefined,
|
||||
columns: {key: string; header: string}[],
|
||||
data: Record<string, any>[]
|
||||
): Blob {
|
||||
const dateStr = new Date().toISOString().slice(0, 10);
|
||||
const cleanTitle = title.replace(/[()\\]/g, '');
|
||||
const cleanSub = (subtitle || '').replace(/[()\\]/g, '');
|
||||
|
||||
let streamText = `BT /F1 16 Tf 40 750 Td (${cleanTitle}) Tj ET\n`;
|
||||
streamText += `BT /F1 10 Tf 40 730 Td (LOYALY.AI Merchant OS Report - Date: ${dateStr}) Tj ET\n`;
|
||||
if (cleanSub) {
|
||||
streamText += `BT /F1 9 Tf 40 715 Td (${cleanSub}) Tj ET\n`;
|
||||
}
|
||||
|
||||
let y = 680;
|
||||
const colHeaders = columns.map((c) => c.header).join(' | ').replace(/[()\\]/g, '');
|
||||
streamText += `BT /F1 11 Tf 40 ${y} Td (${colHeaders}) Tj ET\n`;
|
||||
y -= 20;
|
||||
|
||||
data.forEach((row) => {
|
||||
if (y > 50) {
|
||||
const rowStr = columns
|
||||
.map((c) => String(row[c.key] ?? ''))
|
||||
.join(' | ')
|
||||
.replace(/[()\\]/g, '');
|
||||
streamText += `BT /F1 9 Tf 40 ${y} Td (${rowStr}) Tj ET\n`;
|
||||
y -= 15;
|
||||
}
|
||||
});
|
||||
|
||||
const pdfBody = `%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Length ${streamText.length} >>
|
||||
stream
|
||||
${streamText}endstream
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
|
||||
endobj
|
||||
xref
|
||||
0 6
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
0000000244 00000 n
|
||||
0000000320 00000 n
|
||||
trailer
|
||||
<< /Size 6 /Root 1 0 R >>
|
||||
startxref
|
||||
390
|
||||
%%EOF`;
|
||||
|
||||
return new Blob([pdfBody], {type: 'application/pdf'});
|
||||
}
|
||||
80
src/shared/utils/format.ts
Normal file
80
src/shared/utils/format.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Formatting lives here so a rupee looks the same in a KPI, an axis tick and a
|
||||
* tooltip. Locale is pinned to en-IN — this is an Indian retail product, and
|
||||
* lakh/crore grouping is what merchants read.
|
||||
*
|
||||
* Every formatter is pure and locale-explicit: relying on the runtime default
|
||||
* would make the server and client disagree and produce hydration mismatches.
|
||||
*/
|
||||
|
||||
const NUM = new Intl.NumberFormat('en-IN');
|
||||
const INR = new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
export function formatCount(v: number): string {
|
||||
return NUM.format(Math.round(v));
|
||||
}
|
||||
|
||||
export function formatInr(v: number): string {
|
||||
return INR.format(Math.round(v));
|
||||
}
|
||||
|
||||
/** Drops a trailing ".0" so axis ticks read ₹90k rather than ₹90.0k. */
|
||||
function trim(n: number): string {
|
||||
return n.toFixed(1).replace(/\.0$/, '');
|
||||
}
|
||||
|
||||
/** Axis-scale currency: ₹1.2L rather than ₹1,20,000. */
|
||||
export function formatInrCompact(v: number): string {
|
||||
if (Math.abs(v) >= 10000000) return `₹${trim(v / 10000000)}Cr`;
|
||||
if (Math.abs(v) >= 100000) return `₹${trim(v / 100000)}L`;
|
||||
if (Math.abs(v) >= 1000) return `₹${trim(v / 1000)}k`;
|
||||
return `₹${Math.round(v)}`;
|
||||
}
|
||||
|
||||
export function formatCompact(v: number): string {
|
||||
if (Math.abs(v) >= 10000000) return `${trim(v / 10000000)}Cr`;
|
||||
if (Math.abs(v) >= 100000) return `${trim(v / 100000)}L`;
|
||||
if (Math.abs(v) >= 1000) return `${trim(v / 1000)}k`;
|
||||
return String(Math.round(v));
|
||||
}
|
||||
|
||||
export function formatPct(v: number, digits = 1): string {
|
||||
return `${v.toFixed(digits)}%`;
|
||||
}
|
||||
|
||||
export function formatLyt(v: number): string {
|
||||
return `${NUM.format(Math.round(v))} LYT`;
|
||||
}
|
||||
|
||||
/** Signed delta for the KPI change chip. */
|
||||
export function formatDelta(v: number): string {
|
||||
return `${v > 0 ? '+' : ''}${v.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/** "12 Aug" — short axis label for a yyyy-mm-dd key. */
|
||||
export function formatDayLabel(iso: string | number): string {
|
||||
const d = new Date(String(iso));
|
||||
if (Number.isNaN(d.getTime())) return String(iso);
|
||||
return new Intl.DateTimeFormat('en-IN', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
export function formatByUnit(unit: 'count' | 'inr' | 'lyt' | 'pct') {
|
||||
switch (unit) {
|
||||
case 'inr':
|
||||
return formatInr;
|
||||
case 'lyt':
|
||||
return formatLyt;
|
||||
case 'pct':
|
||||
return (v: number) => formatPct(v);
|
||||
default:
|
||||
return formatCount;
|
||||
}
|
||||
}
|
||||
191
src/shared/utils/icons.ts
Normal file
191
src/shared/utils/icons.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* The single icon map for the whole app.
|
||||
*
|
||||
* Astryx resolves exactly 26 SEMANTIC names through the theme's icon registry
|
||||
* (`<Icon icon="search" />`). Everything else must be passed as a component.
|
||||
* Rather than let feature files import from lucide-react ad hoc — which is how
|
||||
* an icon set drifts — every non-semantic glyph is named once, here.
|
||||
*
|
||||
* Rule: `<Icon icon="search" />` for anything in SEMANTIC_ICONS,
|
||||
* `<Icon icon={ICONS.stores} />` for everything else.
|
||||
*/
|
||||
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Building2,
|
||||
CalendarClock,
|
||||
ChartLine,
|
||||
ChevronsUpDown,
|
||||
CircleAlert,
|
||||
CircleHelp,
|
||||
Clock,
|
||||
Code,
|
||||
Coins,
|
||||
Columns3,
|
||||
CreditCard,
|
||||
Download,
|
||||
Expand,
|
||||
Footprints,
|
||||
Gift,
|
||||
Globe,
|
||||
History,
|
||||
IndianRupee,
|
||||
Info,
|
||||
Keyboard,
|
||||
LayoutDashboard,
|
||||
Lock,
|
||||
LogOut,
|
||||
Maximize2,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
Minimize2,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Percent,
|
||||
Plug,
|
||||
Send,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Shrink,
|
||||
ShoppingCart,
|
||||
Sliders,
|
||||
Square,
|
||||
SquarePen,
|
||||
Sparkles,
|
||||
Store,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Trophy,
|
||||
User,
|
||||
UserCheck,
|
||||
Users,
|
||||
UserX,
|
||||
} from 'lucide-react';
|
||||
import type {IconType} from '@astryxdesign/core/Icon';
|
||||
|
||||
export const ICONS = {
|
||||
// Expansion & Resize
|
||||
expand: Expand,
|
||||
restore: Shrink,
|
||||
fullscreen: Maximize2,
|
||||
minimize: Minimize2,
|
||||
// Navigation
|
||||
dashboard: LayoutDashboard,
|
||||
stores: Store,
|
||||
lyts: Gift,
|
||||
staff: Users,
|
||||
settings: Settings,
|
||||
|
||||
// Settings Modules
|
||||
business: Building2,
|
||||
roles: ShieldCheck,
|
||||
integrations: Plug,
|
||||
api: Code,
|
||||
security: Lock,
|
||||
preferences: Sliders,
|
||||
|
||||
// Dashboard metrics
|
||||
visitors: Footprints,
|
||||
purchases: ShoppingCart,
|
||||
revenue: IndianRupee,
|
||||
activeRewards: BadgeCheck,
|
||||
conversion: Percent,
|
||||
lyt: Coins,
|
||||
|
||||
// Dashboard controls
|
||||
compare: Columns3,
|
||||
|
||||
// Trend / delta
|
||||
up: TrendingUp,
|
||||
down: TrendingDown,
|
||||
|
||||
// Loyaly AI
|
||||
ai: Sparkles,
|
||||
analytics: ChartLine,
|
||||
chat: MessageSquare,
|
||||
mic: Mic,
|
||||
send: Send,
|
||||
panelClose: PanelRightClose,
|
||||
panelOpen: PanelRightOpen,
|
||||
newChat: SquarePen,
|
||||
history: History,
|
||||
attach: Paperclip,
|
||||
plus: Plus,
|
||||
stop: Square,
|
||||
|
||||
// Shell
|
||||
// One control drives the sidebar in all three states, so it needs all three
|
||||
// glyphs: the hamburger below the drawer breakpoint, and the panel pair
|
||||
// above it. PanelLeft*, not PanelRight* — those belong to the Loyaly AI rail
|
||||
// on the opposite edge, and pointing both controls the same way would make
|
||||
// the header read as two buttons for one panel.
|
||||
menu: Menu,
|
||||
sidebarOpen: PanelLeftOpen,
|
||||
sidebarClose: PanelLeftClose,
|
||||
notifications: Bell,
|
||||
|
||||
// Account menu
|
||||
language: Globe,
|
||||
help: CircleHelp,
|
||||
billing: CreditCard,
|
||||
download: Download,
|
||||
profile: User,
|
||||
signOut: LogOut,
|
||||
switcher: ChevronsUpDown,
|
||||
// Help panel. `info` duplicates a semantic name on purpose: the account menu
|
||||
// config types its icons as components, so it cannot pass the string form.
|
||||
docs: BookOpen,
|
||||
keyboard: Keyboard,
|
||||
info: Info,
|
||||
sessions: Clock,
|
||||
|
||||
// Staff / rewards
|
||||
present: UserCheck,
|
||||
absent: UserX,
|
||||
late: Clock,
|
||||
leaderboard: Trophy,
|
||||
expiry: CalendarClock,
|
||||
alert: CircleAlert,
|
||||
} satisfies Record<string, IconType>;
|
||||
|
||||
export type IconKey = keyof typeof ICONS;
|
||||
|
||||
/**
|
||||
* The names that resolve through the theme registry. Prefer these over an
|
||||
* equivalent lucide import so the icon follows the theme.
|
||||
*/
|
||||
export const SEMANTIC_ICONS = [
|
||||
'close',
|
||||
'chevronDown',
|
||||
'chevronLeft',
|
||||
'chevronRight',
|
||||
'check',
|
||||
'success',
|
||||
'error',
|
||||
'warning',
|
||||
'info',
|
||||
'calendar',
|
||||
'clock',
|
||||
'externalLink',
|
||||
'menu',
|
||||
'moreHorizontal',
|
||||
'search',
|
||||
'arrowUp',
|
||||
'arrowDown',
|
||||
'arrowsUpDown',
|
||||
'funnel',
|
||||
'eyeSlash',
|
||||
'viewColumns',
|
||||
'copy',
|
||||
'checkDouble',
|
||||
'wrench',
|
||||
'stop',
|
||||
'microphone',
|
||||
] as const;
|
||||
Reference in New Issue
Block a user