@imaginario27/air-ui-utils 1.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,14 @@
1
+ // https://eslint.nuxt.com/packages/module
2
+
3
+ // @ts-check
4
+ import withNuxt from './.nuxt/eslint.config.mjs'
5
+
6
+ export default withNuxt({
7
+ rules: {
8
+ 'vue/attribute-hyphenation': 'off',
9
+ 'vue/no-multiple-template-root': 'off',
10
+ 'vue/require-default-prop': 'off',
11
+ 'vue/multi-word-component-names': 'off',
12
+ '@typescript-eslint/no-explicit-any': 'warn',
13
+ }
14
+ })
@@ -0,0 +1,8 @@
1
+ export const App = {
2
+ NAME: 'AirUI',
3
+ }
4
+
5
+ export const AppSlug = {
6
+ DOCS: 'docs',
7
+ COMPONENTS: 'components',
8
+ }
@@ -0,0 +1,22 @@
1
+
2
+ export const FieldMaxLength = {
3
+ FULLNAME: 80,
4
+ LASTNAME: 80,
5
+ EMAIL: 254,
6
+ PHONE: 15,
7
+ SUBJECT: 100,
8
+ MESSAGE: 500,
9
+ PASSWORD: 64,
10
+ SEARCH: 80,
11
+ DESCRIPTION: 400,
12
+ }
13
+
14
+ export const FieldError = {
15
+ REQUIRED_FIELD: 'This field is required.',
16
+ REQUIRED_EMAIL: 'Email is required.',
17
+ INVALID_EMAIL: 'Invalid email address.',
18
+ REQUIRED_OPTION: 'Please select an option.',
19
+ INVALID_DATE_RANGE: 'Start date must be before or equal to end date',
20
+ PASSWORDS_DO_NOT_MATCH: 'Passwords do not match.',
21
+ INVALID_URL: 'Invalid URL.',
22
+ }
@@ -0,0 +1,4 @@
1
+ export const AppNotification = {
2
+ COPIED_TO_CLIPBOARD: 'Copied to clipboard',
3
+ COPIED_TO_CLIPBOARD_ERROR: 'Error copying to clipboard',
4
+ }
@@ -0,0 +1,6 @@
1
+ export interface HeaderConfig {
2
+ field: string
3
+ callback?: (value: any) => string
4
+ }
5
+
6
+ export type Headers = Record<string, string | HeaderConfig>
package/nuxt.config.ts ADDED
@@ -0,0 +1,11 @@
1
+ // https://nuxt.com/docs/api/configuration/nuxt-config
2
+
3
+ export default defineNuxtConfig({
4
+ compatibilityDate: "2024-11-01",
5
+ devtools: { enabled: false },
6
+ ssr: false,
7
+
8
+ eslint: {
9
+ // options here
10
+ },
11
+ })
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@imaginario27/air-ui-utils",
3
+ "version": "1.0.0",
4
+ "author": "imaginario27",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/imaginario27/air-ui.git"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "scripts": {
14
+ "build": "nuxt build",
15
+ "dev": "nuxt dev",
16
+ "generate": "nuxt generate",
17
+ "preview": "nuxt preview",
18
+ "postinstall": "nuxt prepare",
19
+ "test": "vitest",
20
+ "publish:patch": "npm version patch --no-git-tag-version && npm publish",
21
+ "publish:minor": "npm version minor --no-git-tag-version && npm publish",
22
+ "publish:major": "npm version major --no-git-tag-version && npm publish"
23
+ },
24
+ "dependencies": {
25
+ "eslint": "^9.36.0",
26
+ "jspdf": "^3.0.3",
27
+ "jspdf-autotable": "^5.0.2",
28
+ "nuxt": "^4.1.2",
29
+ "vue": "^3.5.22",
30
+ "vue-router": "^4.5.1"
31
+ },
32
+ "devDependencies": {
33
+ "@nuxt/test-utils": "^3.19.2",
34
+ "@vitest/coverage-v8": "^3.2.4",
35
+ "@vue/test-utils": "^2.4.6",
36
+ "happy-dom": "^18.0.1",
37
+ "playwright-core": "^1.55.1",
38
+ "prettier": "^3.6.2",
39
+ "ts-node": "^10.9.2",
40
+ "typescript": "^5.9.2",
41
+ "vitest": "^3.2.4"
42
+ }
43
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ // https://nuxt.com/docs/guide/concepts/typescript
3
+ "extends": "./.nuxt/tsconfig.json",
4
+ "compilerOptions": {
5
+ "types": ["vitest/globals"],
6
+ }
7
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Returns the counter content for a stack display based on the number of items, counter type, and item limit.
3
+ *
4
+ * @param items - Array of items to evaluate
5
+ * @param counterType - Type of counter display (ellipsis or numeric)
6
+ * @param itemsLimit - Optional maximum number of items before showing a counter
7
+ *
8
+ * @returns A string representing the counter content
9
+ */
10
+
11
+ export const getStackCounterContent = (
12
+ items: unknown[],
13
+ counterType: StackCounterType,
14
+ itemsLimit?: number | null,
15
+ ) => {
16
+ const ellipsis = '...'
17
+
18
+ if (!items.length && (itemsLimit === null || itemsLimit === undefined) || counterType === StackCounterType.ELLIPSIS) {
19
+ return ellipsis
20
+ }
21
+
22
+
23
+ if (counterType === StackCounterType.COUNTER) {
24
+ const additionalItems = items.length - (itemsLimit ?? 0)
25
+
26
+ return additionalItems > 9 ? '+9' : `+${additionalItems}`
27
+ }
28
+
29
+ return ellipsis
30
+ }
package/utils/data.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Creates a delay for a specified number of milliseconds.
3
+ *
4
+ * @param {number} ms - The duration of the delay in milliseconds.
5
+ * @returns {Promise<void>} A promise that resolves after the specified delay.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * await delay(2000) // Delays execution for 2 seconds
10
+ * ```
11
+ */
12
+ export const delay = (ms: number): Promise<void> => {
13
+ return new Promise((resolve) => setTimeout(resolve, ms))
14
+ }
15
+
16
+ /**
17
+ * Returns a paginated subset of an array.
18
+ *
19
+ * @template T - The type of items in the array.
20
+ * @param {T[]} data - The full array of data items.
21
+ * @param {number} currentPage - The current page number (1-based).
22
+ * @param {number} itemsPerPage - The number of items per page.
23
+ * @param {number} [itemsLimit] - Optional maximum number of items to consider before pagination.
24
+ * @returns {T[]} The paginated array of items.
25
+ */
26
+ export function getPaginatedData<T>(
27
+ data: T[],
28
+ currentPage: number,
29
+ itemsPerPage: number,
30
+ itemsLimit?: number
31
+ ): T[] {
32
+ let result = data
33
+
34
+ // Apply items limit if provided
35
+ if (itemsLimit !== undefined && itemsLimit !== null) {
36
+ result = result.slice(0, itemsLimit)
37
+ }
38
+
39
+ const start = (currentPage - 1) * itemsPerPage
40
+ const end = start + itemsPerPage
41
+
42
+ return result.slice(start, end)
43
+ }
44
+
45
+
46
+ /**
47
+ * Converts an array of objects into SelectOption[] by mapping specified keys or transformation functions.
48
+ *
49
+ * @param originArray - The array of items to convert.
50
+ * @param keyMap - A partial map where each key is a SelectOption field ('text', 'value', etc.)
51
+ * and the value is either a string key to pick from the item, or a function that returns the desired value.
52
+ *
53
+ * @example
54
+ * convertToSelectOptions(users, {
55
+ * text: user => capitalize(user.name),
56
+ * value: user => slugify(user.name),
57
+ * })
58
+ */
59
+ export const convertToSelectOptions = <T = Record<string, any>>(
60
+ originArray: T[],
61
+ keyMap: Partial<Record<keyof SelectOption, keyof T | ((item: T) => any)>>
62
+ ): SelectOption[] => {
63
+ if (!Array.isArray(originArray)) {
64
+ return []
65
+ }
66
+
67
+ return originArray
68
+ .map((item) => {
69
+ const option: Partial<SelectOption> = {}
70
+
71
+ for (const [optionKey, source] of Object.entries(keyMap)) {
72
+ const value =
73
+ typeof source === 'function'
74
+ ? source(item)
75
+ : item[source as keyof T]
76
+
77
+ if (value !== undefined) {
78
+ option[optionKey as keyof SelectOption] = value
79
+ }
80
+ }
81
+
82
+ // Require at least a `value` and one of `text` or `userDisplayName`
83
+ if (option.value === undefined) {
84
+ return null
85
+ }
86
+
87
+ if (!option.text && !option.userDisplayName) {
88
+ return null
89
+ }
90
+
91
+ return option as SelectOption
92
+ })
93
+ .filter(Boolean) as SelectOption[]
94
+ }
package/utils/dates.ts ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Formats an ISO date string to 'dd/mm/yyyy'.
3
+ *
4
+ * @param {string} isoDate - The ISO date string.
5
+ * @returns {string} The formatted date or 'Invalid date' if the input is invalid.
6
+ */
7
+ export const formatISODateToDDMMYYYY = (isoDate: string): string => {
8
+ const date = new Date(isoDate)
9
+ return isNaN(date.getTime())
10
+ ? 'Invalid date'
11
+ : date.toLocaleDateString('en-GB') // Formats to dd/mm/yyyy
12
+ }
13
+
14
+ /**
15
+ * Converts a date string from `YYYY-MM-DD` format to `DD/MM/YYYY` format.
16
+ *
17
+ * @param {string} dateString - The date string in `YYYY-MM-DD` format.
18
+ * @returns {string} The formatted date string in `DD/MM/YYYY` format, or an empty string if the input is invalid.
19
+ */
20
+ export const formatDateToDDMMYYYY = (dateString: string): string => {
21
+ if (!dateString) return ''
22
+
23
+ const [year, month, day] = dateString.split('-').map(Number)
24
+
25
+ // Ensure all values are valid before formatting
26
+ if (!year || !month || !day) return ''
27
+
28
+ return `${day.toString().padStart(2, '0')}/${month.toString().padStart(2, '0')}/${year}`
29
+ }
30
+
31
+
32
+ /**
33
+ * Returns a human-readable relative date string (e.g., "Today", "Yesterday", "2 days ago").
34
+ * If the date is older than 6 days, it formats as 'dd/mm/yyyy'.
35
+ *
36
+ * @param {string} isoDate - The ISO date string.
37
+ * @returns {string} The formatted relative date or standard date format.
38
+ */
39
+ export const formatRelativeDate = (isoDate: string): string => {
40
+ const date = new Date(isoDate)
41
+ if (isNaN(date.getTime())) return 'Invalid date'
42
+
43
+ const now = new Date()
44
+ const nowMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()) // Strip time from today
45
+ const dateMidnight = new Date(date.getFullYear(), date.getMonth(), date.getDate()) // Strip time from input
46
+
47
+ const diffInMs = nowMidnight.getTime() - dateMidnight.getTime()
48
+ const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24)) // Convert ms to days
49
+
50
+ if (diffInDays === 0) return 'Today'
51
+ if (diffInDays === 1) return 'Yesterday'
52
+ if (diffInDays >= 2 && diffInDays <= 6) return `${diffInDays} days ago`
53
+
54
+ return formatISODateToDDMMYYYY(isoDate) // Fallback to dd/mm/yyyy
55
+ }
56
+
57
+ /**
58
+ * Formats a date string (YYYY-MM) into an object containing the full month name and year.
59
+ *
60
+ * @param {string} dateString - The date string in "YYYY-MM" format.
61
+ * @returns {{ month: string; year: number } | null}
62
+ * An object containing the full month name and year, or `null` if the input is invalid.
63
+ *
64
+ * @example
65
+ * formatDateToMonthYear("2025-03")
66
+ * // Returns: { month: "March", year: 2025 }
67
+ *
68
+ * formatDateToMonthYear("")
69
+ * // Returns: null
70
+ */
71
+ export const formatDateToMonthYear = (
72
+ dateString: string
73
+ ): { month: string; year: number } | null => {
74
+ if (!dateString) return null
75
+
76
+ const [year, month] = dateString.split('-')
77
+ const date = new Date(Number(year), Number(month) - 1)
78
+
79
+ return {
80
+ month: date.toLocaleString('en-GB', { month: 'long' }),
81
+ year: date.getFullYear()
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Adds a month to a given ISO date string.
87
+ *
88
+ * @param isoDate - The ISO date string (e.g., '2025-03-15T00:00:00.000Z').
89
+ * @returns A new ISO date string with one month added.
90
+ */
91
+ export const getNextMonthISODate = (isoDate: string): string => {
92
+ const date = new Date(isoDate)
93
+
94
+ // Set the new month while handling month overflow
95
+ date.setMonth(date.getMonth() + 1)
96
+
97
+ return date.toISOString()
98
+ }
99
+
100
+ /**
101
+ * Adds a year to a given ISO date string.
102
+ *
103
+ * @param isoDate - The ISO date string (e.g., '2025-03-15T00:00:00.000Z').
104
+ * @returns A new ISO date string with one year added.
105
+ */
106
+ export const getNextYearToISODate = (isoDate: string): string => {
107
+ const date = new Date(isoDate)
108
+
109
+ // Set the new year while maintaining the month/day
110
+ date.setFullYear(date.getFullYear() + 1)
111
+
112
+ return date.toISOString()
113
+ }
114
+
115
+ /**
116
+ * Formats a date (ISO string or timestamp) into a localized full date with optional time.
117
+ * Example:
118
+ * - formatLocalizedDateTime('2025-09-20T10:15:22', 'en') → "September 20, 2025 (10:15:22)"
119
+ * - formatLocalizedDateTime(1761724212863, 'en', false) → "October 29, 2025"
120
+ *
121
+ * @param dateInput - ISO date string or timestamp
122
+ * @param locale - The locale code (e.g. 'en', 'es', 'fr', 'de')
123
+ * @param showTime - Whether to include the time in the output
124
+ * @returns Formatted localized date string
125
+ */
126
+ export function formatLocalizedDateTime(
127
+ dateInput: string | number,
128
+ locale: string = 'en',
129
+ showTime: boolean = true
130
+ ): string {
131
+ if (!dateInput) return ''
132
+
133
+ const date = new Date(dateInput)
134
+ if (isNaN(date.getTime())) return ''
135
+
136
+ const dateFormatter = new Intl.DateTimeFormat(locale, {
137
+ year: 'numeric',
138
+ month: 'long',
139
+ day: 'numeric',
140
+ })
141
+
142
+ const formattedDate = dateFormatter.format(date)
143
+
144
+ if (!showTime) {
145
+ return formattedDate
146
+ }
147
+
148
+ const timeFormatter = new Intl.DateTimeFormat(locale, {
149
+ hour: '2-digit',
150
+ minute: '2-digit',
151
+ second: '2-digit',
152
+ hour12: false,
153
+ })
154
+
155
+ const formattedTime = timeFormatter.format(date)
156
+ return `${formattedDate} (${formattedTime})`
157
+ }
158
+
159
+ /**
160
+ * Formats a YYYY-MM-DD date string into a localized full date.
161
+ * Example:
162
+ * - formatLocalizedDate('2025-09-24', 'en') → "September 24, 2025"
163
+ * - formatLocalizedDate('2025-09-24', 'es') → "24 de septiembre de 2025"
164
+ *
165
+ * @param dateString - The date string in "YYYY-MM-DD" format
166
+ * @param locale - The locale code (e.g. 'en', 'es', 'fr', 'de')
167
+ * @returns Formatted localized date string
168
+ */
169
+ export const formatLocalizedDate = (
170
+ dateString: string,
171
+ locale: string = 'en'
172
+ ): string => {
173
+ if (!dateString) return ''
174
+
175
+ const date = new Date(dateString)
176
+
177
+ if (isNaN(date.getTime())) return ''
178
+
179
+ const formatter = new Intl.DateTimeFormat(locale, {
180
+ year: 'numeric',
181
+ month: 'long',
182
+ day: 'numeric',
183
+ })
184
+
185
+ return formatter.format(date)
186
+ }
@@ -0,0 +1,15 @@
1
+
2
+ /**
3
+ * Encodes an SVG string to a Data URI.
4
+ *
5
+ * @param {string} svg - The SVG string.
6
+ * @returns {string} The encoded Data URI.
7
+ */
8
+ export const encodeSvgToDataURI = (svg: string): string => {
9
+ return `data:image/svg+xml;charset=utf8,${encodeURIComponent(
10
+ svg.replace(
11
+ '<svg',
12
+ svg.includes('xmlns') ? '<svg' : '<svg xmlns="http://www.w3.org/2000/svg"'
13
+ )
14
+ )}`
15
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Returns an error message depending on the environment.
3
+ *
4
+ * @param devErrorMessage - Error message shown in development mode
5
+ * (usually raw details).
6
+ * @param prodErrorMessage - Error message shown in production mode
7
+ * (user-friendly localized message).
8
+ * @returns A string with the appropriate message.
9
+ *
10
+ */
11
+ export const getEnvErrorMessage = (
12
+ devErrorMessage: string | Error,
13
+ prodErrorMessage: string
14
+ ): string => {
15
+ const devMessage =
16
+ typeof devErrorMessage === 'string'
17
+ ? devErrorMessage
18
+ : devErrorMessage.message
19
+
20
+ return process.dev ? devMessage : prodErrorMessage
21
+ }
@@ -0,0 +1,64 @@
1
+ import { AppNotification } from "../models/constants/notifications"
2
+
3
+ /**
4
+ * A composable to handle clicks outside a specified element.
5
+ *
6
+ * @param elementRef - A reference to the target DOM element.
7
+ * @param callback - A function to call when a click outside the element is detected.
8
+ */
9
+ export const useClickOutside = (
10
+ elementRef: Ref<HTMLElement | null>,
11
+ callback: () => void
12
+ ): void => {
13
+ const handleClick = (event: MouseEvent): void => {
14
+ const element = elementRef.value
15
+
16
+ if (!(element instanceof HTMLElement)) return
17
+
18
+ if (!element.contains(event.target as Node)) {
19
+ callback()
20
+ }
21
+ }
22
+
23
+ watchEffect((cleanup) => {
24
+ document.addEventListener('mousedown', handleClick)
25
+
26
+ cleanup(() => {
27
+ document.removeEventListener('mousedown', handleClick)
28
+ })
29
+ })
30
+ }
31
+
32
+
33
+ /**
34
+ * Copies text to the clipboard and shows success or error notifications.
35
+ * @param text - The text to copy to the clipboard.
36
+ * @param successMessage - The success message to display.
37
+ * @param errorMessage - The error message to display.
38
+ */
39
+ export const copyToClipboard = async (
40
+ text: string,
41
+ successMessage: string = 'Copied to clipboard',
42
+ errorMessage: string = 'Failed to copy to clipboard'
43
+ ): Promise<void> => {
44
+ if (!text) return
45
+
46
+ const { $toast } = useNuxtApp()
47
+
48
+ try {
49
+ await navigator.clipboard.writeText(text)
50
+ $toast.success(successMessage, { toastId: 'clipboard-success' })
51
+ } catch {
52
+ $toast.error(errorMessage, { toastId: 'clipboard-error' })
53
+ }
54
+ }
55
+
56
+
57
+ /**
58
+ * Opens the default email app with the given email address pre-filled.
59
+ * @param email - The email address to open in the default email client.
60
+ */
61
+ export const openEmailApp = (email: string): void => {
62
+ if (!email) return
63
+ window.location.href = `mailto:${email}`
64
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Filters out non-alphabetic characters from a given string.
3
+ *
4
+ * @param {string} value - The input string to be filtered.
5
+ * @returns {string} The filtered string containing only alphabetic characters and spaces.
6
+ */
7
+ export const filterAlphabetic = (value: string): string => {
8
+ return value.replace(/[^a-zA-ZÀ-ÿ\s]/g, '') // Removes non-alphabetic characters
9
+ }
10
+
@@ -0,0 +1,201 @@
1
+
2
+ import { FieldError } from '@/models/constants/form'
3
+
4
+ /**
5
+ * Validates a field value to ensure it is not empty, null, or undefined.
6
+ *
7
+ * @param value - The value to validate. Can be of any type.
8
+ * @param requiredFieldMessage - Optional custom error message to return if the value is invalid.
9
+ * @returns A string containing the error message if invalid, or null if the value is valid.
10
+ */
11
+ export const validateField = (
12
+ value: unknown,
13
+ requiredFieldMessage?: string,
14
+ ): string | null => {
15
+ if (typeof value === 'string' && value.trim() === '') {
16
+ return requiredFieldMessage ?? FieldError.REQUIRED_FIELD
17
+ }
18
+
19
+ if (value === null || value === undefined) {
20
+ return requiredFieldMessage ?? FieldError.REQUIRED_FIELD
21
+ }
22
+
23
+ return null
24
+ }
25
+
26
+ /**
27
+ * Validates whether a given value is a valid email address.
28
+ *
29
+ * @param value - The value to validate as an email. Must be a non-empty string.
30
+ * @param requiredFieldMessage - Optional custom error message to return if the email is empty or missing.
31
+ * @param invalidEmailMessage - Optional custom error message to return if the email format is invalid.
32
+ * @returns A string containing the validation error message if invalid, or null if valid.
33
+ */
34
+ export const validateEmail = (
35
+ value: unknown,
36
+ requiredFieldMessage?: string,
37
+ invalidEmailMessage?: string,
38
+ ): string | null => {
39
+ if (typeof value !== 'string' || !value.trim()) {
40
+ return requiredFieldMessage ?? FieldError.REQUIRED_EMAIL
41
+ }
42
+
43
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
44
+ if (!emailRegex.test(value)) {
45
+ return invalidEmailMessage ?? FieldError.INVALID_EMAIL
46
+ }
47
+
48
+ return null
49
+ }
50
+
51
+ /**
52
+ * Validates that two password values match.
53
+ *
54
+ * @param password - The original password.
55
+ * @param confirmPassword - The repeated password to compare.
56
+ * @param requiredFieldMessage - Optional custom error message to return if the value is invalid.
57
+ * @param mismatchMessage - Optional custom error message if the passwords do not match.
58
+ * @returns A string containing the error message if invalid, or null if the passwords match.
59
+ */
60
+ export const validatePasswordMatch = (
61
+ password: unknown,
62
+ confirmPassword: unknown,
63
+ requiredFieldMessage?: string,
64
+ mismatchMessage?: string,
65
+ ): string | null => {
66
+ if (
67
+ typeof password !== 'string' ||
68
+ !password.trim() ||
69
+ typeof confirmPassword !== 'string' ||
70
+ !confirmPassword.trim()
71
+ ) {
72
+ return requiredFieldMessage ?? FieldError.REQUIRED_FIELD
73
+ }
74
+
75
+ if (password !== confirmPassword) {
76
+ return mismatchMessage ?? FieldError.PASSWORDS_DO_NOT_MATCH
77
+ }
78
+
79
+ return null
80
+ }
81
+
82
+ /**
83
+ * Validates that an end date is not before a start date.
84
+ *
85
+ * @param startDate - The start date to compare from.
86
+ * @param endDate - The end date to compare against.
87
+ * @param requiredFieldMessage - Optional custom message if either date is missing or invalid.
88
+ * @param invalidRangeMessage - Optional custom message if the end date is before the start date.
89
+ * @returns A string containing the error message if invalid, or null if the range is valid.
90
+ */
91
+ export const validateDateRange = (
92
+ startDate: unknown,
93
+ endDate: unknown,
94
+ requiredFieldMessage = FieldError.REQUIRED_FIELD,
95
+ invalidRangeMessage = FieldError.INVALID_DATE_RANGE,
96
+ ): string | null => {
97
+ if (
98
+ typeof startDate !== 'string' || !startDate ||
99
+ typeof endDate !== 'string' || !endDate
100
+ ) {
101
+ return requiredFieldMessage
102
+ }
103
+
104
+ const start = new Date(startDate)
105
+ const end = new Date(endDate)
106
+
107
+ if (isNaN(start.getTime()) || isNaN(end.getTime()) || start > end) {
108
+ return invalidRangeMessage
109
+ }
110
+
111
+ return null
112
+ }
113
+
114
+ /**
115
+ * Validates whether a given value is a valid URL.
116
+ *
117
+ * @param value - The value to validate as a URL.
118
+ * @param requiredFieldMessage - Optional custom message if the value is empty.
119
+ * @param invalidUrlMessage - Optional custom message if the URL format is invalid.
120
+ * @returns A string containing the error message if invalid, or null if valid.
121
+ */
122
+ export const validateUrl = (
123
+ value: unknown,
124
+ requiredFieldMessage = FieldError.REQUIRED_FIELD,
125
+ invalidUrlMessage = FieldError.INVALID_URL,
126
+ ): string | null => {
127
+ if (typeof value !== 'string' || !value.trim()) {
128
+ return requiredFieldMessage
129
+ }
130
+
131
+ try {
132
+ new URL(value)
133
+ return null
134
+ } catch {
135
+ return invalidUrlMessage
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Validates a checkbox/switch field to ensure it is checked (true).
141
+ *
142
+ * @param value - The value to validate. Should be a boolean.
143
+ * @param requiredFieldMessage - Optional custom error message to return if the checkbox is not checked.
144
+ * @returns A string containing the error message if unchecked, or null if valid.
145
+ */
146
+ export const validateBooleanField = (
147
+ value: unknown,
148
+ requiredFieldMessage = FieldError.REQUIRED_FIELD,
149
+ ): string | null => {
150
+ if (typeof value !== 'boolean' || value === false) {
151
+ return requiredFieldMessage
152
+ }
153
+
154
+ return null
155
+ }
156
+
157
+ /**
158
+ * Validates that a given value is a non-empty array (or meets min/max length criteria).
159
+ *
160
+ * @param value - The value to validate. Should be an array.
161
+ * @param requiredFieldMessage - Custom error message if value is not a valid array or empty.
162
+ * @param minLength - Optional minimum number of items required in the array.
163
+ * @param maxLength - Optional maximum number of items allowed in the array.
164
+ * @param lengthErrorMessage - Optional custom error message if min/max length is violated.
165
+ * @returns A string containing the error message if invalid, or null if valid.
166
+ */
167
+ export const validateArrayField = (
168
+ value: unknown,
169
+ requiredFieldMessage = FieldError.REQUIRED_FIELD,
170
+ minLength?: number,
171
+ maxLength?: number,
172
+ lengthErrorMessage?: string,
173
+ ): string | null => {
174
+ // Must be an array
175
+ if (!Array.isArray(value)) {
176
+ return requiredFieldMessage
177
+ }
178
+
179
+ // Must not be empty
180
+ if (value.length === 0) {
181
+ return requiredFieldMessage
182
+ }
183
+
184
+ // Check minimum length
185
+ if (typeof minLength === 'number' && value.length < minLength) {
186
+ return (
187
+ lengthErrorMessage ??
188
+ `At least ${minLength} item${minLength > 1 ? 's' : ''} required`
189
+ )
190
+ }
191
+
192
+ // Check maximum length
193
+ if (typeof maxLength === 'number' && value.length > maxLength) {
194
+ return (
195
+ lengthErrorMessage ??
196
+ `No more than ${maxLength} item${maxLength > 1 ? 's' : ''} allowed`
197
+ )
198
+ }
199
+
200
+ return null
201
+ }
package/utils/grids.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Generates Tailwind CSS grid class string based on column settings for mobile and desktop breakpoints.
3
+ *
4
+ * @param cols - Number of columns for large screens (typically 2, 3, or 4).
5
+ * @param tabletCols - Number of columns for mobile screens (typically 2).
6
+ * @param mobileCols - Number of columns for mobile screens (typically 1 or 2).
7
+ * @param gapClass - Tailwind gap utility class to control spacing between grid items (default is 'gap-6').
8
+ * @returns A string of Tailwind utility classes to control the grid layout responsively.
9
+ *
10
+ */
11
+ export const getGridClasses = (
12
+ cols: number,
13
+ tabletCols: number,
14
+ mobileCols: number,
15
+ gapClass = 'gap-6'
16
+ ): string => {
17
+ const baseClasses = 'grid w-full'
18
+
19
+ const mobileColsMapping: Record<number, string> = {
20
+ 1: 'grid-cols-1',
21
+ 2: 'grid-cols-2',
22
+ 3: 'grid-cols-3',
23
+ 4: 'grid-cols-4',
24
+ }
25
+
26
+ const tabletColsMapping: Record<number, string> = {
27
+ 1: 'sm:grid-cols-1',
28
+ 2: 'sm:grid-cols-2',
29
+ 3: 'sm:grid-cols-3',
30
+ 4: 'sm:grid-cols-4',
31
+ }
32
+
33
+ const colsMapping: Record<number, string> = {
34
+ 1: 'lg:grid-cols-1',
35
+ 2: 'lg:grid-cols-2',
36
+ 3: 'lg:grid-cols-3',
37
+ 4: 'lg:grid-cols-4',
38
+ 5: 'lg:grid-cols-5',
39
+ 6: 'lg:grid-cols-6',
40
+ }
41
+
42
+ const mobileClass = mobileColsMapping[mobileCols] ?? 'grid-cols-1'
43
+ const tabletClass = tabletColsMapping[tabletCols] ?? 'sm:grid-cols-2'
44
+ const desktopClass = colsMapping[cols] ?? 'lg:grid-cols-3'
45
+
46
+ return `${baseClasses} ${gapClass} ${mobileClass} ${tabletClass} ${desktopClass}`
47
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Navigates to the previous URL using the router's back method.
3
+ * If no history exists, it redirects to a specified fallback URL.
4
+ *
5
+ * @param fallback - The URL to navigate to if no history is available.
6
+ */
7
+ export const goBack = (fallback = '/') => {
8
+ const router = useRouter()
9
+
10
+ if (window.history.length > 1) {
11
+ router.back()
12
+ } else {
13
+ router.push(fallback)
14
+ }
15
+ }
package/utils/pages.ts ADDED
@@ -0,0 +1,18 @@
1
+
2
+ /**
3
+ * Generates a formatted page title based on the specified format.
4
+ *
5
+ * @param {string} pageTitle - The main title of the page.
6
+ * @param {string} appName - The name of the app.
7
+ * @param {PageTitleFormat} [format=PageTitleFormat.FULL] - The format of the title, defaulting to FULL.
8
+ * @returns {string} The formatted page title.
9
+ */
10
+ export const pageTitle = (
11
+ pageTitle = 'Page title',
12
+ appName = 'App name',
13
+ format: PageTitleFormat = PageTitleFormat.FULL
14
+ ) => {
15
+ return format === PageTitleFormat.FULL
16
+ ? `${pageTitle} | ${appName}`
17
+ : pageTitle
18
+ }
@@ -0,0 +1,74 @@
1
+
2
+ /**
3
+ * Evaluates secure password conditions.
4
+ * @param password - The password string to evaluate.
5
+ * @returns An object indicating whether each condition is satisfied.
6
+ */
7
+ export const evaluateSecurePasswordConditions = (password: string) => {
8
+ return {
9
+ isLongEnough: password.length >= 12,
10
+ hasMixedCase: /[a-z]/.test(password) && /[A-Z]/.test(password),
11
+ hasNumbersAndSpecialChars: /[0-9]/.test(password) && /[^a-zA-Z0-9]/.test(password),
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Checks if all secure password conditions are fulfilled.
17
+ * @param password - The password string to validate.
18
+ * @returns `true` if all conditions are met, otherwise `false`.
19
+ */
20
+ export const isSecurePassword = (password: string): boolean => {
21
+ const conditions = evaluateSecurePasswordConditions(password)
22
+ for (const key in conditions) {
23
+ if (!conditions[key as keyof typeof conditions]) return false
24
+ }
25
+ return true
26
+ }
27
+
28
+ /**
29
+ * Generates a secure password that meets specified security requirements.
30
+ *
31
+ * @param length - The desired length of the password (default is 12 characters).
32
+ * @returns A randomly generated, secure password string.
33
+ *
34
+ * The generated password will:
35
+ * 1. Be at least 12 characters long (or the specified length).
36
+ * 2. Include at least one lowercase letter, one uppercase letter, one number, and one special character.
37
+ * 3. Be shuffled to prevent predictable patterns.
38
+ */
39
+ export const generateSecurePassword = (length: number = 12): string => {
40
+ const getRandomChar = (charset: string): string => {
41
+ return charset[Math.floor(Math.random() * charset.length)]!
42
+ }
43
+
44
+ const lowerCaseChars = 'abcdefghijklmnopqrstuvwxyz'
45
+ const upperCaseChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
46
+ const numericChars = '0123456789'
47
+ const specialChars = '!@#$%^&*()-_=+[]{}|;:,.<>?'
48
+
49
+ let password = ''
50
+
51
+ // Ensure the password contains at least one character from each required set
52
+ password += getRandomChar(lowerCaseChars)
53
+ password += getRandomChar(upperCaseChars)
54
+ password += getRandomChar(numericChars)
55
+ password += getRandomChar(specialChars)
56
+
57
+ // Fill the rest of the password with random characters from all sets
58
+ const allChars = lowerCaseChars + upperCaseChars + numericChars + specialChars
59
+ while (password.length < length) {
60
+ password += getRandomChar(allChars)
61
+ }
62
+
63
+ // Shuffle the password to make it less predictable
64
+ password = password.split('').sort(() => Math.random() - 0.5).join('')
65
+
66
+ // Ensure the password meets all conditions
67
+ const conditions = evaluateSecurePasswordConditions(password)
68
+ if (!conditions.isLongEnough || !conditions.hasMixedCase || !conditions.hasNumbersAndSpecialChars) {
69
+ // Recursively generate a new password if conditions are not met
70
+ return generateSecurePassword(length)
71
+ }
72
+
73
+ return password
74
+ }
@@ -0,0 +1,52 @@
1
+ import jsPDF from "jspdf"
2
+ import autoTable from "jspdf-autotable"
3
+ import type { Headers } from "@/models/types/pdfExportTable"
4
+
5
+ /**
6
+ * Exports data to a PDF file.
7
+ * @param {Array<Record<string, any>>} data - The data to export.
8
+ * @param {string} title - The title of the PDF.
9
+ * @param {string} fileName - The name of the exported file.
10
+ * @param {Headers} headers - Custom headers structured like the Excel headers.
11
+ */
12
+ export function exportDataToPDF(
13
+ data: Array<Record<string, any>>,
14
+ title: string,
15
+ fileName: string,
16
+ headers: Headers
17
+ ): void {
18
+ const doc = new jsPDF()
19
+
20
+ // Transform headers object into an array of formatted headers
21
+ const formattedHeaders = Object.entries(headers).map(([label, config]) => ({
22
+ label,
23
+ field: typeof config === "string" ? config : config.field,
24
+ callback:
25
+ typeof config === "object" && config.callback
26
+ ? config.callback
27
+ : null,
28
+ }))
29
+
30
+ // Map data rows to match headers
31
+ const tableData = data.map((row) =>
32
+ formattedHeaders.map(({ field, callback }) => {
33
+ const value = field
34
+ .split(".")
35
+ .reduce((obj, key) => obj && obj[key], row)
36
+ return callback ? callback(value) : value
37
+ })
38
+ )
39
+
40
+ doc.text(title, 14, 15)
41
+
42
+ // Use only the header keys for the `head` property in autoTable
43
+ autoTable(doc, {
44
+ head: [formattedHeaders.map(({ label }) => label)],
45
+ body: tableData,
46
+ startY: 20,
47
+ styles: { font: "helvetica", fontSize: 10, overflow: "linebreak" },
48
+ headStyles: { fillColor: [22, 160, 133] },
49
+ })
50
+
51
+ doc.save(fileName)
52
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Returns an array of MDI icon names representing a indicator rating.
3
+ *
4
+ * @param value - A number from 0 to 5 (can include 0.5 steps). Will be clamped between 0 and 5.
5
+ * @returns An array of 5 icon strings representing full, half, or empty indicator.
6
+ *
7
+ */
8
+ export const getRatingIndicator = (
9
+ value: number,
10
+ emptyIndicator: string = 'mdiStarOutline',
11
+ halfIndicator: string = 'mdiStarHalfFull',
12
+ fullIndicator: string = 'mdiStar'
13
+ ): string[] => {
14
+ const clamped = Math.min(5, Math.max(0, Math.round(value * 2) / 2))
15
+ const icons: string[] = []
16
+ let remaining = clamped
17
+
18
+ for (let i = 0; i < 5; i++) {
19
+ if (remaining >= 1) {
20
+ icons.push(fullIndicator)
21
+ } else if (remaining >= 0.5) {
22
+ icons.push(halfIndicator)
23
+ } else {
24
+ icons.push(emptyIndicator)
25
+ }
26
+ remaining -= 1
27
+ }
28
+
29
+ return icons
30
+ }
31
+
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Trims a string to the specified maximum length and appends "..." if it exceeds the limit.
3
+ * Optionally, appends a "Read more" link if `readMoreLink` is provided.
4
+ *
5
+ * @param {string} inputString - The text string to be trimmed.
6
+ * @param {number} maxLength - The maximum allowed length of the string.
7
+ * @param {string} [readMoreText='Read more'] - Optional text for the "Read more" link.
8
+ * @param {string} [readMoreLink] - Optional URL for the "Read more" link. If omitted, only "..." is appended.
9
+ * @returns {string} The trimmed string, with "..." or a "Read more" link if truncated.
10
+ * @throws {Error} If `inputString` is not a string.
11
+ * @throws {Error} If `maxLength` is not a number or negative.
12
+ */
13
+ export const trimText = (
14
+ inputString: string,
15
+ maxLength: number,
16
+ readMoreText: string = 'Read more',
17
+ readMoreLink?: string
18
+ ): string => {
19
+ if (typeof inputString !== 'string') {
20
+ throw new Error('The inputString parameter must be a text string')
21
+ }
22
+ if (typeof maxLength !== 'number' || maxLength < 0) {
23
+ throw new Error('The maxLength parameter must be a non-negative number')
24
+ }
25
+
26
+ // Return the original text if it's within the max length
27
+ if (inputString.length <= maxLength) {
28
+ return inputString
29
+ }
30
+
31
+ // Trim text
32
+ const trimmedText = inputString.substring(0, maxLength) + '...'
33
+
34
+ // Append "Read more" link only if readMoreLink is provided
35
+ return readMoreLink
36
+ ? `${trimmedText} <a href="${readMoreLink}" target="_blank" rel="noopener noreferrer" class="text-text-primary hover:text-text-hover">(${readMoreText})</a>`
37
+ : trimmedText
38
+
39
+ }
40
+
41
+ /**
42
+ * Highlights a keyword inside a given text by wrapping matches in a span with a highlight class.
43
+ *
44
+ * @param {string} text - The full text where the keyword should be highlighted.
45
+ * @param {string} keyword - The word to highlight.
46
+ * @param {string} highlightClass - Optional Tailwind or custom CSS class for styling the highlight.
47
+ * @returns {string} The modified text with highlighted words wrapped in a span.
48
+ */
49
+ export const highlightKeywordInText = (
50
+ text: string = '',
51
+ keyword: string = '',
52
+ highlightClass = 'text-text-active font-semibold'
53
+ ): string => {
54
+ if (!text.trim()) return text
55
+ if (!keyword.trim()) return text
56
+
57
+ // Escape special characters in the keyword to prevent regex errors
58
+ const escapedKeyword = keyword.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')
59
+
60
+ // Create a case-insensitive regular expression for the keyword
61
+ const regex = new RegExp(`(${escapedKeyword})`, 'gi')
62
+
63
+ // Replace matching words with a highlighted <span>
64
+ return text.replace(regex, `<span class="${highlightClass}">$1</span>`)
65
+ }
66
+
67
+ /**
68
+ * Converts a string into a slug format.
69
+ * - Converts to lowercase
70
+ * - Removes special characters
71
+ * - Replaces spaces with hyphens
72
+ *
73
+ * @param {string} text - The input string to slugify
74
+ * @returns {string} - The slugified string
75
+ */
76
+ export const convertStringIntoSlugFormat = (text: string): string => {
77
+ return text
78
+ .toLowerCase()
79
+ .replace(/[^a-z0-9\s]/g, '') // Remove special characters
80
+ .replace(/\s+/g, '-') // Replace spaces with dashes
81
+ .trim()
82
+ }
83
+
84
+ /**
85
+ * Converts the first letter of a string to lowercase while keeping the rest unchanged.
86
+ *
87
+ * @param {string} str - The input string.
88
+ * @returns {string} The transformed string with the first letter in lowercase.
89
+ */
90
+ export const lowercaseFirstLetter = (str: string): string => {
91
+ if (!str) return str
92
+ return str.charAt(0).toLowerCase() + str.slice(1)
93
+ }
94
+
95
+ /**
96
+ * Converts the first letter of a string to upperrcase while keeping the rest unchanged.
97
+ *
98
+ * @param {string} str - The input string.
99
+ * @returns {string} The transformed string with the first letter in uppercase.
100
+ */
101
+ export const capitalizeFirstLetter = (str: string): string => {
102
+ if (!str) return str
103
+ return str.charAt(0).toUpperCase() + str.slice(1)
104
+ }
105
+
106
+ /**
107
+ * Extracts the last four digits from a given credit card number.
108
+ *
109
+ * @param {string} pan - The full credit card number (PAN).
110
+ * @returns {string} The last four digits of the card number, or an empty string if the input is invalid.
111
+ */
112
+ export const getCreditCardLastFourNumbers = (pan: string): string => {
113
+ if (!pan) return ''
114
+ return pan.slice(-4)
115
+ }
116
+
117
+ /**
118
+ * Returns the full name of a user by combining first and last names.
119
+ *
120
+ * @param firstName - The user's first name.
121
+ * @param lastName - The user's last name.
122
+ * @returns The concatenated full name in the format "FirstName LastName".
123
+ */
124
+ export const getUserFullName = (firstName: string, lastName: string): string => {
125
+ return `${firstName} ${lastName}`
126
+ }
127
+
128
+ /**
129
+ * Extracts the initials from a given input.
130
+ *
131
+ * - If the name is empty or invalid, returns "TU" by default.
132
+ * - Takes the first character of up to two words to form the initials.
133
+ *
134
+ * @param input - The input string to generate initials from
135
+ * @returns A string with the initials in uppercase (e.g., "John Doe" -> "JD")
136
+ *
137
+ */
138
+ export const getInitials = (input: string): string => {
139
+ const words = input?.split(' ').filter(Boolean)
140
+
141
+ if (!words?.length) return 'TU'
142
+
143
+ const initials = words.slice(0, 2).map(word => word[0]?.toUpperCase()).join('')
144
+
145
+ return initials || 'TU'
146
+ }
147
+
148
+
package/utils/users.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Generates a unique username based on the user's first name and last names.
3
+ * The username follows the format: `{firstName}.{lastNames}{randomNumber}`.
4
+ *
5
+ * @param {string} firstName - The user's first name.
6
+ * @param {string} lastNames - The user's last names (can include spaces).
7
+ * @returns {string} A generated username in lowercase with a random 4-digit number.
8
+ */
9
+ export const generateUsername = (firstName: string, lastNames: string): string => {
10
+ const normalizedFirstName = firstName.trim().toLowerCase()
11
+ const normalizedLastNames = lastNames.trim().toLowerCase().replace(/\s+/g, '')
12
+
13
+ const randomNumber = Math.floor(1000 + Math.random() * 9000)
14
+
15
+ return `${normalizedFirstName}.${normalizedLastNames}${randomNumber}`
16
+ }
17
+
18
+ /**
19
+ * Generates a display name using the first name and only the first last name.
20
+ * Returns 'Test User' if either parameter is null or undefined.
21
+ *
22
+ * @param {string | null | undefined} firstName - The user's first name.
23
+ * @param {string | null | undefined} lastNames - The user's full last names (may contain multiple words).
24
+ * @returns {string} The formatted display name or 'Test User' if inputs are missing.
25
+ */
26
+ export const getUserDisplayName = (firstName?: string | null, lastNames?: string | null): string => {
27
+ if (!firstName || !lastNames) return 'Test User'
28
+
29
+ const firstLastName = lastNames.split(' ')[0] // Get only the first last name
30
+ return `${firstName} ${firstLastName}`
31
+ }