✨(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.
2
.gitignore
vendored
@@ -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
@@ -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
|
||||
53
README.md
@@ -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
@@ -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"
|
||||
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
WIDGETS_OUTPUT_DIR=/home/website/public/widgets/
|
||||
4
ops/env/widgets.local
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Local development overrides for widgets
|
||||
# Add your local-specific environment variables below:
|
||||
# Example: DJANGO_DEBUG=True
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
10
packages/widgets/Dockerfile
Normal 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
@@ -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'
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
10
packages/widgets/eslint.config.mjs
Normal 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,
|
||||
);
|
||||
16
packages/widgets/index.html
Normal 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
19
packages/widgets/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
31
packages/widgets/src/shared/events.ts
Normal 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,
|
||||
);
|
||||
};
|
||||
43
packages/widgets/src/shared/focus.ts
Normal 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);
|
||||
};
|
||||
};
|
||||
70
packages/widgets/src/shared/script.ts
Normal 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);
|
||||
};
|
||||
30
packages/widgets/src/shared/shadow-dom.ts
Normal 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
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
204
packages/widgets/src/widgets/feedback/main.ts
Normal 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">×</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);
|
||||
158
packages/widgets/src/widgets/feedback/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
367
packages/widgets/src/widgets/lagaufre/main.ts
Normal 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">×</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);
|
||||
271
packages/widgets/src/widgets/lagaufre/styles.css
Normal 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;
|
||||
}
|
||||
1
packages/widgets/src/widgets/loader/icon.svg
Normal 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 |
86
packages/widgets/src/widgets/loader/main.ts
Normal 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);
|
||||
105
packages/widgets/src/widgets/loader/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
25
packages/widgets/tsconfig.json
Normal 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"]
|
||||
}
|
||||
19
packages/widgets/vite.config.ts
Normal 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
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
1
website/public/widgets/demo/feedback-api-error.json
Normal file
@@ -0,0 +1 @@
|
||||
{"success": true, "config": {"captcha": false, "submitUrl": "/error"}}
|
||||
1
website/public/widgets/demo/feedback-api.json
Normal file
@@ -0,0 +1 @@
|
||||
{"success": true, "config": {"captcha": false}}
|
||||
57
website/public/widgets/demo/lagaufre-data.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
5
website/public/widgets/demo/logos/analytics.svg
Normal 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 |
5
website/public/widgets/demo/logos/auth.svg
Normal 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 |
4
website/public/widgets/demo/logos/docs.svg
Normal 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 |
5
website/public/widgets/demo/logos/notifications.svg
Normal 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 |
5
website/public/widgets/demo/logos/payment.svg
Normal 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 |
4
website/public/widgets/demo/logos/storage.svg
Normal 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 |
1
website/public/widgets/dist/feedback.js
vendored
Normal file
1
website/public/widgets/dist/lagaufre.js
vendored
Normal file
1
website/public/widgets/dist/loader.js
vendored
Normal 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)})();
|
||||
64
website/src/content/docs/guides/feedback.mdx
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: Retours utilisateurs
|
||||
sidebar:
|
||||
order: 50
|
||||
---
|
||||
|
||||
<div style="width:300px;margin:auto;">
|
||||

|
||||
</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)
|
||||
BIN
website/src/content/docs/guides/feedback.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
website/src/content/docs/guides/gaufre-v2-anct.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
website/src/content/docs/guides/gaufre-v2-dinum.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
87
website/src/content/docs/guides/gaufre-v2.mdx
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: La Gaufre v2
|
||||
sidebar:
|
||||
order: 40
|
||||
---
|
||||
|
||||
|
||||
<div style="width:400px;margin:auto;">
|
||||

|
||||
</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;">
|
||||

|
||||
</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)
|
||||
98
website/src/pages/widgets-demo/feedback.html
Normal 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>
|
||||
89
website/src/pages/widgets-demo/lagaufre-single.html
Normal 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>
|
||||
297
website/src/pages/widgets-demo/lagaufre.html
Normal 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>
|
||||
69
website/src/pages/widgets-demo/loader.html
Normal 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>
|
||||