(widgets) import widgets code from Messages and setup Docker workflow (#33)

This adds Gaufre v2 with source, documentation, examples and built artefacts.
Also includes the feedback widget from Messages.
This commit is contained in:
Sylvain Zimmer
2025-11-19 15:18:21 +01:00
committed by GitHub
parent 3b2f083d3f
commit 720ee9f4f0
53 changed files with 5375 additions and 4 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# build output
dist/
!website/public/widgets/dist
# content generated by build scripts
website/public/api/backgrounds/v1/*
@@ -24,6 +25,7 @@ pnpm-debug.log*
# environment variables
.env
.env.production
.aws
# macOS-specific files
.DS_Store

240
Makefile Normal file
View File

@@ -0,0 +1,240 @@
# /!\ /!\ /!\ /!\ /!\ /!\ /!\ DISCLAIMER /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\
#
# This Makefile is only meant to be used for DEVELOPMENT purpose as we are
# changing the user id that will run in the container.
#
# PLEASE DO NOT USE IT FOR YOUR CI/PRODUCTION/WHATEVER...
#
# /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\
#
# Note to developers:
#
# While editing this file, please respect the following statements:
#
# 1. Every variable should be defined in the ad hoc VARIABLES section with a
# relevant subsection
# 2. Every new rule should be defined in the ad hoc RULES section with a
# relevant subsection depending on the targeted service
# 3. Rules should be sorted alphabetically within their section
# 4. When a rule has multiple dependencies, you should:
# - duplicate the rule name to add the help string (if required)
# - write one dependency per line to increase readability and diffs
# 5. .PHONY rule statement should be written after the corresponding rule
# ==============================================================================
# VARIABLES
BOLD := \033[1m
RESET := \033[0m
GREEN := \033[1;32m
# -- Docker
# Get the current user ID to use for docker run and docker exec commands
DOCKER_UID = $(shell id -u)
DOCKER_GID = $(shell id -g)
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_RUN = $(COMPOSE) run --rm --build
# ==============================================================================
# RULES
default: help
# -- Project
create-env-files: ## Create empty .local env files for local development
create-env-files: \
ops/env/widgets.local \
ops/env/website.local
.PHONY: create-env-files
bootstrap: ## Prepare the project for local development and start the services
@echo "$(BOLD)"
@echo "╔══════════════════════════════════════════════════════════════════════════════╗"
@echo "║ ║"
@echo "║ 🚀 Welcome to Integration - Shared frontend packages from La Suite! 🚀 ║"
@echo "║ ║"
@echo "║ This will set up your development environment with : ║"
@echo "║ • Docker containers for all services ║"
@echo "║ • Frontend dependencies and build ║"
@echo "║ • Environment configuration files ║"
@echo "║ ║"
@echo "║ Services will be available at: ║"
@echo "║ • Website: http://localhost:8930 ║"
@echo "║ • Widgets: http://localhost:8931 ║"
@echo "║ ║"
@echo "╚══════════════════════════════════════════════════════════════════════════════╝"
@echo "$(RESET)"
@echo "$(GREEN)Starting bootstrap process...$(RESET)"
@echo ""
@$(MAKE) update
@$(MAKE) start
@echo ""
@echo "$(GREEN)🎉 Bootstrap completed successfully!$(RESET)"
@echo ""
@echo "$(BOLD)Next steps:$(RESET)"
@echo " • Visit http://localhost:8930 to access the website"
@echo " • Visit http://localhost:8931 to access the widgets"
@echo " • Run 'make help' to see all available commands"
@echo ""
.PHONY: bootstrap
update: ## Update the project with latest changes
@$(MAKE) create-env-files
@$(MAKE) build
@$(MAKE) widgets-install
@$(MAKE) website-install
.PHONY: update
# -- Docker/compose
build: ## build the project containers
@$(COMPOSE) build
.PHONY: build
down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down
.PHONY: down
logs: ## display all services logs (follow mode)
@$(COMPOSE) logs -f
.PHONY: logs
start: ## start all development services
@$(COMPOSE) up --force-recreate --build -d website-dev widgets-dev --wait
.PHONY: start
status: ## an alias for "docker compose ps"
@$(COMPOSE) ps
.PHONY: status
stop: ## stop all development services
@$(COMPOSE) --profile "*" stop
.PHONY: stop
restart: ## restart all development services
restart: \
stop \
start
.PHONY: restart
# -- Misc
clean: ## restore repository state as it was freshly cloned
git clean -idx
.PHONY: clean
help:
@echo "$(BOLD)messages Makefile"
@echo "Please use 'make $(BOLD)target$(RESET)' where $(BOLD)target$(RESET) is one of:"
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
.PHONY: help
ops/env/%.local:
@echo "# Local development overrides for $(notdir $*)" > $@
@echo "# Add your local-specific environment variables below:" >> $@
@echo "# Example: DJANGO_DEBUG=True" >> $@
@echo "" >> $@
lint: ## run all linters
lint: \
widgets-lint
.PHONY: lint
# Website
website-install: ## install the website locally
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
$(COMPOSE) run --build --rm website-dev npm install $${args:-${1}}
.PHONY: website-install
website-freeze-deps: ## freeze the website dependencies
rm -rf website/package-lock.json
@$(MAKE) website-install
.PHONY: website-freeze-deps
website-shell: ## open a shell in the website container
$(COMPOSE) run --build --rm -p 8930:8930 website-dev /bin/sh
.PHONY: website-shell
website-start: ## start the website container
$(COMPOSE) up --force-recreate --build -d website-dev --wait
@sleep 2
@echo "$(BOLD)"
@echo "╔══════════════════════════════════════════════════════════════════════════════╗"
@echo "║ ║"
@echo "║ 🚀 Website development server with Live Reload is started! 🚀 ║"
@echo "║ ║"
@echo "║ Open your browser at http://localhost:8930 ║"
@echo "║ ║"
@echo "╚══════════════════════════════════════════════════════════════════════════════╝"
@echo "$(RESET)"
.PHONY: website-start
website-stop: ## stop the website container
$(COMPOSE) stop website-dev
.PHONY: website-stop
website-restart: ## restart the website container and rebuild
website-restart: \
website-stop \
website-start
.PHONY: website-restart
# Widgets
widgets-install: ## install the widgets locally
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
$(COMPOSE) run --build --rm widgets-dev npm install $${args:-${1}}
.PHONY: widgets-install
widgets-freeze-deps: ## freeze the widgets dependencies
rm -rf src/widgets/package-lock.json
@$(MAKE) widgets-install
.PHONY: widgets-freeze-deps
widgets-build: ## build the widgets
$(COMPOSE) run --build --rm widgets-dev npm run build
.PHONY: widgets-build
widgets-lint: ## lint the widgets
$(COMPOSE) run --build --rm widgets-dev npm run lint
.PHONY: widgets-lint
widgets-shell: ## open a shell in the widgets container
$(COMPOSE) run --build --rm widgets-dev /bin/sh
.PHONY: widgets-shell
widgets-start: ## start the widgets container
$(COMPOSE) up --force-recreate --build -d widgets-dev --wait
@echo "$(BOLD)"
@echo "╔══════════════════════════════════════════════════════════════════════════════╗"
@echo "║ ║"
@echo "║ 🚀 Widgets development server with Live Reload is started! 🚀 ║"
@echo "║ ║"
@echo "║ Open your browser at http://localhost:8931 ║"
@echo "║ ║"
@echo "╚══════════════════════════════════════════════════════════════════════════════╝"
@echo "$(RESET)"
.PHONY: widgets-start
widgets-stop: ## stop the widgets container
$(COMPOSE) stop widgets-dev
.PHONY: widgets-stop
widgets-restart: ## restart the widgets container and rebuild
widgets-restart: \
widgets-stop \
widgets-build \
widgets-start
.PHONY: widgets-restart
widgets-deploy: ## deploy the widgets to an S3 bucket
@## Error if the env vars WIDGETS_S3_PATH is not set
@if [ -z "$$WIDGETS_S3_PATH" ]; then \
echo "Error: WIDGETS_S3_PATH is not set"; \
exit 1; \
fi; \
docker run --rm -ti -v .aws:/root/.aws -v `pwd`/website/public/widgets/dist:/aws amazon/aws-cli s3 cp --acl public-read --recursive . s3://$(WIDGETS_S3_PATH)
.PHONY: widgets-deploy

View File

@@ -3,8 +3,61 @@
Repo containing code of:
- [@gouvfr-lasuite/integration npm package](https://www.npmjs.com/package/@gouvfr-lasuite/integration) in [`packages/integration`](./packages/integration/)
- Frontend widgets in [`packages/widgets`](./packages/widgets/)
- [La Suite: integration docs and API](https://integration.lasuite.numerique.gouv.fr/) in [`website`](./website/)
## Local development
After checking out the repository, run:
```
$ make bootstrap
```
For both the website and the widgets projects, it will install dependencies, build, and start the development server, respectively at http://localhost:8930 and http://localhost:8931.
To see dev server outputs, run:
```
$ make logs
```
You can view all available commands with:
```
$ make help
```
### Developing the website
If you want a faster startup than `make bootstrap`, you can run more focused commands. For the website:
```
$ make website-start
```
This will start the development server at [http://localhost:8930](http://localhost:8930).
### Developing Widgets
We currently develop some embeddable widgets in the `packages/widgets` directory in this repository.
```
$ make widgets-start
```
This will start the development server at [http://localhost:8931](http://localhost:8931).
You can then build them with:
```
$ make widgets-build
```
And deploy them to an S3 bucket, with `.aws/{config|credentials}` files in the root of the repository.
```
$ WIDGETS_S3_PATH=xxx make widgets-deploy
```
## Licenses
Source code is released under the [MIT](LICENSES/MIT.txt) and other

34
compose.yaml Normal file
View File

@@ -0,0 +1,34 @@
name: integration
services:
website-dev:
user: "${DOCKER_USER:-1000}"
build:
context: ./website
dockerfile: Dockerfile
env_file:
- ops/env/website.defaults
- ops/env/website.local
command: ["npm", "run", "dev"]
volumes:
- ./website/:/home/website/
ports:
- "8930:8930"
widgets-dev:
user: "${DOCKER_USER:-1000}"
build:
context: ./packages/widgets
dockerfile: Dockerfile
env_file:
- ops/env/widgets.defaults
- ops/env/widgets.local
command: ["npm", "run", "dev"]
volumes:
- ./packages/widgets/:/home/widgets/
- ./website/:/home/website/
- ./website/src/pages/widgets-demo/:/home/widgets/widgets-demo/
- ./website/public/widgets/:/home/widgets/widgets/
ports:
- "8931:8931"

View File

@@ -1,2 +1,3 @@
PUBLIC_LASUITE_API_URL=https://integration.lasuite.numerique.gouv.fr
PUBLIC_USE_GAUFRE_SUBSETTED_FONT=1
ASTRO_TELEMETRY_DISABLED=1

4
ops/env/website.local vendored Normal file
View File

@@ -0,0 +1,4 @@
# Local development overrides for website
# Add your local-specific environment variables below:
# Example: DJANGO_DEBUG=True

1
ops/env/widgets.defaults vendored Normal file
View File

@@ -0,0 +1 @@
WIDGETS_OUTPUT_DIR=/home/website/public/widgets/

4
ops/env/widgets.local vendored Normal file
View File

@@ -0,0 +1,4 @@
# Local development overrides for widgets
# Add your local-specific environment variables below:
# Example: DJANGO_DEBUG=True

View File

@@ -27,7 +27,7 @@ This folder is meant to generate the `@gouvfr-lasuite/integration` npm package.
It's a vite app.
To start, `npm install` a first time and copy the example env file: `cp .env.example .env`. Make sure the API env var targets a running API. If you don't want to use the production one, you can run one locally easily: the API is exposed via the `/website` server, go check the README there.
To start, `npm install` a first time. Make sure the API env var targets a running API. If you don't want to use the production one, you can run one locally easily: the API is exposed via the `/website` server, go check the README there.
Then, run the local dev server with `npm run dev`.

View File

@@ -0,0 +1,10 @@
FROM node:22-slim AS widgets-deps
WORKDIR /home/widgets/
RUN npm install -g npm@11.3.0 && npm cache clean -f
ARG DOCKER_USER
USER ${DOCKER_USER}
ENV npm_config_cache=/tmp/npm-cache

31
packages/widgets/build.js Normal file
View File

@@ -0,0 +1,31 @@
import { build } from 'vite'
import { readdirSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const widgetsDir = join(__dirname, 'src', 'widgets')
function discoverWidgets() {
return readdirSync(widgetsDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name)
}
// Run an independent build for each widget
for (const widget of discoverWidgets()) {
await build({
build: {
emptyOutDir: false,
outDir: join(process.env.WIDGETS_OUTPUT_DIR || "", "dist"),
rollupOptions: {
input: join(widgetsDir, widget, 'main.ts'),
output: {
entryFileNames: widget + '.js',
format: 'iife'
}
}
},
})
}

View File

@@ -0,0 +1,10 @@
// @ts-check
import eslint from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
export default defineConfig(
eslint.configs.recommended,
tseslint.configs.recommended,
);

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Widgets demo</title>
</head>
<body>
<h1>Widgets demo</h1>
<ul>
<li><a href="widgets-demo/feedback.html">Feedback</a></li>
<li><a href="widgets-demo/loader.html">Loader</a></li>
<li><a href="widgets-demo/lagaufre.html">La Gaufre</a></li>
</ul>
</body>
</html>

2732
packages/widgets/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
{
"name": "@gouvfr-lasuite/widgets",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 8931",
"build": "tsc && node build.js",
"preview": "vite preview --host 0.0.0.0 --port 8931",
"lint": "prettier --write --print-width=120 src && eslint src && tsc --noEmit"
},
"devDependencies": {
"typescript": "5.9.2",
"vite": "7.1.7",
"eslint": "9.36.0",
"typescript-eslint": "8.45.0",
"prettier": "3.6.2"
}
}

View File

@@ -0,0 +1,31 @@
/* eslint @typescript-eslint/no-explicit-any:0 */
const NAMESPACE = `lasuite-widget`;
export const triggerEvent = (
widgetName: string,
eventName: string,
detail?: Record<string, any>,
root?: Window | Document | HTMLElement | null,
) => {
return (root || document).dispatchEvent(
new CustomEvent(`${NAMESPACE}-${widgetName}-${eventName}`, detail ? { detail } : undefined),
);
};
export const listenEvent = (
widgetName: string,
eventName: string,
root: Window | Document | HTMLElement | null,
once: boolean,
callback: (data: any) => void,
) => {
const cb = (e: any) => callback(e.detail);
const eventFullName = widgetName ? `${NAMESPACE}-${widgetName}-${eventName}` : eventName;
(root || document).addEventListener(eventFullName, cb, once ? { once: true } : undefined);
return () =>
(root || document).removeEventListener(
eventFullName,
cb,
once ? ({ once: true } as EventListenerOptions) : undefined,
);
};

View File

@@ -0,0 +1,43 @@
const isVisible = (element: HTMLElement) => {
return element.offsetWidth > 0 && element.offsetHeight > 0;
};
export const trapFocus = (shadowRoot: ShadowRoot, container: HTMLElement, selector: string) => {
const handleKeydown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const focusable = Array.from(container.querySelectorAll(selector)).filter((element) =>
isVisible(element as HTMLElement),
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && shadowRoot.activeElement === first) {
e.preventDefault();
(last as HTMLElement).focus();
} else if (!e.shiftKey && shadowRoot.activeElement === last) {
e.preventDefault();
(first as HTMLElement).focus();
}
};
container.addEventListener("keydown", handleKeydown);
return () => {
container.removeEventListener("keydown", handleKeydown);
};
};
export const trapEscape = (cb: () => void) => {
const handleKeydown = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.preventDefault();
cb();
};
document.addEventListener("keydown", handleKeydown);
return () => {
document.removeEventListener("keydown", handleKeydown);
};
};

