diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 00000000..98930655 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,11 @@ +--- +name: Bug report +about: Report a problem in Flexfoil +labels: bug +--- + +## What happened? + +## What did you expect? + +Please keep private geometry, contact details, and other sensitive information out of this public issue. diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 00000000..9f4ca96b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,13 @@ +--- +name: Feature request +about: Suggest an improvement to Flexfoil and track its progress +labels: enhancement +--- + +## What problem are you trying to solve? + +## What would you like Flexfoil to do? + +## How do you handle this today? + +Please keep private geometry, contact details, and other sensitive information out of this public issue. diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 00000000..4d4b3308 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,9 @@ +--- +name: Question +about: Ask a question about using Flexfoil +labels: question +--- + +## What do you need help with? + +Please keep private geometry, contact details, and other sensitive information out of this public issue. diff --git a/README.md b/README.md index 5f0d77c2..8c4133d4 100644 --- a/README.md +++ b/README.md @@ -169,3 +169,58 @@ Copyright (c) 2026 Flexcompute, Inc. and Harry Smith. This repository includes third-party components and references commercial dependencies. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for details on AG Grid Enterprise, the TWK Everett typeface, and other attributions. + +### Feature requests and usage analytics + +The desktop and mobile **Feedback & requests** widget links to the public +[feature request tracker](https://github.com/flexcompute/flexfoil/issues?q=is%3Aissue+label%3Aenhancement). +Without `VITE_FEEDBACK_SHEET_URL`, the form opens a GitHub issue draft with the +chosen category and message using an explicit issue template. Labels come from +template metadata, so visitors do not need permission to assign labels. Users must sign in and submit on GitHub; a draft is +not a saved request. Feature requests use the existing `enhancement` label and +GitHub's open/closed status and discussion. A configured feedback service retains +the existing POST flow; its opaque response cannot confirm that a request was saved. + +Local development emits no custom usage events by default. Set +`VITE_SOLVE_RUN_ANALYTICS=true` to enable them. The tracked `.env.production` +enables this flag for production builds, including deployed and packaged UIs; +set `VITE_SOLVE_RUN_ANALYTICS=false` to override it. Each visitor must also accept analytics. **Analytics preferences** +in Help (desktop) or the mobile footer can reopen the consent choice. Rejection +or unavailable consent storage suppresses custom events. The initial Google tag +configuration is deferred until acceptance, with advertising signals disabled. + +| Event | Meaning | Coarse parameters | +| --- | --- | --- | +| `solve_run` | Solve requested, regardless of convergence | `solve_mode`, `solver_mode`, `n_panels` | +| `feature_use` | Panel selected, DAT imported/exported, SVG exported, documentation or feedback/tracker opened | `feature`, optional `panel_id` | +| `feedback_handoff` | GitHub draft opened; not a submitted issue | `feedback_type` | +| `feedback_sent` | Configured feedback POST completed; persistence unconfirmed | `feedback_type` | + +Event page metadata uses the application base URL and referrer origin only. +Custom payloads do not include geometry, filenames, feedback text, contacts or +precise location. Solving, importing and exporting still work with analytics off. +Google Analytics provides approximate country/city dimensions from network +location; no browser geolocation permission is requested. City availability and +accuracy depend on property settings, network routing and privacy thresholds. + +For reports, open the GA4 property containing measurement ID `G-065GK6XBSR`. +Register event-scoped custom dimensions `feature`, `panel_id`, `solve_mode`, +`solver_mode`, and `feedback_type`; register `n_panels` as a custom metric if needed. +Use an Exploration with event count and total users, broken down by feature/panel +or solve mode and Country/City. Filter for the events above and the Flexfoil +application base URL. Reports describe consenting visitors, not every user. +Property access and dimension registration are account-side setup; this code does +not configure the property or expose admin reports to public app users. Check +Enhanced Measurement settings so automatic events do not collect shared route +state or download names separately from these sanitized custom events. + +The regression checks run locally without contacting GA: + +```sh +(cd flexfoil-ui && npx vitest run --maxWorkers=1 && npm run build) +npx playwright test --config e2e/analytics/playwright.config.mts +``` + +The browser fixture mounts the real SolvePanel, feedback widget and consent UI +with an offline solver and in-memory run persistence. It verifies UI/event +contracts, not solver numerics or delivery to GA. Chrome must be installed. diff --git a/docs-site/docs/web-app.mdx b/docs-site/docs/web-app.mdx index f6b797c9..7bf955d3 100644 --- a/docs-site/docs/web-app.mdx +++ b/docs-site/docs/web-app.mdx @@ -178,3 +178,30 @@ click the **Search...** button in the menu bar. database for backup or sharing. - **Mobile layout** — the app adapts to smaller screens with a stacked panel layout. + +## Feature Requests and Usage Analytics + +Open **Feedback & requests** in the desktop or mobile footer to submit a bug, +feature request or question. The feature request tracker opens public GitHub +issues tagged `enhancement`; open/closed status and discussion show progress. +When no private feedback service is configured, the form opens a GitHub draft. +Sign in and submit it there to save the request. Do not include private data in +public issues. A configured private feedback service uses its existing POST flow; +Flexfoil cannot confirm persistence from the service's opaque response. + +With analytics consent, the production app measures solve requests, panel +selections, DAT imports/exports, SVG exports, documentation and feedback activity. +It sends coarse feature metadata, not geometry, filenames, feedback text or contact +details. Shared URL query/fragment data is omitted from custom event page metadata. +Google Analytics supplies approximate country/city reporting; Flexfoil does not +request precise browser location. Reports cover consenting visitors and are viewed +in the private GA4 property, not in the public application. + +Reopen **Help → Analytics preferences** on desktop, or **Analytics preferences** +in the mobile footer, to accept or reject analytics. Rejection stops custom usage +events. For build settings, event definitions and reporting setup, see the +repository README's “Feature requests and usage analytics” section. + +GitHub feedback drafts select an explicit template for bugs, feature requests or +questions, preserving the typed title and message through the handoff. Template +metadata supplies the category label without requiring label-management permissions. diff --git a/e2e/analytics/.gitignore b/e2e/analytics/.gitignore new file mode 100644 index 00000000..51511d1f --- /dev/null +++ b/e2e/analytics/.gitignore @@ -0,0 +1 @@ +test-results/ diff --git a/e2e/analytics/actual-ui.spec.ts b/e2e/analytics/actual-ui.spec.ts new file mode 100644 index 00000000..f4f8b2cf --- /dev/null +++ b/e2e/analytics/actual-ui.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.route('https://**', route => route.abort()); + await page.addInitScript(() => { + localStorage.setItem('flexfoil-onboarding', JSON.stringify({ + lastSeenVersion: '1.0.0', completedTours: ['welcome'], tourProgress: {}, + })); + localStorage.setItem('ff_cookie_consent', 'granted'); + }); +}); + +test('desktop panel selection and export use the real layout and WASM app', async ({ page }, info) => { + await page.goto('/'); + await page.locator('.flexlayout__tab_button').filter({ hasText: 'Data Explorer' }).click(); + await expect.poll(() => page.evaluate(() => window.dataLayer?.some(value => { + const event = Array.from(value as ArrayLike); + return event[0] === 'event' && event[1] === 'feature_use' + && (event[2] as any).panel_id === 'data-explorer'; + }))).toBe(true); + await page.getByRole('button', { name: 'File', exact: true }).click(); + const download = page.waitForEvent('download'); + await page.getByText('Export .dat...', { exact: true }).click(); + await download; + await expect.poll(() => page.evaluate(() => window.dataLayer?.some(value => { + const event = Array.from(value as ArrayLike); + return event[0] === 'event' && (event[2] as any)?.feature === 'export_dat'; + }))).toBe(true); + await page.getByRole('button', { name: 'Help', exact: true }).click(); + await page.getByText('Analytics preferences', { exact: true }).click(); + await expect(page.getByRole('button', { name: 'Reject', exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Reject', exact: true }).click(); + await page.getByRole('button', { name: 'Send feedback' }).click(); + await expect(page.getByRole('link', { name: 'View feature request tracker' })).toBeVisible(); + await page.screenshot({ path: info.outputPath('feedback.png'), animations: 'disabled' }); + await info.attach('desktop-feedback', { path: info.outputPath('feedback.png'), contentType: 'image/png' }); +}); + +test('mobile panels, feedback and consent controls are reachable', async ({ page }, info) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + await page.locator('.mobile-tabs').getByRole('button', { name: 'Solve', exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.dataLayer?.some(value => { + const event = Array.from(value as ArrayLike); + return event[0] === 'event' && (event[2] as any)?.panel_id === 'solve'; + }))).toBe(true); + await page.getByRole('button', { name: 'Send feedback' }).click(); + await expect(page.getByRole('textbox', { name: 'Feedback message' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'View feature request tracker' })).toBeVisible(); + await page.screenshot({ path: info.outputPath('feedback.png'), animations: 'disabled' }); + await info.attach('mobile-feedback', { path: info.outputPath('feedback.png'), contentType: 'image/png' }); + await page.setViewportSize({ width: 320, height: 568 }); + const dialog = page.getByRole('dialog', { name: 'Feedback form' }); + const box = await dialog.boundingBox(); + expect(box?.x).toBeGreaterThanOrEqual(0); + expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(320); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + await page.getByRole('button', { name: 'Analytics preferences', exact: true }).click(); + await page.getByRole('button', { name: 'Reject', exact: true }).click(); + expect(await page.evaluate(() => localStorage.getItem('ff_cookie_consent'))).toBe('denied'); +}); diff --git a/e2e/analytics/fixture.html b/e2e/analytics/fixture.html new file mode 100644 index 00000000..d9cb5ae2 --- /dev/null +++ b/e2e/analytics/fixture.html @@ -0,0 +1 @@ +Solve analytics acceptance
diff --git a/e2e/analytics/fixture.tsx b/e2e/analytics/fixture.tsx new file mode 100644 index 00000000..22ffdabe --- /dev/null +++ b/e2e/analytics/fixture.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import '../../flexfoil-ui/src/App.css'; +import {createRoot} from 'react-dom/client'; +import {FeedbackWidget} from '../../flexfoil-ui/src/components/FeedbackWidget'; +import {CookieConsent} from '../../flexfoil-ui/src/components/CookieConsent'; +import {SolvePanel} from '../../flexfoil-ui/src/components/panels/SolvePanel'; +import {useRunStore} from '../../flexfoil-ui/src/stores/runStore'; +import {useRouteUiStore} from '../../flexfoil-ui/src/stores/routeUiStore'; +import {useAirfoilStore} from '../../flexfoil-ui/src/stores/airfoilStore'; +const params = new URLSearchParams(location.search); +const mode = params.get('mode'); +const consent = params.get('consent') ?? 'granted'; +if (consent !== 'missing') localStorage.setItem('ff_cookie_consent', consent); +(window as any).__events = []; +(window as any).__solves = []; +window.gtag = (...args) => (window as any).__events.push(args); +useRunStore.setState({ready: true, lookup: () => null, addRun: async () => {}, addRunBatch: async () => {}, hashPanels: async () => 'offline-fixture'}); +useAirfoilStore.setState({solverMode: 'inviscid'}); +useRouteUiStore.setState({solveRunMode: mode === 'single_cl' ? 'cl' : 'alpha', solveTargetCl: 0.5, + solvePolarStart: 0, solvePolarEnd: 1, solvePolarStep: 1, + sweepPrimary: {param: 'alpha', start: 0, end: 1, step: 1}, + sweepSecondary: mode === 'sweep_2d' ? {param: 'mach', start: 0, end: 0.1, step: 0.1} : null}); +createRoot(document.getElementById('root')!).render(<>); diff --git a/e2e/analytics/playwright.config.mts b/e2e/analytics/playwright.config.mts new file mode 100644 index 00000000..03803575 --- /dev/null +++ b/e2e/analytics/playwright.config.mts @@ -0,0 +1,18 @@ +import {defineConfig} from '@playwright/test'; +const root = new URL('../../', import.meta.url).pathname; +export default defineConfig({testDir: '.', workers: 1, reporter: 'list', + outputDir: 'test-results/solve-analytics', use: {channel: 'chrome'}, + projects: [{name: 'default-off', testIgnore: 'actual-ui.spec.ts', use: {baseURL: 'http://127.0.0.1:18997'}}, + {name: 'explicit-on', testIgnore: 'actual-ui.spec.ts', use: {baseURL: 'http://127.0.0.1:18998'}}, + {name: 'feedback-service', testIgnore: 'actual-ui.spec.ts', use: {baseURL: 'http://127.0.0.1:18999'}}, + {name: 'actual-app', testMatch: 'actual-ui.spec.ts', use: {baseURL: 'http://127.0.0.1:19000'}}], + webServer: [...[18997, 18998, 18999].map(port => ({cwd: root, + command: `flexfoil-ui/node_modules/.bin/vite --config flexfoil-ui/e2e/analytics.vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`, + env: {VITE_SOLVE_RUN_ANALYTICS: port === 18997 ? 'false' : 'true', + VITE_FEEDBACK_SHEET_URL: port === 18999 ? '/feedback-service' : ''}, + url: `http://127.0.0.1:${port}/e2e/analytics/fixture.html`, reuseExistingServer: false})), { + command: 'npm run dev -- --host 127.0.0.1 --port 19000 --strictPort', + cwd: `${root}flexfoil-ui`, env: {VITE_SOLVE_RUN_ANALYTICS: 'true', VITE_FEEDBACK_SHEET_URL: ''}, + url: 'http://127.0.0.1:19000', reuseExistingServer: false, + }], +}); diff --git a/e2e/analytics/product-analytics.spec.ts b/e2e/analytics/product-analytics.spec.ts new file mode 100644 index 00000000..003a66c8 --- /dev/null +++ b/e2e/analytics/product-analytics.spec.ts @@ -0,0 +1,93 @@ +import { test, expect } from '@playwright/test'; +import { readFileSync } from 'node:fs'; + +for (const consent of ['missing', 'denied']) { + test(`solving works without ${consent} analytics consent`, async ({ page }) => { + await page.route('https://**', route => route.abort()); + await page.goto(`/e2e/analytics/fixture.html?consent=${consent}`); + await page.getByRole('button', { name: 'Run', exact: true }).click(); + await expect.poll(() => page.evaluate(() => (window as any).__solves.length)).toBe(1); + expect(await page.evaluate(() => (window as any).__events)).toEqual([]); + }); +} + +for (const [category, feedbackType, template, label] of [ + ['Feature', 'feature', 'feature-request.md', 'enhancement'], + ['Bug', 'bug', 'bug-report.md', 'bug'], + ['General', 'general', 'question.md', 'question'], +]) { + test(`${category} handoff is public, correctly categorized and never claims submission`, async ({ page }, info) => { + test.skip(info.project.name === 'feedback-service'); + await page.route('https://**', route => route.abort()); + await page.goto('/e2e/analytics/fixture.html?geometry=private#private-foil'); + await page.evaluate(() => { window.open = url => { (window as any).__draftUrl = String(url); return null; }; }); + await page.getByRole('button', { name: 'Send feedback' }).click(); + await page.getByRole('button', { name: category, exact: true }).click(); + await page.getByRole('textbox', { name: 'Feedback message' }).fill('Please add a comparison plot\nPrivate example for the request only'); + const tracker = page.getByRole('link', { name: 'View feature request tracker' }); + expect(new URL(await tracker.getAttribute('href') as string).searchParams.get('q')).toBe('is:issue label:enhancement'); + await expect(page.getByText('A GitHub account is required', { exact: false })).toBeVisible(); + await page.getByRole('button', { name: 'Continue on GitHub' }).click(); + const url = new URL(await page.evaluate(() => (window as any).__draftUrl)); + expect(url.origin).toBe('https://github.com'); + expect(url.pathname).toBe('/flexcompute/flexfoil/issues/new'); + expect(url.searchParams.get('template')).toBe(template); + // Template metadata assigns labels without requiring the visitor to have label permissions. + expect(url.searchParams.has('labels')).toBe(false); + expect(readFileSync(`.github/ISSUE_TEMPLATE/${template}`, 'utf8')).toContain(`labels: ${label}`); + expect(url.searchParams.get('body')).toContain('Please add a comparison plot'); + await expect(page.getByText('Opening a draft does not submit it.', { exact: false })).toBeVisible(); + const events = await page.evaluate(() => (window as any).__events); + if (info.project.name === 'explicit-on') { + expect(events.map((event: any[]) => event[1])).toEqual(['feature_use', 'feedback_handoff']); + expect(events[1][2].feedback_type).toBe(feedbackType); + expect(events[1][2].page_location).toBe(new URL('/', info.project.use.baseURL).href); + } else expect(events).toEqual([]); + expect(JSON.stringify(events)).not.toContain('private'); + expect(JSON.stringify(events)).not.toContain('comparison plot'); + await info.attach('request-handoff', { body: await page.screenshot(), contentType: 'image/png' }); + await page.getByRole('button', { name: 'Close', exact: true }).click(); + await page.getByRole('button', { name: 'Send feedback' }).click(); + await expect(page.getByRole('textbox', { name: 'Feedback message' })).toHaveValue('Please add a comparison plot\nPrivate example for the request only'); + }); +} + +test('acceptance and revocation control subsequent custom events', async ({ page }, info) => { + await page.route('https://**', route => route.abort()); + await page.goto('/e2e/analytics/fixture.html?consent=missing'); + await page.getByRole('button', { name: 'Accept', exact: true }).click(); + await page.getByRole('button', { name: 'Run', exact: true }).click(); + await expect.poll(() => page.evaluate(() => (window as any).__solves.length)).toBe(1); + const events = await page.evaluate(() => (window as any).__events.filter((event: any[]) => event[0] === 'event')); + expect(events.length).toBe(info.project.name === 'default-off' ? 0 : 1); + await page.getByRole('button', { name: 'Analytics preferences', exact: true }).click(); + await page.getByRole('button', { name: 'Reject', exact: true }).click(); + await page.evaluate(() => { (window as any).__events = []; }); + await page.getByRole('button', { name: 'Run', exact: true }).click(); + await expect.poll(() => page.evaluate(() => (window as any).__solves.length)).toBe(2); + expect(await page.evaluate(() => (window as any).__events)).toEqual([]); +}); + +test('configured feedback handles transport failure and distinguishes sending from persistence', async ({ page }, info) => { + test.skip(info.project.name !== 'feedback-service'); + await page.route('https://**', route => route.abort()); + await page.route('**/feedback-service', route => route.abort()); + await page.goto('/e2e/analytics/fixture.html?geometry=private#private-foil'); + await page.getByRole('button', { name: 'Send feedback' }).click(); + await page.getByRole('textbox', { name: 'Feedback message' }).fill('A feature suggestion'); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect(page.getByText('Something went wrong. Please try again.')).toBeVisible(); + await page.unroute('**/feedback-service'); + let body: any; + await page.route('**/feedback-service', async route => { + body = route.request().postDataJSON(); + await route.fulfill({ status: 200, body: '{}' }); + }); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect(page.getByText('delivery cannot be confirmed here.', { exact: false })).toBeVisible(); + expect(body.url).not.toContain('private'); + expect(body.message).toBe('A feature suggestion'); + const events = await page.evaluate(() => (window as any).__events); + expect(events.at(-1)[1]).toBe('feedback_sent'); + expect(JSON.stringify(events)).not.toContain('A feature suggestion'); +}); diff --git a/e2e/analytics/solve-analytics.spec.ts b/e2e/analytics/solve-analytics.spec.ts new file mode 100644 index 00000000..e7e753ab --- /dev/null +++ b/e2e/analytics/solve-analytics.spec.ts @@ -0,0 +1,22 @@ +import {test, expect} from '@playwright/test'; +for (const mode of ['single_alpha', 'single_cl', 'polar', 'sweep_1d', 'sweep_2d']) { + test(`${mode}: analytics opt-in preserves solve dispatch`, async ({page}, info) => { + await page.route('https://**', route => route.abort()); + await page.goto(`/e2e/analytics/fixture.html?mode=${mode}`); + const button = page.getByRole('button', {name: mode === 'polar' ? 'α Polar' : mode.startsWith('sweep') ? 'Generate Sweep' : 'Run', exact: true}); + await expect(button).toBeEnabled(); + await button.click(); + await expect.poll(() => page.evaluate(() => (window as any).__solves.length)).toBeGreaterThan(0); + await expect(button).toBeEnabled(); + const events = await page.evaluate(() => (window as any).__events); + const on = info.project.name !== 'default-off'; + if (on) { + expect(events).toEqual([['event', 'solve_run', {solve_mode: mode, solver_mode: 'inviscid', n_panels: 24, page_location: new URL('/', info.project.use.baseURL).href, page_referrer: ''}]]); + } else expect(events).toEqual([]); + const solves = await page.evaluate(() => (window as any).__solves); + if (mode === 'single_cl') expect(solves.at(-1).cl).toBeCloseTo(0.5, 2); + else expect(solves).toEqual((mode === 'single_alpha' ? [0] : mode === 'sweep_2d' ? [0, 1, 0, 1] : [0, 1]) + .map(alpha => ({alpha, cl: alpha * 0.1}))); + await info.attach('solve-dispatch', {body: JSON.stringify({mode, on, solves}), contentType: 'application/json'}); + }); +} diff --git a/e2e/analytics/wasm-fixture.ts b/e2e/analytics/wasm-fixture.ts new file mode 100644 index 00000000..4758c37a --- /dev/null +++ b/e2e/analytics/wasm-fixture.ts @@ -0,0 +1,13 @@ +// An offline solver boundary for UI dispatch tests; these values are not CFD evidence. +export const isWasmReady = () => true; +export function analyzeAirfoilInviscid(_panels: unknown, alpha: number) { + const result = {success: true, converged: true, cl: alpha * 0.1, cd: 0.01, cm: 0, cp: [], cp_x: [], gamma: [], + psi_0: 0, iterations: 1, residual: 0, x_tr_upper: 1, x_tr_lower: 1}; + (window as any).__solves.push({alpha, cl: result.cl}); + return result; +} +export const analyzeAirfoil = analyzeAirfoilInviscid; +const unused = () => {throw new Error('Unexpected geometry call in offline analytics fixture');}; +export const generateNaca4 = unused, generateNaca4Xfoil = unused, + repanelWithSpacingAndCurvature = unused, repanelXfoil = unused, + deflectFlap = unused; diff --git a/flexfoil-ui/.env.production b/flexfoil-ui/.env.production new file mode 100644 index 00000000..75fda6d5 --- /dev/null +++ b/flexfoil-ui/.env.production @@ -0,0 +1 @@ +VITE_SOLVE_RUN_ANALYTICS=true diff --git a/flexfoil-ui/e2e/analytics.vite.config.ts b/flexfoil-ui/e2e/analytics.vite.config.ts new file mode 100644 index 00000000..46b753cb --- /dev/null +++ b/flexfoil-ui/e2e/analytics.vite.config.ts @@ -0,0 +1,12 @@ +import {defineConfig} from 'vite'; +import react from '@vitejs/plugin-react'; +import {fileURLToPath} from 'node:url'; +const root = fileURLToPath(new URL('../../', import.meta.url)); +const wasmFixture = fileURLToPath(new URL('../../e2e/analytics/wasm-fixture.ts', import.meta.url)); +export default defineConfig({root, plugins: [react(), { + name: 'offline-solver-for-analytics', enforce: 'pre', + resolveId(source) {if (source.endsWith('/lib/wasm') || source === './wasm') return wasmFixture;}, +}], resolve: {dedupe: ['react', 'react-dom'], alias: { + react: fileURLToPath(new URL('../node_modules/react', import.meta.url)), + 'react-dom': fileURLToPath(new URL('../node_modules/react-dom', import.meta.url)), +}}}); diff --git a/flexfoil-ui/index.html b/flexfoil-ui/index.html index c4852411..b2e78386 100644 --- a/flexfoil-ui/index.html +++ b/flexfoil-ui/index.html @@ -16,18 +16,13 @@ ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied', - wait_for_update: 500, - region: ['AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR', - 'DE','GR','HU','IE','IT','LV','LT','LU','MT','NL', - 'PL','PT','RO','SK','SI','ES','SE','IS','LI','NO', - 'GB','CH'] + wait_for_update: 500 }); diff --git a/flexfoil-ui/package-lock.json b/flexfoil-ui/package-lock.json index 85ac836b..f9d2f1f7 100644 --- a/flexfoil-ui/package-lock.json +++ b/flexfoil-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "flexfoil-ui", - "version": "1.1.0-dev", + "version": "1.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flexfoil-ui", - "version": "1.1.0-dev", + "version": "1.1.8", "license": "MIT", "dependencies": { "ag-grid-community": "^35.1.0", diff --git a/flexfoil-ui/package.json b/flexfoil-ui/package.json index 6fea9a6a..e174fa11 100644 --- a/flexfoil-ui/package.json +++ b/flexfoil-ui/package.json @@ -2,7 +2,7 @@ "name": "flexfoil-ui", "private": true, "license": "MIT", - "version": "1.1.4", + "version": "1.1.8", "type": "module", "scripts": { "dev": "vite", diff --git a/flexfoil-ui/src/App.css b/flexfoil-ui/src/App.css index 27287ea7..2b5b89c9 100644 --- a/flexfoil-ui/src/App.css +++ b/flexfoil-ui/src/App.css @@ -988,8 +988,10 @@ button.primary:hover { display: flex; align-items: center; justify-content: center; - height: 24px; - padding: 0 10px; + min-height: 48px; + flex-wrap: wrap; + gap: 8px; + padding: 6px 10px; background: var(--brand-footer-surface); border-top: 1px solid var(--border-color); flex-shrink: 0; @@ -1130,13 +1132,16 @@ button.primary:hover { bottom: 42px; right: 16px; z-index: 9001; - width: 340px; + width: min(340px, calc(100vw - 32px)); + font-size: 13px; + color: var(--text-primary); + letter-spacing: normal; max-height: calc(100vh - 60px); background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 10px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35); - overflow: hidden; + overflow-y: auto; animation: feedback-slide-up 0.18s ease-out; } @@ -1145,6 +1150,11 @@ button.primary:hover { to { opacity: 1; transform: translateY(0); } } +.feedback-panel a, +.mobile-footer .brand-footer__link { + color: var(--text-primary); +} + .feedback-panel__form { display: flex; flex-direction: column; diff --git a/flexfoil-ui/src/components/CookieConsent.tsx b/flexfoil-ui/src/components/CookieConsent.tsx index 686abc10..2d9f610b 100644 --- a/flexfoil-ui/src/components/CookieConsent.tsx +++ b/flexfoil-ui/src/components/CookieConsent.tsx @@ -1,15 +1,18 @@ import { useCallback, useEffect, useState } from 'react'; -import { getStoredConsent, updateGtagConsent, type ConsentStatus } from '../lib/analytics'; +import { getStoredConsent, updateGtagConsent } from '../lib/analytics'; export function CookieConsent() { const [visible, setVisible] = useState(false); useEffect(() => { + const show = () => setVisible(true); + window.addEventListener('flexfoil:analytics-preferences', show); const stored = getStoredConsent(); - if (stored === null) { - const timer = setTimeout(() => setVisible(true), 1200); - return () => clearTimeout(timer); - } + const timer = stored === null ? setTimeout(() => setVisible(true), 1200) : undefined; + return () => { + window.removeEventListener('flexfoil:analytics-preferences', show); + clearTimeout(timer); + }; }, []); const handleAccept = useCallback(() => { @@ -28,8 +31,9 @@ export function CookieConsent() {

- We use cookies for anonymous usage analytics to improve this tool. - No personal data is collected or shared with advertisers.{' '} + With your permission, Google Analytics measures feature use and approximate + location to help us improve Flexfoil. We do not send airfoil geometry, + feedback text, or contact details as analytics events.{' '} ); } - -/** - * Manage consent after initial choice -- call from a settings menu to let - * users change their mind (GDPR right to withdraw). - */ -export function useCookieConsent() { - const [consent, setConsent] = useState(getStoredConsent); - - const grant = useCallback(() => { - updateGtagConsent('granted'); - setConsent('granted'); - }, []); - - const revoke = useCallback(() => { - updateGtagConsent('denied'); - setConsent('denied'); - }, []); - - return { consent, grant, revoke }; -} diff --git a/flexfoil-ui/src/components/DockingLayout.tsx b/flexfoil-ui/src/components/DockingLayout.tsx index 84f7cde1..c558a47b 100644 --- a/flexfoil-ui/src/components/DockingLayout.tsx +++ b/flexfoil-ui/src/components/DockingLayout.tsx @@ -3,7 +3,7 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Layout, Model, Actions, DockLocation, type IJsonModel } from 'flexlayout-react'; +import { Layout, Model, Actions, DockLocation, TabNode, type IJsonModel } from 'flexlayout-react'; // Panel components import { AirfoilCanvas } from './AirfoilCanvas'; @@ -24,9 +24,10 @@ import { FeedbackWidget } from './FeedbackWidget'; import { SolverStatusIndicator } from './SolverStatusBar'; import { MobileLayout } from './MobileLayout'; import { LayoutProvider } from '../contexts/LayoutContext'; -import { defaultLayoutJson, PANELS } from '../layoutConfig'; +import { defaultLayoutJson, PANELS, isPanelId } from '../layoutConfig'; import { useRouteUiStore } from '../stores/routeUiStore'; import { useIsMobile } from '../hooks/useIsMobile'; +import { trackEvent } from '../lib/analytics'; // Storage keys const LAYOUT_STORAGE_KEY = 'flexfoil-layout-v4'; @@ -444,7 +445,14 @@ function DesktopLayout({ wasmStatus }: DockingLayoutProps) { ref={layoutRef} model={model} factory={factory} - onModelChange={handleModelChange} + onModelChange={(nextModel, action) => { + handleModelChange(nextModel); + if (action.type === Actions.SELECT_TAB) { + const node = nextModel.getNodeById(action.data.tabNode); + const panel = node instanceof TabNode ? node.getComponent() : undefined; + if (isPanelId(panel)) trackEvent('feature_use', { feature: 'panel_select', panel_id: panel }); + } + }} onRenderTabSet={onRenderTabSet} />

