@m5kdev/web-ui 0.3.0 → 0.3.2

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.
@@ -4,7 +4,7 @@ import { CalendarDate, getLocalTimeZone, today } from "@internationalized/date";
4
4
  import { useNuqsQueryParams } from "@m5kdev/frontend/modules/table/hooks/useNuqsQueryParams";
5
5
  import { calendarDateToEndOfDayUTC, calendarDateToUTC, dateFilterToRangeValue, } from "@m5kdev/web-ui/modules/table/filterTransformers";
6
6
  import { DateTime } from "luxon";
7
- import { useEffect, useMemo, useState } from "react";
7
+ import { startTransition, useEffect, useMemo, useState } from "react";
8
8
  import { useTranslation } from "react-i18next";
9
9
  const toCalendarDate = (dt) => new CalendarDate(dt.year, dt.month, dt.day);
10
10
  const isSameCalendarDate = (a, b) => a.year === b.year && a.month === b.month && a.day === b.day;
@@ -17,6 +17,10 @@ export const RangeNuqsDatePicker = ({ columnId = "startedAt", endColumnId = "end
17
17
  const dateRange = useMemo(() => {
18
18
  return dateFilterToRangeValue(filters, columnId);
19
19
  }, [filters, columnId]);
20
+ const [localRange, setLocalRange] = useState(dateRange);
21
+ useEffect(() => {
22
+ setLocalRange(dateRange);
23
+ }, [dateRange]);
20
24
  const todayIso = DateTime.utc().toISODate();
21
25
  const quickRangeOptions = useMemo(() => {
22
26
  const now = DateTime.utc();
@@ -186,12 +190,15 @@ export const RangeNuqsDatePicker = ({ columnId = "startedAt", endColumnId = "end
186
190
  const handleDateRangeChange = (range) => {
187
191
  if (!setFilters)
188
192
  return;
189
- if (!range?.start || !range?.end) {
190
- // Remove date filters
193
+ if (range === null) {
194
+ // Explicit clear — remove date filters
191
195
  const newFilters = filters?.filter((f) => f.columnId !== columnId && f.columnId !== endColumnId) ?? [];
192
- setFilters(newFilters);
196
+ startTransition(() => setFilters(newFilters));
193
197
  return;
194
198
  }
199
+ // Incomplete range (e.g. only start selected mid-calendar-selection) — keep current filters
200
+ if (!range.start || !range.end)
201
+ return;
195
202
  // Use intersect to find records where [startedAt, endedAt] overlaps with the selected range
196
203
  // This includes ongoing records (endedAt = NULL) and records that overlap the range
197
204
  const startDate = range.start;
@@ -207,7 +214,7 @@ export const RangeNuqsDatePicker = ({ columnId = "startedAt", endColumnId = "end
207
214
  endColumnId,
208
215
  },
209
216
  ];
210
- setFilters(newFilters);
217
+ startTransition(() => setFilters(newFilters));
211
218
  };
212
219
  const handleQuickRangeChange = (key) => {
213
220
  if (!key) {
@@ -224,7 +231,11 @@ export const RangeNuqsDatePicker = ({ columnId = "startedAt", endColumnId = "end
224
231
  });
225
232
  };
226
233
  return (_jsxs("div", { className: `flex gap-4 flex-row ${className ?? ""}`, children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("span", { className: "text-sm text-muted-foreground", children: dateRangeLabel ??
227
- t(`${translationNamespace}:reports.dateRange`, { defaultValue: "Date range" }) }), _jsx(DateRangePicker, { value: dateRange ?? undefined, onChange: handleDateRangeChange, className: "w-[300px]", granularity: "day", showMonthAndYearPickers: true, maxValue: today(getLocalTimeZone()), popoverProps: {
234
+ t(`${translationNamespace}:reports.dateRange`, { defaultValue: "Date range" }) }), _jsx(DateRangePicker, { "aria-label": "Date range", value: localRange ?? undefined, onChange: setLocalRange, onBlur: () => {
235
+ if (localRange !== undefined) {
236
+ handleDateRangeChange(localRange);
237
+ }
238
+ }, className: "w-[300px]", granularity: "day", showMonthAndYearPickers: true, maxValue: today(getLocalTimeZone()), popoverProps: {
228
239
  portalContainer: document.body,
229
240
  disableAnimation: true,
230
241
  } })] }), showQuickRange && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("span", { className: "text-sm text-muted-foreground", children: quickRangeLabel ??
@@ -10,6 +10,7 @@ export type NuqsTableColumn<T> = ColumnDef<T> & {
10
10
  value: string;
11
11
  }[];
12
12
  endColumnId?: string;
13
+ groupable?: boolean;
13
14
  };
14
15
  type NuqsTableParams<T> = {
15
16
  data: T[];
@@ -1,12 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Checkbox, Popover, PopoverContent, PopoverTrigger } from "@heroui/react";
3
- import { flexRender, getCoreRowModel, getPaginationRowModel, useReactTable, } from "@tanstack/react-table";
4
- import { ChevronDown, ChevronUp } from "lucide-react";
3
+ import { flexRender, getCoreRowModel, getExpandedRowModel, getGroupedRowModel, getPaginationRowModel, useReactTable, } from "@tanstack/react-table";
4
+ import { ChevronDown, ChevronRight, ChevronUp } from "lucide-react";
5
5
  import { useEffect, useMemo, useState } from "react";
6
6
  import { Button } from "../../../components/ui/button";
7
7
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../../../components/ui/table";
8
8
  import { ColumnOrderAndVisibility } from "./ColumnOrderAndVisibility";
9
9
  import { TableFiltering } from "./TableFiltering";
10
+ import { TableGroupBy } from "./TableGroupBy";
10
11
  import { TablePagination } from "./TablePagination";
11
12
  function getStorageKey(columnIds) {
12
13
  const sortedIds = [...columnIds].sort().join(",");
@@ -117,15 +118,17 @@ export const NuqsTable = ({ data, total, columns, tableProps, singleFilter = fal
117
118
  }, [layout, columnIds]);
118
119
  const columnOrder = useMemo(() => layout.map((column) => column.id), [layout]);
119
120
  const columnVisibility = useMemo(() => Object.fromEntries(layout.map((column) => [column.id, column.visibility])), [layout]);
120
- const { limit = 10, page = 1, sorting, setSorting, setPagination, pagination, rowSelection, setRowSelection, setFilters, filters, } = tableProps;
121
+ const { limit = 10, page = 1, sorting, setSorting, setPagination, pagination, rowSelection, setRowSelection, setFilters, filters, grouping, setGrouping, } = tableProps;
122
+ const isGrouped = grouping.length > 0;
123
+ const [expanded, setExpanded] = useState({});
121
124
  // Redirect back if we're on an empty page (past the last page)
122
125
  useEffect(() => {
123
- if (data.length === 0 && page > 1 && total !== undefined) {
126
+ if (!isGrouped && data.length === 0 && page > 1 && total !== undefined) {
124
127
  setPagination?.({ pageIndex: page - 2, pageSize: limit });
125
128
  }
126
- }, [data.length, page, limit, total, setPagination]);
127
- // Calculate pageCount from total if available, otherwise use heuristic
128
- const pageCount = total !== undefined
129
+ }, [data.length, page, limit, total, setPagination, isGrouped]);
130
+ // When grouped, TanStack handles pagination client-side; otherwise use server total
131
+ const serverPageCount = total !== undefined
129
132
  ? Math.ceil(total / limit) || 1
130
133
  : data.length === limit
131
134
  ? page + 1
@@ -135,14 +138,21 @@ export const NuqsTable = ({ data, total, columns, tableProps, singleFilter = fal
135
138
  columns,
136
139
  getRowId: (row) => String(row.id),
137
140
  manualSorting: true,
138
- manualPagination: true,
139
- state: { pagination, sorting, rowSelection, columnOrder, columnVisibility },
140
- pageCount,
141
+ manualPagination: !isGrouped,
142
+ state: { pagination, sorting, rowSelection, columnOrder, columnVisibility, grouping, expanded },
143
+ ...(isGrouped ? {} : { pageCount: serverPageCount }),
141
144
  manualFiltering: true,
145
+ enableGrouping: true,
146
+ groupedColumnMode: false,
147
+ autoResetExpanded: false,
142
148
  onSortingChange: setSorting,
143
149
  onPaginationChange: setPagination,
144
150
  onRowSelectionChange: setRowSelection,
151
+ onGroupingChange: setGrouping,
152
+ onExpandedChange: setExpanded,
145
153
  getCoreRowModel: getCoreRowModel(),
154
+ getGroupedRowModel: getGroupedRowModel(),
155
+ getExpandedRowModel: getExpandedRowModel(),
146
156
  getPaginationRowModel: getPaginationRowModel(),
147
157
  onColumnOrderChange: (updater) => {
148
158
  setLayout((prev) => applyOrder(prev, typeof updater === "function" ? updater(prev.map((i) => i.id)) : updater));
@@ -188,11 +198,37 @@ export const NuqsTable = ({ data, total, columns, tableProps, singleFilter = fal
188
198
  }));
189
199
  return [...baseColumns, ...periodColumns];
190
200
  }, [columns]);
201
+ const groupableColumns = useMemo(() => columns
202
+ .filter((col) => col.groupable)
203
+ .map((col) => ({ id: String(col.id), label: String(col.header) })), [columns]);
204
+ const hasGrouping = grouping.length > 0;
191
205
  const [isFiltersOpen, setIsFiltersOpen] = useState(false);
192
206
  const [isColumnsOpen, setIsColumnsOpen] = useState(false);
193
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex w-full items-center gap-2 justify-end", children: [_jsxs(Popover, { placement: "bottom", isOpen: isFiltersOpen, onOpenChange: setIsFiltersOpen, portalContainer: document.body, children: [_jsx(PopoverTrigger, { children: _jsx(Button, { variant: "outline", size: "sm", children: _jsxs("div", { className: "flex items-center gap-2", children: ["Filters", _jsx(ChevronDown, { className: "h-4 w-4" })] }) }) }), _jsx(PopoverContent, { children: _jsx(TableFiltering, { columns: filterableColumns, onFiltersChange: onFiltersChange, filters: filters ?? [], onClose: () => setIsFiltersOpen(false), singleFilter: singleFilter, filterMethods: filterMethods }) })] }), _jsxs(Popover, { placement: "bottom", isOpen: isColumnsOpen, onOpenChange: setIsColumnsOpen, portalContainer: document.body, children: [_jsx(PopoverTrigger, { children: _jsx(Button, { variant: "outline", size: "sm", children: _jsxs("div", { className: "flex items-center gap-2", children: ["Columns", _jsx(ChevronDown, { className: "h-4 w-4" })] }) }) }), _jsx(PopoverContent, { children: _jsx(ColumnOrderAndVisibility, { layout: layout, onChangeOrder: onChangeOrder, onChangeVisibility: onChangeVisibility, onClose: () => setIsColumnsOpen(false) }) })] })] }), _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: _jsx(Checkbox, { isSelected: table.getIsAllRowsSelected(), onValueChange: (checked) => {
207
+ const [isGroupByOpen, setIsGroupByOpen] = useState(false);
208
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex w-full items-center gap-2 justify-end", children: [_jsxs(Popover, { placement: "bottom", isOpen: isFiltersOpen, onOpenChange: setIsFiltersOpen, portalContainer: document.body, children: [_jsx(PopoverTrigger, { children: _jsx(Button, { variant: "outline", size: "sm", children: _jsxs("div", { className: "flex items-center gap-2", children: ["Filters", _jsx(ChevronDown, { className: "h-4 w-4" })] }) }) }), _jsx(PopoverContent, { children: _jsx(TableFiltering, { columns: filterableColumns, onFiltersChange: onFiltersChange, filters: filters ?? [], onClose: () => setIsFiltersOpen(false), singleFilter: singleFilter, filterMethods: filterMethods }) })] }), groupableColumns.length > 0 && (_jsxs(Popover, { placement: "bottom", isOpen: isGroupByOpen, onOpenChange: setIsGroupByOpen, portalContainer: document.body, children: [_jsx(PopoverTrigger, { children: _jsx(Button, { variant: hasGrouping ? "secondary" : "outline", size: "sm", children: _jsxs("div", { className: "flex items-center gap-2", children: [hasGrouping
209
+ ? `Grouped by: ${grouping.map((id) => groupableColumns.find((c) => c.id === id)?.label ?? id).join(" → ")}`
210
+ : "Group by", _jsx(ChevronDown, { className: "h-4 w-4" })] }) }) }), _jsx(PopoverContent, { children: _jsx(TableGroupBy, { columns: groupableColumns, activeGrouping: grouping, onGroupingChange: (columnIds) => {
211
+ setGrouping(columnIds);
212
+ setExpanded({});
213
+ setPagination?.({ pageIndex: 0, pageSize: limit });
214
+ }, onClose: () => setIsGroupByOpen(false) }) })] })), _jsxs(Popover, { placement: "bottom", isOpen: isColumnsOpen, onOpenChange: setIsColumnsOpen, portalContainer: document.body, children: [_jsx(PopoverTrigger, { children: _jsx(Button, { variant: "outline", size: "sm", children: _jsxs("div", { className: "flex items-center gap-2", children: ["Columns", _jsx(ChevronDown, { className: "h-4 w-4" })] }) }) }), _jsx(PopoverContent, { children: _jsx(ColumnOrderAndVisibility, { layout: layout, onChangeOrder: onChangeOrder, onChangeVisibility: onChangeVisibility, onClose: () => setIsColumnsOpen(false) }) })] })] }), _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: _jsx(Checkbox, { isSelected: table.getIsAllRowsSelected(), onValueChange: (checked) => {
194
215
  table.toggleAllRowsSelected(checked);
195
- } }) }), table.getHeaderGroups()[0].headers.map((header) => (_jsxs(TableHead, { onClick: header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined, children: [flexRender(header.column.columnDef.header, header.getContext()), header.column.getCanSort() && (_jsxs(_Fragment, { children: [header.column.getIsSorted() === "asc" && (_jsx(ChevronUp, { className: "h-4 w-4 inline ml-1" })), header.column.getIsSorted() === "desc" && (_jsx(ChevronDown, { className: "h-4 w-4 inline ml-1" }))] }))] }, header.id)))] }) }), _jsx(TableBody, { children: table.getRowModel().rows.map((row) => (_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Checkbox, { isSelected: row.getIsSelected(), onValueChange: (checked) => {
196
- row.toggleSelected(checked);
197
- } }) }), row.getVisibleCells().map((cell) => (_jsx(TableCell, { children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id)))] }, row.id))) })] }), _jsx(TablePagination, { pageCount: pageCount, page: page, limit: limit, setPagination: setPagination })] }));
216
+ } }) }), table.getHeaderGroups()[0].headers.map((header) => (_jsxs(TableHead, { onClick: header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined, children: [flexRender(header.column.columnDef.header, header.getContext()), header.column.getCanSort() && (_jsxs(_Fragment, { children: [header.column.getIsSorted() === "asc" && (_jsx(ChevronUp, { className: "h-4 w-4 inline ml-1" })), header.column.getIsSorted() === "desc" && (_jsx(ChevronDown, { className: "h-4 w-4 inline ml-1" }))] }))] }, header.id)))] }) }), _jsx(TableBody, { children: table.getRowModel().rows.map((row) => {
217
+ if (row.getIsGrouped()) {
218
+ return (_jsxs(TableRow, { className: "bg-muted/40 font-medium cursor-pointer hover:bg-muted/60", onClick: () => row.toggleExpanded(), children: [_jsx(TableCell, {}), row.getVisibleCells().map((cell) => {
219
+ if (cell.getIsGrouped()) {
220
+ return (_jsx(TableCell, { children: _jsxs("span", { className: "flex items-center gap-1.5", children: [row.getIsExpanded() ? (_jsx(ChevronDown, { className: "h-4 w-4 shrink-0" })) : (_jsx(ChevronRight, { className: "h-4 w-4 shrink-0" })), flexRender(cell.column.columnDef.cell, cell.getContext()), _jsxs("span", { className: "text-muted-foreground font-normal ml-1", children: ["(", row.subRows.length, ")"] })] }) }, cell.id));
221
+ }
222
+ if (cell.getIsAggregated()) {
223
+ return (_jsx(TableCell, { children: flexRender(cell.column.columnDef.aggregatedCell ?? cell.column.columnDef.cell, cell.getContext()) }, cell.id));
224
+ }
225
+ return _jsx(TableCell, {}, cell.id);
226
+ })] }, row.id));
227
+ }
228
+ return (_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Checkbox, { isSelected: row.getIsSelected(), onValueChange: (checked) => {
229
+ row.toggleSelected(checked);
230
+ } }) }), row.getVisibleCells().map((cell) => (_jsx(TableCell, { children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id)))] }, row.id));
231
+ }) })] }), _jsx(TablePagination, { pageCount: isGrouped
232
+ ? Math.ceil(table.getPrePaginationRowModel().rows.length / limit) || 1
233
+ : serverPageCount, page: isGrouped ? table.getState().pagination.pageIndex + 1 : page, limit: limit, setPagination: setPagination })] }));
198
234
  };
