Migrate frontend JavaScript to TypeScript (#3950)

* Migrate frontend JavaScript to TypeScript

* Fix Docker frontend TypeScript checks

* Address TypeScript migration review feedback

* Avoid duplicate cron system admin during patches
This commit is contained in:
Adam Daley authored and GitHub committed 2026-07-11 08:07:26 +01:00
1 parent a47d36adcc
commit 0ac0cdafe0
40 files changed
+883 -208

No files matched your search

+1
View File
@@ -83,6 +83,7 @@ COPY src/themes/huraga/package.json src/themes/huraga/package.json
RUN --mount=type=cache,target=/root/.npm CYPRESS_INSTALL_BINARY=0 npm ci
COPY tsconfig.json ./
COPY src/themes/admin_default ./src/themes/admin_default
COPY src/themes/huraga ./src/themes/huraga
COPY src/modules ./src/modules
@@ -1,4 +1,42 @@
function injectCSRFToken(params, token) {
type ApiParams = FormData | Record<string, unknown> | string | null | undefined;
type RequestBody = FormData | string | null;
interface ParsedResponse {
payload: unknown;
rawText: string;
}
interface ApiErrorPayload {
error?: {
code?: string;
message?: string;
};
result?: unknown;
}
function isApiResponsePayload(payload: unknown): payload is ApiErrorPayload | null {
return payload === null || (typeof payload === 'object' && !Array.isArray(payload));
}
interface MinimalResponse {
ok?: boolean;
status?: number;
statusText?: string;
text?: () => Promise<string>;
}
interface ApiErrorLike {
code?: string;
message?: string;
name?: string;
}
interface ApiTimeoutOptions {
timeoutMs?: number;
timeoutMessage?: string | null;
}
function injectCSRFToken<T extends ApiParams>(params: T, token: string): T {
if (params instanceof FormData) {
if (!params.has('CSRFToken')) {
params.append('CSRFToken', token);
@@ -11,29 +49,29 @@ function injectCSRFToken(params, token) {
return params;
}
function buildRequestBody(method, params, url) {
function buildRequestBody(method: string, params: ApiParams, url: URL): { body: RequestBody; isFormData: boolean } {
const methodLower = method.toLowerCase();
const isFormData = params instanceof FormData;
let body = null;
let body: RequestBody = null;
if (methodLower === 'get') {
if (isFormData) {
for (const [key, value] of params.entries()) {
if (key !== 'CSRFToken') {
url.searchParams.append(key, value);
url.searchParams.append(key, String(value));
}
}
} else if (params && typeof params === 'object') {
Object.keys(params)
.filter((key) => key !== 'CSRFToken')
.forEach((key) => url.searchParams.append(key, params[key]));
.forEach((key) => url.searchParams.append(key, String(params[key])));
} else if (params) {
url.search = params;
url.search = String(params);
url.searchParams.delete('CSRFToken');
}
} else if (['post', 'put', 'patch', 'delete'].includes(methodLower)) {
if (isFormData || typeof params === 'string') {
body = params;
body = params as RequestBody;
} else {
body = JSON.stringify(params);
}
@@ -42,8 +80,16 @@ function buildRequestBody(method, params, url) {
return { body, isFormData };
}
function buildHeaders({ url, body, isFormData, csrfToken, origin }) {
const headers = {
function buildHeaders(
{ url, body, isFormData, csrfToken, origin }: {
url: URL;
body: RequestBody;
isFormData: boolean;
csrfToken?: string | null;
origin: string;
},
): Record<string, string> {
const headers: Record<string, string> = {
'Accept': 'application/json',
};
if (url.origin === origin) {
@@ -56,12 +102,12 @@ function buildHeaders({ url, body, isFormData, csrfToken, origin }) {
return headers;
}
async function parseResponseBody(response) {
async function parseResponseBody(response: MinimalResponse): Promise<ParsedResponse> {
if (response.status === 204) {
return { payload: null, rawText: '' };
}
const text = await response.text();
const text = await response.text?.() ?? '';
if (!text) {
return { payload: null, rawText: '' };
}
@@ -73,10 +119,11 @@ async function parseResponseBody(response) {
}
}
function validateHttpResponse(response, parsed) {
function validateHttpResponse(response: MinimalResponse, parsed: ParsedResponse): ApiErrorPayload | null {
if (!response.ok) {
const error = new Error(parsed.payload?.error?.message || `HTTP error ${response.status}: ${response.statusText}`);
error.code = parsed.payload?.error?.code || `http_${response.status}`;
const payload = isApiResponsePayload(parsed.payload) ? parsed.payload : null;
const error = new Error(payload?.error?.message || `HTTP error ${response.status}: ${response.statusText}`);
error.code = payload?.error?.code || `http_${response.status}`;
error.status = response.status;
error.rawBody = parsed.rawText;
throw error;
@@ -86,10 +133,10 @@ function validateHttpResponse(response, parsed) {
throw new Error('Invalid or non-JSON response from server');
}
return parsed.payload;
return isApiResponsePayload(parsed.payload) ? parsed.payload : null;
}
function interpretResponse(payload) {
function interpretResponse(payload: ApiErrorPayload | null) {
if (!payload) {
return null;
}
@@ -103,7 +150,7 @@ function interpretResponse(payload) {
return payload.result;
}
function normalizeApiError(error, { timeoutMs, timeoutMessage }) {
function normalizeApiError(error: ApiErrorLike, { timeoutMs = 30000, timeoutMessage = null }: ApiTimeoutOptions = {}) {
if (error.name === 'AbortError') {
return {
message: timeoutMessage || `Request timed out after ${timeoutMs / 1000} seconds`,
@@ -1,3 +1,4 @@
// @ts-nocheck -- Runtime DOM/widget integration; converted to TS without changing behavior.
/**
* FOSSBilling API for JavaScript.
*
@@ -8,7 +9,7 @@
* with this source code in the file LICENSE
*/
import { parseDataAttr } from './parse-data-attr.mjs';
import { parseDataAttr } from './parse-data-attr.mts';
import {
injectCSRFToken,
buildRequestBody,
@@ -17,11 +18,11 @@ import {
validateHttpResponse,
interpretResponse,
normalizeApiError,
} from './api-helpers.mjs';
} from './api-helpers.mts';
import {
dispatchLinkAction,
createLinkLoadingState,
} from './link-helpers.mjs';
} from './link-helpers.mts';
/**
* Tools for the API wrapper.
@@ -1,3 +1,4 @@
// @ts-nocheck -- Runtime DOM/widget integration; converted to TS without changing behavior.
/**
* FOSSBilling browser runtime.
*
File renamed without changes.
File renamed without changes.
@@ -9,11 +9,11 @@ import {
normalizeApiError,
parseResponseBody,
validateHttpResponse,
} from '../api-helpers.mjs';
} from '../api-helpers.mts';
describe('API request helpers', () => {
test('injects a missing CSRF token without replacing an existing one', () => {
const params = {};
const params: Record<string, unknown> = {};
assert.equal(injectCSRFToken(params, 'token'), params);
assert.equal(params.CSRFToken, 'token');
@@ -103,7 +103,7 @@ describe('API response helpers', () => {
{ ok: false, status: 422, statusText: 'Invalid' },
{ payload: { error: { message: 'Bad input', code: 'bad_input' } }, rawText: 'body' },
),
(error) => error.message === 'Bad input' && error.code === 'bad_input' && error.status === 422,
(error: any) => error.message === 'Bad input' && error.code === 'bad_input' && error.status === 422,
);
assert.throws(
() => validateHttpResponse({ ok: true }, { payload: null, rawText: '<html>' }),
@@ -116,7 +116,7 @@ describe('API response helpers', () => {
assert.equal(interpretResponse({ result: 0 }), 0);
assert.throws(
() => interpretResponse({ error: { message: 'Denied', code: 'denied' } }),
(error) => error.message === 'Denied' && error.code === 'denied',
(error: any) => error.message === 'Denied' && error.code === 'denied',
);
});
@@ -1,52 +1,54 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { createLinkLoadingState, dispatchLinkAction } from '../link-helpers.mjs';
import { createLinkLoadingState, dispatchLinkAction } from '../link-helpers.mts';
globalThis.window = globalThis;
const testGlobal = globalThis as any;
const createClassList = (classes = []) => ({
testGlobal.window = testGlobal;
const createClassList = (classes: string[] = []) => ({
classes: new Set(classes),
add(value) { this.classes.add(value); },
remove(value) { this.classes.delete(value); },
contains(value) { return this.classes.has(value); },
add(value: string) { this.classes.add(value); },
remove(value: string) { this.classes.delete(value); },
contains(value: string) { return this.classes.has(value); },
});
const createElement = () => ({
const createElement = (): any => ({
className: '',
attributes: {},
classList: createClassList(),
getAttribute(name) { return this.attributes[name] ?? null; },
setAttribute(name, value) { this.attributes[name] = String(value); },
getAttribute(name: string) { return this.attributes[name] ?? null; },
setAttribute(name: string, value: string) { this.attributes[name] = String(value); },
});
globalThis.document = {
testGlobal.document = {
createElement,
createTextNode: (text) => ({ textContent: text }),
createTextNode: (text: string) => ({ textContent: text }),
querySelector: () => null,
};
const createLink = ({ disabled = false, parent = null } = {}) => ({
const createLink = ({ disabled = false, parent = null }: { disabled?: boolean; parent?: any } = {}): any => ({
innerHTML: '<span>Original</span>',
attributes: {},
classList: createClassList(disabled ? ['disabled'] : []),
parentElement: parent,
getAttribute(name) { return this.attributes[name] ?? null; },
setAttribute(name, value) { this.attributes[name] = String(value); },
removeAttribute(name) { delete this.attributes[name]; },
replaceChildren(...children) { this.children = children; },
getAttribute(name: string) { return this.attributes[name] ?? null; },
setAttribute(name: string, value: string) { this.attributes[name] = String(value); },
removeAttribute(name: string) { delete this.attributes[name]; },
replaceChildren(...children: any[]) { this.children = children; },
closest() { return parent; },
});
describe('dispatchLinkAction', () => {
test('dispatches links without a modal directly', () => {
let request;
let request: unknown;
dispatchLinkAction({}, '/endpoint', null, (...args) => { request = args; });
assert.deepEqual(request, ['GET', '/endpoint']);
});
test('uses native confirm when the modal library is unavailable', () => {
let request;
let request: unknown;
window.confirm = (message) => {
assert.equal(message, 'Continue?');
return true;
@@ -78,7 +80,7 @@ describe('dispatchLinkAction', () => {
return 'Because';
};
let request;
let request: unknown;
dispatchLinkAction(
{ modal: { type: 'prompt', key: 'reason', label: 'Reason', value: 'Default' } },
'/endpoint',
@@ -89,9 +91,9 @@ describe('dispatchLinkAction', () => {
});
test('configures library confirm and prompt callbacks', () => {
const created = [];
const modals = { create: (config) => created.push(config) };
const requests = [];
const created: any[] = [];
const modals = { create: (config: any) => created.push(config) };
const requests: unknown[] = [];
dispatchLinkAction(
{ modal: { type: 'confirm', title: 'Delete', button: 'Delete', buttonColor: 'danger' } },
@@ -157,7 +159,7 @@ describe('createLinkLoadingState', () => {
children: [],
appendChild(child) { this.children.push(child); },
};
const removed = [];
const removed: unknown[] = [];
const listeners = new Map();
window.addEventListener = (name, listener) => listeners.set(name, listener);
window.removeEventListener = (name, listener) => {
@@ -167,11 +169,11 @@ describe('createLinkLoadingState', () => {
};
const originalCreateElement = document.createElement;
document.createElement = () => {
const element = originalCreateElement();
document.createElement = (() => {
const element = originalCreateElement('span');
element.remove = () => removed.push(element);
return element;
};
}) as typeof document.createElement;
const state = createLinkLoadingState(createLink({ parent }));
state.set({
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { parseDataAttr } from '../parse-data-attr.mjs';
import { parseDataAttr } from '../parse-data-attr.mts';
const parse = (value) => parseDataAttr(JSON.stringify(value));
@@ -1,3 +1,4 @@
// @ts-nocheck -- Runtime DOM/widget integration; converted to TS without changing behavior.
/**
* Default FOSSBilling rich text editor adapter.
*
@@ -6,7 +6,7 @@ import {
ensureDir,
sharedLoaders,
writeAssetManifest,
} from './tools/esbuild-helpers.mjs';
} from './tools/esbuild-helpers.mts';
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = resolve(__dirname, '..');
@@ -20,21 +20,21 @@ async function build() {
await ensureDir(resolve(outputDir, 'editor'));
await buildJsFile({
entryPoint: resolve(__dirname, 'core/fossbilling.js'),
entryPoint: resolve(__dirname, 'core/fossbilling.ts'),
outfile: resolve(outputDir, 'js/fossbilling.js'),
isProduction,
drop: [],
});
await buildJsFile({
entryPoint: resolve(__dirname, 'core/api.js'),
entryPoint: resolve(__dirname, 'core/api.ts'),
outfile: resolve(outputDir, 'js/api.js'),
isProduction,
drop: [],
});
await buildJsFile({
entryPoint: resolve(__dirname, 'editor/ckeditor.js'),
entryPoint: resolve(__dirname, 'editor/ckeditor.ts'),
outfile: resolve(outputDir, 'editor/ckeditor.js'),
isProduction,
drop: [],
@@ -3,7 +3,7 @@ import { dirname, extname, join, relative, resolve } from 'path';
import { readdirSync } from 'fs';
import { fileURLToPath } from 'url';
import { parseManifest, extractIconReferencesFromContent, computeIconErrors } from './icon-check-helpers.mjs';
import { parseManifest, extractIconReferencesFromContent, computeIconErrors } from './icon-check-helpers.mts';
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
@@ -32,7 +32,7 @@ const themes = [
},
];
const sourceExtensions = new Set(['.twig', '.js', '.mjs', '.php']);
const sourceExtensions = new Set(['.twig', '.ts', '.php']);
function walkFiles(dir) {
const files = [];
-59
View File
@@ -1,59 +0,0 @@
import { readdirSync } from 'fs';
import { extname, join, resolve } from 'path';
import { spawnSync } from 'child_process';
import { dirname } from 'path';
import { fileURLToPath } from 'url';
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const scanRoots = [
resolve(rootDir, 'frontend'),
resolve(rootDir, 'src/themes/admin_default'),
resolve(rootDir, 'src/themes/huraga'),
];
const sourceExtensions = new Set(['.js', '.mjs']);
function walkFiles(dir) {
const files = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (path.includes('/assets/build/') || path.includes('/node_modules/')) {
continue;
}
if (entry.isDirectory()) {
files.push(...walkFiles(path));
continue;
}
if (sourceExtensions.has(extname(entry.name))) {
files.push(path);
}
}
return files;
}
const files = scanRoots.flatMap(walkFiles).sort();
const failures = [];
for (const file of files) {
const result = spawnSync(process.execPath, ['--check', file], {
encoding: 'utf8',
});
if (result.status !== 0) {
failures.push({ file, stderr: result.stderr.trim() });
}
}
if (failures.length > 0) {
for (const failure of failures) {
console.error(`${failure.file}\n${failure.stderr}`);
}
process.exit(1);
}
console.log(`Checked JavaScript syntax for ${files.length} files`);
@@ -5,8 +5,48 @@ import { PurgeCSS } from 'purgecss';
import { dirname, join, resolve } from 'path';
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'fs/promises';
import * as esbuild from 'esbuild';
import type { BuildOptions, Drop, Loader, Plugin } from 'esbuild';
export const sharedLoaders = {
export type LoaderMap = Record<string, Loader>;
interface ThemeBuildPaths {
buildDir: string;
jsDir: string;
cssDir: string;
symbolDir: string;
}
interface PurgeOptions {
themePath?: string;
enabled?: boolean;
area?: 'admin' | 'client';
additionalStandardSafelist?: RegExp[];
}
interface CssBuildOptions {
entryPoint: string;
outfile: string;
nodeModulesDir: string;
isProduction: boolean;
loader?: LoaderMap;
themePath?: string;
purge?: Omit<PurgeOptions, 'themePath' | 'enabled'>;
}
interface JsBuildOptions {
entryPoint: string;
outfile?: string;
outdir?: string;
entryNames?: string;
chunkNames?: string;
isProduction: boolean;
loader?: LoaderMap;
drop?: Drop[];
splitting?: boolean;
format?: BuildOptions['format'];
}
export const sharedLoaders: LoaderMap = {
'.svg': 'file',
'.woff': 'file',
'.woff2': 'file',
@@ -15,7 +55,7 @@ export const sharedLoaders = {
'.webp': 'file',
};
export function sassPlugin(nodeModulesDir, isProduction) {
export function sassPlugin(nodeModulesDir: string, isProduction: boolean): Plugin {
return {
name: 'sass',
setup(build) {
@@ -37,24 +77,24 @@ export function sassPlugin(nodeModulesDir, isProduction) {
};
}
export async function ensureDir(dir) {
export async function ensureDir(dir: string) {
await mkdir(dir, { recursive: true });
}
export async function removeDirContents(dir) {
export async function removeDirContents(dir: string) {
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
await rm(join(dir, entry.name), { recursive: true, force: true });
}
} catch (error) {
if (error.code !== 'ENOENT') {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
}
export function getThemeBuildPaths(themePath) {
export function getThemeBuildPaths(themePath: string): ThemeBuildPaths {
const buildDir = resolve(themePath, 'assets/build');
return {
@@ -65,14 +105,14 @@ export function getThemeBuildPaths(themePath) {
};
}
export async function prepareThemeBuildDirs(paths) {
export async function prepareThemeBuildDirs(paths: ThemeBuildPaths) {
await removeDirContents(paths.buildDir);
await ensureDir(paths.jsDir);
await ensureDir(paths.cssDir);
await ensureDir(paths.symbolDir);
}
export async function postprocessCssFile(cssPath, isProduction) {
export async function postprocessCssFile(cssPath: string, isProduction: boolean) {
const css = await readFile(cssPath, 'utf8');
const mapPath = `${cssPath}.map`;
let prevMap;
@@ -81,7 +121,7 @@ export async function postprocessCssFile(cssPath, isProduction) {
try {
prevMap = await readFile(mapPath, 'utf8');
} catch (error) {
if (error.code !== 'ENOENT') {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
@@ -99,7 +139,7 @@ export async function postprocessCssFile(cssPath, isProduction) {
}
}
export async function purgeCssFile(cssFilePath, options = {}) {
export async function purgeCssFile(cssFilePath: string, options: PurgeOptions) {
const {
themePath,
enabled = false,
@@ -111,6 +151,10 @@ export async function purgeCssFile(cssFilePath, options = {}) {
return;
}
if (!themePath) {
throw new Error('PurgeCSS requires a themePath when enabled');
}
try {
const css = await readFile(cssFilePath, 'utf8');
const modulesPath = resolve(themePath, '../../modules');
@@ -119,10 +163,10 @@ export async function purgeCssFile(cssFilePath, options = {}) {
const purgeCSSResult = await new PurgeCSS().purge({
content: [
`${themePath}/html/**/*.twig`,
`${themePath}/assets/**/*.js`,
`${themePath}/assets/**/*.{js,ts}`,
`${modulesPath}/*/templates/${moduleArea}/**/*.twig`,
],
css: [{ raw: css, extension: 'css' }],
css: [{ raw: css, extension: 'css' } as unknown as { raw: string }],
safelist: {
standard: [
/^fi-/,
@@ -168,11 +212,12 @@ export async function purgeCssFile(cssFilePath, options = {}) {
console.log(`PurgeCSS applied to ${cssFilePath.split('/').pop()}`);
}
} catch (error) {
throw new Error(`PurgeCSS failed for ${cssFilePath.split('/').pop()}: ${error.message}`);
const message = error instanceof Error ? error.message : String(error);
throw new Error(`PurgeCSS failed for ${cssFilePath.split('/').pop()}: ${message}`);
}
}
function formatBytes(bytes) {
function formatBytes(bytes: number) {
if (bytes < 1024) {
return `${bytes}b`;
}
@@ -189,12 +234,12 @@ function formatBytes(bytes) {
return `${size.toFixed(1)}${unit}`;
}
export async function logFileSize(filePath, label = filePath.split('/').pop()) {
export async function logFileSize(filePath: string, label = filePath.split('/').pop()) {
const fileStat = await stat(filePath);
console.log(` ${label}: ${formatBytes(fileStat.size)}`);
}
export async function buildCssFile(options) {
export async function buildCssFile(options: CssBuildOptions) {
const {
entryPoint,
outfile,
@@ -222,6 +267,10 @@ export async function buildCssFile(options) {
await postprocessCssFile(outfile, isProduction);
if (purge) {
if (isProduction && !themePath) {
throw new Error(`Cannot purge ${outfile}: themePath is required`);
}
await purgeCssFile(outfile, {
themePath,
enabled: isProduction,
@@ -232,7 +281,7 @@ export async function buildCssFile(options) {
await logFileSize(outfile, `${outfile.split('/').pop()} after CSS post-processing`);
}
export async function buildJsFile(options) {
export async function buildJsFile(options: JsBuildOptions) {
const {
entryPoint,
outfile,
@@ -246,7 +295,7 @@ export async function buildJsFile(options) {
format = splitting ? 'esm' : undefined,
} = options;
const buildOptions = {
const buildOptions: BuildOptions = {
entryPoints: [entryPoint],
bundle: true,
platform: 'browser',
@@ -280,6 +329,6 @@ export async function buildJsFile(options) {
await esbuild.build(buildOptions);
}
export async function writeAssetManifest(buildDir, manifest) {
export async function writeAssetManifest(buildDir: string, manifest: Record<string, string>) {
await writeFile(join(buildDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
}
File renamed without changes.
@@ -124,10 +124,12 @@ export async function generateIconSprite(options) {
spriter.add(filePath, fileName, content);
}
const result = await new Promise((res, rej) => {
type SpriteCompileResult = { symbol: { sprite: { contents: string } } };
const result = await new Promise<SpriteCompileResult>((res, rej) => {
spriter.compile((error, result) => {
if (error) rej(error);
else res(result);
else res(result as SpriteCompileResult);
});
});
@@ -5,7 +5,7 @@ import {
computeIconErrors,
extractIconReferencesFromContent,
parseManifest,
} from '../icon-check-helpers.mjs';
} from '../icon-check-helpers.mts';
describe('icon manifest helpers', () => {
test('normalizes manifest entries and tracks dynamic icons', () => {
+159
View File
@@ -0,0 +1,159 @@
type ApiParams = Record<string, unknown> | FormData | string | null | undefined;
type ApiSuccessHandler = (result: unknown) => void;
type ApiErrorHandler = (error: { message: string; code?: string }) => void;
interface FOSSBillingApiNamespace {
get(endpoint: string, params?: ApiParams, successHandler?: ApiSuccessHandler, errorHandler?: ApiErrorHandler): void;
post(endpoint: string, params?: ApiParams, successHandler?: ApiSuccessHandler, errorHandler?: ApiErrorHandler): void;
put(endpoint: string, params?: ApiParams, successHandler?: ApiSuccessHandler, errorHandler?: ApiErrorHandler): void;
patch(endpoint: string, params?: ApiParams, successHandler?: ApiSuccessHandler, errorHandler?: ApiErrorHandler): void;
delete(endpoint: string, params?: ApiParams, successHandler?: ApiSuccessHandler, errorHandler?: ApiErrorHandler): void;
}
interface FOSSBillingEditorInstance {
destroy?: () => void | Promise<void>;
getData: () => string;
setData: (data: string) => void;
model?: {
document?: {
on?: (event: string, callback: () => void) => void;
};
};
}
interface FOSSBillingEditorAdapter {
create: (element: HTMLElement, options?: Record<string, unknown>) => Promise<FOSSBillingEditorInstance>;
}
interface FOSSBillingEditorRegistry {
all(): FOSSBillingEditorInstance[];
create(element: HTMLElement, options?: Record<string, unknown>): Promise<FOSSBillingEditorInstance>;
get(elementOrName: HTMLElement | string): FOSSBillingEditorInstance | null;
registerAdapter(name: string, adapter: FOSSBillingEditorAdapter): void;
syncForm(form: HTMLFormElement, formData?: FormData): void;
validateForm(form: HTMLFormElement): boolean;
}
interface FOSSBillingRuntime {
api?: {
admin: FOSSBillingApiNamespace;
client: FOSSBillingApiNamespace;
guest: FOSSBillingApiNamespace;
makeRequest: (
method: string,
url: string,
params?: ApiParams,
successHandler?: ApiSuccessHandler,
errorHandler?: ApiErrorHandler,
enableLoader?: boolean,
timeoutMs?: number,
timeoutMessage?: string | null,
) => void;
_afterComplete: (element: Element, result: unknown) => void;
};
backToTop?: () => void;
charts?: {
renderTimeSeriesSparkline: (target: HTMLElement | string, data: unknown, options?: Record<string, unknown>) => unknown;
};
cookieCreate?: (name: string, value: string, days?: number) => void;
cookieRead?: (name: string) => string | null;
detectTimezone?: () => string;
editor?: FOSSBillingEditorRegistry;
initTimezone?: () => void;
message?: (message: string, type?: string) => void;
ready?: (callback: (runtime: FOSSBillingRuntime) => void) => void;
tools?: {
getBaseURL: (url: string) => string;
getCSRFToken: () => string | null;
isJSON: (jsonString: string) => boolean;
serializeFormData: (formData: FormData) => string;
serializeFormDataToJSON: (formData: FormData) => string;
serializeFormDataToObject: (formData: FormData) => Record<string, unknown>;
};
ui?: {
notify: (message: string, type?: string) => void;
};
}
interface ModalCreateOptions {
confirmButton?: string;
confirmButtonColor?: string;
confirmCallback?: () => void;
content?: string;
label?: string;
promptConfirmCallback?: (value: string) => void;
title?: string;
type?: string;
value?: string;
}
interface ModalsRuntime {
create(options: ModalCreateOptions): unknown;
create(type: string, options: ModalCreateOptions): unknown;
hideAll(): void;
}
interface BootstrapConstructor<T = unknown> {
new (element: Element, options?: Record<string, unknown>): T;
getInstance?: (element: Element) => T | null;
getOrCreateInstance?: (element: Element) => T;
}
interface BootstrapRuntime {
Collapse: BootstrapConstructor;
Modal: BootstrapConstructor<{ hide: () => void; show: () => void }>;
Popover: BootstrapConstructor<{ hide: () => void; show: () => void }>;
Tab: BootstrapConstructor<{ show: () => void }>;
Toast: BootstrapConstructor<{ show: () => void }>;
Tooltip: BootstrapConstructor<{ dispose: () => void; hide: () => void; show: () => void }>;
}
declare global {
const bootstrap: BootstrapRuntime;
const FOSSBilling: FOSSBillingRuntime;
const Modals: ModalsRuntime | undefined;
interface Window {
FOSSBilling: FOSSBillingRuntime;
}
interface Error {
code?: string;
rawBody?: string;
status?: number;
}
var bootstrap: BootstrapRuntime;
var flashMessage: ((options: { message?: string; reload?: boolean | string; type?: string }) => void) | undefined;
var FOSSBilling: FOSSBillingRuntime;
var Modals: ModalsRuntime | undefined;
var TomSelect: unknown;
}
declare module '*.css' {
const css: string;
export default css;
}
declare module '*?raw' {
const content: string;
export default content;
}
declare module '@ckeditor/*/dist/*.css' {
const css: string;
export default css;
}
declare module 'bootstrap/dist/js/bootstrap.esm.js' {
export const Collapse: BootstrapRuntime['Collapse'];
export const Modal: BootstrapRuntime['Modal'];
export const Popover: BootstrapRuntime['Popover'];
export const Tab: BootstrapRuntime['Tab'];
export const Toast: BootstrapRuntime['Toast'];
export const Tooltip: BootstrapRuntime['Tooltip'];
}
declare module 'sortable-tablesort/dist/sortable.min.js';
export {};
+402 -1
View File
@@ -11,6 +11,7 @@
"src/themes/admin_default"
],
"devDependencies": {
"@types/node": "^24.13.3",
"autoprefixer": "10.5.2",
"ckeditor5": "48.3.0",
"esbuild": "0.28.1",
@@ -18,9 +19,11 @@
"postcss": "8.5.16",
"purgecss": "8.0.0",
"sass": "1.101.0",
"svg-sprite": "2.0.4"
"svg-sprite": "2.0.4",
"typescript": "^7.0.2"
},
"engines": {
"node": ">=24",
"npm": ">=11"
}
},
@@ -2134,6 +2137,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/triple-beam": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz",
@@ -2148,6 +2161,346 @@
"dev": true,
"license": "MIT"
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz",
@@ -5269,6 +5622,48 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc"
},
"engines": {
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -5645,6 +6040,9 @@
},
"devDependencies": {
"@tabler/icons": "3.44.0"
},
"engines": {
"node": ">=24"
}
},
"src/themes/huraga": {
@@ -5656,6 +6054,9 @@
},
"devDependencies": {
"@tabler/icons": "3.44.0"
},
"engines": {
"node": ">=24"
}
}
}
+9 -6
View File
@@ -6,14 +6,14 @@
"scripts": {
"build": "npm run check && npm run build-core && npm run build-themes",
"build:production": "NODE_ENV=production npm run build",
"build-core": "node ./frontend/esbuild.mjs",
"build-core": "node ./frontend/esbuild.mts",
"build-themes": "npm run build-admin_default && npm run build -w huraga",
"build-huraga": "npm run build -w huraga",
"build-admin_default": "npm run build -w admin_default",
"check": "npm run check-js-syntax && npm run check-icons",
"check-js-syntax": "node ./frontend/tools/check-js-syntax.mjs",
"check-icons": "node ./frontend/tools/check-icons.mjs",
"test:js": "node --test 'frontend/core/tests/**/*.test.mjs' 'frontend/tools/tests/**/*.test.mjs'",
"check": "npm run check-types && npm run check-icons",
"check-types": "tsc --noEmit",
"check-icons": "node ./frontend/tools/check-icons.mts",
"test:js": "node --test 'frontend/core/tests/**/*.test.mts' 'frontend/tools/tests/**/*.test.mts'",
"cy:open": "npm exec --yes cypress@15.15.0 -- open",
"cy:run": "npm exec --yes cypress@15.15.0 -- run",
"cy:run:record": "npm exec --yes cypress@15.15.0 -- run --record"
@@ -30,6 +30,7 @@
"src/themes/admin_default"
],
"devDependencies": {
"@types/node": "^24.13.3",
"autoprefixer": "10.5.2",
"ckeditor5": "48.3.0",
"esbuild": "0.28.1",
@@ -37,7 +38,8 @@
"postcss": "8.5.16",
"purgecss": "8.0.0",
"sass": "1.101.0",
"svg-sprite": "2.0.4"
"svg-sprite": "2.0.4",
"typescript": "^7.0.2"
},
"config": {
"platform": {
@@ -45,6 +47,7 @@
}
},
"engines": {
"node": ">=24",
"npm": ">=11"
}
}
+11 -1
View File
@@ -2100,7 +2100,17 @@ class UpdatePatcher implements InjectionAwareInterface
}
if ($this->tableHasColumn('admin', 'role')) {
$this->executeSql("UPDATE `admin` SET `system_name` = 'cron' WHERE `role` = 'cron' AND (`system_name` IS NULL OR `system_name` = '') ORDER BY `id` ASC LIMIT 1;");
$this->executeSql("
UPDATE `admin`
SET `system_name` = 'cron'
WHERE `role` = 'cron'
AND (`system_name` IS NULL OR `system_name` = '')
AND NOT EXISTS (
SELECT 1 FROM (SELECT `id` FROM `admin` WHERE `system_name` = 'cron' LIMIT 1) AS existing_cron
)
ORDER BY `id` ASC
LIMIT 1;
");
$this->executeSql('ALTER TABLE `admin` DROP COLUMN `role`;');
}
@@ -1,6 +1,7 @@
import './js/ui/modals';
// @ts-nocheck -- Runtime DOM/widget integration; converted to TS without changing behavior.
import './js/ui/modals.ts';
import * as tabler from '@tabler/core/js/tabler.js';
import './js/fossbilling';
import './js/fossbilling.ts';
globalThis.bootstrap = tabler.bootstrap;
@@ -103,14 +104,14 @@ function loadAdminFeature(loader, name) {
document.addEventListener('DOMContentLoaded', () => {
if (document.querySelector('.datepicker')) {
loadAdminFeature(async () => {
const { default: initDatepickers } = await import('./js/datepicker');
const { default: initDatepickers } = await import('./js/datepicker.ts');
initDatepickers();
}, 'datepickers');
}
if (document.querySelector('.js-locale-selector, .autocomplete-selector, .canned_response_select')) {
loadAdminFeature(async () => {
const { default: initTomSelectControls } = await import('./js/tomselect');
const { default: initTomSelectControls } = await import('./js/tomselect.ts');
initTomSelectControls();
}, 'Tom Select controls');
}
@@ -123,7 +124,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (document.querySelector('#theme-settings')) {
loadAdminFeature(async () => {
const { default: initThemeSettings } = await import('./js/ui/theme_settings');
const { default: initThemeSettings } = await import('./js/ui/theme_settings.ts');
initThemeSettings();
}, 'theme settings');
}
@@ -1,7 +1,20 @@
import Litepicker from 'litepicker';
type LitepickerOptions = ConstructorParameters<typeof Litepicker>[0];
interface LitepickerDate {
format: (format: string) => string;
}
type DateRangeInput = HTMLInputElement & {
dataset: DOMStringMap & {
nameFrom: string;
nameTo: string;
};
};
export default function initDatepickers() {
document.querySelectorAll('.datepicker').forEach(element => {
document.querySelectorAll<HTMLInputElement>('.datepicker').forEach(element => {
element.autocomplete = 'off';
new Litepicker({
element: element,
@@ -12,16 +25,16 @@ export default function initDatepickers() {
minYear: 1930,
months: !!element.dataset.pickMonth,
years: !!element.dataset.pickYear
},
} as LitepickerOptions['dropdowns'],
singleMode: !isRanger(element),
delimiter: ' to ',
buttonText: {
previousMonth: '<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M15 6l-6 6l6 6"></path></svg>',
nextMonth: '<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M9 6l6 6l-6 6"></path></svg>',
reset: '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-clock-x" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M20.926 13.15a9 9 0 1 0 -7.835 7.784"></path><path d="M12 7v5l2 2"></path><path d="M22 22l-5 -5"></path><path d="M17 22l5 -5"></path></svg>',
},
} as LitepickerOptions['buttonText'],
setup: (picker) => {
picker.on('selected', (date, dateTo) => {
picker.on('selected', (date: LitepickerDate, dateTo?: LitepickerDate) => {
if (isRanger(element) && dateTo) {
setDateToEl(element.dataset.nameFrom, date.format('YYYY-MM-DD'), element);
setDateToEl(element.dataset.nameTo, dateTo.format('YYYY-MM-DD'), element);
@@ -33,19 +46,19 @@ export default function initDatepickers() {
});
}
function isRanger(element) {
return element.dataset.nameFrom && element.dataset.nameTo;
function isRanger(element: HTMLInputElement): element is DateRangeInput {
return Boolean(element.dataset.nameFrom && element.dataset.nameTo);
}
function setDateToEl(name, value, siblingEl = null) {
let element = document.getElementById(name);
function setDateToEl(name: string, value: string, siblingEl: HTMLElement) {
let element = document.getElementById(name) as HTMLInputElement | null;
if (element === null) {
element = document.createElement('input');
element.type = 'hidden';
element.id = name;
element.name = name;
element.value = value;
siblingEl.parentNode.insertBefore(element, siblingEl.nextSibling);
siblingEl.parentNode!.insertBefore(element, siblingEl.nextSibling);
return;
}
element.value = value;
@@ -1,7 +1,8 @@
import backToTop from "./ui/backToTop";
// @ts-nocheck -- Runtime DOM/widget integration; converted to TS without changing behavior.
import backToTop from "./ui/backToTop.ts";
function renderTimeSeriesSparkline(...args) {
return import("./ui/charts").then(({ renderTimeSeriesSparkline: renderChart }) => renderChart(...args));
return import("./ui/charts.ts").then(({ renderTimeSeriesSparkline: renderChart }) => renderChart(...args));
}
globalThis.FOSSBilling = Object.assign(globalThis.FOSSBilling || {}, {
@@ -1,5 +1,6 @@
// @ts-nocheck -- Runtime DOM/widget integration; converted to TS without changing behavior.
import TomSelect from 'tom-select';
import { getCSRFToken, getBaseURL } from './utils';
import { getCSRFToken, getBaseURL } from './utils.ts';
globalThis.TomSelect = TomSelect;
const createTomSelectTemplate = (data, escape, options = {}) => {
@@ -1,3 +1,4 @@
// @ts-nocheck -- Runtime DOM/widget integration; converted to TS without changing behavior.
import Chart from 'chart.js/auto';
const chartInstances = new WeakMap();
@@ -1,3 +1,4 @@
// @ts-nocheck -- Runtime DOM/widget integration; converted to TS without changing behavior.
/**
* JavaScript for the FOSSBilling modals. No jQuery required.
*
@@ -78,7 +78,7 @@ export default function initThemeSettings() {
hrEl.classList.add('my-3');
headingEl.classList.add('mb-3');
el.before(hrEl); el.before(headingEl);
headingEl.innerText = el.innerText;
headingEl.textContent = el.textContent;
el.remove();
});
@@ -12,7 +12,7 @@
* Get CSRF token from cookie
* @returns {string|null} CSRF token or null if not found
*/
export function getCSRFToken() {
export function getCSRFToken(): string | null {
const match = document.cookie.match(/csrf_token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
}
@@ -22,7 +22,7 @@ export function getCSRFToken() {
* @param {string} path - Relative path
* @returns {string} Absolute URL
*/
export function getBaseURL(path) {
export function getBaseURL(path: string): string {
if (!path) return '';
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
@@ -36,7 +36,7 @@ export function getBaseURL(path) {
* @param {HTMLElement|Document} [scope=document] - Scope to search within
* @returns {HTMLElement|null} Found element or null
*/
export function safeQuerySelector(selector, scope = document) {
export function safeQuerySelector(selector: string, scope: ParentNode = document): Element | null {
try {
return scope.querySelector(selector);
} catch (error) {
@@ -51,7 +51,7 @@ export function safeQuerySelector(selector, scope = document) {
* @param {HTMLElement|Document} [scope=document] - Scope to search within
* @returns {NodeList|[]} Found elements or empty array
*/
export function safeQuerySelectorAll(selector, scope = document) {
export function safeQuerySelectorAll(selector: string, scope: ParentNode = document): NodeListOf<Element> | [] {
try {
return scope.querySelectorAll(selector);
} catch (error) {
@@ -66,9 +66,9 @@ export function safeQuerySelectorAll(selector, scope = document) {
* @param {number} wait - Time to wait in milliseconds
* @returns {Function} Debounced function
*/
export function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
export function debounce<T extends (...args: unknown[]) => void>(func: T, wait: number) {
let timeout: ReturnType<typeof setTimeout> | undefined;
return function executedFunction(this: unknown, ...args: Parameters<T>) {
const context = this;
const later = () => {
clearTimeout(timeout);
@@ -85,10 +85,10 @@ export function debounce(func, wait) {
* @param {number} limit - Time limit in milliseconds
* @returns {Function} Throttled function
*/
export function throttle(func, limit) {
let lastFunc;
let lastRan;
return function(...args) {
export function throttle<T extends (...args: unknown[]) => void>(func: T, limit: number) {
let lastFunc: ReturnType<typeof setTimeout> | undefined;
let lastRan: number | undefined;
return function(this: unknown, ...args: Parameters<T>) {
const context = this;
if (!lastRan) {
func.apply(context, args);
@@ -110,7 +110,7 @@ export function throttle(func, limit) {
* @param {HTMLElement} element - Element to check
* @returns {boolean} True if element is in viewport
*/
export function isElementInViewport(element) {
export function isElementInViewport(element: Element | null): boolean {
if (!element) return false;
const rect = element.getBoundingClientRect();
@@ -127,7 +127,7 @@ export function isElementInViewport(element) {
* @param {HTMLElement|string} target - Element or selector
* @param {Object} [options] - Scroll options
*/
export function scrollToElement(target, options = {}) {
export function scrollToElement(target: Element | string, options: ScrollIntoViewOptions = {}) {
const element = typeof target === 'string' ? safeQuerySelector(target) : target;
if (!element) return;
@@ -146,7 +146,7 @@ export function scrollToElement(target, options = {}) {
* @param {number} [decimals=2] - Number of decimals
* @returns {string} Formatted string
*/
export function formatBytes(bytes, decimals = 2) {
export function formatBytes(bytes: number, decimals = 2): string {
if (!Number.isFinite(bytes) || bytes < 0) return '0 Bytes';
if (bytes === 0) return '0 Bytes';
@@ -165,14 +165,17 @@ export function formatBytes(bytes, decimals = 2) {
* @param {...Object} objects - Objects to merge
* @returns {Object} Merged object
*/
export function deepMerge(...objects) {
const result = {};
export function deepMerge(...objects: Array<Record<string, unknown>>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const obj of objects) {
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
result[key] = deepMerge(result[key] || {}, obj[key]);
const current = typeof result[key] === 'object' && result[key] !== null && !Array.isArray(result[key])
? result[key] as Record<string, unknown>
: {};
result[key] = deepMerge(current, obj[key] as Record<string, unknown>);
} else {
result[key] = obj[key];
}
@@ -7,14 +7,14 @@ import {
prepareThemeBuildDirs,
sharedLoaders,
writeAssetManifest,
} from '../../../frontend/tools/esbuild-helpers.mjs';
import { buildIconSprite } from '../../../frontend/tools/icon-sprite.mjs';
} from '../../../frontend/tools/esbuild-helpers.mts';
import { buildIconSprite } from '../../../frontend/tools/icon-sprite.mts';
const __dirname = dirname(fileURLToPath(import.meta.url));
const isProduction = process.env.NODE_ENV === 'production';
const rootDir = resolve(__dirname, '../../..');
const nodeModulesDir = resolve(rootDir, 'node_modules');
const adminLoaders = { ...sharedLoaders, '.svg': 'dataurl' };
const adminLoaders = { ...sharedLoaders, '.svg': 'dataurl' } as const;
async function build() {
console.log(`Building admin_default theme (${isProduction ? 'production' : 'development'}) with esbuild ...`);
@@ -62,7 +62,7 @@ async function build() {
});
await buildJsFile({
entryPoint: resolve(__dirname, 'assets/fossbilling.js'),
entryPoint: resolve(__dirname, 'assets/fossbilling.ts'),
outdir: paths.jsDir,
entryNames: '[name]',
chunkNames: 'chunks/[name]-[hash]',
+5 -2
View File
@@ -1,8 +1,11 @@
{
"name": "admin_default",
"scripts": {
"build": "node ./esbuild.mjs",
"dev": "node ./esbuild.mjs"
"build": "node ./esbuild.mts",
"dev": "node ./esbuild.mts"
},
"engines": {
"node": ">=24"
},
"dependencies": {
"@tabler/core": "^1.2.0",
@@ -1,7 +1,8 @@
// @ts-nocheck -- Runtime DOM/widget integration; converted to TS without changing behavior.
import { Tooltip, Toast, Modal, Collapse, Tab } from 'bootstrap/dist/js/bootstrap.esm.js';
import './js/utils';
import initTheme from './js/ui/theme.js';
import initPhoneInput from './js/phone-input.js';
import './js/utils.ts';
import initTheme from './js/ui/theme.ts';
import initPhoneInput from './js/phone-input.ts';
globalThis.bootstrap = { Tooltip, Toast, Modal, Collapse, Tab };
@@ -126,7 +127,7 @@ document.addEventListener('DOMContentLoaded', () => {
const languageSelector = document.querySelector('.js-locale-selector');
if (languageSelector) {
// Dynamically import TomSelect module with error handling
import('./js/tomselect.js')
import('./js/tomselect.ts')
.then(module => {
if (typeof module.default === 'function') {
module.default();
@@ -1,7 +1,8 @@
import intlTelInput from 'intl-tel-input';
import type { Iso2 } from 'intl-tel-input';
export default function initPhoneInput() {
const inputs = document.querySelectorAll('.js-phone-input');
const inputs = document.querySelectorAll<HTMLInputElement>('.js-phone-input');
if (inputs.length === 0) {
return;
@@ -9,20 +10,21 @@ export default function initPhoneInput() {
const countries = intlTelInput.getAllCountries();
const supportedCountries = new Set(countries.map((country) => country.iso2));
const isSupportedCountry = (country: string): country is Iso2 => supportedCountries.has(country as Iso2);
inputs.forEach((input) => {
const form = input.closest('form') || document;
const countryCodeInput = form.querySelector('input[name="phone_cc"], input[name$="[phone_cc]"]');
const countryCodeInput = form.querySelector<HTMLInputElement>('input[name="phone_cc"], input[name$="[phone_cc]"]');
if (!countryCodeInput) {
return;
}
const initialPhoneCountryCode = countryCodeInput.value.trim();
const countrySelect = form.querySelector('select[name="country"], select[name$="[country]"]');
const countrySelect = form.querySelector<HTMLSelectElement>('select[name="country"], select[name$="[country]"]');
const selectedCountry = (countrySelect?.value || input.dataset.initialCountry || '').toLowerCase();
const initialCountry = countries.find((country) => country.dialCode === initialPhoneCountryCode)?.iso2
|| (supportedCountries.has(selectedCountry) ? selectedCountry : '');
|| (isSupportedCountry(selectedCountry) ? selectedCountry : '');
const iti = intlTelInput(input, {
initialCountry,
@@ -45,7 +47,7 @@ export default function initPhoneInput() {
countrySelect?.addEventListener('change', () => {
const country = countrySelect.value.toLowerCase();
if (supportedCountries.has(country)) {
if (isSupportedCountry(country)) {
iti.setSelectedCountry(country);
syncFields();
}
@@ -1,3 +1,4 @@
// @ts-nocheck -- Runtime DOM/widget integration; converted to TS without changing behavior.
/**
* Huraga theme TomSelect setup - lazy loaded only when needed
*/
@@ -1,11 +1,10 @@
/**
* Huraga theme utilities - minimal version of FOSSBilling utilities
* Only includes functionality actually used by the Huraga theme
*/
globalThis.FOSSBilling = Object.assign(globalThis.FOSSBilling || {}, {
message: (message, type = "info") => {
let color;
window.FOSSBilling = Object.assign(window.FOSSBilling || {}, {
message: (message: string, type = "info") => {
let color: string;
switch (type) {
case "error":
color = "danger";
@@ -30,7 +29,6 @@ globalThis.FOSSBilling = Object.assign(globalThis.FOSSBilling || {}, {
element.setAttribute("aria-live", "assertive");
element.setAttribute("aria-atomic", "true");
// Create header div and its children elements
const headerDiv = document.createElement("div");
headerDiv.className = "toast-header";
@@ -52,7 +50,6 @@ globalThis.FOSSBilling = Object.assign(globalThis.FOSSBilling || {}, {
element.appendChild(headerDiv);
// Create body div and set its text content
const bodyDiv = document.createElement("div");
bodyDiv.className = "toast-body";
bodyDiv.textContent = message;
@@ -62,7 +59,6 @@ globalThis.FOSSBilling = Object.assign(globalThis.FOSSBilling || {}, {
container.removeChild(element);
});
// Create a new Bootstrap toast instance and show it
const toast = new bootstrap.Toast(element);
toast.show();
}
@@ -7,8 +7,8 @@ import {
prepareThemeBuildDirs,
sharedLoaders,
writeAssetManifest,
} from '../../../frontend/tools/esbuild-helpers.mjs';
import { buildIconSprite } from '../../../frontend/tools/icon-sprite.mjs';
} from '../../../frontend/tools/esbuild-helpers.mts';
import { buildIconSprite } from '../../../frontend/tools/icon-sprite.mts';
const __dirname = dirname(fileURLToPath(import.meta.url));
const isProduction = process.env.NODE_ENV === 'production';
@@ -36,7 +36,7 @@ async function build() {
});
await buildJsFile({
entryPoint: resolve(__dirname, 'assets/huraga.js'),
entryPoint: resolve(__dirname, 'assets/huraga.ts'),
outdir: paths.jsDir,
entryNames: '[name]',
chunkNames: 'chunks/[name]-[hash]',
+5 -2
View File
@@ -1,8 +1,11 @@
{
"name": "huraga",
"scripts": {
"dev": "node ./esbuild.mjs",
"build": "node ./esbuild.mjs"
"dev": "node ./esbuild.mts",
"build": "node ./esbuild.mts"
},
"engines": {
"node": ">=24"
},
"dependencies": {
"bootstrap": "^5.3.3",
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"isolatedModules": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"module": "Preserve",
"moduleDetection": "force",
"moduleResolution": "Bundler",
"noEmit": true,
"noImplicitAny": false,
"skipLibCheck": true,
"strict": false,
"target": "ES2022",
"types": ["node"]
},
"include": [
"frontend/types/**/*.d.ts",
"frontend/**/*.ts",
"frontend/**/*.mts",
"src/themes/admin_default/**/*.ts",
"src/themes/admin_default/**/*.mts",
"src/themes/huraga/**/*.ts",
"src/themes/huraga/**/*.mts"
],
"exclude": [
"**/assets/build/**",
"**/node_modules/**"
]
}