mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
UI ux/move footer links to settings (#6606)
<img width="2056" height="1044" alt="Screenshot 2026-06-11 at 2 15 34 PM" src="https://github.com/user-attachments/assets/e58a9f8f-7172-4f30-ab28-0760b66249c9" /> <img width="2056" height="1045" alt="Screenshot 2026-06-11 at 2 15 43 PM" src="https://github.com/user-attachments/assets/890b7a0b-740f-4c7f-9a48-c9a2c28e8ded" />
This commit is contained in:
@@ -20,17 +20,18 @@
|
||||
|
||||
--cc-separator-border-color: #e0e0e0;
|
||||
|
||||
--cc-toggle-on-bg: #007bff;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #ffffff;
|
||||
--cc-toggle-off-knob-bg: #ffffff;
|
||||
/* Toggle colors mirror Mantine Switch (light scheme) */
|
||||
--cc-toggle-on-bg: var(--mantine-primary-color-filled, #007bff);
|
||||
--cc-toggle-off-bg: var(--mantine-color-gray-3, #dee2e6);
|
||||
--cc-toggle-on-knob-bg: var(--mantine-color-white, #ffffff);
|
||||
--cc-toggle-off-knob-bg: var(--mantine-color-white, #ffffff);
|
||||
|
||||
--cc-toggle-enabled-icon-color: #ffffff;
|
||||
--cc-toggle-disabled-icon-color: #ffffff;
|
||||
|
||||
--cc-toggle-readonly-bg: #f1f3f4;
|
||||
--cc-toggle-readonly-knob-bg: #79747e;
|
||||
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
|
||||
--cc-toggle-readonly-bg: var(--mantine-color-disabled, #f1f3f4);
|
||||
--cc-toggle-readonly-knob-bg: var(--mantine-color-gray-0, #f8f9fa);
|
||||
--cc-toggle-readonly-knob-icon-color: transparent;
|
||||
|
||||
--cc-section-category-border: #e0e0e0;
|
||||
|
||||
@@ -69,17 +70,18 @@
|
||||
|
||||
--cc-separator-border-color: #555555;
|
||||
|
||||
--cc-toggle-on-bg: #4dabf7;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #2d2d2d;
|
||||
--cc-toggle-off-knob-bg: #2d2d2d;
|
||||
/* Toggle colors mirror Mantine Switch (dark scheme) */
|
||||
--cc-toggle-on-bg: var(--mantine-primary-color-filled, #4dabf7);
|
||||
--cc-toggle-off-bg: var(--mantine-color-dark-5, #555555);
|
||||
--cc-toggle-on-knob-bg: var(--mantine-color-white, #ffffff);
|
||||
--cc-toggle-off-knob-bg: var(--mantine-color-white, #ffffff);
|
||||
|
||||
--cc-toggle-enabled-icon-color: #2d2d2d;
|
||||
--cc-toggle-disabled-icon-color: #2d2d2d;
|
||||
|
||||
--cc-toggle-readonly-bg: #555555;
|
||||
--cc-toggle-readonly-knob-bg: #8e8e8e;
|
||||
--cc-toggle-readonly-knob-icon-color: #555555;
|
||||
--cc-toggle-readonly-bg: var(--mantine-color-disabled, #555555);
|
||||
--cc-toggle-readonly-knob-bg: var(--mantine-color-dark-3, #8e8e8e);
|
||||
--cc-toggle-readonly-knob-icon-color: transparent;
|
||||
|
||||
--cc-section-category-border: #555555;
|
||||
|
||||
@@ -176,9 +178,16 @@
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
/* Lower z-index so cookie banner appears behind onboarding modals */
|
||||
/* Banner sits above the chat FAB but behind all modals and onboarding; value
|
||||
is Z_INDEX_COOKIE_CONSENT_BANNER, set as this variable by useCookieConsent */
|
||||
#cc-main {
|
||||
z-index: 100 !important;
|
||||
z-index: var(--z-index-cookie-consent) !important;
|
||||
}
|
||||
|
||||
/* Preferences dialog sits above the settings modal it opens from; value is
|
||||
Z_INDEX_COOKIE_PREFERENCES_MODAL, set as this variable by useCookieConsent */
|
||||
.show--preferences #cc-main {
|
||||
z-index: var(--z-index-cookie-preferences) !important;
|
||||
}
|
||||
|
||||
/* Ensure consent modal text is visible in both themes */
|
||||
@@ -203,3 +212,63 @@
|
||||
#cc-main .cm__link {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
/* ── Category toggles restyled to match Mantine Switch (size sm) ──────────
|
||||
Mantine sm metrics: 38×20 track, 14px plain thumb, 2.5px inline padding,
|
||||
150ms ease transitions, no icon inside the thumb. Colors come from the
|
||||
--cc-toggle-* variables above, which point at the Mantine palette. */
|
||||
#cc-main .section__toggle,
|
||||
#cc-main .section__toggle-wrapper,
|
||||
#cc-main .toggle__icon,
|
||||
#cc-main .toggle__label {
|
||||
width: 38px !important;
|
||||
height: 20px !important;
|
||||
border-radius: 1000px !important;
|
||||
}
|
||||
|
||||
/* Track: flat fill, no outline ring or border */
|
||||
#cc-main .toggle__icon {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
transition: background-color 150ms ease !important;
|
||||
}
|
||||
|
||||
#cc-main .section__toggle:checked ~ .toggle__icon {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Always-enabled categories = Mantine disabled switch (must out-prioritise
|
||||
the !important checked-track rule above) */
|
||||
#cc-main .section__toggle:checked:disabled ~ .toggle__icon {
|
||||
background: var(--cc-toggle-readonly-bg) !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
#cc-main .section__toggle:disabled {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
/* Thumb: small plain circle, vertically centred, no drop shadow */
|
||||
#cc-main .toggle__icon-circle {
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
top: 3px !important;
|
||||
left: 2.5px !important;
|
||||
box-shadow: none !important;
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
background-color 150ms ease !important;
|
||||
}
|
||||
|
||||
/* Checked thumb travel: 38 − 14 − 2.5 = 21.5px end position */
|
||||
#cc-main .section__toggle:checked ~ .toggle__icon .toggle__icon-circle {
|
||||
transform: translateX(19px) !important;
|
||||
}
|
||||
|
||||
/* Mantine switches have no check/cross glyph inside the thumb */
|
||||
#cc-main .toggle__icon-on,
|
||||
#cc-main .toggle__icon-off {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -5860,6 +5860,10 @@ subscribeToPro = "Subscribe to Pro"
|
||||
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
|
||||
deleteConfirmTitle = "Delete {{label}} policy?"
|
||||
|
||||
[policies]
|
||||
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
|
||||
deleteConfirmTitle = "Delete {{label}} policy?"
|
||||
|
||||
[printFile]
|
||||
title = "Print File"
|
||||
|
||||
@@ -6803,6 +6807,18 @@ title = "Keyboard Shortcuts"
|
||||
mac = "Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut."
|
||||
windows = "Include Ctrl, Alt, or another modifier in your shortcut."
|
||||
|
||||
[settings.legal]
|
||||
label = "Legal"
|
||||
title = "Legal"
|
||||
|
||||
[settings.legal.cookiePreferences]
|
||||
description = "Review or change your cookie consent choices."
|
||||
manage = "Manage"
|
||||
|
||||
[settings.legal.documents]
|
||||
description = "Policies and legal information for this service."
|
||||
title = "Legal Documents"
|
||||
|
||||
[settings.licensingAnalytics]
|
||||
audit = "Audit"
|
||||
plan = "Plan"
|
||||
@@ -7490,10 +7506,6 @@ title = "Upload to Server"
|
||||
updateButton = "Update on Server"
|
||||
uploadButton = "Upload to Server"
|
||||
|
||||
[survey]
|
||||
nav = "Survey"
|
||||
title = "Stirling-PDF Survey"
|
||||
|
||||
[swagger]
|
||||
tags = "api,documentation,swagger,endpoints,development"
|
||||
title = "API Documentation"
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
import { isBaseWorkbench } from "@app/types/workbench";
|
||||
import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useCookieConsent } from "@app/hooks/useCookieConsent";
|
||||
import styles from "@app/components/layout/Workbench.module.css";
|
||||
|
||||
import WorkbenchBar from "@app/components/shared/WorkbenchBar";
|
||||
import LandingPage from "@app/components/shared/LandingPage";
|
||||
import Footer from "@app/components/shared/Footer";
|
||||
import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton";
|
||||
import { ChatFAB } from "@app/components/chat/ChatFAB";
|
||||
|
||||
@@ -37,6 +37,10 @@ export default function Workbench() {
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { config } = useAppConfig();
|
||||
|
||||
// The consent banner used to be initialised by the footer; the legal links
|
||||
// now live in Settings → Legal, so the workbench owns the banner lifecycle.
|
||||
useCookieConsent({ analyticsEnabled: config?.enableAnalytics === true });
|
||||
|
||||
// Use context-based hooks to eliminate all prop drilling
|
||||
const { selectors } = useFileState();
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
@@ -252,15 +256,6 @@ export default function Workbench() {
|
||||
{renderMainContent()}
|
||||
</Suspense>
|
||||
</Box>
|
||||
|
||||
<Footer
|
||||
analyticsEnabled={config?.enableAnalytics === true}
|
||||
termsAndConditions={config?.termsAndConditions}
|
||||
privacyPolicy={config?.privacyPolicy}
|
||||
cookiePolicy={config?.cookiePolicy}
|
||||
impressum={config?.impressum}
|
||||
accessibilityStatement={config?.accessibilityStatement}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useConfigNavSections } from "@app/components/shared/config/configNavSections";
|
||||
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { COOKIE_CONSENT_SCROLL_SHARD } from "@app/hooks/useCookieConsent";
|
||||
import "@app/components/shared/AppConfigModal.css";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import {
|
||||
@@ -216,6 +217,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
padding={0}
|
||||
fullScreen={isMobile}
|
||||
styles={{ content: { overflowY: "hidden", overscrollBehavior: "none" } }}
|
||||
removeScrollProps={{ shards: [COOKIE_CONSENT_SCROLL_SHARD] }}
|
||||
>
|
||||
<div className="modal-container">
|
||||
{/* Left navigation */}
|
||||
|
||||
@@ -77,15 +77,6 @@ export default function Footer({
|
||||
color: forceLightMode ? "#495057" : undefined,
|
||||
}}
|
||||
>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
id="survey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://stirlingpdf.info/s/cm28y3niq000o56dv7liv8wsu"
|
||||
>
|
||||
{t("survey.nav", "Survey")}
|
||||
</a>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NavKey } from "@app/components/shared/config/types";
|
||||
import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection";
|
||||
import GeneralSection from "@app/components/shared/config/configSections/GeneralSection";
|
||||
import HelpSection from "@app/components/shared/config/configSections/HelpSection";
|
||||
import LegalSection from "@app/components/shared/config/configSections/LegalSection";
|
||||
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
@@ -70,6 +71,17 @@ export const useConfigNavSections = (
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("settings.legal.title", "Legal"),
|
||||
items: [
|
||||
{
|
||||
key: "legal",
|
||||
label: t("settings.legal.label", "Legal"),
|
||||
icon: "gavel-rounded",
|
||||
component: <LegalSection />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return sections;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import React from "react";
|
||||
import { Anchor, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useFooterInfo } from "@app/hooks/useFooterInfo";
|
||||
import { useCookieConsent } from "@app/hooks/useCookieConsent";
|
||||
|
||||
interface LegalLink {
|
||||
key: string;
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const LegalSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const { footerInfo } = useFooterInfo();
|
||||
|
||||
const analyticsEnabled =
|
||||
config?.enableAnalytics ?? footerInfo?.analyticsEnabled ?? false;
|
||||
const privacyPolicy = config?.privacyPolicy ?? footerInfo?.privacyPolicy;
|
||||
const termsAndConditions =
|
||||
config?.termsAndConditions ?? footerInfo?.termsAndConditions;
|
||||
const accessibilityStatement =
|
||||
config?.accessibilityStatement ?? footerInfo?.accessibilityStatement;
|
||||
const cookiePolicy = config?.cookiePolicy ?? footerInfo?.cookiePolicy;
|
||||
const impressum = config?.impressum ?? footerInfo?.impressum;
|
||||
|
||||
const { showCookiePreferences } = useCookieConsent({
|
||||
analyticsEnabled: analyticsEnabled === true,
|
||||
});
|
||||
|
||||
const isValidLink = (link?: string) => link && link.trim().length > 0;
|
||||
|
||||
const legalLinks: LegalLink[] = [
|
||||
{
|
||||
key: "privacy",
|
||||
label: t("legal.privacy", "Privacy Policy"),
|
||||
href: isValidLink(privacyPolicy)
|
||||
? privacyPolicy!
|
||||
: "https://www.stirling.com/privacy",
|
||||
},
|
||||
{
|
||||
key: "terms",
|
||||
label: t("legal.terms", "Terms and Conditions"),
|
||||
href: isValidLink(termsAndConditions)
|
||||
? termsAndConditions!
|
||||
: "https://www.stirling.com/terms",
|
||||
},
|
||||
...(isValidLink(accessibilityStatement)
|
||||
? [
|
||||
{
|
||||
key: "accessibility",
|
||||
label: t("legal.accessibility", "Accessibility"),
|
||||
href: accessibilityStatement!,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isValidLink(cookiePolicy)
|
||||
? [
|
||||
{
|
||||
key: "cookie",
|
||||
label: t("legal.cookie", "Cookie Policy"),
|
||||
href: cookiePolicy!,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isValidLink(impressum)
|
||||
? [
|
||||
{
|
||||
key: "impressum",
|
||||
label: t("legal.impressum", "Impressum"),
|
||||
href: impressum!,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const renderLink = (link: LegalLink) => (
|
||||
<Anchor
|
||||
key={link.key}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{link.label}
|
||||
<LocalIcon icon="open-in-new-rounded" width="0.9rem" height="0.9rem" />
|
||||
</Group>
|
||||
</Anchor>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{t("settings.legal.documents.title", "Legal Documents")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"settings.legal.documents.description",
|
||||
"Policies and legal information for this service.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Stack gap="sm">{legalLinks.map(renderLink)}</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{analyticsEnabled === true && (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{t("legal.showCookieBanner", "Cookie Preferences")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"settings.legal.cookiePreferences.description",
|
||||
"Review or change your cookie consent choices.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
id="cookieBanner"
|
||||
onClick={showCookiePreferences}
|
||||
>
|
||||
{t("settings.legal.cookiePreferences.manage", "Manage")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default LegalSection;
|
||||
@@ -31,6 +31,7 @@ export const VALID_NAV_KEYS = [
|
||||
"adminStorageSharing",
|
||||
"adminMcp",
|
||||
"help",
|
||||
"legal",
|
||||
"payg",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ import { useEffect, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import {
|
||||
Z_INDEX_COOKIE_CONSENT_BANNER,
|
||||
Z_INDEX_COOKIE_PREFERENCES_MODAL,
|
||||
} from "@app/styles/zIndex";
|
||||
import { TOUR_STATE_EVENT, type TourStatePayload } from "@app/constants/events";
|
||||
import { getCookieConsentOverrides } from "@app/extensions/cookieConsentConfig";
|
||||
|
||||
@@ -11,6 +15,8 @@ declare global {
|
||||
run: (config: any) => void;
|
||||
show: (show?: boolean) => void;
|
||||
hide: () => void;
|
||||
showPreferences: () => void;
|
||||
hidePreferences: () => void;
|
||||
getCookie: (name?: string) => any;
|
||||
acceptedCategory: (category: string) => boolean;
|
||||
acceptedService: (serviceName: string, category: string) => boolean;
|
||||
@@ -23,6 +29,16 @@ interface CookieConsentConfig {
|
||||
forceLightMode?: boolean;
|
||||
}
|
||||
|
||||
// Shard so Mantine's scroll-lock doesn't swallow events on the consent dialog;
|
||||
// lazy because #cc-main only exists post-load.
|
||||
export const COOKIE_CONSENT_SCROLL_SHARD = {
|
||||
get current(): HTMLElement | null {
|
||||
return typeof document === "undefined"
|
||||
? null
|
||||
: document.getElementById("cc-main");
|
||||
},
|
||||
};
|
||||
|
||||
export const useCookieConsent = ({
|
||||
analyticsEnabled = false,
|
||||
forceLightMode = false,
|
||||
@@ -34,6 +50,16 @@ export const useCookieConsent = ({
|
||||
useEffect(() => {
|
||||
if (!analyticsEnabled) return;
|
||||
|
||||
// Bridge the layering constants to the static cookie-consent stylesheets
|
||||
document.documentElement.style.setProperty(
|
||||
"--z-index-cookie-consent",
|
||||
String(Z_INDEX_COOKIE_CONSENT_BANNER),
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--z-index-cookie-preferences",
|
||||
String(Z_INDEX_COOKIE_PREFERENCES_MODAL),
|
||||
);
|
||||
|
||||
const mainCSS = document.createElement("link");
|
||||
mainCSS.rel = "stylesheet";
|
||||
mainCSS.href = `${BASE_PATH}/css/cookieconsent.css`;
|
||||
@@ -391,7 +417,10 @@ export const useCookieConsent = ({
|
||||
|
||||
const showCookiePreferences = useCallback(() => {
|
||||
if (isInitialized && window.CookieConsent) {
|
||||
window.CookieConsent?.show(true);
|
||||
// Open the detailed preferences dialog directly (not the consent
|
||||
// banner) — it gets the `.show--preferences` class, which the CSS
|
||||
// raises above the settings modal.
|
||||
window.CookieConsent?.showPreferences();
|
||||
}
|
||||
}, [isInitialized]);
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ export const Z_INDEX_DRAG_BADGE = 1001;
|
||||
// Modal that appears on top of config modal (e.g., restart confirmation, update modal)
|
||||
export const Z_INDEX_OVER_CONFIG_MODAL = 2000;
|
||||
|
||||
// Cookie-consent banner — above the chat FAB (1050), below all modals and onboarding; reaches CSS via --z-index-cookie-consent
|
||||
export const Z_INDEX_COOKIE_CONSENT_BANNER = 1060;
|
||||
// Cookie-consent preferences dialog — above the config modal it opens from; reaches CSS via --z-index-cookie-preferences
|
||||
export const Z_INDEX_COOKIE_PREFERENCES_MODAL = 1450;
|
||||
|
||||
// Sign-in modal — must appear above all app UI including config and analytics modals
|
||||
export const Z_INDEX_SIGN_IN_MODAL = 9000;
|
||||
|
||||
|
||||
@@ -1,103 +1,107 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import { openSettings } from "@app/tests/helpers/ui-helpers";
|
||||
|
||||
test.describe("18. Cookie Preferences", () => {
|
||||
test.describe("18.1 Cookie Banner", () => {
|
||||
test("should open and configure cookie preferences from footer", async ({
|
||||
test("should open and configure cookie preferences from Settings → Legal", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Step 1: Locate the "Cookie Preferences" button in the footer
|
||||
const cookieButton = page
|
||||
.locator(
|
||||
'#cookieBanner, button:has-text("Preferensi Cookie"), button:has-text("Cookie Preferences")',
|
||||
)
|
||||
.first();
|
||||
// Analytics must be enabled for the Cookie Preferences button to render
|
||||
await page.route("**/api/v1/ui-data/footer-info", (route) =>
|
||||
route.fulfill({ json: { analyticsEnabled: true } }),
|
||||
);
|
||||
await page.goto("/");
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
const isVisible = await cookieButton.isVisible().catch(() => false);
|
||||
if (!isVisible) {
|
||||
test.skip(
|
||||
true,
|
||||
"Cookie Preferences button not visible — analytics may be disabled",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Step 1: The "Cookie Preferences" button lives in Settings → Legal
|
||||
await openSettings(page);
|
||||
const legalNav = page.locator('[data-tour="admin-legal-nav"]').first();
|
||||
await expect(legalNav).toBeVisible({ timeout: 5000 });
|
||||
await legalNav.click();
|
||||
|
||||
await expect(cookieButton).toBeVisible();
|
||||
const cookieButton = page.locator("#cookieBanner").first();
|
||||
await expect(cookieButton).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Step 2: Click the Cookie Preferences button
|
||||
await cookieButton.click({ force: true });
|
||||
// The consent library lazy-loads when the Legal section mounts
|
||||
await page.waitForFunction(
|
||||
() => (window as unknown as { CookieConsent?: unknown }).CookieConsent,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Step 3: Verify the cookie consent dialog opens
|
||||
// The CookieConsent library renders inside #cc-main
|
||||
// Step 2: Click the button — it opens the detailed preferences dialog
|
||||
// directly. No force: the click must land with the settings modal open,
|
||||
// proving the dialog stacks above it.
|
||||
await cookieButton.click();
|
||||
|
||||
// Step 3: Verify the preferences dialog opens inside #cc-main
|
||||
const ccMain = page.locator("#cc-main");
|
||||
const consentDialog = ccMain.getByRole("dialog").first();
|
||||
await expect(consentDialog).toBeVisible({ timeout: 5000 });
|
||||
const prefsDialog = ccMain.locator(".pm").first();
|
||||
await expect(prefsDialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Step 4: Verify options are available
|
||||
// The initial consent view shows: "Oke", "Tidak, terima kasih", "Kelola preferensi"
|
||||
const okeBtn = ccMain
|
||||
.locator('button:has-text("Oke"), button:has-text("OK")')
|
||||
.first();
|
||||
const noThanksBtn = ccMain
|
||||
.locator(
|
||||
'button:has-text("Tidak, terima kasih"), button:has-text("No Thanks")',
|
||||
)
|
||||
.first();
|
||||
const manageBtn = ccMain
|
||||
.locator(
|
||||
'button:has-text("Kelola preferensi"), button:has-text("Manage preferences")',
|
||||
)
|
||||
// Step 4: Verify the dialog sits above the settings modal
|
||||
// (Z_INDEX_CONFIG_MODAL = 1400)
|
||||
const zIndex = await page.evaluate(() => {
|
||||
const el = document.querySelector("#cc-main");
|
||||
return el ? Number(getComputedStyle(el).zIndex) : -1;
|
||||
});
|
||||
expect(zIndex).toBeGreaterThan(1400);
|
||||
|
||||
// Step 5: Verify the preferences panel shows cookie categories
|
||||
const necessaryCategory = ccMain
|
||||
.locator("text=/Strictly Necessary|necessary/i")
|
||||
.first();
|
||||
const analyticsCategory = ccMain.locator("text=/Analytics/i").first();
|
||||
await expect(necessaryCategory.or(analyticsCategory).first()).toBeVisible(
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
const hasOke = await okeBtn.isVisible().catch(() => false);
|
||||
const hasNoThanks = await noThanksBtn.isVisible().catch(() => false);
|
||||
const hasManage = await manageBtn.isVisible().catch(() => false);
|
||||
|
||||
expect(hasOke || hasNoThanks || hasManage).toBe(true);
|
||||
|
||||
// Step 5: Click "Kelola preferensi" to open the detailed preferences panel
|
||||
if (hasManage) {
|
||||
await manageBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify the preferences panel shows cookie categories
|
||||
const necessaryCategory = ccMain
|
||||
.locator(
|
||||
"text=/Cookie yang Sangat Diperlukan|Strictly Necessary|necessary/i",
|
||||
)
|
||||
.first();
|
||||
const analyticsCategory = ccMain
|
||||
.locator("text=/Analitik|Analytics/i")
|
||||
.first();
|
||||
await expect(
|
||||
necessaryCategory.or(analyticsCategory).first(),
|
||||
).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Click "Terima semua" (Accept all) or "Simpan preferensi" (Save preferences) to close
|
||||
const acceptAllBtn = ccMain
|
||||
.locator(
|
||||
'button:has-text("Terima semua"), button:has-text("Accept all")',
|
||||
)
|
||||
.first();
|
||||
const savePrefBtn = ccMain
|
||||
.locator(
|
||||
'button:has-text("Simpan preferensi"), button:has-text("Save preferences")',
|
||||
)
|
||||
.first();
|
||||
|
||||
if (await acceptAllBtn.isVisible().catch(() => false)) {
|
||||
await acceptAllBtn.click();
|
||||
} else if (await savePrefBtn.isVisible().catch(() => false)) {
|
||||
await savePrefBtn.click();
|
||||
}
|
||||
} else if (hasOke) {
|
||||
await okeBtn.click();
|
||||
} else if (hasNoThanks) {
|
||||
await noThanksBtn.click();
|
||||
// Step 6: Expand all sections and verify the dialog body wheel-scrolls
|
||||
// while the settings modal is open (regression: the modal's
|
||||
// react-remove-scroll lock swallowed wheel events on the dialog)
|
||||
const expanders = ccMain.locator(
|
||||
".pm__section--expandable .pm__section-title",
|
||||
);
|
||||
const expanderCount = await expanders.count();
|
||||
for (let i = 0; i < expanderCount; i++) {
|
||||
await expanders.nth(i).click();
|
||||
}
|
||||
const dialogBody = ccMain.locator(".pm__body").first();
|
||||
const canScroll = await dialogBody.evaluate(
|
||||
(el) => el.scrollHeight > el.clientHeight,
|
||||
);
|
||||
if (canScroll) {
|
||||
const box = await dialogBody.boundingBox();
|
||||
await page.mouse.move(
|
||||
box!.x + box!.width / 2,
|
||||
box!.y + box!.height / 2,
|
||||
);
|
||||
await page.mouse.wheel(0, 300);
|
||||
await expect
|
||||
.poll(() => dialogBody.evaluate((el) => el.scrollTop), {
|
||||
timeout: 3000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
await dialogBody.evaluate((el) => {
|
||||
el.scrollTop = 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Step 6: Verify the dialog is dismissed
|
||||
await expect(consentDialog).toBeHidden({ timeout: 5000 });
|
||||
// Step 7: Accept all (or save) to close — again without force
|
||||
const acceptAllBtn = ccMain
|
||||
.locator('button:has-text("Accept all")')
|
||||
.first();
|
||||
const savePrefBtn = ccMain
|
||||
.locator('button:has-text("Save preferences")')
|
||||
.first();
|
||||
|
||||
if (await acceptAllBtn.isVisible().catch(() => false)) {
|
||||
await acceptAllBtn.click();
|
||||
} else {
|
||||
await savePrefBtn.click();
|
||||
}
|
||||
|
||||
// Step 8: Verify the dialog is dismissed
|
||||
await expect(prefsDialog).toBeHidden({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import { mockAppApis, seedCookieConsent } from "@app/tests/helpers/api-stubs";
|
||||
import { openSettings } from "@app/tests/helpers/ui-helpers";
|
||||
|
||||
test.describe("2. Main Dashboard / Home Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -87,33 +88,29 @@ test.describe("2. Main Dashboard / Home Page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("2.4 Dashboard - Footer Links", () => {
|
||||
test("should display footer links with correct URLs", async ({ page }) => {
|
||||
await expect(page.getByText("Survey").first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
test.describe("2.4 Dashboard - Legal Links", () => {
|
||||
test("legal links live in Settings → Legal, not in a footer", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(
|
||||
page.locator('[data-testid="config-button"]').first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The dashboard footer (survey + legal links) was removed
|
||||
await expect(page.locator(".footer-link")).toHaveCount(0);
|
||||
await expect(page.getByText("Survey")).toHaveCount(0);
|
||||
|
||||
await openSettings(page);
|
||||
const legalNav = page.locator('[data-tour="admin-legal-nav"]').first();
|
||||
await expect(legalNav).toBeVisible({ timeout: 10000 });
|
||||
await legalNav.click();
|
||||
|
||||
await expect(page.getByText("Privacy Policy").first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText(/Terms/i).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText("Discord").first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText("GitHub").first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const githubLink = page
|
||||
.locator('a[href*="github.com/Stirling-Tools/Stirling-PDF"]')
|
||||
.first();
|
||||
await expect(githubLink).toBeVisible();
|
||||
|
||||
const discordLink = page
|
||||
.locator('a[href*="discord.gg/Cn8pWhQRxZ"]')
|
||||
.first();
|
||||
await expect(discordLink).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -68,12 +68,16 @@ export const useConfigNavSections = (
|
||||
],
|
||||
};
|
||||
|
||||
// In local mode only show Preferences + Connection Mode — everything else
|
||||
// requires a server and will 500 or show irrelevant admin UI.
|
||||
// In local mode only show Preferences + Connection Mode + Legal — everything
|
||||
// else requires a server and will 500 or show irrelevant admin UI.
|
||||
if (isLocalMode) {
|
||||
const result: ConfigNavSection[] = [];
|
||||
if (sections.length > 0) result.push(sections[0]);
|
||||
result.push(connectionModeSection);
|
||||
const legalSection = sections.find((section) =>
|
||||
section.items.some((item) => item.key === "legal"),
|
||||
);
|
||||
if (legalSection) result.push(legalSection);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
.chat-fab-trigger {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: calc(var(--footer-height, 2rem) + 16px);
|
||||
bottom: 16px;
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
/* Include box-shadow so the ChatFABButton hover shadow still animates */
|
||||
|
||||
@@ -15,8 +15,7 @@ const PANEL_HEIGHT_PX = 520;
|
||||
const PANEL_MIN_WIDTH_PX = 300;
|
||||
const PANEL_MIN_HEIGHT_PX = 380;
|
||||
const FAB_GAP_PX = 16;
|
||||
// footer-height (2rem = 32px) + gap so the panel clears the footer
|
||||
const FAB_BOTTOM_OFFSET_PX = 32 + FAB_GAP_PX;
|
||||
const FAB_BOTTOM_OFFSET_PX = FAB_GAP_PX;
|
||||
|
||||
const RESET_MS = 380;
|
||||
const RESET_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
|
||||
|
||||
@@ -9,6 +9,7 @@ import Overview from "@app/components/shared/config/configSections/Overview";
|
||||
import { createSaasConfigNavSections } from "@app/components/shared/config/saasConfigNavSections";
|
||||
import { NavKey } from "@app/components/shared/config/types";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
import { COOKIE_CONSENT_SCROLL_SHARD } from "@app/hooks/useCookieConsent";
|
||||
import "@app/components/shared/AppConfigModal.css";
|
||||
import {
|
||||
Z_INDEX_OVER_FULLSCREEN_SURFACE,
|
||||
@@ -167,6 +168,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
overlayProps={{ opacity: 0.35, blur: 2 }}
|
||||
padding={0}
|
||||
fullScreen={isMobile}
|
||||
removeScrollProps={{ shards: [COOKIE_CONSENT_SCROLL_SHARD] }}
|
||||
// Hidden (not closed) while a child overlay like the PAYG UpgradeModal
|
||||
// is up — see the appConfig:overlay listener above. The focus trap and
|
||||
// escape/outside-close must release too: the trap would steal focus
|
||||
|
||||
@@ -10,6 +10,7 @@ import PasswordSecurity from "@app/components/shared/config/configSections/Passw
|
||||
import ApiKeys from "@app/components/shared/config/configSections/ApiKeys";
|
||||
import Plan from "@app/components/shared/config/configSections/Plan";
|
||||
import McpSection from "@app/components/shared/config/configSections/McpSection";
|
||||
import LegalSection from "@app/components/shared/config/configSections/LegalSection";
|
||||
import TeamSection from "@app/components/shared/config/configSections/TeamSection";
|
||||
|
||||
type OverviewComponent = React.ComponentType<{ onLogoutClick: () => void }>;
|
||||
@@ -148,6 +149,36 @@ function appendMcpSection(
|
||||
);
|
||||
}
|
||||
|
||||
// Legal links (privacy policy, terms, etc.). Shown to anonymous users too —
|
||||
// it's public information.
|
||||
function appendLegalSection(
|
||||
sections: ConfigNavSection[],
|
||||
t: TFunction<"translation", undefined>,
|
||||
): ConfigNavSection[] {
|
||||
const hasLegal = sections.some((section) =>
|
||||
section.items.some((item) => item.key === "legal"),
|
||||
);
|
||||
|
||||
if (hasLegal) {
|
||||
return sections;
|
||||
}
|
||||
|
||||
return [
|
||||
...sections,
|
||||
{
|
||||
title: t("settings.legal.title", "Legal"),
|
||||
items: [
|
||||
{
|
||||
key: "legal" as const,
|
||||
label: t("settings.legal.label", "Legal"),
|
||||
icon: "gavel-rounded",
|
||||
component: <LegalSection />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function createSaasConfigNavSections(
|
||||
Overview: OverviewComponent,
|
||||
onLogoutClick: () => void,
|
||||
@@ -209,6 +240,8 @@ export function createSaasConfigNavSections(
|
||||
sections = appendBillingSection(sections, t);
|
||||
}
|
||||
|
||||
sections = appendLegalSection(sections, t);
|
||||
|
||||
if (isDev) {
|
||||
console.debug("[AppConfigModal] SaaS navigation sections", sections);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user