View File

@@ -0,0 +1,70 @@
import { triggerEvent } from "./events";
type WidgetEvent = [string, string, Record<string, unknown> | undefined];
type EventArray = Array<WidgetEvent> & { _loaded?: Record<string, number> };
declare global {
var _lasuite_widget: EventArray;
}
// This could have been an enum but we want to support erasableSyntaxOnly TS settings
export const STATE_NOT_LOADED = 0;
export const STATE_LOADING = 1;
export const STATE_LOADED = 2;
export const getLoaded = (widgetName: string) => {
return window._lasuite_widget?._loaded?.[widgetName];
};
export const setLoaded = (widgetName: string, status: number) => {
if (!window._lasuite_widget?._loaded) return;
window._lasuite_widget._loaded[widgetName] = status;
};
// Replace the push method of the _lasuite_widget array used for communication between the widget and the page
export const installHook = (widgetName: string) => {
if (!window._lasuite_widget) {
window._lasuite_widget = [] as EventArray;
}
const W = window._lasuite_widget;
// Keep track of the loaded state of each widget
if (!W._loaded) {
W._loaded = {} as Record<string, number>;
}
if (getLoaded(widgetName) !== STATE_LOADED) {
// Replace the push method of the _lasuite_widget array used for communication between the widget and the page
W.push = ((...elts: WidgetEvent[]): number => {
for (const elt of elts) {
// If the target widget is loaded, fire the event
if (getLoaded(elt[0]) === STATE_LOADED) {
triggerEvent(elt[0], elt[1], elt[2]);
} else {
W[W.length] = elt;
}
}
return W.length;
}) as typeof Array.prototype.push;
setLoaded(widgetName, STATE_LOADED);
// Empty the existing array and re-push all events that were received before the hook was installed
for (const evt of W.splice(0, W.length)) {
W.push(evt);
}
}
// Finally, fire an event to signal that we are loaded
triggerEvent(widgetName, "loaded");
};
// Loads another widget from the same directory
export const injectScript = (url: string, type: string = "") => {
const newScript = document.createElement("script");
newScript.src = url;
newScript.type = type;
newScript.defer = true;
document.body.appendChild(newScript);
};

View File

@@ -0,0 +1,30 @@
// Shared utility for creating shadow DOM widgets
export function createShadowWidget(widgetName: string, htmlContent: string, cssContent: string): HTMLDivElement {
const id = `lasuite-widget-${widgetName}-shadow`;
// Check if widget already exists
const existingWidget = document.getElementById(id);
if (existingWidget) {
existingWidget.remove();
}
// Create container element
const container = document.createElement("div");
container.id = id;
// Create shadow root
const shadow = container.attachShadow({ mode: "open" });
// Create style element for scoped CSS
const style = document.createElement("style");
style.textContent = cssContent;
// Create content element
const content = document.createElement("div");
content.innerHTML = htmlContent;
// Append style and content to shadow DOM
shadow.appendChild(style);
shadow.appendChild(content);
return container;
}

