mirror of
https://github.com/FOSSBilling/FOSSBilling.git
synced 2026-10-23 03:37:01 +09:00
Bundle default admin/client themes into a theme package (#4271)
This commit is contained in:
1 parent
8dda64f8e4
commit
bae0bffcfd
114 files changed
+1418
-577
No files matched your search
+2
-2
@@ -23,8 +23,8 @@ if [ ! -d "$nodeModulesPath" ] || [ ! -f "$nodeBuildTimestamp" ] || [ "$packageL
|
|||||||
npm ci --include=dev
|
npm ci --include=dev
|
||||||
npmBuildNeeded=1
|
npmBuildNeeded=1
|
||||||
elif [ ! -f "src/public/assets/manifest.json" ] \
|
elif [ ! -f "src/public/assets/manifest.json" ] \
|
||||||
|| [ ! -f "src/themes/admin_default/assets/build/manifest.json" ] \
|
|| [ ! -f "src/themes/default/admin/assets/build/manifest.json" ] \
|
||||||
|| [ ! -f "src/themes/huraga/assets/build/manifest.json" ]; then
|
|| [ ! -f "src/themes/default/client/assets/build/manifest.json" ]; then
|
||||||
npmBuildNeeded=1
|
npmBuildNeeded=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ node_modules
|
|||||||
### friendsofphp/php-cs-fixer ###
|
### friendsofphp/php-cs-fixer ###
|
||||||
.php-cs-fixer.cache
|
.php-cs-fixer.cache
|
||||||
src/install/php_error.log
|
src/install/php_error.log
|
||||||
src/themes/huraga/config/settings_data.json
|
src/themes/default/client/config/settings_data.json
|
||||||
output.log
|
output.log
|
||||||
|
|
||||||
### Playwright ###
|
### Playwright ###
|
||||||
|
|||||||
@@ -105,8 +105,8 @@ npm run build
|
|||||||
This command builds assets for:
|
This command builds assets for:
|
||||||
|
|
||||||
* Core public browser assets in `src/public/assets`
|
* Core public browser assets in `src/public/assets`
|
||||||
* `admin_default` theme
|
* `admin_default` theme (`src/themes/default/admin`)
|
||||||
* `huraga` theme
|
* `huraga` theme (`src/themes/default/client`)
|
||||||
|
|
||||||
The core build script is `frontend/esbuild.mjs`. Theme build scripts are defined in each theme's `package.json` and use local `esbuild.mjs` files for configuration:
|
The core build script is `frontend/esbuild.mjs`. Theme build scripts are defined in each theme's `package.json` and use local `esbuild.mjs` files for configuration:
|
||||||
|
|
||||||
@@ -130,10 +130,10 @@ npm run build-huraga
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Theme development builds
|
# Theme development builds
|
||||||
cd src/themes/admin_default && npm run dev
|
cd src/themes/default/admin && npm run dev
|
||||||
|
|
||||||
# Watch mode for active Huraga development
|
# Watch mode for active Huraga development
|
||||||
cd src/themes/huraga && npm run dev
|
cd src/themes/default/client && npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
@@ -189,7 +189,7 @@ src/
|
|||||||
├── cron.php # Scheduled tasks entry point
|
├── cron.php # Scheduled tasks entry point
|
||||||
├── library/ # Core libraries and third-party integrations
|
├── library/ # Core libraries and third-party integrations
|
||||||
├── modules/ # Application modules (50+ modules)
|
├── modules/ # Application modules (50+ modules)
|
||||||
├── themes/ # UI themes (admin_default, huraga)
|
├── themes/ # UI themes (shipped `default` package: default/admin, default/client, default/shared)
|
||||||
├── public/ # Public core assets, branding, and gateway icons
|
├── public/ # Public core assets, branding, and gateway icons
|
||||||
├── data/ # Runtime data (cache, logs, uploads)
|
├── data/ # Runtime data (cache, logs, uploads)
|
||||||
├── install/ # Installation scripts and assets
|
├── install/ # Installation scripts and assets
|
||||||
@@ -201,7 +201,7 @@ tests/ # Pest, live API, and end-to-end test structure
|
|||||||
|
|
||||||
### Front-end Guidelines
|
### Front-end Guidelines
|
||||||
|
|
||||||
* `admin_default` theme icons are compiled from the `src/themes/admin_default/assets/icons` directory and can be referenced from within the Twig template like so:
|
* `admin_default` theme icons are compiled from the `src/themes/default/admin/custom-icons` directory and can be referenced from within the Twig template like so:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<svg class="icon">
|
<svg class="icon">
|
||||||
|
|||||||
+6
-6
@@ -80,14 +80,14 @@ FROM node:${NODE_VERSION}-bookworm-slim AS frontend-assets
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
COPY src/themes/admin_default/package.json src/themes/admin_default/package.json
|
COPY src/themes/default/admin/package.json src/themes/default/admin/package.json
|
||||||
COPY src/themes/huraga/package.json src/themes/huraga/package.json
|
COPY src/themes/default/client/package.json src/themes/default/client/package.json
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.npm npm ci
|
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||||
|
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
COPY src/themes/admin_default ./src/themes/admin_default
|
COPY src/themes/default/admin ./src/themes/default/admin
|
||||||
COPY src/themes/huraga ./src/themes/huraga
|
COPY src/themes/default/client ./src/themes/default/client
|
||||||
COPY src/modules ./src/modules
|
COPY src/modules ./src/modules
|
||||||
COPY frontend ./frontend
|
COPY frontend ./frontend
|
||||||
|
|
||||||
@@ -110,8 +110,8 @@ COPY src ./src
|
|||||||
COPY README.md LICENSE ./src/
|
COPY README.md LICENSE ./src/
|
||||||
COPY --from=php-vendor /app/src/vendor ./src/vendor
|
COPY --from=php-vendor /app/src/vendor ./src/vendor
|
||||||
COPY --from=frontend-assets /app/src/public/assets ./src/public/assets
|
COPY --from=frontend-assets /app/src/public/assets ./src/public/assets
|
||||||
COPY --from=frontend-assets /app/src/themes/admin_default/assets/build ./src/themes/admin_default/assets/build
|
COPY --from=frontend-assets /app/src/themes/default/admin/assets/build ./src/themes/default/admin/assets/build
|
||||||
COPY --from=frontend-assets /app/src/themes/huraga/assets/build ./src/themes/huraga/assets/build
|
COPY --from=frontend-assets /app/src/themes/default/client/assets/build ./src/themes/default/client/assets/build
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
find ./src/modules -type d -name tests -prune -exec rm -rf {} +; \
|
find ./src/modules -type d -name tests -prune -exec rm -rf {} +; \
|
||||||
|
|||||||
+7
-3
@@ -55,6 +55,7 @@
|
|||||||
"Customtest",
|
"Customtest",
|
||||||
"mysite",
|
"mysite",
|
||||||
"myhost",
|
"myhost",
|
||||||
|
"mypackage",
|
||||||
"inversed",
|
"inversed",
|
||||||
"Autoresponder",
|
"Autoresponder",
|
||||||
"initialises",
|
"initialises",
|
||||||
@@ -177,6 +178,8 @@
|
|||||||
"tablist",
|
"tablist",
|
||||||
"subpages",
|
"subpages",
|
||||||
"subpage",
|
"subpage",
|
||||||
|
"subpath",
|
||||||
|
"subpaths",
|
||||||
"keyup",
|
"keyup",
|
||||||
"aligncenter",
|
"aligncenter",
|
||||||
"tabpanel",
|
"tabpanel",
|
||||||
@@ -234,6 +237,7 @@
|
|||||||
"upath",
|
"upath",
|
||||||
"pathe",
|
"pathe",
|
||||||
"tabler",
|
"tabler",
|
||||||
|
"tblr",
|
||||||
"apexcharts",
|
"apexcharts",
|
||||||
"topnav",
|
"topnav",
|
||||||
"yourparamvalue",
|
"yourparamvalue",
|
||||||
@@ -608,7 +612,7 @@
|
|||||||
],
|
],
|
||||||
"ignorePaths": [
|
"ignorePaths": [
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
"src/themes/*/assets/**",
|
"src/themes/**/assets/**",
|
||||||
"src/install/assets/**",
|
"src/install/assets/**",
|
||||||
"src/install/sql/**",
|
"src/install/sql/**",
|
||||||
"src/modules/*/assets/**",
|
"src/modules/*/assets/**",
|
||||||
@@ -636,8 +640,8 @@
|
|||||||
"src/modules/Invoice/templates/pdf/fonts/OFL.txt",
|
"src/modules/Invoice/templates/pdf/fonts/OFL.txt",
|
||||||
"src/modules/System/templates/admin/mod_system_settings.html.twig",
|
"src/modules/System/templates/admin/mod_system_settings.html.twig",
|
||||||
"src/vendor",
|
"src/vendor",
|
||||||
"src/themes/*/build",
|
"src/themes/**/build",
|
||||||
"src/themes/*/node_modules",
|
"src/themes/**/node_modules",
|
||||||
"phpstan.neon",
|
"phpstan.neon",
|
||||||
"*.config.js",
|
"*.config.js",
|
||||||
".github/**",
|
".github/**",
|
||||||
|
|||||||
+5
-5
@@ -9,16 +9,16 @@ FOSSBilling builds shared browser assets from `frontend/` and theme assets from
|
|||||||
- `npm run check` runs lightweight JavaScript syntax checks and validates theme icon manifests.
|
- `npm run check` runs lightweight JavaScript syntax checks and validates theme icon manifests.
|
||||||
- `npm run build-admin_default` and `npm run build-huraga` build a single theme.
|
- `npm run build-admin_default` and `npm run build-huraga` build a single theme.
|
||||||
|
|
||||||
Generated assets are written to `src/public/assets` and `src/themes/*/assets/build`. These paths are ignored by Git and are copied into release artifacts by the Docker build.
|
Generated assets are written to `src/public/assets` and `src/themes/**/assets/build`. These paths are ignored by Git and are copied into release artifacts by the Docker build.
|
||||||
|
|
||||||
## Theme Dependencies
|
## Theme Dependencies
|
||||||
|
|
||||||
Themes own the UI packages they consume. Shared tooling in `frontend/tools` can help compile assets, generate sprites, and post-process CSS, but it should not make a theme depend on a specific UI package.
|
Themes own the UI packages they consume. Shared tooling in `frontend/tools` can help compile assets, generate sprites, and post-process CSS, but it should not make a theme depend on a specific UI package.
|
||||||
|
|
||||||
Examples:
|
Examples (the shipped `default` theme package bundles both — `src/themes/default/admin` and `src/themes/default/client`; their npm workspace package names, `admin_default` and `huraga`, are unrelated to their directory paths):
|
||||||
|
|
||||||
- `admin_default` owns Tabler, Coloris, Chart.js, Litepicker, Tom Select, Flag Icons, and its Tabler icon package.
|
- `admin_default` (`src/themes/default/admin`) owns Tabler, Coloris, Chart.js, Litepicker, Tom Select, Flag Icons, and its Tabler icon package.
|
||||||
- `huraga` owns Bootstrap, Tom Select, Flag Icons, and its Tabler icon package.
|
- `huraga` (`src/themes/default/client`) owns Bootstrap, Tom Select, Flag Icons, and its Tabler icon package.
|
||||||
- `svg-sprite` is a root development dependency because the shared sprite helper imports it.
|
- `svg-sprite` is a root development dependency because the shared sprite helper imports it.
|
||||||
|
|
||||||
## Icons
|
## Icons
|
||||||
@@ -34,6 +34,6 @@ Run `npm run check-icons` before changing icon usage.
|
|||||||
|
|
||||||
## CSS
|
## CSS
|
||||||
|
|
||||||
Theme vendor CSS belongs in `src/themes/*/assets/css/vendor.css`. Theme-specific styles belong in the theme Sass entrypoint.
|
Theme vendor CSS belongs in `src/themes/**/assets/css/vendor.css`. Theme-specific styles belong in the theme Sass entrypoint. Rules that are genuinely identical across the default admin and client themes belong in `frontend/styles/_shared-theme.scss` instead of being duplicated.
|
||||||
|
|
||||||
PurgeCSS runs only for production builds. A PurgeCSS failure fails production builds so release CSS problems are caught early.
|
PurgeCSS runs only for production builds. A PurgeCSS failure fails production builds so release CSS problems are caught early.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Rules genuinely identical between the default admin and client themes.
|
||||||
|
// Deliberately small and conservative: most component styling is
|
||||||
|
// framework-specific (Tabler vs Bootstrap custom properties) and stays
|
||||||
|
// duplicated on purpose rather than forcing a shared abstraction here.
|
||||||
|
|
||||||
|
.fb-phone-country-code {
|
||||||
|
width: 5.5rem;
|
||||||
|
flex: 0 0 5.5rem;
|
||||||
|
max-width: 5.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every property here was identical between the two themes except
|
||||||
|
// background-color, which is genuinely framework-specific (Tabler's
|
||||||
|
// --tblr-bg-surface vs Bootstrap's --bs-secondary-bg) - each theme's own
|
||||||
|
// stylesheet still sets that one property itself, after @use-ing this file.
|
||||||
|
.db-avatar {
|
||||||
|
display: inline-block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
background-size: 100% 100%;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
@@ -9,23 +9,23 @@ const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|||||||
|
|
||||||
const themes = [
|
const themes = [
|
||||||
{
|
{
|
||||||
code: 'huraga',
|
code: 'default/client',
|
||||||
area: 'client',
|
area: 'client',
|
||||||
themePath: resolve(rootDir, 'src/themes/huraga'),
|
themePath: resolve(rootDir, 'src/themes/default/client'),
|
||||||
scanPaths: [
|
scanPaths: [
|
||||||
resolve(rootDir, 'src/themes/huraga/html'),
|
resolve(rootDir, 'src/themes/default/client/html'),
|
||||||
resolve(rootDir, 'src/themes/huraga/assets'),
|
resolve(rootDir, 'src/themes/default/client/assets'),
|
||||||
resolve(rootDir, 'src/modules'),
|
resolve(rootDir, 'src/modules'),
|
||||||
],
|
],
|
||||||
disallowFilled: true,
|
disallowFilled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'admin_default',
|
code: 'default/admin',
|
||||||
area: 'admin',
|
area: 'admin',
|
||||||
themePath: resolve(rootDir, 'src/themes/admin_default'),
|
themePath: resolve(rootDir, 'src/themes/default/admin'),
|
||||||
scanPaths: [
|
scanPaths: [
|
||||||
resolve(rootDir, 'src/themes/admin_default/html'),
|
resolve(rootDir, 'src/themes/default/admin/html'),
|
||||||
resolve(rootDir, 'src/themes/admin_default/assets'),
|
resolve(rootDir, 'src/themes/default/admin/assets'),
|
||||||
resolve(rootDir, 'src/modules'),
|
resolve(rootDir, 'src/modules'),
|
||||||
],
|
],
|
||||||
dynamicNavigation: true,
|
dynamicNavigation: true,
|
||||||
|
|||||||
Generated
+8
-6
@@ -7,8 +7,8 @@
|
|||||||
"name": "fossbilling",
|
"name": "fossbilling",
|
||||||
"license": "Apache License 2.0",
|
"license": "Apache License 2.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"src/themes/huraga",
|
"src/themes/default/client",
|
||||||
"src/themes/admin_default"
|
"src/themes/default/admin"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
@@ -2515,7 +2515,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/admin_default": {
|
"node_modules/admin_default": {
|
||||||
"resolved": "src/themes/admin_default",
|
"resolved": "src/themes/default/admin",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
@@ -3797,7 +3797,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/huraga": {
|
"node_modules/huraga": {
|
||||||
"resolved": "src/themes/huraga",
|
"resolved": "src/themes/default/client",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/immutable": {
|
"node_modules/immutable": {
|
||||||
@@ -6072,7 +6072,8 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/themes/admin_default": {
|
"src/themes/default/admin": {
|
||||||
|
"name": "admin_default",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tabler/core": "^1.2.0",
|
"@tabler/core": "^1.2.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
@@ -6088,7 +6089,8 @@
|
|||||||
"node": ">=24"
|
"node": ">=24"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/themes/huraga": {
|
"src/themes/default/client": {
|
||||||
|
"name": "huraga",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"flag-icons": "^7.2.3",
|
"flag-icons": "^7.2.3",
|
||||||
|
|||||||
+2
-2
@@ -26,8 +26,8 @@
|
|||||||
"url": "https://github.com/FOSSBilling/FOSSBilling/issues"
|
"url": "https://github.com/FOSSBilling/FOSSBilling/issues"
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"src/themes/huraga",
|
"src/themes/default/client",
|
||||||
"src/themes/admin_default"
|
"src/themes/default/admin"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ define('PATH_MODS', Path::join(PATH_ROOT, 'modules'));
|
|||||||
define('PATH_DATA', Path::join(PATH_ROOT, 'data'));
|
define('PATH_DATA', Path::join(PATH_ROOT, 'data'));
|
||||||
define('PATH_CACHE', Path::join(PATH_DATA, 'cache'));
|
define('PATH_CACHE', Path::join(PATH_DATA, 'cache'));
|
||||||
define('PATH_LOG', Path::join(PATH_DATA, 'log'));
|
define('PATH_LOG', Path::join(PATH_DATA, 'log'));
|
||||||
define('HURAGA_CONFIG', Path::join(PATH_THEMES, 'huraga', 'config', 'settings_data.json'));
|
define('CLIENT_THEME_CONFIG', Path::join(PATH_THEMES, 'default', 'client', 'config', 'settings_data.json'));
|
||||||
define('HURAGA_CONFIG_TEMPLATE', Path::join(PATH_THEMES, 'huraga', 'config', 'settings_data.json.example'));
|
define('CLIENT_THEME_CONFIG_TEMPLATE', Path::join(PATH_THEMES, 'default', 'client', 'config', 'settings_data.json.example'));
|
||||||
define('PATH_HTACCESS', Path::join(PATH_ROOT, '.htaccess'));
|
define('PATH_HTACCESS', Path::join(PATH_ROOT, '.htaccess'));
|
||||||
define('PAGE_INSTALL', Path::join('./assets', 'install.html.twig'));
|
define('PAGE_INSTALL', Path::join('./assets', 'install.html.twig'));
|
||||||
define('PAGE_RESULT', Path::join('./assets', 'result.html.twig'));
|
define('PAGE_RESULT', Path::join('./assets', 'result.html.twig'));
|
||||||
@@ -543,8 +543,8 @@ final class FOSSBilling_Installer
|
|||||||
$this->installPortable();
|
$this->installPortable();
|
||||||
|
|
||||||
// Copy config templates when applicable
|
// Copy config templates when applicable
|
||||||
if (!$this->filesystem->exists(HURAGA_CONFIG) && $this->filesystem->exists(HURAGA_CONFIG_TEMPLATE)) {
|
if (!$this->filesystem->exists(CLIENT_THEME_CONFIG) && $this->filesystem->exists(CLIENT_THEME_CONFIG_TEMPLATE)) {
|
||||||
$this->filesystem->copy(HURAGA_CONFIG_TEMPLATE, HURAGA_CONFIG); // Copy the file instead of renaming it. This allows local dev instances to not need to restore the original file manually.
|
$this->filesystem->copy(CLIENT_THEME_CONFIG_TEMPLATE, CLIENT_THEME_CONFIG); // Copy the file instead of renaming it. This allows local dev instances to not need to restore the original file manually.
|
||||||
}
|
}
|
||||||
|
|
||||||
// If .htaccess doesn't exist, fetch the latest from GitHub.
|
// If .htaccess doesn't exist, fetch the latest from GitHub.
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ LOCK TABLES `setting` WRITE;
|
|||||||
|
|
||||||
INSERT INTO `setting` (`id`, `param`, `value`, `public`, `category`, `hash`, `created_at`, `updated_at`)
|
INSERT INTO `setting` (`id`, `param`, `value`, `public`, `category`, `hash`, `created_at`, `updated_at`)
|
||||||
VALUES
|
VALUES
|
||||||
(1,'last_patch','114',0,NULL,NULL,NOW(),NOW()),
|
(1,'last_patch','115',0,NULL,NULL,NOW(),NOW()),
|
||||||
(2,'company_name','Company Name',0,NULL,NULL,NOW(),NOW()),
|
(2,'company_name','Company Name',0,NULL,NULL,NOW(),NOW()),
|
||||||
(3,'company_email','support@yourcompany.com',0,NULL,NULL,NOW(),NOW()),
|
(3,'company_email','support@yourcompany.com',0,NULL,NULL,NOW(),NOW()),
|
||||||
(4,'company_signature','FOSSBilling.org - Client Management, Invoicing and Support Software',0,NULL,NULL,NOW(),NOW()),
|
(4,'company_signature','FOSSBilling.org - Client Management, Invoicing and Support Software',0,NULL,NULL,NOW(),NOW()),
|
||||||
@@ -321,7 +321,7 @@ VALUES
|
|||||||
(15,'invoice_due_days','5',0,NULL,NULL,NOW(),NOW()),
|
(15,'invoice_due_days','5',0,NULL,NULL,NOW(),NOW()),
|
||||||
(16,'invoice_auto_approval','1',0,NULL,NULL,NOW(),NOW()),
|
(16,'invoice_auto_approval','1',0,NULL,NULL,NOW(),NOW()),
|
||||||
(17,'invoice_issue_days_before_expire','14',0,NULL,NULL,NOW(),NOW()),
|
(17,'invoice_issue_days_before_expire','14',0,NULL,NULL,NOW(),NOW()),
|
||||||
(18,'theme','huraga',0,NULL,NULL,NOW(),NOW()),
|
(18,'theme','default/client',0,NULL,NULL,NOW(),NOW()),
|
||||||
(19,'issue_invoice_days_before_expire','7',0,NULL,NULL,NOW(),NOW()),
|
(19,'issue_invoice_days_before_expire','7',0,NULL,NULL,NOW(),NOW()),
|
||||||
(20,'invoice_refund_logic','credit_note',0,NULL,NULL,NOW(),NOW()),
|
(20,'invoice_refund_logic','credit_note',0,NULL,NULL,NOW(),NOW()),
|
||||||
(21,'invoice_cn_series','CN-',0,NULL,NULL,NOW(),NOW()),
|
(21,'invoice_cn_series','CN-',0,NULL,NULL,NOW(),NOW()),
|
||||||
|
|||||||
@@ -79,10 +79,11 @@ class SentryHelper
|
|||||||
'theme',
|
'theme',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Themes we want to receive error reports for
|
// Themes we want to receive error reports for. extractName() (below) resolves
|
||||||
|
// to the top-level directory under PATH_THEMES, so 'default' alone covers
|
||||||
|
// default/admin, default/client, and default/shared.
|
||||||
private const array ALLOWED_THEMES = [
|
private const array ALLOWED_THEMES = [
|
||||||
'admin_default',
|
'default',
|
||||||
'huraga',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Array containing instance IDs that are blacklisted from error reporting and a timestamp of when their blacklist expires.
|
// Array containing instance IDs that are blacklisted from error reporting and a timestamp of when their blacklist expires.
|
||||||
|
|||||||
@@ -145,8 +145,9 @@ class TwigFactory
|
|||||||
|
|
||||||
$service = $this->di['mod_service']('theme');
|
$service = $this->di['mod_service']('theme');
|
||||||
$theme = $service->getCurrentAdminAreaTheme();
|
$theme = $service->getCurrentAdminAreaTheme();
|
||||||
|
$sharedPath = $service->getPackageSharedHtmlPath($theme['code']);
|
||||||
|
|
||||||
$loader = new TwigLoader(AppArea::ADMIN, Path::join(PATH_THEMES, $theme['code']));
|
$loader = new TwigLoader(AppArea::ADMIN, Path::join(PATH_THEMES, $theme['code']), $sharedPath);
|
||||||
$twig->setLoader($loader);
|
$twig->setLoader($loader);
|
||||||
|
|
||||||
$twig->addGlobal('theme', $theme);
|
$twig->addGlobal('theme', $theme);
|
||||||
@@ -176,8 +177,9 @@ class TwigFactory
|
|||||||
$code = $service->getCurrentClientAreaThemeCode();
|
$code = $service->getCurrentClientAreaThemeCode();
|
||||||
$theme = $service->getTheme($code);
|
$theme = $service->getTheme($code);
|
||||||
$settings = $service->getThemeSettings($theme);
|
$settings = $service->getThemeSettings($theme);
|
||||||
|
$sharedPath = $service->getPackageSharedHtmlPath($code);
|
||||||
|
|
||||||
$loader = new TwigLoader(AppArea::CLIENT, Path::join(PATH_THEMES, $code));
|
$loader = new TwigLoader(AppArea::CLIENT, Path::join(PATH_THEMES, $code), $sharedPath);
|
||||||
$twig->setLoader($loader);
|
$twig->setLoader($loader);
|
||||||
|
|
||||||
$twig->addGlobal('current_theme', $code);
|
$twig->addGlobal('current_theme', $code);
|
||||||
|
|||||||
@@ -24,16 +24,21 @@ class TwigLoader extends FilesystemLoader
|
|||||||
/**
|
/**
|
||||||
* Create new TwigLoader with FOSSBilling-specific path configuration.
|
* Create new TwigLoader with FOSSBilling-specific path configuration.
|
||||||
*
|
*
|
||||||
* @param AppArea $appArea either 'admin' or 'client'
|
* @param AppArea $appArea either 'admin' or 'client'
|
||||||
* @param string $themePath path to the theme directory
|
* @param string $themePath path to the theme (or theme package area) directory
|
||||||
|
* @param string|null $sharedHtmlPath path to a theme package's `shared/html` directory, or
|
||||||
|
* null when `$themePath` isn't part of a package. Resolved
|
||||||
|
* via Theme\Service::getPackageSharedHtmlPath(). Lowest
|
||||||
|
* priority tier; silently skipped if it doesn't exist.
|
||||||
*/
|
*/
|
||||||
public function __construct(AppArea $appArea, string $themePath)
|
public function __construct(AppArea $appArea, string $themePath, ?string $sharedHtmlPath = null)
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
|
|
||||||
$this->filesystem = new Filesystem();
|
$this->filesystem = new Filesystem();
|
||||||
|
|
||||||
// Load path in priority order: custom theme files, default theme, module templates.
|
// Load path in priority order: custom theme files, default theme,
|
||||||
|
// module templates, package-shared theme resources.
|
||||||
$paths = [];
|
$paths = [];
|
||||||
$customPath = Path::join($themePath, 'html_custom');
|
$customPath = Path::join($themePath, 'html_custom');
|
||||||
if ($this->filesystem->exists($customPath)) {
|
if ($this->filesystem->exists($customPath)) {
|
||||||
@@ -45,6 +50,10 @@ class TwigLoader extends FilesystemLoader
|
|||||||
}
|
}
|
||||||
$paths = array_merge($paths, $this->getModuleTemplatePaths($appArea));
|
$paths = array_merge($paths, $this->getModuleTemplatePaths($appArea));
|
||||||
|
|
||||||
|
if ($sharedHtmlPath !== null && $this->filesystem->exists($sharedHtmlPath)) {
|
||||||
|
$paths[] = $sharedHtmlPath;
|
||||||
|
}
|
||||||
|
|
||||||
$this->setPaths($paths);
|
$this->setPaths($paths);
|
||||||
|
|
||||||
// Add additional path to load symbols if they exist.
|
// Add additional path to load symbols if they exist.
|
||||||
|
|||||||
@@ -190,6 +190,13 @@ class UpdatePatcher implements InjectionAwareInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Portable (plain UPDATE ... WHERE, no MySQL-specific syntax) and idempotent, so it
|
||||||
|
// runs on every platform rather than being folded into the MySQL-only patch loop above -
|
||||||
|
// a PostgreSQL/SQLite install predating this change never runs patch115() at all, and
|
||||||
|
// would otherwise be left with a theme that never got renamed and orphaned saved settings
|
||||||
|
// forever.
|
||||||
|
$this->migrateThemePackageLayout();
|
||||||
|
|
||||||
// Additive structural sync runs on every platform, MySQL/MariaDB included: it picks up any
|
// Additive structural sync runs on every platform, MySQL/MariaDB included: it picks up any
|
||||||
// column/table/index that's on entity metadata but not yet applied, without needing a
|
// column/table/index that's on entity metadata but not yet applied, without needing a
|
||||||
// hand-written patch for it - the only mechanism at all on PostgreSQL/SQLite, and on
|
// hand-written patch for it - the only mechanism at all on PostgreSQL/SQLite, and on
|
||||||
@@ -678,6 +685,7 @@ class UpdatePatcher implements InjectionAwareInterface
|
|||||||
112 => 'patch112',
|
112 => 'patch112',
|
||||||
113 => 'patch113',
|
113 => 'patch113',
|
||||||
114 => 'patch114',
|
114 => 'patch114',
|
||||||
|
115 => 'patch115',
|
||||||
];
|
];
|
||||||
ksort($patches, SORT_NATURAL);
|
ksort($patches, SORT_NATURAL);
|
||||||
|
|
||||||
@@ -2834,6 +2842,81 @@ class UpdatePatcher implements InjectionAwareInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function patch115(): void
|
||||||
|
{
|
||||||
|
$this->migrateThemePackageLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bundles the shipped themes into one package: admin_default -> default/admin,
|
||||||
|
* huraga -> default/client. Third-party themes are untouched.
|
||||||
|
*
|
||||||
|
* Unlike the raw-DDL patches above, this is plain, portable SQL (no backticks,
|
||||||
|
* ENGINE=, or ON DUPLICATE KEY UPDATE) and a filesystem rename - neither is
|
||||||
|
* MySQL-specific, so this is called both from patch115() (for MySQL/MariaDB's
|
||||||
|
* sequential patch-level bookkeeping) and unconditionally from
|
||||||
|
* applyCorePatches() below, so PostgreSQL/SQLite installs - which never run the
|
||||||
|
* patchNNN() loop at all - still get migrated. Every step is idempotent, so
|
||||||
|
* running it twice on a MySQL/MariaDB install (once via patch115(), once via
|
||||||
|
* the unconditional call) is a harmless no-op the second time.
|
||||||
|
*/
|
||||||
|
private function migrateThemePackageLayout(): void
|
||||||
|
{
|
||||||
|
$filesystem = $this->filesystem;
|
||||||
|
|
||||||
|
// Each shipped theme's old code, new code, and the setting param that selects it.
|
||||||
|
$renames = [
|
||||||
|
'admin_default' => ['newCode' => 'default/admin', 'settingParam' => 'admin_theme'],
|
||||||
|
'huraga' => ['newCode' => 'default/client', 'settingParam' => 'theme'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($renames as $oldCode => $rename) {
|
||||||
|
$oldPath = Path::join(PATH_THEMES, $oldCode);
|
||||||
|
$newPath = Path::join(PATH_THEMES, $rename['newCode']);
|
||||||
|
|
||||||
|
if ($filesystem->exists($oldPath) && !$filesystem->exists($newPath)) {
|
||||||
|
$filesystem->mkdir(Path::getDirectory($newPath));
|
||||||
|
$filesystem->rename($oldPath, $newPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A code-only deploy (e.g. `git pull`) already moves every tracked file via the
|
||||||
|
// checkout itself, before this ever runs - the rename above then finds $newPath
|
||||||
|
// already there and skips. What's left behind at $oldPath at that point is mostly
|
||||||
|
// gitignored leftovers (a rebuilt assets/build/, huraga's config/settings_data.json
|
||||||
|
// cache, which regenerates on its own - the setting it holds is now in the database,
|
||||||
|
// migrated below), but TwigLoader's `html_custom` override directory and extra files
|
||||||
|
// dropped into `custom-icons` are genuinely untracked local customizations a checkout
|
||||||
|
// never touches - discarding $oldPath outright would destroy them. Mirror anything not
|
||||||
|
// already present at $newPath over first (never overwriting what the checkout already
|
||||||
|
// placed there) so those customizations survive the rename, then discard what's left.
|
||||||
|
if ($filesystem->exists($oldPath) && $filesystem->exists($newPath)) {
|
||||||
|
$filesystem->mirror($oldPath, $newPath, null, ['override' => false]);
|
||||||
|
$filesystem->remove($oldPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe/no-op if the row doesn't currently hold the old value.
|
||||||
|
$this->executeSql('UPDATE setting SET value = :new_value WHERE param = :param AND value = :old_value', [
|
||||||
|
'new_value' => $rename['newCode'],
|
||||||
|
'param' => $rename['settingParam'],
|
||||||
|
'old_value' => $oldCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Saved theme settings/presets live in extension_meta, keyed by the theme's
|
||||||
|
// name string (Theme\Service::updateSettings()/setCurrentThemePreset()) -
|
||||||
|
// 'settings' rows in rel_id, the 'preset'/'current' row in meta_key. Without
|
||||||
|
// this, a staff member's customized theme settings would silently fall back
|
||||||
|
// to the shipped defaults once the theme is renamed.
|
||||||
|
$this->executeSql("UPDATE extension_meta SET rel_id = :new_code WHERE extension = 'mod_theme' AND rel_type = 'settings' AND rel_id = :old_code", [
|
||||||
|
'new_code' => $rename['newCode'],
|
||||||
|
'old_code' => $oldCode,
|
||||||
|
]);
|
||||||
|
$this->executeSql("UPDATE extension_meta SET meta_key = :new_code WHERE extension = 'mod_theme' AND rel_type = 'preset' AND rel_id = 'current' AND meta_key = :old_code", [
|
||||||
|
'new_code' => $rename['newCode'],
|
||||||
|
'old_code' => $oldCode,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function patch114(): void
|
private function patch114(): void
|
||||||
{
|
{
|
||||||
// Enforce unique session_id on cart at the DB level (matches the Cart entity
|
// Enforce unique session_id on cart at the DB level (matches the Cart entity
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "orderbutton",
|
"id": "orderbutton",
|
||||||
"type": "mod",
|
"type": "mod",
|
||||||
"name": "Order Button",
|
"name": "Order Button",
|
||||||
"description": "Provides ability to easily attach order form to any template. It is used by huraga template by default. ",
|
"description": "Provides ability to easily attach order form to any template. It is used by the default client theme. ",
|
||||||
"icon_url": "icon.svg",
|
"icon_url": "icon.svg",
|
||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ test('getPairs returns array', function (): void {
|
|||||||
/** @var Mockery\Expectation $expectation */
|
/** @var Mockery\Expectation $expectation */
|
||||||
$expectation = $themeService->shouldReceive('getCurrentClientAreaThemeCode');
|
$expectation = $themeService->shouldReceive('getCurrentClientAreaThemeCode');
|
||||||
$expectation->atLeast()->once();
|
$expectation->atLeast()->once();
|
||||||
$expectation->andReturn('huraga');
|
$expectation->andReturn('default/client');
|
||||||
|
|
||||||
$di = container();
|
$di = container();
|
||||||
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $themeService);
|
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $themeService);
|
||||||
|
|||||||
@@ -86,21 +86,43 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set new theme as default.
|
* Set new theme as default for an area. The caller must state the area
|
||||||
|
* via `client` rather than having it inferred from the theme code, since
|
||||||
|
* a package-shaped code (e.g. `default/admin`) can't be reliably
|
||||||
|
* classified by name alone.
|
||||||
*/
|
*/
|
||||||
#[RequiredParams(['code' => 'Theme code was not passed'])]
|
#[RequiredParams(['code' => 'Theme code was not passed'])]
|
||||||
public function select($data): bool
|
public function select($data): bool
|
||||||
{
|
{
|
||||||
$this->checkPermissions('theme', 'manage');
|
$this->checkPermissions('theme', 'manage');
|
||||||
|
|
||||||
$theme = $this->getService()->getTheme($data['code']);
|
if (!array_key_exists('client', $data)) {
|
||||||
|
throw new \FOSSBilling\InformationException('The "client" parameter is required.');
|
||||||
|
}
|
||||||
|
if ($this->isInvalidClientParameter($data['client'])) {
|
||||||
|
throw new \FOSSBilling\InformationException('Invalid "client" parameter.');
|
||||||
|
}
|
||||||
|
$client = Tools::normalizeBoolean($data['client'], true);
|
||||||
|
|
||||||
|
// Confirm the code is actually available for the target area before
|
||||||
|
// persisting it. Merely existing on disk isn't enough - a package's
|
||||||
|
// `shared` tier or the opposite area's code would otherwise pass,
|
||||||
|
// leaving the area's Twig environment pointed at a directory with no
|
||||||
|
// layouts of its own.
|
||||||
|
$isAvailableForArea = false;
|
||||||
|
foreach ($this->getService()->getThemes($client) as $theme) {
|
||||||
|
if ($theme['code'] === $data['code']) {
|
||||||
|
$isAvailableForArea = true;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$isAvailableForArea) {
|
||||||
|
throw new \FOSSBilling\InformationException('Theme ":code" is not available for the selected area.', [':code' => $data['code']]);
|
||||||
|
}
|
||||||
|
|
||||||
$systemService = $this->getDi()['mod_service']('system');
|
$systemService = $this->getDi()['mod_service']('system');
|
||||||
if ($theme->isAdminAreaTheme()) {
|
$systemService->setParamValue($client ? 'theme' : 'admin_theme', $data['code']);
|
||||||
$systemService->setParamValue('admin_theme', $data['code']);
|
|
||||||
} else {
|
|
||||||
$systemService->setParamValue('theme', $data['code']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear theme cache so subsequent calls get the updated theme
|
// Clear theme cache so subsequent calls get the updated theme
|
||||||
\Box\Mod\Theme\Service::clearThemeCache();
|
\Box\Mod\Theme\Service::clearThemeCache();
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ class Admin implements \FOSSBilling\InjectionAwareInterface
|
|||||||
|
|
||||||
public function register(\Box_App &$app): void
|
public function register(\Box_App &$app): void
|
||||||
{
|
{
|
||||||
$app->get('/theme/:theme', 'get_theme', ['theme' => '[a-z0-9-_]+'], static::class);
|
// Allows '/' so package-shaped theme codes (e.g. 'default/admin') match.
|
||||||
$app->post('/theme/:theme', 'save_theme_settings', ['theme' => '[a-z0-9-_]+'], static::class);
|
$app->get('/theme/:theme', 'get_theme', ['theme' => '[a-zA-Z0-9_\-/]+'], static::class);
|
||||||
|
$app->post('/theme/:theme', 'save_theme_settings', ['theme' => '[a-zA-Z0-9_\-/]+'], static::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -35,11 +35,6 @@ class Theme
|
|||||||
return $this->name;
|
return $this->name;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isAdminAreaTheme(): bool
|
|
||||||
{
|
|
||||||
return str_contains((string) $this->name, 'admin_');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isAssetsPathWritable(): bool
|
public function isAssetsPathWritable(): bool
|
||||||
{
|
{
|
||||||
return is_writable($this->getPathAssets());
|
return is_writable($this->getPathAssets());
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ class Service implements InjectionAwareInterface
|
|||||||
|
|
||||||
public function getCurrentAdminAreaTheme(): array
|
public function getCurrentAdminAreaTheme(): array
|
||||||
{
|
{
|
||||||
$default = 'admin_default';
|
$default = 'default/admin';
|
||||||
|
|
||||||
if (self::$adminThemeCache !== null) {
|
if (self::$adminThemeCache !== null) {
|
||||||
// Apply default logic when returning from cache
|
// Apply default logic when returning from cache
|
||||||
@@ -320,7 +320,7 @@ class Service implements InjectionAwareInterface
|
|||||||
{
|
{
|
||||||
if (self::$clientThemeCache !== null) {
|
if (self::$clientThemeCache !== null) {
|
||||||
// Apply default logic when returning from cache
|
// Apply default logic when returning from cache
|
||||||
return !empty(self::$clientThemeCache) ? self::$clientThemeCache : 'huraga';
|
return !empty(self::$clientThemeCache) ? self::$clientThemeCache : 'default/client';
|
||||||
}
|
}
|
||||||
|
|
||||||
$theme = $this->di['dbal']->fetchOne("SELECT value FROM setting WHERE param = 'theme' ");
|
$theme = $this->di['dbal']->fetchOne("SELECT value FROM setting WHERE param = 'theme' ");
|
||||||
@@ -328,10 +328,15 @@ class Service implements InjectionAwareInterface
|
|||||||
// Cache the raw database value (use empty string instead of null to mark as cached)
|
// Cache the raw database value (use empty string instead of null to mark as cached)
|
||||||
self::$clientThemeCache = $theme ?? '';
|
self::$clientThemeCache = $theme ?? '';
|
||||||
|
|
||||||
return !empty($theme) ? $theme : 'huraga';
|
return !empty($theme) ? $theme : 'default/client';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* A theme directory is either flat (`{name}/html`, classified by the
|
||||||
|
* legacy `str_contains($name, 'admin')` naming) or a package
|
||||||
|
* (`{name}/admin/html` and/or `{name}/client/html`, each area listed
|
||||||
|
* under its own bucket via the resolved code `{name}/{area}`).
|
||||||
|
*
|
||||||
* @return mixed[]
|
* @return mixed[]
|
||||||
*/
|
*/
|
||||||
public function getThemes($client = true): array
|
public function getThemes($client = true): array
|
||||||
@@ -341,14 +346,22 @@ class Service implements InjectionAwareInterface
|
|||||||
|
|
||||||
$finder = new Finder();
|
$finder = new Finder();
|
||||||
$finder->directories()->in($path)->depth('== 0')->ignoreDotFiles(true);
|
$finder->directories()->in($path)->depth('== 0')->ignoreDotFiles(true);
|
||||||
foreach ($finder as $file) {
|
foreach ($finder as $dir) {
|
||||||
|
$name = $dir->getFilename();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!$client && str_contains($file->getFilename(), 'admin')) {
|
if ($this->filesystem->exists(Path::join($dir->getPathname(), 'html'))) {
|
||||||
$list[] = $this->buildThemeConfig($file->getFilename());
|
// Flat theme: unchanged legacy behavior.
|
||||||
|
if ($client === !str_contains($name, 'admin')) {
|
||||||
|
$list[] = $this->buildThemeConfig($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($client && !str_contains($file->getFilename(), 'admin')) {
|
$area = $client ? 'client' : 'admin';
|
||||||
$list[] = $this->buildThemeConfig($file->getFilename());
|
if ($this->filesystem->exists(Path::join($dir->getPathname(), $area, 'html'))) {
|
||||||
|
$list[] = $this->buildThemeConfig("$name/$area");
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->di['logger']->error($e->getMessage());
|
$this->di['logger']->error($e->getMessage());
|
||||||
@@ -358,13 +371,29 @@ class Service implements InjectionAwareInterface
|
|||||||
return $list;
|
return $list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a theme package's `shared/html` fallback directory from one
|
||||||
|
* area's already-resolved code (e.g. `'default/admin'`). A code with no
|
||||||
|
* `/` is a flat theme and has no package to share with.
|
||||||
|
*/
|
||||||
|
public function getPackageSharedHtmlPath(string $code): ?string
|
||||||
|
{
|
||||||
|
if (!str_contains($code, '/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$packagePath = Path::getDirectory(Path::join($this->getThemesPath(), $code));
|
||||||
|
|
||||||
|
return Path::join($packagePath, 'shared', 'html');
|
||||||
|
}
|
||||||
|
|
||||||
public function getThemeConfig($client = true, $mod = null)
|
public function getThemeConfig($client = true, $mod = null)
|
||||||
{
|
{
|
||||||
if ($client) {
|
if ($client) {
|
||||||
$default = 'huraga';
|
$default = 'default/client';
|
||||||
$theme = $this->getCurrentClientAreaThemeCode();
|
$theme = $this->getCurrentClientAreaThemeCode();
|
||||||
} else {
|
} else {
|
||||||
$default = 'admin_default';
|
$default = 'default/admin';
|
||||||
$systemService = $this->di['mod_service']('system');
|
$systemService = $this->di['mod_service']('system');
|
||||||
$theme = $systemService->getParamValue('admin_theme', $default);
|
$theme = $systemService->getParamValue('admin_theme', $default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<span>{{ theme.code|capitalize }}</span>
|
<span>{{ theme.name|default(theme.code|capitalize) }}</span>
|
||||||
{% if current_client_theme.code == theme.code %}
|
{% if current_client_theme.code == theme.code %}
|
||||||
<span class="badge text-bg-success">{{ 'Current'|trans }}</span>
|
<span class="badge text-bg-success">{{ 'Current'|trans }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if current_client_theme.code != theme.code %}
|
{% if current_client_theme.code != theme.code %}
|
||||||
<a class="btn btn-primary" href="{{ 'theme/select'|api_url(query: { 'code': theme.code }) }}" {{ fb_api_link({ redirect: 'extension/settings/theme'|url }) }}>
|
<a class="btn btn-primary" href="{{ 'theme/select'|api_url(query: { 'code': theme.code, 'client': true }) }}" {{ fb_api_link({ redirect: 'extension/settings/theme'|url }) }}>
|
||||||
<svg class="icon">
|
<svg class="icon">
|
||||||
<use href="#check"/>
|
<use href="#check"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<span>{{ theme.code|replace({ 'admin_': '' })|capitalize }}</span>
|
<span>{{ theme.name|default(theme.code|capitalize) }}</span>
|
||||||
{% if current_admin_theme.code == theme.code %}
|
{% if current_admin_theme.code == theme.code %}
|
||||||
<span class="badge text-bg-success">{{ 'Current'|trans }}</span>
|
<span class="badge text-bg-success">{{ 'Current'|trans }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if current_admin_theme.code != theme.code %}
|
{% if current_admin_theme.code != theme.code %}
|
||||||
<a class="btn btn-primary" href="{{ 'theme/select'|api_url(query: {'code' : theme.code }) }}" {{ fb_api_link({ redirect: 'extension/settings/theme'|url }) }}>
|
<a class="btn btn-primary" href="{{ 'theme/select'|api_url(query: {'code' : theme.code, 'client': false }) }}" {{ fb_api_link({ redirect: 'extension/settings/theme'|url }) }}>
|
||||||
<svg class="icon">
|
<svg class="icon">
|
||||||
<use href="#check"/>
|
<use href="#check"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -56,27 +56,24 @@ test('testGet', function (): void {
|
|||||||
expect($result)->toBeArray();
|
expect($result)->toBeArray();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('testSelectNotAdminTheme', function (): void {
|
test('testSelectClientTheme', function (): void {
|
||||||
$api = apiEndpoint(new Admin());
|
$api = apiEndpoint(new Admin());
|
||||||
$data = [
|
$data = [
|
||||||
'code' => 'pjw',
|
'code' => 'default/client',
|
||||||
|
'client' => true,
|
||||||
];
|
];
|
||||||
|
|
||||||
$themeMock = Mockery::mock(Box\Mod\Theme\Model\Theme::class);
|
|
||||||
$themeMock->shouldReceive('isAdminAreaTheme')
|
|
||||||
->atLeast()
|
|
||||||
->once()
|
|
||||||
->andReturn(false);
|
|
||||||
|
|
||||||
$serviceMock = Mockery::mock(Box\Mod\Theme\Service::class);
|
$serviceMock = Mockery::mock(Box\Mod\Theme\Service::class);
|
||||||
$serviceMock->shouldReceive('getTheme')
|
$serviceMock->shouldReceive('getThemes')
|
||||||
->atLeast()
|
->atLeast()
|
||||||
->once()
|
->once()
|
||||||
->andReturn($themeMock);
|
->with(true)
|
||||||
|
->andReturn([['code' => 'default/client']]);
|
||||||
|
|
||||||
$systemServiceMock = Mockery::mock(Box\Mod\System\Service::class);
|
$systemServiceMock = Mockery::mock(Box\Mod\System\Service::class);
|
||||||
$systemServiceMock->shouldReceive('setParamValue')
|
$systemServiceMock->shouldReceive('setParamValue')
|
||||||
->with('theme', Mockery::any());
|
->once()
|
||||||
|
->with('theme', 'default/client');
|
||||||
$staffServiceMock = Mockery::mock(Box\Mod\Staff\Service::class)->shouldIgnoreMissing();
|
$staffServiceMock = Mockery::mock(Box\Mod\Staff\Service::class)->shouldIgnoreMissing();
|
||||||
$staffServiceMock->shouldReceive('checkPermissionsAndThrowException')
|
$staffServiceMock->shouldReceive('checkPermissionsAndThrowException')
|
||||||
->atLeast()
|
->atLeast()
|
||||||
@@ -95,24 +92,21 @@ test('testSelectNotAdminTheme', function (): void {
|
|||||||
test('testSelectAdminTheme', function (): void {
|
test('testSelectAdminTheme', function (): void {
|
||||||
$api = apiEndpoint(new Admin());
|
$api = apiEndpoint(new Admin());
|
||||||
$data = [
|
$data = [
|
||||||
'code' => 'pjw',
|
'code' => 'default/admin',
|
||||||
|
'client' => false,
|
||||||
];
|
];
|
||||||
|
|
||||||
$themeMock = Mockery::mock(Box\Mod\Theme\Model\Theme::class);
|
|
||||||
$themeMock->shouldReceive('isAdminAreaTheme')
|
|
||||||
->atLeast()
|
|
||||||
->once()
|
|
||||||
->andReturn(true);
|
|
||||||
|
|
||||||
$serviceMock = Mockery::mock(Box\Mod\Theme\Service::class);
|
$serviceMock = Mockery::mock(Box\Mod\Theme\Service::class);
|
||||||
$serviceMock->shouldReceive('getTheme')
|
$serviceMock->shouldReceive('getThemes')
|
||||||
->atLeast()
|
->atLeast()
|
||||||
->once()
|
->once()
|
||||||
->andReturn($themeMock);
|
->with(false)
|
||||||
|
->andReturn([['code' => 'default/admin']]);
|
||||||
|
|
||||||
$systemServiceMock = Mockery::mock(Box\Mod\System\Service::class);
|
$systemServiceMock = Mockery::mock(Box\Mod\System\Service::class);
|
||||||
$systemServiceMock->shouldReceive('setParamValue')
|
$systemServiceMock->shouldReceive('setParamValue')
|
||||||
->with('admin_theme', Mockery::any());
|
->once()
|
||||||
|
->with('admin_theme', 'default/admin');
|
||||||
$staffServiceMock = Mockery::mock(Box\Mod\Staff\Service::class)->shouldIgnoreMissing();
|
$staffServiceMock = Mockery::mock(Box\Mod\Staff\Service::class)->shouldIgnoreMissing();
|
||||||
$staffServiceMock->shouldReceive('checkPermissionsAndThrowException')
|
$staffServiceMock->shouldReceive('checkPermissionsAndThrowException')
|
||||||
->atLeast()
|
->atLeast()
|
||||||
@@ -128,6 +122,84 @@ test('testSelectAdminTheme', function (): void {
|
|||||||
expect($result)->toBeTrue();
|
expect($result)->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('testSelectRequiresClientParameter', function (): void {
|
||||||
|
$api = apiEndpoint(new Admin());
|
||||||
|
$data = ['code' => 'default/admin'];
|
||||||
|
|
||||||
|
$staffServiceMock = Mockery::mock(Box\Mod\Staff\Service::class)->shouldIgnoreMissing();
|
||||||
|
$staffServiceMock->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once();
|
||||||
|
|
||||||
|
$di = container();
|
||||||
|
$di['mod_service'] = $di->protect(fn (string $name = ''): Mockery\MockInterface => $staffServiceMock);
|
||||||
|
$api->setDi($di);
|
||||||
|
$api->setService(Mockery::mock(Box\Mod\Theme\Service::class));
|
||||||
|
|
||||||
|
expect(fn (): bool => $api->select($data))
|
||||||
|
->toThrow(FOSSBilling\InformationException::class, 'The "client" parameter is required.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('testSelectRejectsInvalidClientParameter', function (): void {
|
||||||
|
$api = apiEndpoint(new Admin());
|
||||||
|
$data = ['code' => 'default/admin', 'client' => 'not-a-boolean'];
|
||||||
|
|
||||||
|
$staffServiceMock = Mockery::mock(Box\Mod\Staff\Service::class)->shouldIgnoreMissing();
|
||||||
|
$staffServiceMock->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once();
|
||||||
|
|
||||||
|
$di = container();
|
||||||
|
$di['mod_service'] = $di->protect(fn (string $name = ''): Mockery\MockInterface => $staffServiceMock);
|
||||||
|
$api->setDi($di);
|
||||||
|
$api->setService(Mockery::mock(Box\Mod\Theme\Service::class));
|
||||||
|
|
||||||
|
expect(fn (): bool => $api->select($data))
|
||||||
|
->toThrow(FOSSBilling\InformationException::class, 'Invalid "client" parameter.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('testSelectRejectsThemeFromOppositeArea', function (): void {
|
||||||
|
$api = apiEndpoint(new Admin());
|
||||||
|
$data = ['code' => 'default/admin', 'client' => true];
|
||||||
|
|
||||||
|
$serviceMock = Mockery::mock(Box\Mod\Theme\Service::class);
|
||||||
|
$serviceMock->shouldReceive('getThemes')
|
||||||
|
->atLeast()
|
||||||
|
->once()
|
||||||
|
->with(true)
|
||||||
|
->andReturn([['code' => 'default/client']]);
|
||||||
|
|
||||||
|
$staffServiceMock = Mockery::mock(Box\Mod\Staff\Service::class)->shouldIgnoreMissing();
|
||||||
|
$staffServiceMock->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once();
|
||||||
|
|
||||||
|
$di = container();
|
||||||
|
$di['mod_service'] = $di->protect(fn (string $name = ''): Mockery\MockInterface => $staffServiceMock);
|
||||||
|
$api->setDi($di);
|
||||||
|
$api->setService($serviceMock);
|
||||||
|
|
||||||
|
expect(fn (): bool => $api->select($data))
|
||||||
|
->toThrow(FOSSBilling\InformationException::class, 'Theme "default/admin" is not available for the selected area.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('testSelectRejectsPackageSharedTierCode', function (): void {
|
||||||
|
$api = apiEndpoint(new Admin());
|
||||||
|
$data = ['code' => 'default/shared', 'client' => true];
|
||||||
|
|
||||||
|
$serviceMock = Mockery::mock(Box\Mod\Theme\Service::class);
|
||||||
|
$serviceMock->shouldReceive('getThemes')
|
||||||
|
->atLeast()
|
||||||
|
->once()
|
||||||
|
->with(true)
|
||||||
|
->andReturn([['code' => 'default/client']]);
|
||||||
|
|
||||||
|
$staffServiceMock = Mockery::mock(Box\Mod\Staff\Service::class)->shouldIgnoreMissing();
|
||||||
|
$staffServiceMock->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once();
|
||||||
|
|
||||||
|
$di = container();
|
||||||
|
$di['mod_service'] = $di->protect(fn (string $name = ''): Mockery\MockInterface => $staffServiceMock);
|
||||||
|
$api->setDi($di);
|
||||||
|
$api->setService($serviceMock);
|
||||||
|
|
||||||
|
expect(fn (): bool => $api->select($data))
|
||||||
|
->toThrow(FOSSBilling\InformationException::class, 'Theme "default/shared" is not available for the selected area.');
|
||||||
|
});
|
||||||
|
|
||||||
test('testPresetDelete', function (): void {
|
test('testPresetDelete', function (): void {
|
||||||
$api = apiEndpoint(new Admin());
|
$api = apiEndpoint(new Admin());
|
||||||
$data = [
|
$data = [
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ use function Tests\Helpers\container;
|
|||||||
*
|
*
|
||||||
* @return list<DOMElement>
|
* @return list<DOMElement>
|
||||||
*/
|
*/
|
||||||
function renderHuragaFooterLinkCheckboxes(array $settings): array
|
function renderClientThemeFooterLinkCheckboxes(array $settings): array
|
||||||
{
|
{
|
||||||
$twig = new Environment(new ArrayLoader(), ['strict_variables' => true]);
|
$twig = new Environment(new ArrayLoader(), ['strict_variables' => true]);
|
||||||
$twig->addFilter(new TwigFilter('trans', static fn (mixed $value): string => (string) $value));
|
$twig->addFilter(new TwigFilter('trans', static fn (mixed $value): string => (string) $value));
|
||||||
|
|
||||||
$theme = new Theme('huraga');
|
$theme = new Theme('default/client');
|
||||||
$html = SandboxedStringRenderer::render(
|
$html = SandboxedStringRenderer::render(
|
||||||
$twig,
|
$twig,
|
||||||
$theme->getSettingsPageHtml(),
|
$theme->getSettingsPageHtml(),
|
||||||
@@ -140,8 +140,8 @@ test('getTheme renders theme preset', function (): void {
|
|||||||
$di['mod'] = $di->protect(fn () => $modMock);
|
$di['mod'] = $di->protect(fn () => $modMock);
|
||||||
$controller->setDi($di);
|
$controller->setDi($di);
|
||||||
|
|
||||||
$boxAppMock->shouldReceive('getRequest')->andReturn(Symfony\Component\HttpFoundation\Request::create('/theme/huraga'));
|
$boxAppMock->shouldReceive('getRequest')->andReturn(Symfony\Component\HttpFoundation\Request::create('/theme/default/client'));
|
||||||
$controller->get_theme($boxAppMock, 'huraga');
|
$controller->get_theme($boxAppMock, 'default/client');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('save theme settings reads body from request and strips preset control keys', function (): void {
|
test('save theme settings reads body from request and strips preset control keys', function (): void {
|
||||||
@@ -149,7 +149,7 @@ test('save theme settings reads body from request and strips preset control keys
|
|||||||
$di = container();
|
$di = container();
|
||||||
|
|
||||||
$themeMock = Mockery::mock(Theme::class);
|
$themeMock = Mockery::mock(Theme::class);
|
||||||
$themeMock->shouldReceive('getName')->andReturn('huraga');
|
$themeMock->shouldReceive('getName')->andReturn('default/client');
|
||||||
$themeMock->shouldReceive('isAssetsPathWritable')->andReturn(true);
|
$themeMock->shouldReceive('isAssetsPathWritable')->andReturn(true);
|
||||||
|
|
||||||
$themeServiceMock = Mockery::mock(Box\Mod\Theme\Service::class);
|
$themeServiceMock = Mockery::mock(Box\Mod\Theme\Service::class);
|
||||||
@@ -182,7 +182,7 @@ test('save theme settings reads body from request and strips preset control keys
|
|||||||
$di['events_manager'] = $eventsManager;
|
$di['events_manager'] = $eventsManager;
|
||||||
$controller->setDi($di);
|
$controller->setDi($di);
|
||||||
|
|
||||||
$request = Symfony\Component\HttpFoundation\Request::create('/theme/huraga', 'POST', [
|
$request = Symfony\Component\HttpFoundation\Request::create('/theme/default/client', 'POST', [
|
||||||
'color' => 'blue',
|
'color' => 'blue',
|
||||||
'save-current-setting' => '1',
|
'save-current-setting' => '1',
|
||||||
'save-current-setting-preset' => 'My Preset',
|
'save-current-setting-preset' => 'My Preset',
|
||||||
@@ -192,15 +192,15 @@ test('save theme settings reads body from request and strips preset control keys
|
|||||||
$boxAppMock->shouldReceive('getRequest')->once()->andReturn($request);
|
$boxAppMock->shouldReceive('getRequest')->once()->andReturn($request);
|
||||||
$boxAppMock->shouldReceive('redirect')
|
$boxAppMock->shouldReceive('redirect')
|
||||||
->once()
|
->once()
|
||||||
->with('/theme/huraga')
|
->with('/theme/default/client')
|
||||||
->andReturn(new Symfony\Component\HttpFoundation\RedirectResponse('/theme/huraga'));
|
->andReturn(new Symfony\Component\HttpFoundation\RedirectResponse('/theme/default/client'));
|
||||||
|
|
||||||
$response = $controller->save_theme_settings($boxAppMock, 'huraga');
|
$response = $controller->save_theme_settings($boxAppMock, 'default/client');
|
||||||
expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\RedirectResponse::class);
|
expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\RedirectResponse::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('huraga footer link checkboxes submit canonical enabled values', function (): void {
|
test('default/client footer link checkboxes submit canonical enabled values', function (): void {
|
||||||
$checkboxes = renderHuragaFooterLinkCheckboxes([]);
|
$checkboxes = renderClientThemeFooterLinkCheckboxes([]);
|
||||||
|
|
||||||
expect($checkboxes)->toHaveCount(5);
|
expect($checkboxes)->toHaveCount(5);
|
||||||
foreach ($checkboxes as $index => $checkbox) {
|
foreach ($checkboxes as $index => $checkbox) {
|
||||||
@@ -216,7 +216,7 @@ test('theme settings restore checkbox values saved with the browser default', fu
|
|||||||
$settings['footer_link_' . $index . '_enabled'] = 'on';
|
$settings['footer_link_' . $index . '_enabled'] = 'on';
|
||||||
}
|
}
|
||||||
|
|
||||||
$checkboxes = renderHuragaFooterLinkCheckboxes($settings);
|
$checkboxes = renderClientThemeFooterLinkCheckboxes($settings);
|
||||||
|
|
||||||
expect($checkboxes)->toHaveCount(5);
|
expect($checkboxes)->toHaveCount(5);
|
||||||
foreach ($checkboxes as $checkbox) {
|
foreach ($checkboxes as $checkbox) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$existingTheme = 'huraga';
|
$existingTheme = 'default/client';
|
||||||
|
|
||||||
test('get name', function () use ($existingTheme): void {
|
test('get name', function () use ($existingTheme): void {
|
||||||
$themeModel = new Box\Mod\Theme\Model\Theme($existingTheme);
|
$themeModel = new Box\Mod\Theme\Model\Theme($existingTheme);
|
||||||
@@ -24,12 +24,6 @@ test('not existing theme', function (): void {
|
|||||||
})->toThrow(FOSSBilling\Exception::class, "Theme '{$themeName}' does not exist.");
|
})->toThrow(FOSSBilling\Exception::class, "Theme '{$themeName}' does not exist.");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('is admin area theme', function () use ($existingTheme): void {
|
|
||||||
$theme = new Box\Mod\Theme\Model\Theme($existingTheme);
|
|
||||||
$result = $theme->isAdminAreaTheme();
|
|
||||||
expect($result)->toBeBool();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is assets path writable', function () use ($existingTheme): void {
|
test('is assets path writable', function () use ($existingTheme): void {
|
||||||
$theme = new Box\Mod\Theme\Model\Theme($existingTheme);
|
$theme = new Box\Mod\Theme\Model\Theme($existingTheme);
|
||||||
$result = $theme->isAssetsPathWritable();
|
$result = $theme->isAssetsPathWritable();
|
||||||
@@ -127,3 +121,13 @@ test('get path settings data file', function () use ($existingTheme): void {
|
|||||||
expect($result)->toBeString();
|
expect($result)->toBeString();
|
||||||
expect(str_contains($result, 'settings_data.json'))->toBeTrue();
|
expect(str_contains($result, 'settings_data.json'))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a package-shaped name (containing a slash) resolves paths correctly', function (): void {
|
||||||
|
$theme = new Box\Mod\Theme\Model\Theme('default/admin');
|
||||||
|
|
||||||
|
expect($theme->getName())->toBe('default/admin')
|
||||||
|
->and($theme->getPath())->toBe(Symfony\Component\Filesystem\Path::join(PATH_THEMES, 'default', 'admin'))
|
||||||
|
->and($theme->getPathHtml())->toBe(Symfony\Component\Filesystem\Path::join(PATH_THEMES, 'default', 'admin', 'html'))
|
||||||
|
->and($theme->getPathConfig())->toBe(Symfony\Component\Filesystem\Path::join(PATH_THEMES, 'default', 'admin', 'config'))
|
||||||
|
->and($theme->getUrl())->toContain('default/admin');
|
||||||
|
});
|
||||||
@@ -13,9 +13,11 @@ declare(strict_types=1);
|
|||||||
use Box\Mod\Extension\Entity\ExtensionMeta;
|
use Box\Mod\Extension\Entity\ExtensionMeta;
|
||||||
use Box\Mod\Theme\Model;
|
use Box\Mod\Theme\Model;
|
||||||
use Box\Mod\Theme\Service;
|
use Box\Mod\Theme\Service;
|
||||||
|
use Symfony\Component\Filesystem\Path;
|
||||||
|
|
||||||
use function Tests\Helpers\container;
|
use function Tests\Helpers\container;
|
||||||
use function Tests\Helpers\injectMockFilesystem;
|
use function Tests\Helpers\injectMockFilesystem;
|
||||||
|
use function Tests\Helpers\moduleService;
|
||||||
|
|
||||||
function themeContainerWithRepository(Mockery\MockInterface $repository, ?Mockery\MockInterface $em = null): Pimple\Container
|
function themeContainerWithRepository(Mockery\MockInterface $repository, ?Mockery\MockInterface $em = null): Pimple\Container
|
||||||
{
|
{
|
||||||
@@ -45,7 +47,7 @@ test('getDi returns the dependency injection container', function (): void {
|
|||||||
|
|
||||||
test('getTheme returns a Theme model instance', function (): void {
|
test('getTheme returns a Theme model instance', function (): void {
|
||||||
$service = new Service();
|
$service = new Service();
|
||||||
$result = $service->getTheme('huraga');
|
$result = $service->getTheme('default/client');
|
||||||
expect($result)->toBeInstanceOf(Model\Theme::class);
|
expect($result)->toBeInstanceOf(Model\Theme::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -426,7 +428,7 @@ test('getCurrentClientAreaThemeCode returns theme code', function (): void {
|
|||||||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||||||
$dbalMock->shouldReceive('fetchOne')->once()
|
$dbalMock->shouldReceive('fetchOne')->once()
|
||||||
->with("SELECT value FROM setting WHERE param = 'theme' ")
|
->with("SELECT value FROM setting WHERE param = 'theme' ")
|
||||||
->andReturn('huraga');
|
->andReturn('default/client');
|
||||||
|
|
||||||
$di = container();
|
$di = container();
|
||||||
$di['dbal'] = $dbalMock;
|
$di['dbal'] = $dbalMock;
|
||||||
@@ -434,7 +436,7 @@ test('getCurrentClientAreaThemeCode returns theme code', function (): void {
|
|||||||
|
|
||||||
$result = $service->getCurrentClientAreaThemeCode();
|
$result = $service->getCurrentClientAreaThemeCode();
|
||||||
expect($result)->toBeString();
|
expect($result)->toBeString();
|
||||||
expect($result)->toBe('huraga');
|
expect($result)->toBe('default/client');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getCurrentClientAreaThemeCode falls back when the setting is missing', function (): void {
|
test('getCurrentClientAreaThemeCode falls back when the setting is missing', function (): void {
|
||||||
@@ -449,5 +451,108 @@ test('getCurrentClientAreaThemeCode falls back when the setting is missing', fun
|
|||||||
$di['dbal'] = $dbalMock;
|
$di['dbal'] = $dbalMock;
|
||||||
$service->setDi($di);
|
$service->setDi($di);
|
||||||
|
|
||||||
expect($service->getCurrentClientAreaThemeCode())->toBe('huraga');
|
expect($service->getCurrentClientAreaThemeCode())->toBe('default/client');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPackageSharedHtmlPath resolves the shared/html sibling for a package-shaped code', function (): void {
|
||||||
|
$service = new Service();
|
||||||
|
|
||||||
|
$result = $service->getPackageSharedHtmlPath('default/admin');
|
||||||
|
expect($result)->toBe(Path::join(PATH_THEMES, 'default', 'shared', 'html'));
|
||||||
|
|
||||||
|
$result = $service->getPackageSharedHtmlPath('default/client');
|
||||||
|
expect($result)->toBe(Path::join(PATH_THEMES, 'default', 'shared', 'html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPackageSharedHtmlPath returns null for a flat, non-package code', function (): void {
|
||||||
|
$service = new Service();
|
||||||
|
|
||||||
|
expect($service->getPackageSharedHtmlPath('some-flat-theme'))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// $layout: top-level dir name => list of relative subpaths to create as
|
||||||
|
// directories, e.g. ['html'] for a flat theme, ['admin/html', 'client/html']
|
||||||
|
// for a package. getThemes() enumerates a real filesystem path, so this
|
||||||
|
// builds a temp root independent of the production `default` package.
|
||||||
|
function makeFixtureThemesRoot(array $layout): string
|
||||||
|
{
|
||||||
|
$filesystem = new Symfony\Component\Filesystem\Filesystem();
|
||||||
|
$root = Path::join(sys_get_temp_dir(), 'fossbilling-get-themes-test-' . bin2hex(random_bytes(8)));
|
||||||
|
|
||||||
|
foreach ($layout as $name => $subpaths) {
|
||||||
|
foreach ($subpaths as $subpath) {
|
||||||
|
$filesystem->mkdir(Path::join($root, $name, $subpath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $root;
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildThemeConfig() (called per theme getThemes() lists) needs 'extension'
|
||||||
|
// mod_service to return a real array from getCoreAndActiveModules(), or
|
||||||
|
// array_unique() there would be handed null.
|
||||||
|
function themesRootDi(): Pimple\Container
|
||||||
|
{
|
||||||
|
$extensionServiceMock = Mockery::mock()->shouldIgnoreMissing();
|
||||||
|
$extensionServiceMock->shouldReceive('getCoreAndActiveModules')->andReturn([]);
|
||||||
|
|
||||||
|
$di = container();
|
||||||
|
$di['mod_service'] = $di->protect(moduleService(['extension' => $extensionServiceMock]));
|
||||||
|
|
||||||
|
return $di;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('getThemes lists a package with only an admin area in the admin bucket only', function (): void {
|
||||||
|
$root = makeFixtureThemesRoot(['mypackage' => ['admin/html']]);
|
||||||
|
|
||||||
|
$serviceMock = Mockery::mock(Service::class)->makePartial();
|
||||||
|
$serviceMock->shouldReceive('getThemesPath')->andReturn($root);
|
||||||
|
$serviceMock->setDi(themesRootDi());
|
||||||
|
|
||||||
|
expect(array_column($serviceMock->getThemes(false), 'code'))->toBe(['mypackage/admin']);
|
||||||
|
expect($serviceMock->getThemes(true))->toBe([]);
|
||||||
|
|
||||||
|
(new Symfony\Component\Filesystem\Filesystem())->remove($root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getThemes lists a package with only a client area in the client bucket only', function (): void {
|
||||||
|
$root = makeFixtureThemesRoot(['mypackage' => ['client/html']]);
|
||||||
|
|
||||||
|
$serviceMock = Mockery::mock(Service::class)->makePartial();
|
||||||
|
$serviceMock->shouldReceive('getThemesPath')->andReturn($root);
|
||||||
|
$serviceMock->setDi(themesRootDi());
|
||||||
|
|
||||||
|
expect(array_column($serviceMock->getThemes(true), 'code'))->toBe(['mypackage/client']);
|
||||||
|
expect($serviceMock->getThemes(false))->toBe([]);
|
||||||
|
|
||||||
|
(new Symfony\Component\Filesystem\Filesystem())->remove($root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getThemes lists a package with both areas in both buckets', function (): void {
|
||||||
|
$root = makeFixtureThemesRoot(['mypackage' => ['admin/html', 'client/html']]);
|
||||||
|
|
||||||
|
$serviceMock = Mockery::mock(Service::class)->makePartial();
|
||||||
|
$serviceMock->shouldReceive('getThemesPath')->andReturn($root);
|
||||||
|
$serviceMock->setDi(themesRootDi());
|
||||||
|
|
||||||
|
expect(array_column($serviceMock->getThemes(false), 'code'))->toBe(['mypackage/admin']);
|
||||||
|
expect(array_column($serviceMock->getThemes(true), 'code'))->toBe(['mypackage/client']);
|
||||||
|
|
||||||
|
(new Symfony\Component\Filesystem\Filesystem())->remove($root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getThemes still classifies a flat theme by its name, unaffected by the package shape', function (): void {
|
||||||
|
$root = makeFixtureThemesRoot([
|
||||||
|
'my-admin-theme' => ['html'],
|
||||||
|
'my-client-theme' => ['html'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$serviceMock = Mockery::mock(Service::class)->makePartial();
|
||||||
|
$serviceMock->shouldReceive('getThemesPath')->andReturn($root);
|
||||||
|
$serviceMock->setDi(themesRootDi());
|
||||||
|
|
||||||
|
expect(array_column($serviceMock->getThemes(false), 'code'))->toBe(['my-admin-theme']);
|
||||||
|
expect(array_column($serviceMock->getThemes(true), 'code'))->toBe(['my-client-theme']);
|
||||||
|
|
||||||
|
(new Symfony\Component\Filesystem\Filesystem())->remove($root);
|
||||||
});
|
});
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{% set logo_url = logo_url|default(company.logo_url) %}
|
|
||||||
{% set dark_logo_url = dark_logo_url|default(company.logo_url_dark) %}
|
|
||||||
{% set link_url = link_url|default('/'|url) %}
|
|
||||||
{% set link_class = link_class|default('') %}
|
|
||||||
{% set image_class = image_class|default('') %}
|
|
||||||
{% set image_style = image_style|default(null) %}
|
|
||||||
{% set fallback_class = fallback_class|default('') %}
|
|
||||||
|
|
||||||
<a{% if link_class %} class="{{ link_class }}"{% endif %} href="{{ link_url }}">
|
|
||||||
{% if logo_url or dark_logo_url %}
|
|
||||||
<img class="hide-theme-light{% if image_class %} {{ image_class }}{% endif %}" src="{{ dark_logo_url|default(logo_url) }}" alt="{{ company.name }}"{% if image_style %} style="{{ image_style }}"{% endif %}>
|
|
||||||
<img class="hide-theme-dark{% if image_class %} {{ image_class }}{% endif %}" src="{{ logo_url|default(dark_logo_url) }}" alt="{{ company.name }}"{% if image_style %} style="{{ image_style }}"{% endif %}>
|
|
||||||
{% else %}
|
|
||||||
<span{% if fallback_class %} class="{{ fallback_class }}"{% endif %}>{{ company.name }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
@use "flag-icons/sass/variables" as flagicons-variables;
|
@use "flag-icons/sass/variables" as flagicons-variables;
|
||||||
|
|
||||||
$flag-icons-path: "../../../../../node_modules/flag-icons/flags";
|
$flag-icons-path: "../../../../../../node_modules/flag-icons/flags";
|
||||||
|
|
||||||
.flag {
|
.flag {
|
||||||
position: relative;
|
position: relative;
|
||||||
File renamed without changes.
+1
-14
@@ -1,5 +1,6 @@
|
|||||||
@use "flags";
|
@use "flags";
|
||||||
@use "theme_settings";
|
@use "theme_settings";
|
||||||
|
@use "../../../../../../frontend/styles/shared-theme";
|
||||||
|
|
||||||
.sticky-nav .nav-link {
|
.sticky-nav .nav-link {
|
||||||
color: var(--tblr-body-color) !important;
|
color: var(--tblr-body-color) !important;
|
||||||
@@ -283,12 +284,6 @@ body {
|
|||||||
padding: 0 0 0.85rem;
|
padding: 0 0 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fb-phone-country-code {
|
|
||||||
width: 5.5rem;
|
|
||||||
flex: 0 0 5.5rem;
|
|
||||||
max-width: 5.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 992px) {
|
@media (min-width: 992px) {
|
||||||
.new-client-modal-dialog {
|
.new-client-modal-dialog {
|
||||||
max-width: min(820px, calc(100vw - 2rem));
|
max-width: min(820px, calc(100vw - 2rem));
|
||||||
@@ -334,15 +329,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.db-avatar {
|
.db-avatar {
|
||||||
display: inline-block;
|
|
||||||
flex-shrink: 0;
|
|
||||||
vertical-align: middle;
|
|
||||||
background-color: var(--tblr-bg-surface);
|
background-color: var(--tblr-bg-surface);
|
||||||
background-size: 100% 100%;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
border-radius: 50%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 992px) {
|
@media (min-width: 992px) {
|
||||||
File renamed without changes.
@@ -7,12 +7,12 @@ import {
|
|||||||
prepareThemeBuildDirs,
|
prepareThemeBuildDirs,
|
||||||
sharedLoaders,
|
sharedLoaders,
|
||||||
writeAssetManifest,
|
writeAssetManifest,
|
||||||
} from '../../../frontend/tools/esbuild-helpers.mts';
|
} from '../../../../frontend/tools/esbuild-helpers.mts';
|
||||||
import { buildIconSprite } from '../../../frontend/tools/icon-sprite.mts';
|
import { buildIconSprite } from '../../../../frontend/tools/icon-sprite.mts';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
const rootDir = resolve(__dirname, '../../..');
|
const rootDir = resolve(__dirname, '../../../..');
|
||||||
const nodeModulesDir = resolve(rootDir, 'node_modules');
|
const nodeModulesDir = resolve(rootDir, 'node_modules');
|
||||||
const adminLoaders = { ...sharedLoaders, '.svg': 'dataurl' } as const;
|
const adminLoaders = { ...sharedLoaders, '.svg': 'dataurl' } as const;
|
||||||
|
|
||||||
@@ -72,10 +72,10 @@ async function build() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await writeAssetManifest(paths.buildDir, {
|
await writeAssetManifest(paths.buildDir, {
|
||||||
'build/fossbilling.js': '/themes/admin_default/assets/build/js/fossbilling.js',
|
'build/fossbilling.js': '/themes/default/admin/assets/build/js/fossbilling.js',
|
||||||
'build/vendor.css': '/themes/admin_default/assets/build/css/vendor.css',
|
'build/vendor.css': '/themes/default/admin/assets/build/css/vendor.css',
|
||||||
'build/fossbilling.css': '/themes/admin_default/assets/build/css/fossbilling.css',
|
'build/fossbilling.css': '/themes/default/admin/assets/build/css/fossbilling.css',
|
||||||
'build/symbol/icons-sprite.svg': '/themes/admin_default/assets/build/symbol/icons-sprite.svg',
|
'build/symbol/icons-sprite.svg': '/themes/default/admin/assets/build/symbol/icons-sprite.svg',
|
||||||
});
|
});
|
||||||
|
|
||||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||||
File renamed without changes.
File renamed without changes.
+2
@@ -60,6 +60,8 @@
|
|||||||
<h1 class="d-none d-sm-flex navbar-brand d-none-navbar-horizontal pe-0 pe-md-3">
|
<h1 class="d-none d-sm-flex navbar-brand d-none-navbar-horizontal pe-0 pe-md-3">
|
||||||
{{ include('partial_company_logo.html.twig', {
|
{{ include('partial_company_logo.html.twig', {
|
||||||
company: company,
|
company: company,
|
||||||
|
logo_url: company.logo_url,
|
||||||
|
dark_logo_url: company.logo_url_dark,
|
||||||
image_class: 'navbar-brand-image'
|
image_class: 'navbar-brand-image'
|
||||||
}) }}
|
}) }}
|
||||||
</h1>
|
</h1>
|
||||||
File renamed without changes.
+5
-136
@@ -76,64 +76,7 @@
|
|||||||
{% endmacro build_form %}
|
{% endmacro build_form %}
|
||||||
|
|
||||||
{% macro status_name(status) %}
|
{% macro status_name(status) %}
|
||||||
{% if status is same as(null) or (status is empty and status != '0') %}
|
{% include 'partial_status_name_shared.html.twig' with {status: status} %}
|
||||||
-
|
|
||||||
{% else %}
|
|
||||||
{% set status = status|replace({'_': " "})|title|trim %}
|
|
||||||
{% if status == 'Active' %}
|
|
||||||
{{ 'Active'|trans }}
|
|
||||||
{% elseif status == 'Pending Setup' %}
|
|
||||||
{{ 'Pending Setup'|trans }}
|
|
||||||
{% elseif status == 'Failed Setup' %}
|
|
||||||
{{ 'Failed Setup'|trans }}
|
|
||||||
{% elseif status == 'Failed Renew' %}
|
|
||||||
{{ 'Failed Renewal'|trans }}
|
|
||||||
{% elseif status == 'Suspended' %}
|
|
||||||
{{ 'Suspended'|trans }}
|
|
||||||
{% elseif status == 'Canceled' %}
|
|
||||||
{{ 'Canceled'|trans }}
|
|
||||||
{% elseif status == 'Inactive' %}
|
|
||||||
{{ 'Inactive'|trans }}
|
|
||||||
{% elseif status == 'Enabled' %}
|
|
||||||
{{ 'Enabled'|trans }}
|
|
||||||
{% elseif status == 'Disabled' %}
|
|
||||||
{{ 'Disabled'|trans }}
|
|
||||||
{% elseif status == 'Draft' %}
|
|
||||||
{{ 'Draft'|trans }}
|
|
||||||
{% elseif status == 'Sent' %}
|
|
||||||
{{ 'Sent'|trans }}
|
|
||||||
{% elseif status == 'Sending' %}
|
|
||||||
{{ 'Sending'|trans }}
|
|
||||||
{% elseif status == 'Unsent' %}
|
|
||||||
{{ 'Unsent'|trans }}
|
|
||||||
{% elseif status == 'Paid' %}
|
|
||||||
{{ 'Paid'|trans }}
|
|
||||||
{% elseif status == 'Unpaid' %}
|
|
||||||
{{ 'Unpaid'|trans }}
|
|
||||||
{% elseif status == 'Refunded' %}
|
|
||||||
{{ 'Refunded'|trans }}
|
|
||||||
{% elseif status == 'Approved' %}
|
|
||||||
{{ 'Approved'|trans }}
|
|
||||||
{% elseif status == 'Processed' %}
|
|
||||||
{{ 'Processed'|trans }}
|
|
||||||
{% elseif status == 'Received' %}
|
|
||||||
{{ 'Received'|trans }}
|
|
||||||
{% elseif status == 'Error' %}
|
|
||||||
{{ 'Error'|trans }}
|
|
||||||
{% elseif status == 'Unknown' %}
|
|
||||||
{{ 'Unknown'|trans }}
|
|
||||||
{% elseif status == 'Complete' %}
|
|
||||||
{{ 'Complete'|trans }}
|
|
||||||
{% elseif status == 'Completed' %}
|
|
||||||
{{ 'Completed'|trans }}
|
|
||||||
{% elseif status == 'Success' %}
|
|
||||||
{{ 'Success'|trans }}
|
|
||||||
{% elseif status == 'Failed' %}
|
|
||||||
{{ 'Failed'|trans }}
|
|
||||||
{% else %}
|
|
||||||
{{ status|trans }}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% endmacro status_name %}
|
{% endmacro status_name %}
|
||||||
|
|
||||||
{% macro ip_lookup_link(ip) %}
|
{% macro ip_lookup_link(ip) %}
|
||||||
@@ -170,82 +113,8 @@
|
|||||||
{% endmacro selectboxtld %}
|
{% endmacro selectboxtld %}
|
||||||
|
|
||||||
{% macro recaptcha() %}
|
{% macro recaptcha() %}
|
||||||
|
{% include 'partial_recaptcha_shared.html.twig' %}
|
||||||
{% if guest.extension_is_on({"mod":"antispam"}) %}
|
{% endmacro recaptcha %}
|
||||||
{% set config = guest.antispam_recaptcha %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if config is defined and config.enabled %}
|
|
||||||
{% set provider = config.captcha_provider|default('recaptcha_v2') %}
|
|
||||||
{% if provider == 'turnstile' %}
|
|
||||||
{% if config.turnstile_site_key %}
|
|
||||||
<div class="mb-3">
|
|
||||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
|
||||||
<div class="cf-turnstile" data-sitekey="{{ config.turnstile_site_key }}"></div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% elseif provider == 'recaptcha_v2' %}
|
|
||||||
{% if config.publickey %}
|
|
||||||
<div class="mb-3">
|
|
||||||
<script src='https://www.google.com/recaptcha/api.js' async defer></script>
|
|
||||||
<div class="g-recaptcha" data-sitekey="{{ config.publickey }}"></div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% elseif provider == 'recaptcha_v3' %}
|
|
||||||
{% if config.publickey %}
|
|
||||||
<script src="https://www.google.com/recaptcha/api.js?render={{ config.publickey }}" async defer></script>
|
|
||||||
<input type="hidden" name="g-recaptcha-response" value="">
|
|
||||||
<input type="hidden" name="g-recaptcha-action" value="fossbilling_submit">
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
const script = document.currentScript;
|
|
||||||
const form = script.closest('form');
|
|
||||||
if (!form) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', function (event) {
|
|
||||||
if (form.dataset.recaptchaV3Verified === 'true') {
|
|
||||||
form.dataset.recaptchaV3Verified = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
|
|
||||||
if (!window.grecaptcha) {
|
|
||||||
form.dataset.recaptchaV3Verified = 'true';
|
|
||||||
form.requestSubmit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenField = form.querySelector('input[name="g-recaptcha-response"]');
|
|
||||||
const actionField = form.querySelector('input[name="g-recaptcha-action"]');
|
|
||||||
window.grecaptcha.ready(function () {
|
|
||||||
window.grecaptcha.execute('{{ config.publickey|e('js') }}', { action: actionField.value }).then(function (token) {
|
|
||||||
tokenField.value = token;
|
|
||||||
form.dataset.recaptchaV3Verified = 'true';
|
|
||||||
form.requestSubmit();
|
|
||||||
}).catch(function () {
|
|
||||||
form.dataset.recaptchaV3Verified = 'true';
|
|
||||||
form.requestSubmit();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, true);
|
|
||||||
}());
|
|
||||||
</script>
|
|
||||||
{% endif %}
|
|
||||||
{% elseif provider == 'hcaptcha' %}
|
|
||||||
{% if config.hcaptcha_site_key %}
|
|
||||||
<div class="mb-3">
|
|
||||||
<script src='https://js.hcaptcha.com/1/api.js' async defer></script>
|
|
||||||
<div class="h-captcha" data-sitekey="{{ config.hcaptcha_site_key }}"></div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro table_search() %}
|
{% macro table_search() %}
|
||||||
<div style="position: relative;">
|
<div style="position: relative;">
|
||||||
@@ -384,5 +253,5 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro empty_list(colspan, message) %}
|
{% macro empty_list(colspan, message) %}
|
||||||
<td class="text-muted" colspan="{{ colspan|default(5) }}">{{ (message|default('The list is empty'))|trans }}</td>
|
{% include 'partial_empty_list_shared.html.twig' with {colspan: colspan, message: message} %}
|
||||||
{% endmacro %}
|
{% endmacro empty_list %}
|
||||||
File renamed without changes.
+2
@@ -8,6 +8,8 @@
|
|||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
{{ include('partial_company_logo.html.twig', {
|
{{ include('partial_company_logo.html.twig', {
|
||||||
company: company,
|
company: company,
|
||||||
|
logo_url: company.logo_url,
|
||||||
|
dark_logo_url: company.logo_url_dark,
|
||||||
link_url: '/'|url(area: 'client'),
|
link_url: '/'|url(area: 'client'),
|
||||||
image_style: 'height: 60px; width: auto;',
|
image_style: 'height: 60px; width: auto;',
|
||||||
fallback_class: 'h1'
|
fallback_class: 'h1'
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
@use "flag-icons/sass/variables" as flagicons-variables;
|
@use "flag-icons/sass/variables" as flagicons-variables;
|
||||||
|
|
||||||
$flag-icons-path: "../../../../../node_modules/flag-icons/flags";
|
$flag-icons-path: "../../../../../../node_modules/flag-icons/flags";
|
||||||
|
|
||||||
.fib {
|
.fib {
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
+1
-14
@@ -1,4 +1,5 @@
|
|||||||
@use "flags";
|
@use "flags";
|
||||||
|
@use "../../../../../../frontend/styles/shared-theme";
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -147,12 +148,6 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fb-phone-country-code {
|
|
||||||
width: 5.5rem;
|
|
||||||
flex: 0 0 5.5rem;
|
|
||||||
max-width: 5.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iti {
|
.iti {
|
||||||
--iti-border-color: var(--bs-border-color);
|
--iti-border-color: var(--bs-border-color);
|
||||||
--iti-country-selector-bg: var(--bs-body-bg);
|
--iti-country-selector-bg: var(--bs-body-bg);
|
||||||
@@ -412,13 +407,5 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.db-avatar {
|
.db-avatar {
|
||||||
display: inline-block;
|
|
||||||
flex-shrink: 0;
|
|
||||||
vertical-align: middle;
|
|
||||||
background-color: var(--bs-secondary-bg);
|
background-color: var(--bs-secondary-bg);
|
||||||
background-size: 100% 100%;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
border-radius: 50%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -7,13 +7,13 @@ import {
|
|||||||
prepareThemeBuildDirs,
|
prepareThemeBuildDirs,
|
||||||
sharedLoaders,
|
sharedLoaders,
|
||||||
writeAssetManifest,
|
writeAssetManifest,
|
||||||
} from '../../../frontend/tools/esbuild-helpers.mts';
|
} from '../../../../frontend/tools/esbuild-helpers.mts';
|
||||||
import { buildIconSprite } from '../../../frontend/tools/icon-sprite.mts';
|
import { buildIconSprite } from '../../../../frontend/tools/icon-sprite.mts';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
const purgeSafelist = [/^hide-/, /^iti/];
|
const purgeSafelist = [/^hide-/, /^iti/];
|
||||||
const rootDir = resolve(__dirname, '../../..');
|
const rootDir = resolve(__dirname, '../../../..');
|
||||||
const nodeModulesDir = resolve(rootDir, 'node_modules');
|
const nodeModulesDir = resolve(rootDir, 'node_modules');
|
||||||
|
|
||||||
async function build() {
|
async function build() {
|
||||||
@@ -73,10 +73,10 @@ async function build() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await writeAssetManifest(paths.buildDir, {
|
await writeAssetManifest(paths.buildDir, {
|
||||||
'build/huraga.js': '/themes/huraga/assets/build/js/huraga.js',
|
'build/huraga.js': '/themes/default/client/assets/build/js/huraga.js',
|
||||||
'build/vendor.css': '/themes/huraga/assets/build/css/vendor.css',
|
'build/vendor.css': '/themes/default/client/assets/build/css/vendor.css',
|
||||||
'build/huraga.css': '/themes/huraga/assets/build/css/huraga.css',
|
'build/huraga.css': '/themes/default/client/assets/build/css/huraga.css',
|
||||||
'build/symbol/icons-sprite.svg': '/themes/huraga/assets/build/symbol/icons-sprite.svg',
|
'build/symbol/icons-sprite.svg': '/themes/default/client/assets/build/symbol/icons-sprite.svg',
|
||||||
});
|
});
|
||||||
|
|
||||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||||
File renamed without changes.
+1
@@ -92,6 +92,7 @@
|
|||||||
dark_logo_url: settings.show_company_logo ? company.logo_url_dark : null,
|
dark_logo_url: settings.show_company_logo ? company.logo_url_dark : null,
|
||||||
link_class: 'navbar-brand',
|
link_class: 'navbar-brand',
|
||||||
image_class: 'd-none d-sm-block',
|
image_class: 'd-none d-sm-block',
|
||||||
|
image_height: '45px',
|
||||||
show_mobile_name: true
|
show_mobile_name: true
|
||||||
}) }}
|
}) }}
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
|
||||||
File renamed without changes.
@@ -0,0 +1,103 @@
|
|||||||
|
{% macro markdown_quote(text) %}
|
||||||
|
{% for line in text|split('\n') %}
|
||||||
|
> {{ line }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endmacro markdown_quote %}
|
||||||
|
|
||||||
|
{% macro recaptcha() %}
|
||||||
|
{% include 'partial_recaptcha_shared.html.twig' %}
|
||||||
|
{% endmacro recaptcha %}
|
||||||
|
|
||||||
|
{% macro selectbox(name, options, selected, required, nullOption) %}
|
||||||
|
<select name="{{ name }}" class="form-control{% if required %} is-required{% endif %}">
|
||||||
|
{% if nullOption %}<option value="">{{ nullOption }}</option>{% endif %}
|
||||||
|
{% for val, label in options %}
|
||||||
|
<option value="{{ val }}" {% if selected == val %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endmacro selectbox %}
|
||||||
|
|
||||||
|
{% macro status_name(status) %}
|
||||||
|
{% include 'partial_status_name_shared.html.twig' with {status: status} %}
|
||||||
|
{% endmacro status_name %}
|
||||||
|
|
||||||
|
|
||||||
|
{% macro status_badge(status, type) %}
|
||||||
|
{% set status_title = status|replace({'_': " "})|title|trim %}
|
||||||
|
{% if status_title == 'Active' %}
|
||||||
|
{% set status_label = 'Active'|trans %}
|
||||||
|
{% elseif status_title == 'Pending Setup' %}
|
||||||
|
{% set status_label = 'Pending Setup'|trans %}
|
||||||
|
{% elseif status_title == 'Failed Setup' %}
|
||||||
|
{% set status_label = 'Failed Setup'|trans %}
|
||||||
|
{% elseif status_title == 'Failed Renew' %}
|
||||||
|
{% set status_label = 'Failed Renewal'|trans %}
|
||||||
|
{% elseif status_title == 'Suspended' %}
|
||||||
|
{% set status_label = 'Suspended'|trans %}
|
||||||
|
{% elseif status_title == 'Canceled' %}
|
||||||
|
{% set status_label = 'Canceled'|trans %}
|
||||||
|
{% elseif status_title == 'Paid' %}
|
||||||
|
{% set status_label = 'Paid'|trans %}
|
||||||
|
{% elseif status_title == 'Unpaid' %}
|
||||||
|
{% set status_label = 'Unpaid'|trans %}
|
||||||
|
{% elseif status_title == 'Refunded' %}
|
||||||
|
{% set status_label = 'Refunded'|trans %}
|
||||||
|
{% else %}
|
||||||
|
{% set status_label = status_title|trans %}
|
||||||
|
{% endif %}
|
||||||
|
{% if type == 'order' %}
|
||||||
|
{% if status == 'active' %}{% set badge_class = 'bg-success' %}
|
||||||
|
{% elseif status == 'pending_setup' %}{% set badge_class = 'bg-warning' %}
|
||||||
|
{% elseif status == 'failed_setup' or status == 'suspended' or status == 'failed_renew' %}{% set badge_class = 'bg-danger' %}
|
||||||
|
{% elseif status == 'canceled' %}{% set badge_class = 'bg-secondary' %}
|
||||||
|
{% else %}{% set badge_class = 'bg-primary' %}
|
||||||
|
{% endif %}
|
||||||
|
{% elseif type == 'ticket' %}
|
||||||
|
{% if status == 'open' %}{% set badge_class = 'bg-success' %}
|
||||||
|
{% elseif status == 'on_hold' %}{% set badge_class = 'bg-warning' %}
|
||||||
|
{% elseif status == 'closed' %}{% set badge_class = 'bg-secondary' %}
|
||||||
|
{% else %}{% set badge_class = 'bg-primary' %}
|
||||||
|
{% endif %}
|
||||||
|
{% elseif type == 'invoice' %}
|
||||||
|
{% if status == 'paid' %}{% set badge_class = 'bg-success' %}
|
||||||
|
{% elseif status == 'unpaid' %}{% set badge_class = 'bg-warning' %}
|
||||||
|
{% elseif status == 'refunded' %}{% set badge_class = 'bg-info' %}
|
||||||
|
{% else %}{% set badge_class = 'bg-primary' %}
|
||||||
|
{% endif %}
|
||||||
|
{% elseif type == 'client' %}
|
||||||
|
{% if status == 'active' %}{% set badge_class = 'bg-success' %}
|
||||||
|
{% elseif status == 'suspended' %}{% set badge_class = 'bg-danger' %}
|
||||||
|
{% elseif status == 'canceled' %}{% set badge_class = 'bg-secondary' %}
|
||||||
|
{% else %}{% set badge_class = 'bg-primary' %}
|
||||||
|
{% endif %}
|
||||||
|
{% elseif type == 'subscription' %}
|
||||||
|
{% if status == 'active' %}{% set badge_class = 'bg-success' %}
|
||||||
|
{% elseif status == 'canceled' %}{% set badge_class = 'bg-secondary' %}
|
||||||
|
{% elseif status == 'past_due' %}{% set badge_class = 'bg-danger' %}
|
||||||
|
{% else %}{% set badge_class = 'bg-primary' %}
|
||||||
|
{% endif %}
|
||||||
|
{% elseif type == 'post' %}
|
||||||
|
{% if status == 'published' %}{% set badge_class = 'bg-success' %}
|
||||||
|
{% elseif status == 'draft' %}{% set badge_class = 'bg-secondary' %}
|
||||||
|
{% else %}{% set badge_class = 'bg-primary' %}
|
||||||
|
{% endif %}
|
||||||
|
{% elseif type == 'tx' %}
|
||||||
|
{% if status == 'success' %}{% set badge_class = 'bg-success' %}
|
||||||
|
{% elseif status == 'failed' %}{% set badge_class = 'bg-danger' %}
|
||||||
|
{% elseif status == 'pending' %}{% set badge_class = 'bg-warning' %}
|
||||||
|
{% elseif status == 'refunded' %}{% set badge_class = 'bg-info' %}
|
||||||
|
{% else %}{% set badge_class = 'bg-primary' %}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if status == 'active' or status == 'open' or status == 'paid' or status == 'published' %}{% set badge_class = 'bg-success' %}
|
||||||
|
{% elseif status == 'pending_setup' or status == 'on_hold' or status == 'unpaid' or status == 'pending' %}{% set badge_class = 'bg-warning' %}
|
||||||
|
{% elseif status == 'failed_setup' or status == 'suspended' or status == 'failed_renew' or status == 'canceled' or status == 'closed' or status == 'failed' %}{% set badge_class = 'bg-danger' %}
|
||||||
|
{% else %}{% set badge_class = 'bg-primary' %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<span class="badge {{ badge_class }}">{{ status_label }}</span>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro empty_list(colspan, message) %}
|
||||||
|
{% include 'partial_empty_list_shared.html.twig' with {colspan: colspan, message: message} %}
|
||||||
|
{% endmacro empty_list %}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user