@@ -0,0 +1,12 @@
1
+ interface TableGroupByColumn {
2
+ id: string;
3
+ label: string;
4
+ }
5
+ interface TableGroupByProps {
6
+ columns: TableGroupByColumn[];
7
+ activeGrouping: string[];
8
+ onGroupingChange: (columnIds: string[]) => void;
9
+ onClose: () => void;
10
+ }
11
+ export declare const TableGroupBy: ({ columns, activeGrouping, onGroupingChange, onClose, }: TableGroupByProps) => import("react/jsx-runtime").JSX.Element;
12
+ export {};
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Button } from "../../../components/ui/button";
4
+ import { cn } from "../../../lib/utils";
5
+ export const TableGroupBy = ({ columns, activeGrouping, onGroupingChange, onClose, }) => {
6
+ const [selected, setSelected] = useState(activeGrouping);
7
+ const toggle = (id) => {
8
+ setSelected((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
9
+ };
10
+ const onApply = () => {
11
+ onGroupingChange(selected);
12
+ onClose();
13
+ };
14
+ const onClear = () => {
15
+ setSelected([]);
16
+ };
17
+ return (_jsxs("div", { className: "flex flex-col gap-2 p-1 min-w-[180px]", children: [_jsx("div", { className: "flex flex-col gap-0.5", children: columns.map((col) => (_jsxs("button", { type: "button", className: cn("text-left px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors flex items-center gap-2", selected.includes(col.id) && "bg-muted font-medium"), onClick: () => toggle(col.id), children: [_jsx("span", { className: cn("h-4 w-4 rounded border border-muted-foreground/40 flex items-center justify-center shrink-0 text-xs", selected.includes(col.id) && "bg-primary border-primary text-primary-foreground"), children: selected.includes(col.id) ? "✓" : "" }), col.label, selected.includes(col.id) && selected.length > 1 && (_jsx("span", { className: "text-muted-foreground text-xs ml-auto", children: selected.indexOf(col.id) + 1 }))] }, col.id))) }), _jsxs("div", { className: "flex gap-1.5", children: [_jsx(Button, { size: "sm", variant: "outline", onClick: onClear, className: "flex-1", children: "Clear" }), _jsx(Button, { size: "sm", onClick: onApply, className: "flex-1", children: "Apply" })] })] }));
18
+ };
@@ -1,19 +1,32 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
2
3
  import { Input } from "../../../components/ui/input";
3
4
  import { Pagination, PaginationContent, PaginationNext, PaginationPrevious, } from "../../../components/ui/pagination";
4
5
  export const TablePagination = ({ pageCount, page = 1, limit = 10, setPagination, }) => {
6
+ const [inputValue, setInputValue] = useState(String(page));
7
+ useEffect(() => {
8
+ setInputValue(String(page));
9
+ }, [page]);
10
+ const commitPage = () => {
11
+ const newPage = Number(inputValue);
12
+ if (Number.isFinite(newPage) && newPage >= 1 && newPage <= pageCount) {
13
+ setPagination?.({ pageIndex: newPage - 1, pageSize: limit });
14
+ }
15
+ else {
16
+ setInputValue(String(page));
17
+ }
18
+ };
5
19
  const isFirstPage = page === 1;
6
20
  const isLastPage = page >= pageCount;
7
- return (_jsx(Pagination, { children: _jsxs(PaginationContent, { children: [_jsx(PaginationPrevious, { isActive: !isFirstPage, onClick: () => {
21
+ return (_jsx(Pagination, { children: _jsxs(PaginationContent, { children: [_jsx(PaginationPrevious, { isActive: !isFirstPage, "aria-disabled": isFirstPage, className: isFirstPage ? "pointer-events-none opacity-50" : undefined, onClick: () => {
8
22
  if (!isFirstPage) {
9
23
  setPagination?.({ pageIndex: page - 2, pageSize: limit });
10
24
  }
11
- } }), _jsx(Input, { type: "number", value: page, min: 1, max: pageCount, onChange: (e) => {
12
- const newPage = e.target.valueAsNumber;
13
- if (newPage >= 1 && newPage <= pageCount) {
14
- setPagination?.({ pageIndex: newPage - 1, pageSize: limit });
15
- }
16
- } }), _jsx(PaginationNext, { isActive: !isLastPage, onClick: () => {
25
+ } }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(Input, { type: "number", value: inputValue, min: 1, max: pageCount, onChange: (e) => setInputValue(e.target.value), onBlur: commitPage, onKeyDown: (e) => {
26
+ if (e.key === "Enter") {
27
+ commitPage();
28
+ }
29
+ } }), _jsxs("span", { className: "text-sm text-muted-foreground whitespace-nowrap", children: ["/ ", pageCount] })] }), _jsx(PaginationNext, { isActive: !isLastPage, "aria-disabled": isLastPage, className: isLastPage ? "pointer-events-none opacity-50" : undefined, onClick: () => {
17
30
  if (!isLastPage) {
18
31
  setPagination?.({ pageIndex: page, pageSize: limit });
19
32
  }
@@ -0,0 +1,192 @@
1
+ {
2
+ "auth.login.apple": "Login with Apple",
3
+ "auth.login.google": "Continue with Google",
4
+ "auth.login.email": "Email",
5
+ "auth.login.password": "Password",
6
+ "auth.login.forgotPassword": "Forgot your password?",
7
+ "auth.login.button": "Login",
8
+ "auth.login.noAccount": "Don't have an account?",
9
+ "auth.login.signUp": "Sign up",
10
+ "auth.login.welcome": "Welcome back",
11
+ "auth.login.description": "Login using your emaill and password",
12
+ "auth.login.orContinueWith": "Or continue with",
13
+ "auth.login.placeholder.email": "m@example.com",
14
+ "auth.signup.button": "Sign Up",
15
+ "auth.signup.signingUp": "Signing up...",
16
+ "auth.signup.success": "Account created successfully",
17
+ "auth.signup.successMessage": "Account created successfully",
18
+ "auth.signup.emailRequired": "Email is required",
19
+ "auth.signup.passwordRequired": "Password is required",
20
+ "auth.signup.createAccount": "Create an account",
21
+ "auth.signup.description": "Sign up to get started",
22
+ "auth.signup.alreadyHaveAccount": "Already have an account?",
23
+ "auth.signup.verificationEmailSent.title": "Verification email sent",
24
+ "auth.signup.verificationEmailSent.description": "We've sent a verification email to your inbox. Please click the link in the email to verify your account before logging in.",
25
+ "auth.signup.verificationEmailSent.openEmail": "Open your email",
26
+ "auth.errors.authentication": "Authentication Error",
27
+ "auth.errors.server": "Server Error",
28
+ "common.termsOfService": "Terms of Service",
29
+ "common.privacyPolicy": "Privacy Policy",
30
+ "common.byClickingContinue": "By clicking continue, you agree to our",
31
+ "common.and": "and",
32
+ "common.cancel": "Cancel",
33
+ "common.save": "Save",
34
+ "common.saving": "Saving...",
35
+ "image.crop.title": "Crop Image",
36
+ "image.crop.failedContext": "Failed to get canvas context",
37
+ "image.crop.failedCrop": "Failed to get cropped image",
38
+ "image.crop.error": "Error cropping image:",
39
+ "upload.errors.invalidType": "Please select a valid image file (JPG, PNG, or WebP)",
40
+ "upload.errors.tooLarge": "File size must be less than 5MB",
41
+ "profile.updated": "Profile updated",
42
+ "profile.updateDescription": "Your profile has been updated.",
43
+ "profile.error": "Error",
44
+ "profile.errorDescription": "Failed to update profile. Please try again.",
45
+ "common.copySuccess": "Copied to clipboard",
46
+ "common.copyError": "Failed to copy to clipboard",
47
+ "common.copy": "Copy",
48
+ "common.copied": "Copied!",
49
+ "alert.dismissLabel": "Dismiss alert",
50
+ "profile.placeholders.name": "Enter your name",
51
+ "profile.settings.title": "Profile Settings",
52
+ "profile.settings.description": "Update your profile information below.",
53
+ "avatar.preview.alt": "Avatar preview",
54
+ "ui.steps.progressLabel": "Progress",
55
+ "ui.sidebar.toggleLabel": "Toggle Sidebar",
56
+ "ui.breadcrumb.label": "breadcrumb",
57
+ "ui.segment.resetLabel": "Reset",
58
+ "auth.forgotPassword.button": "Reset Password",
59
+ "auth.forgotPassword.title": "Forgot Password",
60
+ "auth.forgotPassword.description": "Enter your email to reset your password",
61
+ "auth.forgotPassword.rememberPassword": "Remember your password?",
62
+ "auth.forgotPassword.success": "Password reset email sent",
63
+ "auth.forgotPassword.error": "Failed to send password reset email",
64
+ "auth.resetPassword.title": "Reset Password",
65
+ "auth.resetPassword.description": "Enter your new password",
66
+ "auth.resetPassword.newPassword": "New Password",
67
+ "auth.resetPassword.confirmPassword": "Confirm Password",
68
+ "auth.resetPassword.button": "Reset Password",
69
+ "auth.resetPassword.passwordRequired": "Password is required",
70
+ "auth.resetPassword.confirmPasswordRequired": "Please confirm your password",
71
+ "auth.resetPassword.passwordMismatch": "Passwords do not match",
72
+ "auth.resetPassword.tokenRequired": "Token is required",
73
+ "auth.resetPassword.success": "Password reset successfully",
74
+ "auth.resetPassword.error": "Failed to reset password",
75
+ "preferences.title": "Preferences",
76
+ "preferences.submit": "Save",
77
+ "billing.beta.badge": "Beta",
78
+ "billing.title": "Billing & Pricing",
79
+ "billing.subtitle": "{{appName}} is currently free to use during the beta. We’re finalizing pricing for the production launch.",
80
+ "billing.card.free.title": "Free during Beta",
81
+ "billing.card.free.description": "Use the full product while we refine it.",
82
+ "billing.card.free.price": "$0",
83
+ "billing.card.free.priceSuffix": "for now",
84
+ "billing.card.free.feature.fullAccess": "Full access during beta",
85
+ "billing.card.free.feature.noCard": "No credit card required",
86
+ "billing.card.free.feature.riskFree": "Try features risk‑free",
87
+ "billing.card.progress.title": "Pricing In Progress",
88
+ "billing.card.progress.description": "Final pricing will apply at production readiness.",
89
+ "billing.card.progress.feature.transparentPlans": "Transparent plans",
90
+ "billing.card.progress.feature.fairValue": "Fair value for teams and individuals",
91
+ "billing.card.progress.feature.simpleBilling": "Simple, predictable billing",
92
+ "billing.card.notice.title": "Plenty of Notice",
93
+ "billing.card.notice.description": "We’ll notify you well in advance of any changes.",
94
+ "billing.card.notice.feature.timeToDecide": "Ample time to decide whether to continue",
95
+ "billing.card.notice.feature.safeData": "Your projects/data remain safe",
96
+ "billing.card.notice.feature.autoFreeTier": "If a free tier exists, you’ll be moved automatically",
97
+ "impersonating.message": "You are currently impersonating a user",
98
+ "impersonating.stop": "Stop Impersonating",
99
+ "sidebar.user.upgradeToPro": "Upgrade to Pro",
100
+ "sidebar.user.adminDashboard": "Admin Dashboard",
101
+ "sidebar.user.account": "Account",
102
+ "sidebar.user.billing": "Billing",
103
+ "sidebar.user.logout": "Log out",
104
+ "auth.login.descriptionWithProviders": "Login using your favorite provider",
105
+ "auth.login.linkedin": "Sign in with LinkedIn",
106
+ "auth.login.microsoft": "Sign in with Microsoft",
107
+ "auth.waitlist.title": "Join the waitlist",
108
+ "auth.waitlist.description": "We don't allow signups yet, but you can join the waitlist to be notified when we launch.",
109
+ "auth.waitlist.email": "Email",
110
+ "auth.waitlist.button": "Join the waitlist",
111
+ "auth.waitlist.success": "You successfully joined the waitlist",
112
+ "auth.waitlist.error": "Failed to join waitlist. Please try again.",
113
+ "auth.errors.invitationCodeInvalid": "Invalid or expired invitation code",
114
+ "auth.error.title": "Authentication Failed",
115
+ "auth.error.description": "We encountered an issue with your authentication request",
116
+ "auth.error.alertTitle": "Authentication Error",
117
+ "auth.error.alertDescription": "Your authentication request could not be completed. This may be due to an expired or invalid link, or a server error. Please try again.",
118
+ "auth.error.backToLogin": "Back to Login",
119
+ "auth.error.backToSignup": "Back to Sign Up",
120
+ "sidebar.invites.title": "Invite Friends",
121
+ "auth.login.lastUsed": "Last Used",
122
+ "auth.waitlist.placeholder.email": "your@email.com",
123
+ "organization.roles.member": "member",
124
+ "organization.roles.admin": "admin",
125
+ "organization.roles.owner": "owner",
126
+ "organization.settings.title": "Organization settings",
127
+ "organization.settings.description": "Update organization name, slug, and metadata.",
128
+ "organization.settings.noActive": "No active organization selected.",
129
+ "organization.settings.manageOnly": "Only organization admins and owners can manage organization settings.",
130
+ "organization.settings.form.name": "Name",
131
+ "organization.settings.form.slug": "Slug",
132
+ "organization.settings.form.metadata": "Metadata (JSON)",
133
+ "organization.settings.form.metadataInvalidJson": "Metadata must be valid JSON.",
134
+ "organization.settings.form.metadataInvalid": "Invalid metadata value.",
135
+ "organization.settings.button.save": "Save organization",
136
+ "organization.settings.updateSuccess": "Organization updated",
137
+ "organization.settings.updateError": "Failed to update organization",
138
+ "organization.settings.loadError": "Failed to load organization",
139
+ "organization.members.loadError": "Failed to load organization",
140
+ "organization.members.title": "Organization members",
141
+ "organization.members.noActive": "No active organization selected.",
142
+ "organization.members.manageOnly": "Only organization admins and owners can manage members and invitations.",
143
+ "organization.members.description": "{{name}} member and invitation management.",
144
+ "organization.members.defaultName": "Organization",
145
+ "organization.members.loadMembersError": "Failed to load members",
146
+ "organization.members.loadInvitationsError": "Failed to load invitations",
147
+ "organization.members.roleUpdateSuccess": "Member role updated",
148
+ "organization.members.roleUpdateError": "Failed to update member role",
149
+ "organization.members.removeMemberSuccess": "Member removed",
150
+ "organization.members.removeMemberError": "Failed to remove member",
151
+ "organization.members.emailRequired": "Email is required",
152
+ "organization.members.inviteSuccess": "Invitation sent",
153
+ "organization.members.inviteError": "Failed to invite member",
154
+ "organization.members.cancelInvitationSuccess": "Invitation canceled",
155
+ "organization.members.cancelInvitationError": "Failed to cancel invitation",
156
+ "organization.members.inviteLinkCopied": "Invitation link copied",
157
+ "organization.members.copyInviteLinkError": "Failed to copy invitation link",
158
+ "organization.members.unknownName": "Unknown",
159
+ "organization.members.invitedUser": "Invited user",
160
+ "organization.members.emailLabel": "Email",
161
+ "organization.members.emailPlaceholder": "name@company.com",
162
+ "organization.members.roleLabel": "Role",
163
+ "organization.members.inviteButton": "Invite",
164
+ "organization.members.tableTitle": "Organization members and invitations",
165
+ "organization.members.columnName": "Name",
166
+ "organization.members.columnEmail": "Email",
167
+ "organization.members.columnRole": "Role",
168
+ "organization.members.columnStatus": "Status",
169
+ "organization.members.columnActions": "Actions",
170
+ "organization.members.tableEmpty": "No members or invitations yet.",
171
+ "organization.members.roleFor": "Role for {{name}}",
172
+ "organization.members.statusActive": "active",
173
+ "organization.members.statusInvited": "invited",
174
+ "organization.members.removeMember": "Remove member",
175
+ "organization.members.copyInviteLink": "Copy invitation link",
176
+ "organization.members.cancelInvitation": "Cancel invitation",
177
+ "organization.members.roleUnknown": "Unknown role",
178
+ "organization.invitation.errorMissing": "Invitation id is missing.",
179
+ "organization.invitation.acceptFailed": "Failed to accept organization invitation.",
180
+ "organization.invitation.activateFailed": "Failed to switch to invited organization.",
181
+ "organization.invitation.accepted": "Invitation accepted",
182
+ "organization.invitation.error": "Invitation Error",
183
+ "organization.invitation.unableAccept": "Unable to accept invitation.",
184
+ "organization.invitation.accepting": "Accepting invitation",
185
+ "organization.invitation.redirecting": "Invitation accepted. Redirecting...",
186
+ "organization.invitation.pleaseWait": "Please wait while we process your invitation.",
187
+ "organization.switcher.label": "Organization",
188
+ "organization.switcher.failedToLoadOrganizations": "Failed to load organizations",
189
+ "organization.switcher.failedToSwitchOrganization": "Failed to switch organization",
190
+ "organization.switcher.organizationSwitched": "Organization switched",
191
+ "organization.switcher.retry": "Retry"
192
+ }