1
packages/widgets/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,204 @@
import styles from "./styles.css?inline";
import { createShadowWidget } from "../../shared/shadow-dom";
import { installHook } from "../../shared/script";
import { listenEvent, triggerEvent } from "../../shared/events";
import { trapFocus, trapEscape } from "../../shared/focus";
const widgetName = "feedback";
type ConfigData = {
title?: string;
placeholder?: string;
emailPlaceholder?: string;
submitText?: string;
successText?: string;
successText2?: string;
closeLabel?: string;
submitUrl?: string;
};
type ConfigResponse = {
success?: boolean;
detail?: string;
captcha?: boolean;
config?: ConfigData;
};
type FeedbackWidgetArgs = {
title?: string;
placeholder?: string;
emailPlaceholder?: string;
submitText?: string;
successText?: string;
successText2?: string;
closeLabel?: string;
submitUrl?: string;
api: string;
channel: string;
email?: string;
bottomOffset?: number;
rightOffset?: number;
};
listenEvent(widgetName, "init", null, false, async (args: FeedbackWidgetArgs) => {
if (!args.api || !args.channel) {
console.error("Feedback widget requires an API URL and a channel ID");
return;
}
let configData: ConfigData | undefined;
try {
const config = await fetch(`${args.api}config/`, {
headers: {
"X-Channel-ID": args.channel,
},
});
const configResponse = (await config.json()) as ConfigResponse;
if (!configResponse.success) throw new Error(configResponse.detail || "Unknown error");
if (configResponse.captcha) throw new Error("Captcha is not supported yet");
configData = configResponse.config;
} catch (error) {
console.error("Error fetching config", error);
triggerEvent(widgetName, "closed");
return;
}
const title = args.title || configData?.title || "Feedback";
const placeholder = args.placeholder || configData?.placeholder || "Share your feedback...";
const emailPlaceholder = args.emailPlaceholder || configData?.emailPlaceholder || "Your email...";
const submitText = args.submitText || configData?.submitText || "Send Feedback";
const successText = args.successText || configData?.successText || "Thank you for your feedback!";
const successText2 = args.successText2 || configData?.successText2 || "";
const closeLabel = args.closeLabel || configData?.closeLabel || "Close the feedback widget";
/* prettier-ignore */
const htmlContent =
`<div id="wrapper">` +
`<div id="header">` +
`<h6 id="title"></h6>` +
`<button id="close">&times;</button>` +
`</div>` +
`<div id="content">` +
`<form>` +
`<textarea id="feedback-text" autocomplete="off" required></textarea>` +
`<input type="email" id="email" autocomplete="email" required>` +
`<button type="submit" id="submit"></button>` +
`</form>` +
`<div id="error" aria-live="polite" role="status"></div>` +
`<div aria-live="polite" role="status" id="success">` +
`<i aria-hidden="true">✔</i>` +
`<p id="success-text"></p>` +
`<p id="success-text2"></p>` +
`</div>` +
`</div>` +
`</div>`;
// Create shadow DOM widget
const shadowContainer = createShadowWidget(widgetName, htmlContent, styles);
const shadowRoot = shadowContainer.shadowRoot!;
const $ = shadowRoot.querySelector.bind(shadowRoot);
const wrapper = $<HTMLDivElement>("#wrapper")!;
const titleSpan = $<HTMLHeadingElement>("#title")!;
const submitBtn = $<HTMLButtonElement>("#submit")!;
const feedbackText = $<HTMLTextAreaElement>("#feedback-text")!;
const errorDiv = $<HTMLDivElement>("#error")!;
const closeBtn = $<HTMLButtonElement>("#close")!;
const emailInput = $<HTMLInputElement>("#email")!;
const form = $<HTMLFormElement>("form")!;
const successDiv = $<HTMLDivElement>("#success")!;
const successTextP = $<HTMLParagraphElement>("#success-text")!;
const successText2P = $<HTMLParagraphElement>("#success-text2")!;
wrapper.style.bottom = 20 + (args.bottomOffset || 0) + "px";
wrapper.style.right = 20 + (args.rightOffset || 0) + "px";
titleSpan.textContent = title;
feedbackText.placeholder = placeholder;
emailInput.placeholder = emailPlaceholder;
submitBtn.textContent = submitText;
closeBtn.setAttribute("aria-label", closeLabel);
if (args.email) {
emailInput.remove();
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorDiv.textContent = "";
const message = feedbackText.value.trim();
const email = args.email || emailInput.value.trim();
try {
if (!message) {
feedbackText.focus();
throw new Error("Missing value");
}
if (!email) {
emailInput.focus();
throw new Error("Missing value");
}
const ret = await fetch(configData?.submitUrl || `${args.api}deliver/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Channel-ID": args.channel,
},
body: JSON.stringify({ textBody: message, email }),
});
let retData;
try {
retData = await ret.json();
} catch {
throw new Error("Invalid response from server");
}
if (!retData.success) throw new Error(retData.detail || "Unknown error");
form.remove();
successDiv.style.display = "flex";
requestAnimationFrame(() => {
// This RAF call allows screen readers to register the aria-live area
successTextP.textContent = successText;
successText2P.textContent = successText2;
});
} catch (error) {
errorDiv.style.display = "block";
errorDiv.textContent = "⚠ " + (error instanceof Error ? error.message : "Unknown error");
}
});
let untrapFocus: (() => void) | null = null;
let untrapEscape: (() => void) | null = null;
let removeCloseListener: () => void = () => {};
const closeWidget = () => {
shadowRoot.host.remove();
if (untrapFocus) {
untrapFocus();
}
if (untrapEscape) {
untrapEscape();
}
if (removeCloseListener) {
removeCloseListener();
}
triggerEvent(widgetName, "closed");
};
closeBtn.addEventListener("click", closeWidget);
removeCloseListener = listenEvent(widgetName, "close", null, false, closeWidget);
document.body.appendChild(shadowContainer);
feedbackText.focus();
untrapFocus = trapFocus(shadowRoot, wrapper, "textarea,input,button");
untrapEscape = trapEscape(() => {
triggerEvent(widgetName, "close");
});
triggerEvent(widgetName, "opened");
});
installHook(widgetName);

View File

@@ -0,0 +1,158 @@
/* Widget styles */
#wrapper {
position: fixed;
z-index: 1000;
width: 350px;
height: 400px;
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
}
#header {
background: #000091;
color: white;
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 12px 12px 0 0;
}
h6 {
padding: 16px;
font-weight: 600;
font-size: 16px;
margin: 0;
}
#close {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 18px;
margin-right: 12px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
}
#content {
display: flex;
flex: 1;
flex-direction: column;
}
form {
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
#email {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 12px;
font-family: inherit;
font-size: 14px;
}
#feedback-text {
flex: 1;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 12px;
font-family: inherit;
font-size: 14px;
resize: none;
}
#submit {
background: #000091;
color: white;
border: 2px solid #000091;
border-radius: 8px;
padding: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
#submit:hover,
#close:hover {
background: #1212ff;
}
#feedback-text:focus,
#email:focus,
#submit:focus {
outline: 2px solid #0a76f6;
border-color: white;
}
#close:focus {
outline: 2px solid white;
}
#status {
font-size: 14px;
font-weight: 500;
text-align: center;
}
#success {
display: none;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
}
#success i {
background: #27a658;
color: white;
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: bold;
margin-bottom: 20px;
font-style: normal;
}
#success p {
color: #0f4e27;
margin: 0;
font-size: 16px;
font-weight: 500;
margin-bottom: 30px;
}
#error {
color: #dc3545;
margin-bottom: 12px;
}
#error:empty {
margin: 0;
}
/* Responsive */
@media (max-width: 420px) {
#wrapper {
width: 100%;
right: 0px !important;
border-radius: 0;
}
}

View File

@@ -0,0 +1,367 @@
import styles from "./styles.css?inline";
import { createShadowWidget } from "../../shared/shadow-dom";
import { installHook } from "../../shared/script";
import { listenEvent, triggerEvent } from "../../shared/events";
import { trapFocus, trapEscape } from "../../shared/focus";
const widgetName = "lagaufre";
type Service = {
name: string;
url: string;
maturity?: string;
logo?: string;
};
type Organization = {
name: string;
type: string;
siret: string;
};
type ServicesResponse = {
organization?: Organization;
services: Service[];
error?: unknown;
};
type GaufreWidgetArgs = {
api?: string;
position?: string | (() => Record<string, number | string>);
top?: number;
bottom?: number;
left?: number;
right?: number;
data?: ServicesResponse;
fontFamily?: string;
background?: string;
headerLogo?: string;
headerUrl?: string;
open?: boolean;
label?: string;
closeLabel?: string;
headerLabel?: string;
loadingText?: string;
newWindowLabelSuffix?: string;
showFooter?: boolean;
dialogElement?: HTMLElement;
buttonElement?: HTMLElement;
};
let loaded = false;
// Initialize widget (load data and prepare shadow DOM)
listenEvent(widgetName, "init", null, false, async (args: GaufreWidgetArgs) => {
if (!args.api && !args.data) {
console.error("Missing API URL");
return;
}
if (loaded) {
triggerEvent(widgetName, "destroy");
await new Promise((resolve) => setTimeout(resolve, 10));
}
const listeners: (() => void)[] = [];
let isVisible = false;
// https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
/* prettier-ignore */
const htmlContent =
`<div id="wrapper" role="dialog" aria-modal="true" tabindex="-1">` +
((args.headerLogo && args.headerUrl) ? (
`<div id="header">` +
`<a href="${args.headerUrl}" target="_blank">` +
`<img src="${args.headerLogo}" id="header-logo">` +
`</a>` +
`<button type="button" id="close">&times;</button>` +
`</div>`
) : "") +
`<div id="content">` +
`<div id="loading">Loading...</div>` +
`<ul role="list" id="services-grid" style="display: none;"></ul>` +
`<div id="error" style="display: none;"></div>` +
`</div>` +
(args.showFooter ? `<div id="footer">` +
`<button id="ok-button">OK</button>` +
`</div>` : "") +
`</div>`;
// Create shadow DOM widget
const shadowContainer = createShadowWidget(widgetName, htmlContent, styles);
const shadowRoot = shadowContainer.shadowRoot!;
const wrapper = shadowRoot.querySelector<HTMLDivElement>("#wrapper")!;
const loadingDiv = shadowRoot.querySelector<HTMLDivElement>("#loading")!;
const servicesGrid = shadowRoot.querySelector<HTMLDivElement>("#services-grid")!;
const errorDiv = shadowRoot.querySelector<HTMLDivElement>("#error")!;
const closeBtn = shadowRoot.querySelector<HTMLButtonElement>("#close");
const okBtn = shadowRoot.querySelector<HTMLButtonElement>("#ok-button")!;
const headerLogo = shadowRoot.querySelector<HTMLImageElement>("#header-logo");
// Configure dynamic properties
const configure = (newArgs: GaufreWidgetArgs) => {
const directions = ["top", "bottom", "left", "right"] as const;
const applyPos = (obj: Record<string, number | string>) => {
directions.forEach((prop) => {
wrapper.style[prop] = typeof obj[prop] === "number" ? `${obj[prop]}px` : "unset";
});
};
if (!directions.every((d) => newArgs[d] === undefined)) {
applyPos(newArgs as Record<string, number | string>);
}
// Positioning parameters
if (newArgs.position) {
if (typeof newArgs.position === "function") {
const pos = newArgs.position();
wrapper.style.position = pos.position as string;
applyPos(pos);
} else {
wrapper.style.position = newArgs.position;
}
}
// Apply font family (inherit from parent or use provided)
if (newArgs.fontFamily) {
wrapper.style.fontFamily = newArgs.fontFamily;
}
// Apply background gradient if requested
if (newArgs.background) {
wrapper.style.background = newArgs.background;
}
// Apply texts
const label = newArgs.label || "Services";
const closeLabel = newArgs.closeLabel || "Close";
loadingDiv.textContent = newArgs.loadingText || "Loading…";
wrapper.setAttribute("aria-label", label);
if (closeBtn) {
closeBtn.setAttribute("aria-label", closeLabel);
}
if (headerLogo) {
headerLogo.alt = (newArgs.headerLabel || "About LaSuite") + (newArgs.newWindowLabelSuffix || "");
}
};
configure(args);
listeners.push(
listenEvent("", "resize", window, false, () => {
configure(args);
}),
);
// Initially hide the widget
wrapper.style.display = "none";
const showError = (message: string) => {
loadingDiv.style.display = "none";
servicesGrid.style.display = "none";
errorDiv.style.display = "block";
errorDiv.textContent = message;
};
const renderServices = (data: ServicesResponse) => {
// Clear previous content
servicesGrid.innerHTML = "";
data.services.forEach((service) => {
if (!service.logo) return;
if (service.maturity == "stable") delete service.maturity;
const serviceCard = document.createElement("li");
serviceCard.className = `service-card`;
/* prettier-ignore */
serviceCard.innerHTML =
`<a target="_blank">` +
`<img alt="" class="service-logo" onerror="this.style.display='none'">` +
`<div class="service-info">` +
`<div class="service-name"></div>` +
`</div>` +
`</a>`;
const anchor = serviceCard.querySelector<HTMLAnchorElement>("a")!;
const img = serviceCard.querySelector<HTMLImageElement>("img")!;
const serviceName = serviceCard.querySelector<HTMLDivElement>(".service-name")!;
if (service.maturity) {
const maturityBadge = document.createElement("div");
maturityBadge.className = "maturity-badge";
maturityBadge.textContent = service.maturity;
anchor.insertBefore(maturityBadge, img.nextSibling);
}
anchor.setAttribute(
"aria-label",
service.name + (service.maturity ? ` (${service.maturity})` : "") + (args.newWindowLabelSuffix || ""),
);
anchor.href = service.url;
img.src = service.logo;
serviceName.textContent = service.name;
servicesGrid.appendChild(serviceCard);
});
loadingDiv.style.display = "none";
errorDiv.style.display = "none";
servicesGrid.style.display = "grid";
};
// Load data
if (args.data) {
renderServices(args.data);
} else {
// Fetch services from API
try {
const response = await fetch(args.api!, {
method: "GET",
});
const data = (await response.json()) as ServicesResponse;
if (data.error) {
showError(`Error: ${JSON.stringify(data.error)}`);
} else if (data.services && data.services.length > 0) {
renderServices(data);
} else {
showError("No services found");
}
} catch (error) {
showError(`Failed to load services: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
const handleClickOutside = (event: MouseEvent) => {
if (args.dialogElement) {
return;
}
if (!shadowContainer.contains(event.target as Node)) {
triggerEvent(widgetName, "close");
}
};
let untrapFocus: (() => void) | null = null;
let untrapEscape: (() => void) | null = null;
// Open widget (show the prepared shadow DOM)
listeners.push(
listenEvent(widgetName, "open", null, false, () => {
wrapper.style.display = "block";
// Add click outside listener after a short delay to prevent immediate closing or double-clicks.
setTimeout(() => {
isVisible = true;
document.addEventListener("click", handleClickOutside);
wrapper.focus();
}, 200);
untrapFocus = trapFocus(shadowRoot, wrapper, "a,button");
if (!args.dialogElement) {
untrapEscape = trapEscape(() => {
triggerEvent(widgetName, "close");
});
}
if (args.buttonElement) {
args.buttonElement.setAttribute("aria-expanded", "true");
}
triggerEvent(widgetName, "opened");
}),
);
// Close widget (hide the shadow DOM)
listeners.push(
listenEvent(widgetName, "close", null, false, () => {
if (untrapFocus) {
untrapFocus();
}
if (untrapEscape) {
untrapEscape();
}
if (!isVisible) {
return; // Already closed
}
wrapper.style.display = "none";
isVisible = false;
// Return the focus to the button that opened the widget if any
if (args.buttonElement) {
args.buttonElement.focus();
args.buttonElement.setAttribute("aria-expanded", "false");
}
// Remove click outside listener
document.removeEventListener("click", handleClickOutside);
triggerEvent(widgetName, "closed");
}),
);
// Toggle widget visibility
listeners.push(
listenEvent(widgetName, "toggle", null, false, () => {
if (isVisible) {
triggerEvent(widgetName, "close");
} else {
triggerEvent(widgetName, "open");
}
}),
);
listeners.push(listenEvent(widgetName, "configure", null, false, configure));
// Close button click handlers
if (okBtn) {
okBtn.addEventListener("click", () => {
triggerEvent(widgetName, "close");
});
}
if (closeBtn) {
closeBtn.addEventListener("click", () => {
triggerEvent(widgetName, "close");
});
}
if (args.buttonElement) {
listeners.push(
listenEvent("", "click", args.buttonElement, false, () => {
triggerEvent(widgetName, "toggle");
}),
);
}
// Add to DOM but keep hidden
if (args.dialogElement) {
args.dialogElement.appendChild(shadowContainer);
} else {
wrapper.className = "wrapper-dialog";
document.body.appendChild(shadowContainer);
}
listenEvent(widgetName, "destroy", null, true, () => {
triggerEvent(widgetName, "close");
loaded = false;
shadowContainer.remove();
// Unlisten all events
listeners.forEach((listener) => listener());
});
loaded = true;
triggerEvent(widgetName, "initialized");
if (args.open) {
triggerEvent(widgetName, "open");
}
});
installHook(widgetName);

View File

@@ -0,0 +1,271 @@
/* La Gaufre Widget styles */
#wrapper {
display: flex;
flex-direction: column;
font-family: inherit;
}
.wrapper-dialog {
position: fixed;
top: 60px;
right: 60px;
z-index: 1000;
width: 340px;
max-height: 480px;
background: white;
border-radius: 0.25rem;
box-shadow: 0 0px 6px rgba(0, 0, 145, 0.1);
border: 1px solid #e5e5e5;
overflow: hidden;
}
#header {
display: flex;
justify-content: center;
align-items: center;
padding: 4px 16px;
background: transparent;
border-bottom: 1px solid #dfe2ea;
min-height: 48px;
position: relative;
}
#header-logo {
height: 25px;
width: auto;
object-fit: contain;
display: block;
margin: 0 auto;
}
#close {
position: absolute;
right: 16px;
}
#close {
background: none;
border: none;
color: #64748b;
cursor: pointer;
font-size: 36px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s;
}
#close:hover {
background-color: #f0f0f0;
}
#close:focus {
outline: 2px solid #0a76f6;
}
#footer {
display: flex;
padding: 16px;
background: transparent;
border-top: 1px solid #dfe2ea;
justify-content: flex-end;
}
#ok-button {
background: #3e5de7;
color: white;
border: none;
border-radius: 6px;
padding: 8px 16px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
min-width: 60px;
}
#ok-button:hover {
background: #1d4ed8;
}
#ok-button:focus {
outline: 2px solid #0a76f6;
}
#content {
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
overflow-y: auto;
min-height: 0;
}
/* Loading state */
#loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: #666;
font-size: 14px;
}
/* Error state */
#error {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: #dc3545;
font-size: 14px;
text-align: center;
}
/* Services grid */
#services-grid {
grid-template-columns: repeat(3, 1fr);
gap: 4px;
justify-items: center;
list-style: none;
padding: 0;
margin: 0;
}
/* Service cards */
.service-card {
background: transparent;
text-align: center;
transition: all 0.2s ease;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
list-style: none;
padding: 0;
margin: 0;
}
.service-card:hover {
background-color: #eef1f4;
border-radius: 6px;
}
/* Service logo container */
.service-card a {
position: relative;
padding: 8px;
width: 100%;
text-decoration: none;
display: block;
}
.service-logo {
width: 42px;
height: 42px;
object-fit: contain;
}
.maturity-badge {
position: absolute;
top: 40px;
left: 50%;
transform: translateX(-50%);
background: #eef1f4;
color: #2845c1;
border-radius: 12px;
padding: 2px 4px;
font-size: 9px;
line-height: 9px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.3px;
white-space: nowrap;
}
/* Service info */
.service-info {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding-top: 4px;
}
.service-name {
font-weight: 600;
font-size: 14px;
color: #1e40af;
margin-bottom: 2px;
line-height: 1.2;
text-align: center;
}
/* Responsive design */
@media (max-width: 480px) {
#wrapper {
width: 100%;
right: 0 !important;
left: 0 !important;
border-radius: 0;
bottom: 0 !important;
top: 0 !important;
max-height: 100vh;
position: fixed !important;
}
#header {
height: 40px;
display: flex;
}
#header-logo {
height: 35px;
}
#footer {
display: flex;
}
#services-grid {
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.service-card {
width: 70px;
padding: 6px;
}
.service-logo,
.service-logo-placeholder {
width: 40px;
height: 40px;
}
.service-name {
font-size: 11px;
}
}
/* Scrollbar styling */
#content::-webkit-scrollbar {
width: 4px;
}
#content::-webkit-scrollbar-track {
background: transparent;
}
#content::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 2px;
}
#content::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}

