update ui update and fix layout issue

This commit is contained in:
2026-08-06 13:21:10 +05:30
parent 92322bff16
commit e5f8144fb3
251 changed files with 9041 additions and 2119 deletions

View 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;

View 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;'});
}

View 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;',
});
}

View 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);
}

View 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'});
}

View 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
View 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;