diff --git a/flexfoil-ui/src/components/FeedbackWidget.tsx b/flexfoil-ui/src/components/FeedbackWidget.tsx index e6c80c29..be3a4c93 100644 --- a/flexfoil-ui/src/components/FeedbackWidget.tsx +++ b/flexfoil-ui/src/components/FeedbackWidget.tsx @@ -1,16 +1,19 @@ import { useCallback, useRef, useState } from 'react'; +import { trackEvent } from '../lib/analytics'; + +const REQUEST_TRACKER_URL = 'https://github.com/flexcompute/flexfoil/issues?q=is%3Aissue+label%3Aenhancement'; const GOOGLE_SHEET_URL: string | undefined = import.meta.env.VITE_FEEDBACK_SHEET_URL; type FeedbackType = 'bug' | 'feature' | 'general'; -const TYPE_LABELS: Record = { - bug: { label: 'Bug', icon: '🐛' }, - feature: { label: 'Feature', icon: '💡' }, - general: { label: 'General', icon: '💬' }, +const TYPE_LABELS: Record = { + bug: { label: 'Bug', icon: '🐛', template: 'bug-report.md' }, + feature: { label: 'Feature', icon: '💡', template: 'feature-request.md' }, + general: { label: 'General', icon: '💬', template: 'question.md' }, }; -type SubmitState = 'idle' | 'sending' | 'success' | 'error'; +type SubmitState = 'idle' | 'sending' | 'success' | 'github' | 'error'; export function FeedbackWidget() { const [open, setOpen] = useState(false); @@ -30,6 +33,7 @@ export function FeedbackWidget() { const handleClose = useCallback(() => { setOpen(false); if (submitState === 'success') reset(); + else if (submitState === 'github') setSubmitState('idle'); }, [submitState, reset]); const handleSubmit = useCallback( @@ -37,6 +41,18 @@ export function FeedbackWidget() { e.preventDefault(); if (!message.trim()) return; + if (!GOOGLE_SHEET_URL) { + const params = new URLSearchParams({ + title: message.trim().split('\n')[0].slice(0, 80), + body: message.trim(), + template: TYPE_LABELS[type].template, + }); + window.open(`https://github.com/flexcompute/flexfoil/issues/new?${params}`, '_blank', 'noopener,noreferrer'); + trackEvent('feedback_handoff', { feedback_type: type }); + setSubmitState('github'); + return; + } + setSubmitState('sending'); const payload = { @@ -44,24 +60,20 @@ export function FeedbackWidget() { message: message.trim(), contact: contact.trim() || undefined, timestamp: new Date().toISOString(), - url: window.location.href, + url: window.location.origin + window.location.pathname, userAgent: navigator.userAgent, }; - if (!GOOGLE_SHEET_URL) { - console.info('[Feedback] No VITE_FEEDBACK_SHEET_URL configured. Payload:', payload); - setSubmitState('success'); - return; - } - try { await fetch(GOOGLE_SHEET_URL, { method: 'POST', + // ponytail: opaque responses cannot confirm persistence; use a CORS-aware service for receipts. mode: 'no-cors', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); setSubmitState('success'); + trackEvent('feedback_sent', { feedback_type: type }); } catch (err) { console.error('Feedback submission failed:', err); setSubmitState('error'); @@ -77,6 +89,8 @@ export function FeedbackWidget() { onClick={() => { setOpen((prev) => !prev); if (submitState === 'success') reset(); + else if (submitState === 'github') setSubmitState('idle'); + if (!open) trackEvent('feature_use', { feature: 'feedback_open' }); }} aria-label="Send feedback" title="Send feedback" @@ -90,16 +104,16 @@ export function FeedbackWidget() { strokeLinejoin="round" /> - Feedback + Feedback & requests {open && (
- {submitState === 'success' ? ( + {submitState === 'success' || submitState === 'github' ? (
✓ -

Thanks for your feedback!

-

We read every submission.

+

{submitState === 'github' ? 'Finish your submission on GitHub' : 'Feedback sent'}

+

{submitState === 'github' ? 'Submit your draft on GitHub to add it to the tracker. Opening a draft does not submit it.' : 'The request was sent to our feedback service; delivery cannot be confirmed here.'}

@@ -107,7 +121,7 @@ export function FeedbackWidget() { ) : (
- Send Feedback + Feedback & feature requests
@@ -116,6 +130,8 @@ export function FeedbackWidget() { key={t} type="button" className={`feedback-panel__type-btn${type === t ? ' feedback-panel__type-btn--active' : ''}`} + aria-label={TYPE_LABELS[t].label} + aria-pressed={type === t} onClick={() => setType(t)} > {TYPE_LABELS[t].icon} @@ -126,6 +142,7 @@ export function FeedbackWidget() {