View File

@@ -0,0 +1 @@
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" transform="matrix(-1, 0, 0, 1, 0, 0)"><g stroke-width="0"></g><g stroke-linecap="round" stroke-linejoin="round"></g><g><path d="M7 9H17M7 13H17M21 20L17.6757 18.3378C17.4237 18.2118 17.2977 18.1488 17.1656 18.1044C17.0484 18.065 16.9277 18.0365 16.8052 18.0193C16.6672 18 16.5263 18 16.2446 18H6.2C5.07989 18 4.51984 18 4.09202 17.782C3.71569 17.5903 3.40973 17.2843 3.21799 16.908C3 16.4802 3 15.9201 3 14.8V7.2C3 6.07989 3 5.51984 3.21799 5.09202C3.40973 4.71569 3.71569 4.40973 4.09202 4.21799C4.51984 4 5.0799 4 6.2 4H17.8C18.9201 4 19.4802 4 19.908 4.21799C20.2843 4.40973 20.5903 4.71569 20.782 5.09202C21 5.51984 21 6.0799 21 7.2V20Z" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> </g></svg>

After

Width:  |  Height:  |  Size: 832 B

View File

@@ -0,0 +1,86 @@
import styles from "./styles.css?inline";
import { createShadowWidget } from "../../shared/shadow-dom";
import icon from "./icon.svg?raw";
import { injectScript, installHook, getLoaded, setLoaded, STATE_LOADED, STATE_LOADING } from "../../shared/script";
import { triggerEvent, listenEvent } from "../../shared/events";
const widgetName = "loader";
type LoaderWidgetArgs = {
widget: string;
closeLabel: string;
label: string;
params: Record<string, unknown>;
script: string;
scriptType: string;
};
// The init event is sent from the embedding code
listenEvent(widgetName, "init", null, false, (args: LoaderWidgetArgs) => {
const targetWidget = args.widget || "feedback";
const htmlContent = `<div><button type="button">${icon}</button></div>`;
// Create shadow DOM widget
const shadowContainer = createShadowWidget(widgetName, htmlContent, styles);
const shadowRoot = shadowContainer.shadowRoot!;
const btn = shadowRoot.querySelector<HTMLButtonElement>("button")!;
const ariaOpen = () => {
btn.setAttribute("aria-label", String(args.closeLabel || "Close widget"));
btn.setAttribute("aria-expanded", "true");
// TODO: How could we set the aria-controls attribute too, given that we
// have no id for the widget? Should we ask for it via an event?
};
const ariaClose = () => {
btn.setAttribute("aria-label", String(args.label || "Load widget"));
btn.setAttribute("aria-expanded", "false");
};
ariaClose();
listenEvent(targetWidget, "closed", null, false, () => {
btn.classList.remove("opened");
ariaClose();
});
listenEvent(targetWidget, "opened", null, false, () => {
btn.classList.add("opened");
ariaOpen();
});
btn.addEventListener("click", () => {
if (btn.classList.contains("opened")) {
triggerEvent(targetWidget, "close");
return;
}
const loadTimeout = setTimeout(() => {
btn.classList.remove("loading");
}, 10000);
// Add loading state to the UI
btn.classList.add("loading");
const loadedCallback = () => {
clearTimeout(loadTimeout);
btn.classList.remove("loading");
const params = Object.assign({}, args.params);
params.bottomOffset = btn.offsetHeight + 20;
window._lasuite_widget.push([targetWidget, "init", params]);
};
if (getLoaded(targetWidget) === STATE_LOADED) {
loadedCallback();
} else {
listenEvent(targetWidget, "loaded", null, true, loadedCallback);
// If it isn't even loading, we need to inject the script
if (!getLoaded(targetWidget)) {
injectScript(args.script, args.scriptType || "");
setLoaded(targetWidget, STATE_LOADING);
}
}
});
document.body.appendChild(shadowContainer);
});
installHook(widgetName);

View File

@@ -0,0 +1,105 @@
/* Widget styles */
div {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
}
button {
width: 60px;
height: 60px;
border-radius: 50%;
background: #000091;
border: none;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 145, 0.3);
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
background-color 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
}
svg {
width: 35px;
height: 35px;
}
button:focus-visible {
outline: 3px solid #0a76f6;
outline-offset: 2px;
}
button:hover {
background: #1212ff;
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(18, 18, 255, 0.4);
}
button:active {
transform: scale(0.95);
}
button.loading {
background: #2323ff;
}
button.loading::after {
content: "";
width: 25px;
height: 25px;
border: 3px solid transparent;
border-top: 3px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
button.loading svg {
display: none;
}
button.opened svg {
display: none;
}
button.opened::after {
content: "+";
font-size: 50px;
color: white;
height: 60px;
line-height: 60px;
font-family: Arial;
transform: rotate(45deg);
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@media (max-width: 420px) {
button {
width: 40px;
height: 40px;
}
svg {
width: 25px;
height: 25px;
}
div {
right: 15px;
bottom: 15px;
}
button.opened::after {
font-size: 40px;
}
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
export default defineConfig({
server: {
proxy: {
'^/widgets/dist/(.+)\\.js$': {
target: 'http://localhost:8931',
rewrite: (path) => {
const match = path.match(/^\/widgets\/dist\/(.+)\.js$/)
if (match) {
return `/src/widgets/${match[1]}/main.ts`
}
return path
}
}
}
}
})

10
website/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM node:22-slim AS website-deps
WORKDIR /home/website/
RUN npm install -g npm@11.3.0 && npm cache clean -f
ARG DOCKER_USER
USER ${DOCKER_USER}
ENV npm_config_cache=/tmp/npm-cache

View File

@@ -21,7 +21,6 @@ This is a starlight-based Astro app. Follow the official docs if more info is ne
```sh
npm install
cp .env.example .env
npm run dev
```

View File

@@ -7,12 +7,12 @@
"node": "20"
},
"scripts": {
"dev": "astro dev",
"dev": "astro dev --port 8930 --host 0.0.0.0",
"start": "node ./server.mjs",
"build-backgrounds": "node ./bin/build-services-backgrounds.mjs",
"gaufre-glyphhanger-cmd": "node ./bin/gaufre-font-cmd.mjs",
"build": "astro check && npm run build-backgrounds && astro build",
"preview": "astro preview",
"preview": "astro preview --port 8930 --host 0.0.0.0",
"astro": "astro"
},
"dependencies": {

View File

@@ -0,0 +1 @@
{"success": true, "config": {"captcha": false, "submitUrl": "/error"}}

View File

@@ -0,0 +1 @@
{"success": true, "config": {"captcha": false}}

View File

@@ -0,0 +1,57 @@
{
"organization": {
"name": "Example Organization",
"type": "Public Administration",
"siret": "12345678901234"
},
"services": [
{
"id": 1,
"name": "Authentication Service",
"url": "https://example.com/auth",
"maturity": "stable",
"logo": "/widgets/demo/logos/auth.svg",
"subscribed": true
},
{
"id": 2,
"name": "Document Portal",
"url": "https://example.com/docs",
"maturity": "stable",
"logo": "/widgets/demo/logos/docs.svg",
"subscribed": true
},
{
"id": 3,
"name": "Payment Gateway",
"url": "https://example.com/payments",
"maturity": "stable",
"logo": "/widgets/demo/logos/payment.svg",
"subscribed": false
},
{
"id": 4,
"name": "Analytics Dashboard",
"url": "https://example.com/analytics",
"maturity": "beta",
"logo": "/widgets/demo/logos/analytics.svg",
"subscribed": true
},
{
"id": 5,
"name": "Notification Center",
"url": "https://example.com/notifications",
"maturity": "alpha",
"logo": "/widgets/demo/logos/notifications.svg",
"subscribed": false
},
{
"id": 6,
"name": "File Storage",
"url": "https://example.com/storage",
"maturity": "stable",
"logo": "/widgets/demo/logos/storage.svg",
"subscribed": true
}
]
}

View File

@@ -0,0 +1,5 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="8" fill="#ffc107"/>
<path d="M20 12L24 20L20 28L16 20L20 12Z" fill="white"/>
<path d="M20 16L22 20L20 24L18 20L20 16Z" fill="#ffc107"/>
</svg>

After

Width:  |  Height:  |  Size: 278 B

View File

@@ -0,0 +1,5 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="8" fill="#000091"/>
<path d="M20 12C15.58 12 12 15.58 12 20C12 24.42 15.58 28 20 28C24.42 28 28 24.42 28 20C28 15.58 24.42 12 20 12ZM20 26C16.69 26 14 23.31 14 20C14 16.69 16.69 14 20 14C23.31 14 26 16.69 26 20C26 23.31 23.31 26 20 26Z" fill="white"/>
<path d="M20 16C17.79 16 16 17.79 16 20C16 22.21 17.79 24 20 24C22.21 24 24 22.21 24 20C24 17.79 22.21 16 20 16Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 523 B

View File

@@ -0,0 +1,4 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="8" fill="#28a745"/>
<path d="M12 14H28V26H12V14ZM14 16V24H26V16H14ZM16 18H24V20H16V18ZM16 21H20V22H16V21Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 262 B

View File

@@ -0,0 +1,5 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="8" fill="#17a2b8"/>
<path d="M20 12C15.58 12 12 15.58 12 20C12 24.42 15.58 28 20 28C24.42 28 28 24.42 28 20C28 15.58 24.42 12 20 12ZM20 26C16.69 26 14 23.31 14 20C14 16.69 16.69 14 20 14C23.31 14 26 16.69 26 20C26 23.31 23.31 26 20 26Z" fill="white"/>
<path d="M18 18H22V20H18V18ZM18 21H22V22H18V21ZM18 24H22V25H18V24Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 477 B

View File

@@ -0,0 +1,5 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="8" fill="#dc3545"/>
<path d="M20 12C15.58 12 12 15.58 12 20C12 24.42 15.58 28 20 28C24.42 28 28 24.42 28 20C28 15.58 24.42 12 20 12ZM20 26C16.69 26 14 23.31 14 20C14 16.69 16.69 14 20 14C23.31 14 26 16.69 26 20C26 23.31 23.31 26 20 26Z" fill="white"/>
<path d="M18 18H22V22H18V18Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 439 B

View File

@@ -0,0 +1,4 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="8" fill="#6f42c1"/>
<path d="M14 16H26V24H14V16ZM16 18V22H24V18H16ZM18 20H22V21H18V20Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 243 B

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
website/public/widgets/dist/loader.js vendored Normal file
View File

@@ -0,0 +1 @@
(function(){"use strict";const m='div{position:fixed;bottom:20px;right:20px;z-index:1000}button{width:60px;height:60px;border-radius:50%;background:#000091;border:none;cursor:pointer;box-shadow:0 4px 12px #0000914d;transition:transform .2s ease,box-shadow .2s ease,background-color .2s ease;display:flex;align-items:center;justify-content:center;padding:0;margin:0}svg{width:35px;height:35px}button:focus-visible{outline:3px solid #0a76f6;outline-offset:2px}button:hover{background:#1212ff;transform:scale(1.1);box-shadow:0 6px 16px #1212ff66}button:active{transform:scale(.95)}button.loading{background:#2323ff}button.loading:after{content:"";width:25px;height:25px;border:3px solid transparent;border-top:3px solid white;border-radius:50%;animation:spin 1s linear infinite}button.loading svg{display:none}button.opened svg{display:none}button.opened:after{content:"+";font-size:50px;color:#fff;height:60px;line-height:60px;font-family:Arial;transform:rotate(45deg)}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@media (max-width: 420px){button{width:40px;height:40px}svg{width:25px;height:25px}div{right:15px;bottom:15px}button.opened:after{font-size:40px}}';function x(e,t,n){const i=`lasuite-widget-${e}-shadow`,a=document.getElementById(i);a&&a.remove();const o=document.createElement("div");o.id=i;const s=o.attachShadow({mode:"open"}),d=document.createElement("style");d.textContent=n;const u=document.createElement("div");return u.innerHTML=t,s.appendChild(d),s.appendChild(u),o}const v='<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" transform="matrix(-1, 0, 0, 1, 0, 0)"><g stroke-width="0"></g><g stroke-linecap="round" stroke-linejoin="round"></g><g><path d="M7 9H17M7 13H17M21 20L17.6757 18.3378C17.4237 18.2118 17.2977 18.1488 17.1656 18.1044C17.0484 18.065 16.9277 18.0365 16.8052 18.0193C16.6672 18 16.5263 18 16.2446 18H6.2C5.07989 18 4.51984 18 4.09202 17.782C3.71569 17.5903 3.40973 17.2843 3.21799 16.908C3 16.4802 3 15.9201 3 14.8V7.2C3 6.07989 3 5.51984 3.21799 5.09202C3.40973 4.71569 3.71569 4.40973 4.09202 4.21799C4.51984 4 5.0799 4 6.2 4H17.8C18.9201 4 19.4802 4 19.908 4.21799C20.2843 4.40973 20.5903 4.71569 20.782 5.09202C21 5.51984 21 6.0799 21 7.2V20Z" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> </g></svg>',f="lasuite-widget",p=(e,t,n,i)=>document.dispatchEvent(new CustomEvent(`${f}-${e}-${t}`,n?{detail:n}:void 0)),r=(e,t,n,i,a)=>{const o=d=>a(d.detail),s=e?`${f}-${e}-${t}`:t;return document.addEventListener(s,o,i?{once:!0}:void 0),()=>document.removeEventListener(s,o,i?{once:!0}:void 0)},C=1,l=2,c=e=>window._lasuite_widget?._loaded?.[e],h=(e,t)=>{window._lasuite_widget?._loaded&&(window._lasuite_widget._loaded[e]=t)},_=e=>{window._lasuite_widget||(window._lasuite_widget=[]);const t=window._lasuite_widget;if(t._loaded||(t._loaded={}),c(e)!==l){t.push=(...n)=>{for(const i of n)c(i[0])===l?p(i[0],i[1],i[2]):t[t.length]=i;return t.length},h(e,l);for(const n of t.splice(0,t.length))t.push(n)}p(e,"loaded")},E=(e,t="")=>{const n=document.createElement("script");n.src=e,n.type=t,n.defer=!0,document.body.appendChild(n)},g="loader";r(g,"init",null,!1,e=>{const t=e.widget||"feedback",n=`<div><button type="button">${v}</button></div>`,i=x(g,n,m),o=i.shadowRoot.querySelector("button"),s=()=>{o.setAttribute("aria-label",String(e.closeLabel||"Close widget")),o.setAttribute("aria-expanded","true")},d=()=>{o.setAttribute("aria-label",String(e.label||"Load widget")),o.setAttribute("aria-expanded","false")};d(),r(t,"closed",null,!1,()=>{o.classList.remove("opened"),d()}),r(t,"opened",null,!1,()=>{o.classList.add("opened"),s()}),o.addEventListener("click",()=>{if(o.classList.contains("opened")){p(t,"close");return}const u=setTimeout(()=>{o.classList.remove("loading")},1e4);o.classList.add("loading");const w=()=>{clearTimeout(u),o.classList.remove("loading");const b=Object.assign({},e.params);b.bottomOffset=o.offsetHeight+20,window._lasuite_widget.push([t,"init",b])};c(t)===l?w():(r(t,"loaded",null,!0,w),c(t)||(E(e.script,e.scriptType||""),h(t,C)))}),document.body.appendChild(i)}),_(g)})();

View File

@@ -0,0 +1,64 @@
---
title: Retours utilisateurs
sidebar:
order: 50
---
<div style="width:300px;margin:auto;">
![](./feedback.png)
</div>
Ce widget permet aux visiteurs de votre service d'envoyer des questions ou des retours à votre équipe.
Ces retours sont ensuite consultables dans une instance de [Messages](https://github.com/suitenumerique/messages),
qui permet d'y répondre collaborativement par email.
## Intégration
Il y a deux façons principales d'intégrer ce widget dans votre service:
### 1. Via le composant React
Ce composant sera bientôt disponible dans le [UI Kit](https://github.com/suitenumerique/ui-kit).
En attendant, vous pouvez :
* soit [copier ce composant](https://github.com/suitenumerique/messages/blob/main/src/frontend/src/features/ui/components/feedback-widget/index.tsx)
si vous souhaitez intégrer la pop-in de retour avec son bouton de chargement qui s'affiche en bas à droite des pages,
* soit [copier ce composant](https://github.com/suitenumerique/messages/blob/main/src/frontend/src/features/ui/components/feedback-button/index.tsx)
si vous souhaitez juste ouvrir la pop-in de retour dynamiquement, avec un bouton existant de votre application par exemple.
### 2. Via le script JavaScript
Cette option est à privilégier si vous n'utilisez pas React ou si vous ne souhaitez pas intégrer l'UI Kit dans votre application.
Voici comment l'intégrer avec le bouton de chargement :
```html
<script src="https://cdn.votresuite.fr/loader.js" async></script>
<script>
window._lasuite_widget = window._lasuite_widget || [];
_lasuite_widget.push(["loader", "init", {
"params": {
"api": "https://votre.instance.de.messages/api/",
"channel": "xyz-xyz-xyz"
},
"script": "https://cdn.votresuite.fr/feedback.js",
"widget": "feedback",
"label": "Poser une question"
}]);
</script>
```
D'autres exemples d'intégration sont disponibles ci-dessous.
## Exemples
Nous avons une documentation interactive avec plusieurs exemples de configuration pour :
* [version avec bouton de chargement](/widgets-demo/loader).
* [version sans bouton de chargement](/widgets-demo/feedback).
Vous pouvez aussi tester La Gaufre v2 en ligne sur ces services :
* [Suite territoriale - site vitrine](https://suiteterritoriale.anct.gouv.fr/)
* [Suite territoriale - Messages](https://messages.suite.anct.gouv.fr)

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -0,0 +1,87 @@
---
title: La Gaufre v2
sidebar:
order: 40
---
<div style="width:400px;margin:auto;">
![](./gaufre-v2-dinum.png)
</div>
La Gaufre v2 est une évolution de la [première version](/guides/gaufre) historique, qui ajoute plusieurs fonctionnalités :
* Un design en accord avec le [UI Kit](https://github.com/suitenumerique/ui-kit) de LaSuite.
* Un chargement des services à afficher en JSON, qui peut être effectué via une API dynamique ou fichier statique.
* L'utilisation du "Shadow DOM" qui permet aux styles du widget d'être indépendants de ceux de la page
En configurant la Gaufre v2, il est possible de changer son contenu ou même son apparence.
Voici par exemple une Gaufre pour la Suite territoriale :
<div style="width:400px;margin:30px auto;">
![](./gaufre-v2-anct.png)
</div>
## Intégration
Il y a deux façons principales d'intégrer La Gaufre v2 dans votre service:
### 1. Via le composant React
Ce composant, qui intègre le bouton "Gaufre" ainsi que la pop-in, sera bientôt disponible dans le [UI Kit](https://github.com/suitenumerique/ui-kit).
En attendant, vous pouvez [copier ce composant](https://github.com/suitenumerique/messages/blob/main/src/frontend/src/features/ui/components/lagaufre/index.tsx).
### 2. Via le script JavaScript
Cette option est à privilégier si vous n'utilisez pas React ou si vous ne souhaitez pas intégrer l'UI Kit dans votre application.
Dans ce cas, vous devez intégrer le bouton "Gaufre". Le script gérera la pop-in et s'occupera de mettre à jour les attributs nécessaires sur le bouton lors des interactions.
Voici un exemple complet d'intégration dans ce cas :
```html
<button type="button" id="gaufre_button" aria-label="Ouvrir la Gaufre" aria-expanded="false">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<path fill="#000091" id="square" d="M2.7959 0.5C3.26483 0.5 3.49956 0.49985 3.68848 0.564453C4.03934 0.684581 4.31542 0.960658 4.43555 1.31152C4.50015 1.50044 4.5 1.73517 4.5 2.2041V2.7959C4.5 3.26483 4.50015 3.49956 4.43555 3.68848C4.31542 4.03934 4.03934 4.31542 3.68848 4.43555C3.49956 4.50015 3.26483 4.5 2.7959 4.5H2.2041C1.73517 4.5 1.50044 4.50015 1.31152 4.43555C0.960658 4.31542 0.684581 4.03934 0.564453 3.68848C0.49985 3.49956 0.5 3.26483 0.5 2.7959V2.2041C0.5 1.73517 0.49985 1.50044 0.564453 1.31152C0.684581 0.960658 0.960658 0.684581 1.31152 0.564453C1.50044 0.49985 1.73517 0.5 2.2041 0.5H2.7959Z" />
</defs>
<use href="#square" transform="translate(0, 0)"/><use href="#square" transform="translate(6.5, 0)"/><use href="#square" transform="translate(13, 0)"/><use href="#square" transform="translate(0, 6.5)"/><use href="#square" transform="translate(6.5, 6.5)"/><use href="#square" transform="translate(13, 6.5)"/><use href="#square" transform="translate(0, 13)"/><use href="#square" transform="translate(6.5, 13)"/><use href="#square" transform="translate(13, 13)"/>
</svg>
</button>
<script src="https://cdn.votresuite.fr/lagaufre.js" async></script>
<script>
const button = document.getElementById("gaufre_button");
window._lasuite_widget = window._lasuite_widget || [];
_lasuite_widget.push(['lagaufre', 'init', {
api: 'https://lasuite.numerique.gouv.fr/api/services',
label: "Services de la Suite numérique",
closeLabel: "Fermer le menu",
headerLabel: "À propos",
loadingText: "Chargement…"
newWindowLabelSuffix: " (nouvelle fenêtre)",
buttonElement: button,
position: () => {
return {
position: "absolute",
top: button.offsetTop + button.offsetHeight + 10,
right: window.innerWidth - button.offsetLeft - button.offsetWidth
}
}
}]);
</script>
```
Vous pouvez tester cet exemple sur cette [page dédiée](/widgets-demo/lagaufre-single).
Nous avons une documentation interactive avec plusieurs [autres exemples de configuration](/widgets-demo/lagaufre).
## Intégrations existantes
Vous pouvez tester La Gaufre v2 en ligne sur ces services :
* [Suite territoriale - Messages](https://messages.suite.anct.gouv.fr)

View File

@@ -0,0 +1,98 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Feedback Widget Demo</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
background: #f5f5f5;
}
* {
color: #666;
text-align: center;
}
h1 {
color: #333;
text-align: center;
}
.controls {
text-align: center;
margin: 20px 0;
}
button {
margin: 0 10px;
padding: 10px 20px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #0056b3;
}
</style>
</head>
<body>
<a href="/">Back to index</a>
<h1>Feedback Widget Demo</h1>
<script type="module" src="/widgets/dist/feedback.js"></script>
<script>
window._lasuite_widget = window._lasuite_widget || [];
_lasuite_widget.push(["feedback", "init", {
"title": "Do you have any feedback?",
"api": "/widgets/demo/feedback-api.json?",
"channel": "xyz-xyz-xyz",
"successText2": "We will get back to you as soon as possible. Let's add some text too to test the widget behaving correctly."
}]);
</script>
<button onclick='_lasuite_widget.push(["feedback", "close"]);'>Close</button>
<button onclick='
_lasuite_widget.push(["feedback", "init", {
"email": "test@test.com",
"title": "Do you have any feedback?",
"api": "/widgets/demo/feedback-api.json?",
"channel": "xyz-xyz-xyz"
}]);
'>Re-init with email</button>
<br/><br/>
<button onclick='
_lasuite_widget.push(["feedback", "init", {
"email": "test@test.com",
"title": "Do you have any feedback?",
"api": "/error",
"channel": "xyz-xyz-xyz"
}]);
'>Re-init with loading error</button>
<button onclick='
_lasuite_widget.push(["feedback", "init", {
"email": "test@test.com",
"title": "Do you have any feedback?",
"api": "/widgets/demo/feedback-api-error.json?",
"channel": "xyz-xyz-xyz"
}]);
'>Re-init with submit error</button>
<br/><br/>
<button onclick='
_lasuite_widget.push(["feedback", "init", {
"title": "With offset",
"api": "/widgets/demo/feedback-api.json?",
"channel": "xyz-xyz-xyz",
"bottomOffset": 100,
"rightOffset": 50
}]);
'>Re-init with position offset</button>
</body>
</html>

View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>La Gaufre Widget Demo</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.demo-container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
button {
float:right;
cursor: pointer;
display:flex;
border: none;
background: none;
padding: 8px;
}
</style>
</head>
<body>
<div class="demo-container">
<p>
<a href="/">Back to index</a>
</p>
<script src="/widgets/dist/lagaufre.js" async type="module"></script>
<button type="button" id="gaufre_button" aria-label="Ouvrir la Gaufre" aria-expanded="false">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<path fill="#000091" id="square" d="M2.7959 0.5C3.26483 0.5 3.49956 0.49985 3.68848 0.564453C4.03934 0.684581 4.31542 0.960658 4.43555 1.31152C4.50015 1.50044 4.5 1.73517 4.5 2.2041V2.7959C4.5 3.26483 4.50015 3.49956 4.43555 3.68848C4.31542 4.03934 4.03934 4.31542 3.68848 4.43555C3.49956 4.50015 3.26483 4.5 2.7959 4.5H2.2041C1.73517 4.5 1.50044 4.50015 1.31152 4.43555C0.960658 4.31542 0.684581 4.03934 0.564453 3.68848C0.49985 3.49956 0.5 3.26483 0.5 2.7959V2.2041C0.5 1.73517 0.49985 1.50044 0.564453 1.31152C0.684581 0.960658 0.960658 0.684581 1.31152 0.564453C1.50044 0.49985 1.73517 0.5 2.2041 0.5H2.7959Z" />
</defs>
<use href="#square" transform="translate(0, 0)"/><use href="#square" transform="translate(6.5, 0)"/><use href="#square" transform="translate(13, 0)"/><use href="#square" transform="translate(0, 6.5)"/><use href="#square" transform="translate(6.5, 6.5)"/><use href="#square" transform="translate(13, 6.5)"/><use href="#square" transform="translate(0, 13)"/><use href="#square" transform="translate(6.5, 13)"/><use href="#square" transform="translate(13, 13)"/>
</svg>
</button>
<script>
const button = document.getElementById("gaufre_button");
window._lasuite_widget = window._lasuite_widget || [];
_lasuite_widget.push(['lagaufre', 'init', {
api: '/widgets/demo/lagaufre-data.json',
label: "Services de la Suite numérique",
closeLabel: "Fermer le menu",
headerLabel: "À propos",
loadingText: "Chargement…",
newWindowLabelSuffix: " (nouvelle fenêtre)",
buttonElement: button,
position: () => {
return {
position: "absolute",
top: button.offsetTop + button.offsetHeight + 10,
right: window.innerWidth - button.offsetLeft - button.offsetWidth
}
}
}]);
</script>
<h1>La Gaufre - Single Page Demo</h1>
<p>
Try opening the Gaufre by clicking on the button on the top right! You can also use keyboard navigation.
</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
</p>
</body>
</html>
</div>

View File

@@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>La Gaufre Widget Demo</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.demo-container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.demo-section {
margin-bottom: 30px;
padding: 20px;
border: 1px solid #e0e0e0;
border-radius: 8px;
}
.demo-section h3 {
margin-top: 0;
color: #333;
}
button {
background: #000091;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
margin: 5px;
}
button:hover {
background: #1212ff;
}
.code-block {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 15px;
margin: 10px 0;
font-family: monospace;
font-size: 14px;
overflow-x: auto;
}
</style>
</head>
<body>
<div class="demo-container">
<a href="/">Back to index</a>
<h1>La Gaufre Widget Demo</h1>
<p>This demo shows how to use the La Gaufre widget to display a list of services with subscription status.</p>
<div class="demo-section">
<h2>Basic Usage</h2>
<p>Open the widget with default settings:</p>
<button type="button" onclick="openBasicWidget(this)">Open La Gaufre Widget</button>
<div class="code-block"><pre>
_lasuite_widget.push(['lagaufre', 'init', {
api: '/widgets/demo/lagaufre-data.json',
open: true
}]);</pre></div>
</div>
<div class="demo-section">
<h2>With ANCT Data</h2>
<p>Open the widget with ANCT services data:</p>
<button type="button" onclick="openBasicWidget(this)">Open with ANCT Data</button>
<div class="code-block"><pre>
_lasuite_widget.push(['lagaufre', 'init', {
api: 'https://operateurs.suite.anct.gouv.fr/api/v1.0/lagaufre/services/?operator=9f5624fc-ef99-4d10-ae3f-403a81eb16ef&siret=21870030000013',
open: true
}]);</pre></div>
</div>
<div class="demo-section">
<h2>With LaSuite data and style</h2>
<p>Open the widget with gradient background:</p>
<button type="button" onclick="openBasicWidget(this)">Open with LaSuite Data</button>
<div class="code-block"><pre>
// With gradient background
_lasuite_widget.push(['lagaufre', 'init', {
api: 'https://lasuite.numerique.gouv.fr/api/services',
open: true,
background: 'linear-gradient(180deg, #eceffd 0%, #FFFFFF 20%)',
headerLogo: 'https://lasuite.numerique.gouv.fr/_next/static/media/suite-numerique.ebdb6ce9.svg',
headerUrl: 'https://lasuite.numerique.gouv.fr',
showFooter: true
}]);</pre></div>
</div>
<div class="demo-section">
<h2>With Custom Font</h2>
<p>Open the widget with custom font family:</p>
<button type="button" onclick="openBasicWidget(this)">Open with Custom Font</button>
<div class="code-block"><pre>
// With custom font
_lasuite_widget.push(['lagaufre', 'init', {
api: '/widgets/demo/lagaufre-data.json',
open: true,
fontFamily: 'Georgia, serif'
}]);</pre></div>
</div>
<div class="demo-section">
<h2>Positioning Options</h2>
<p>Control widget position and behavior:</p>
<button type="button" onclick="openBasicWidget(this)">Top Right</button>
<div class="code-block"><pre>
// Top Right positioning
_lasuite_widget.push(['lagaufre', 'init', {
api: '/widgets/demo/lagaufre-data.json',
open: true,
label: 'La Gaufre - Top Right',
position: 'fixed',
top: 20,
right: 20
}]);</pre></div>
<button type="button" onclick="openBasicWidget(this)">Bottom Left</button>
<div class="code-block"><pre>
// Bottom Left positioning
_lasuite_widget.push(['lagaufre', 'init', {
api: '/widgets/demo/lagaufre-data.json',
open: true,
label: 'La Gaufre - Bottom Left',
position: 'fixed',
bottom: 20,
left: 20
}]);</pre></div>
<button type="button" onclick="openBasicWidget(this)">Below button (absolute)</button>
<div class="code-block"><pre>
// Absolute & dynamic positioning, tied to a button
(button) => {
_lasuite_widget.push(['lagaufre', 'init', {
api: '/widgets/demo/lagaufre-data.json',
open: true,
label: 'La Gaufre - Bottom Left',
position: () => {
return {
position: 'absolute',
top: button.offsetTop + button.offsetHeight + 10,
right: window.innerWidth - button.offsetLeft - button.offsetWidth
};
},
buttonElement: button
}]);
}</pre></div>
</div>
<div class="demo-section">
<h2>No dialog</h2>
<p>You can insert the widget into an existing container:</p>
<div id="custom-box" style="width: 100%; max-width: 500px; height: 300px; overflow: hidden; background-color: #f0f0f0; border: 1px solid blue; border-radius: 8px; padding: 10px;"></div>
<button type="button" onclick="openBasicWidget(this)">Open inside box</button>
<div class="code-block"><pre>
// No dialog
_lasuite_widget.push(['lagaufre', 'init', {
api: '/widgets/demo/lagaufre-data.json',
open: true,
dialogElement: document.getElementById('custom-box')
}]);</pre></div>
<button type="button" onclick="openBasicWidget(this)">Close widget</button>
<div class="code-block"><pre>
_lasuite_widget.push(['lagaufre', 'close']);</pre></div>
</div>
<div class="demo-section">
<h2>With hardcoded data</h2>
<p>You can pass static data and avoid any API calls:</p>
<button type="button" onclick="openBasicWidget(this)">Open La Gaufre Widget</button>
<div class="code-block"><pre>
_lasuite_widget.push(['lagaufre', 'init', {
open: true,
data: {
"services": [
{
"name": "Authentication Service",
"url": "https://example.com/auth",
"logo": "/widgets/demo/logos/auth.svg",
},
{
"name": "Document Portal",
"url": "https://example.com/docs",
"logo": "/widgets/demo/logos/docs.svg",
},
]
}
}]);</pre></div>
</div>
<div class="demo-section">
<h2>Open and close Widget Programmatically</h2>
<p>Close the widget programmatically after 2 seconds:</p>
<button type="button" onclick="openBasicWidget(this)">Open Widget</button>
<div class="code-block"><pre>
_lasuite_widget.push(['lagaufre', 'init', {
api: '/widgets/demo/lagaufre-data.json'
}]);
setTimeout(() => {
_lasuite_widget.push(['lagaufre', 'open']);
}, 300);
setTimeout(() => {
_lasuite_widget.push(['lagaufre', 'close']);
}, 2000);</pre></div>
<button type="button" onclick="openBasicWidget(this)">Toggle Widget Programmatically</button>
<div class="code-block"><pre>
_lasuite_widget.push(['lagaufre', 'toggle']);</pre></div>
</div>
<div class="demo-section">
<h2>Full localization</h2>
<p>You can fully localize the widget:</p>
<button type="button" onclick="openBasicWidget(this)">Open Widget</button>
<div class="code-block"><pre>
// Full localization
_lasuite_widget.push(['lagaufre', 'init', {
api: '/widgets/demo/lagaufre-data.json',
open: true,
label: 'La Gaufre',
headerLogo: 'https://lasuite.numerique.gouv.fr/_next/static/media/suite-numerique.ebdb6ce9.svg',
headerUrl: 'https://lasuite.numerique.gouv.fr',
headerLabel: 'A propos de LaSuite',
closeLabel: 'Fermer la liste des services',
loadingText: 'Chargement des services...',
newWindowLabelSuffix: ' (nouvelle fenêtre)'
}]);</pre></div>
</div>
<div class="demo-section">
<h2>API Response Format</h2>
<p>The widget expects the following API response format:</p>
<div class="code-block"><pre>
{
"services": [
{
"name": "Service Name",
"url": "https://service-url.com",
"maturity": "stable",
"logo": "/path/to.svg"
}
]
}</pre></div>
</div>
</div>
<!-- Load the La Gaufre widget -->
<script type="module" src="/widgets/dist/lagaufre.js"></script>
<script>
// Initialize the widget system
window._lasuite_widget = window._lasuite_widget || [];
function openBasicWidget(button) {
// Find the code block that immediately follows this button
const codeBlock = button.nextElementSibling;
if (codeBlock && codeBlock.classList.contains('code-block')) {
// Extract the JavaScript code from the code block
const codeText = codeBlock.textContent.trim();
// Remove comments and clean up the code
const cleanCode = codeText.replace(/^\/\/.*$/gm, '').trim();
// Execute the code
try {
var ret = eval(cleanCode);
if (typeof ret === 'function') {
return ret(button);;
}
} catch (error) {
console.error('Error executing widget code:', error);
alert('Error executing widget code: ' + error.message);
}
} else {
console.error('No code block found after button');
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,69 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Loader Widget Demo</title>
<style>
* {
color: #666;
text-align: center;
}
html {
font-family: Arial, sans-serif;
padding: 0px;
background: #f5f5f5;
}
button {
margin: 0 10px;
padding: 10px 20px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #0056b3;
}
</style>
</head>
<body>
<a href="/">Back to index</a>
<h1>Loader Widget Demo</h1>
<button onclick="document.documentElement.style.backgroundColor = '#000'">Make background dark</button>
<button onclick="document.documentElement.style.backgroundColor = '#fff'">Make background light</button>
<button onclick='
_lasuite_widget.push(["loader", "init", {
"params": {
"title": "Do you have any feedback?",
"api": "/widgets/demo/feedback-api.json?",
"channel": "xyz-xyz-xyz"
},
"script": "/error",
"scriptType": "module",
"widget": "feedback",
"label": "Feedback Widget"
}]);
'>Re-init with target loading error</button>
<script type="module" src="/widgets/dist/loader.js"></script>
<script>
window._lasuite_widget = window._lasuite_widget || [];
_lasuite_widget.push(["loader", "init", {
"params": {
"title": "Do you have any feedback?",
"api": "/widgets/demo/feedback-api.json?",
"channel": "xyz-xyz-xyz",
"successText2": "We'll get back to you soon."
},
"script": "/widgets/dist/feedback.js",
"scriptType": "module",
"widget": "feedback",
"label": "Feedback Widget"
}]);
</script>
</body>
</html>