Support Usager : Implémentation de la messagerie Crisp (#2329)

* feat: Ajoute l'iframe crisp ainsi que le bloc custom html

* wip

* feat: Renomme le dossier standup.. + ajoute la fonction crisp

* fix: Install not broken version + add update meta

* feat: Ajoute le formulaire

* fix: Corrige htmlFor et id

* feat: Ajoute la logique pour récupérer nombre de réponses et les issues

* fix: uncomment stuff

* fix: Retire log

* chore: Renomme fonction

* chore: Renomme fonction

* chore: Renomme fonction

* feat: Retire commentaires

* fix: Refacto urlParams

* chore : Nettoyage de reliquats

* fix : Ajoute TextAreaField et utilise TextField

* fix: Style issues

* fix: Améliore types

* feat: Cleaning

* feat: Ajoute variable d'env website id

* feat: Met à jour README

* wip placeholder url

* feat: Ajoute une fonction de validation du body

* feat: Ajoute validation

* chore: update yarn.lock

* fix: Add missing secret ref + cleaning
pull/2351/head
Benjamin Arias 2022-10-24 15:03:14 +02:00 committed by GitHub
parent 492f6f9026
commit a51920b44c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 868 additions and 145 deletions

View File

@ -50,6 +50,9 @@ jobs:
ATINTERNET_API_SECRET_KEY: ${{ secrets.ATINTERNET_API_SECRET_KEY }}
ATINTERNET_API_ACCESS_KEY: ${{ secrets.ATINTERNET_API_ACCESS_KEY }}
PLAUSIBLE_API_KEY: ${{ secrets.PLAUSIBLE_API_KEY }}
CRISP_API_IDENTIFIER: ${{ secrets.CRISP_API_IDENTIFIER }}
CRISP_API_KEY: ${{ secrets.CRISP_API_KEY }}
CRISP_WEBSITE_ID: ${{ secrets.CRISP_WEBSITE_ID }}
- name: Build app
run: yarn workspace site build
env:
@ -101,6 +104,9 @@ jobs:
ATINTERNET_API_SECRET_KEY: ${{ secrets.ATINTERNET_API_SECRET_KEY }}
ATINTERNET_API_ACCESS_KEY: ${{ secrets.ATINTERNET_API_ACCESS_KEY }}
PLAUSIBLE_API_KEY: ${{ secrets.PLAUSIBLE_API_KEY }}
CRISP_API_IDENTIFIER: ${{ secrets.CRISP_API_IDENTIFIER }}
CRISP_API_KEY: ${{ secrets.CRISP_API_KEY }}
CRISP_WEBSITE_ID: ${{ secrets.CRISP_WEBSITE_ID }}
- name: Build Storybook
run: yarn workspace site build:storybook
- uses: actions/upload-artifact@v2

View File

@ -25,6 +25,9 @@ jobs:
ATINTERNET_API_SECRET_KEY: ${{ secrets.ATINTERNET_API_SECRET_KEY }}
ATINTERNET_API_ACCESS_KEY: ${{ secrets.ATINTERNET_API_ACCESS_KEY }}
PLAUSIBLE_API_KEY: ${{ secrets.PLAUSIBLE_API_KEY }}
CRISP_API_IDENTIFIER: ${{ secrets.CRISP_API_IDENTIFIER }}
CRISP_API_KEY: ${{ secrets.CRISP_API_KEY }}
CRISP_WEBSITE_ID: ${{ secrets.CRISP_WEBSITE_ID }}
- name: Remove temporary algolia index
run: yarn workspace site algolia:clean
env:

View File

@ -21,6 +21,9 @@ jobs:
ATINTERNET_API_SECRET_KEY: ${{ secrets.ATINTERNET_API_SECRET_KEY }}
ATINTERNET_API_ACCESS_KEY: ${{ secrets.ATINTERNET_API_ACCESS_KEY }}
PLAUSIBLE_API_KEY: ${{ secrets.PLAUSIBLE_API_KEY }}
CRISP_API_IDENTIFIER: ${{ secrets.CRISP_API_IDENTIFIER }}
CRISP_API_KEY: ${{ secrets.CRISP_API_KEY }}
CRISP_WEBSITE_ID: ${{ secrets.CRISP_WEBSITE_ID }}
- run: yarn test:type
unit:

View File

@ -11,12 +11,12 @@
"exoneration-covid",
"api",
"site",
"standup-mattermost-bot"
"server"
],
"scripts": {
"scalingo-postbuild": "echo \"$APP_DIR\" ; CI=true ; yarn test:type && yarn \"build:$APP_DIR\" && yarn workspaces focus \"$APP_DIR\" --production",
"build:api": "yarn workspaces focus api && yarn workspace api run build && yarn workspaces focus --all && yarn test",
"build:standup-mattermost-bot": "yarn workspaces focus standup-mattermost-bot && yarn workspace standup-mattermost-bot run build",
"build:server": "yarn workspaces focus server && yarn workspace server run build",
"lint:eslintrc": "npx eslint-config-prettier .eslintrc.cjs",
"lint:eslint": "NODE_OPTIONS='--max-old-space-size=4096' eslint .",
"lint:eslint:fix": "yarn lint:eslint --fix",
@ -34,7 +34,7 @@
"i18n:check": "yarn workspace site i18n:check",
"i18n:translate": "yarn workspace site i18n:translate"
},
"//": "Resolve conflicts with @types/... in api and standup-mattermost-bot",
"//": "Resolve conflicts with @types/... in api and server",
"resolutions": {
"@types/cacheable-request": "8.3.1",
"@types/responselike": "^1.0.0",

View File

@ -2,3 +2,6 @@ CLIENT_ID=
CLIENT_SECRET=
MONGO_URL=mongodb://root:example@localhost:27017/
ORIGIN=http://localhost:4000
CRISP_API_IDENTIFIER=
CRISP_API_KEY=
CRISP_WEBSITE_ID=

View File

@ -1,4 +1,9 @@
# Bot Mattermost pour stand-up asynchrone
# Server : serveur back-end de Mon-Entreprise
Serveur hébergé sur Scalingo, utilisé pour faire tourner le bot Stand-up ainsi que pour mettre à disposition des fonctions back-end Node.js.
## Jobs
### Bot Mattermost pour stand-up asynchrone
Ce bot envoie une notification périodique sur un canal Mattermost dédié afin que chaque membre de l'équipe puisse écrire en réponse où il en est dans ses tâches.
@ -6,7 +11,13 @@ Il désigne également qui sera l'animateur pour chaque jour de la semaine à ve
Et peut-être plus à venir...
# Détail technique
#### Détail technique
Le bot peut être installé facilement sur Scalingo, vous pouvez aller voir le `Procfile` à la racine du projet.
Il nécessite une base de données Mongodb pour stocker les token d'authentification et la liste des animateurs de la semaine.
## Functions
### Send Crisp message
Cette fonction utilise l'API Crisp pour rediriger les messages en provenance de notre formulaire de suggestions vers la messagerie Crisp afin de prendre en charge ces retours.

View File

@ -1,12 +1,12 @@
{
"name": "standup-mattermost-bot",
"name": "server",
"license": "MIT",
"version": "2.0.0",
"description": "Code source du standup-mattermost-bot",
"description": "Code source du serveur backend mon-entreprise",
"repository": {
"type": "git",
"url": "https://github.com/betagouv/mon-entreprise.git",
"directory": "standup-mattermost-bot"
"directory": "server"
},
"private": true,
"type": "module",
@ -20,9 +20,11 @@
"@koa/cors": "^3.4.1",
"@koa/router": "^12.0.0",
"bree": "^9.1.2",
"crisp-api": "6.3.0",
"dotenv": "^16.0.3",
"got": "^12.5.1",
"koa": "^2.13.4",
"koa-body": "^5.0.0",
"mongodb": "^4.10.0",
"nodemon": "^2.0.20"
},

View File

@ -0,0 +1,85 @@
import Crisp from 'crisp-api'
export type BodyType = {
subject: string
message: string
email: string
}
type SendMessageParamsType = {
type: string
content: string
from: string
origin: string
subject: string
}
type ConversationMetaType = {
email: string
subject: string
nickname: string
}
type CrispType = {
authenticateTier: (
type: string,
identifier: string | undefined,
key: string | undefined
) => void
website: {
createNewConversation: (id: string) => Promise<{ session_id: string }>
sendMessageInConversation: (
website_id: string,
session_id: string,
params: SendMessageParamsType
) => void
updateConversationMetas: (
website_id: string,
session_id: string,
meta: ConversationMetaType
) => Promise<void>
}
}
export const sendCrispMessage = async (body: BodyType) => {
try {
const { message, email, subject } = body || {}
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const CrispClient = new Crisp() as unknown as CrispType
CrispClient.authenticateTier(
'plugin',
process.env.CRISP_API_IDENTIFIER,
process.env.CRISP_API_KEY
)
const WEBSITE_ID = process.env.CRISP_WEBSITE_ID as string
const result = await CrispClient.website.createNewConversation(WEBSITE_ID)
const { session_id: sessionId } = result
await CrispClient.website.updateConversationMetas(WEBSITE_ID, sessionId, {
email,
nickname: email,
subject,
})
CrispClient.website.sendMessageInConversation(
WEBSITE_ID,
sessionId,
{
type: 'text',
content: message,
subject,
from: 'operator',
origin: 'chat',
}
)
} catch (e) {
// eslint-disable-next-line no-console
console.error(e)
}
}

View File

@ -2,6 +2,7 @@ import cors from '@koa/cors'
import Router from '@koa/router'
import 'dotenv/config'
import Koa from 'koa'
import koaBody from 'koa-body'
import {
clientId,
clientSecret,
@ -9,10 +10,11 @@ import {
redirectUri,
serverUrl,
} from './config.js'
import { BodyType, sendCrispMessage } from './functions/send-crisp-message.js'
import { bree } from './jobs.js'
import { initMongodb } from './mongodb.js'
import { getAccessToken } from './oauth.js'
import { snakeToCamelCaseKeys } from './utils.js'
import { snakeToCamelCaseKeys, validateCrispBody } from './utils.js'
const mongo = await initMongodb()
@ -81,6 +83,21 @@ router.get('/oauth', async (ctx) => {
}
})
router.post('/send-crisp-message', koaBody(), async (ctx) => {
try {
const body = validateCrispBody(ctx.request.body as BodyType)
await sendCrispMessage(body)
ctx.status = 200
} catch (err) {
// eslint-disable-next-line no-console
console.error(err)
ctx.status = 400
}
})
app.use(router.routes())
app.use(router.allowedMethods())

View File

@ -1,3 +1,5 @@
import { BodyType } from './functions/send-crisp-message.js'
type CamelCase<S extends string> =
S extends `${infer P1}_${infer P2}${infer P3}`
? `${Lowercase<P1>}${Uppercase<P2>}${CamelCase<P3>}`
@ -33,3 +35,23 @@ export const shuffleArray = <T>(array: T[]) => {
return shuffle
}
const isStringAndNotEmpty = (value: string) =>
value !== undefined && value !== '' && typeof value === 'string'
const SHORT_MAX_LENGTH = 254
export const validateCrispBody = (body: BodyType): BodyType => {
const { subject, message, email } = body || {}
if (
isStringAndNotEmpty(subject) &&
subject.length <= SHORT_MAX_LENGTH &&
isStringAndNotEmpty(message) &&
isStringAndNotEmpty(email) &&
email.length <= SHORT_MAX_LENGTH
) {
return body
}
throw Error('Body validation failed.')
}

View File

@ -1,7 +1,7 @@
[[headers]]
for = "/*"
[headers.values]
Content-Security-Policy = "default-src 'self' mon-entreprise.fr; style-src 'self' 'unsafe-inline' mon-entreprise.zammad.com; connect-src 'self' *.incubateur.net raw.githubusercontent.com tm.urssaf.fr mon-entreprise.zammad.com api.recherche-entreprises.fabrique.social.gouv.fr geo.api.gouv.fr *.algolia.net *.algolianet.com polyfill.io code.jquery.com jedonnemonavis.numerique.gouv.fr user-images.githubusercontent.com; form-action 'self' *.sibforms.com *.incubateur.net mon-entreprise.zammad.com; script-src 'self' 'unsafe-inline' 'unsafe-eval' tm.urssaf.fr *.incubateur.net stonly.com code.jquery.com mon-entreprise.zammad.com polyfill.io; img-src 'self' data: mon-entreprise.urssaf.fr tm.urssaf.fr user-images.githubusercontent.com jedonnemonavis.numerique.gouv.fr; frame-src 'self' https://www.youtube-nocookie.com https://codesandbox.io https://place-des-entreprises.beta.gouv.fr https://reso-staging.osc-fr1.scalingo.io https://stackblitz.com"
Content-Security-Policy = "default-src 'self' mon-entreprise.fr; style-src 'self' 'unsafe-inline' mon-entreprise.zammad.com; connect-src 'self' *.incubateur.net raw.githubusercontent.com tm.urssaf.fr mon-entreprise.zammad.com api.recherche-entreprises.fabrique.social.gouv.fr geo.api.gouv.fr *.algolia.net *.algolianet.com polyfill.io jedonnemonavis.numerique.gouv.fr user-images.githubusercontent.com; form-action 'self' *.sibforms.com *.incubateur.net mon-entreprise.zammad.com; script-src 'self' 'unsafe-inline' 'unsafe-eval' tm.urssaf.fr *.incubateur.net stonly.com code.jquery.com mon-entreprise.zammad.com polyfill.io; img-src 'self' data: mon-entreprise.urssaf.fr tm.urssaf.fr user-images.githubusercontent.com jedonnemonavis.numerique.gouv.fr; frame-src 'self' https://www.youtube-nocookie.com https://codesandbox.io https://place-des-entreprises.beta.gouv.fr https://reso-staging.osc-fr1.scalingo.io https://stackblitz.com"
[dev]
autoLaunch = false

View File

@ -311,7 +311,6 @@ async function fetchDailyVisits() {
}
async function fetchMonthlyVisits() {
console.log('fetchMonthlyVisits')
const pages = uniformiseData([
...flattenPage(await fetchApi(buildSimulateursQuery(last36Months, 'M'))),
...flattenPage(await fetchApi(buildCreerQuery(last36Months, 'M'))),
@ -341,7 +340,80 @@ async function fetchMonthlyVisits() {
}
}
async function fetchUserAnswersStats() {
const getISODatesStartEndPreviousMonth = () => {
const dateFirstDayPreviousMonth = new Date()
// On prend le premier jour du mois dernier
dateFirstDayPreviousMonth.setMonth(dateFirstDayPreviousMonth.getMonth() - 1)
dateFirstDayPreviousMonth.setDate(1)
dateFirstDayPreviousMonth.setUTCHours(0, 0, 0, 0)
const dateLastDayPreviousMonth = new Date(dateFirstDayPreviousMonth)
// Ici l'index 0 permet de récupérer le dernier jour du mois précédent
dateLastDayPreviousMonth.setDate(0)
dateLastDayPreviousMonth.setUTCHours(23, 59, 59, 999)
return {
startISODatePreviousMonth: dateFirstDayPreviousMonth.toISOString(),
endISODatePreviousMonth: dateLastDayPreviousMonth.toISOString(),
}
}
async function fetchPaginatedCrispConversations(pageNumber, urlParams) {
const response = await fetch(
`https://api.crisp.chat/v1/website/d8247abb-cac5-4db6-acb2-cea0c00d8524/conversations/${pageNumber}${
urlParams ? `?${urlParams}` : ''
}`,
{
method: 'get',
headers: {
Authorization: `Basic ${btoa(
`${process.env.CRISP_API_IDENTIFIER}:${process.env.CRISP_API_KEY}`
)}`,
'X-Crisp-Tier': 'plugin',
},
}
)
const result = await response.json()
return result?.data
}
async function fetchAllCrispConversations({ urlParams }) {
try {
let isEndPagination = false
let pageCount = 1
const dataConversations = []
while (!isEndPagination) {
const paginatedData = await fetchPaginatedCrispConversations(
pageCount,
urlParams
)
// Array vide : plus rien à fetch de plus
if (paginatedData.length === 0) {
isEndPagination = true
}
dataConversations.push(...(paginatedData || []))
pageCount++
}
return dataConversations
} catch (e) {
console.log('error', e)
}
}
async function fetchCrispAnsweredConversationsLastMonth() {
const { startISODatePreviousMonth, endISODatePreviousMonth } =
getISODatesStartEndPreviousMonth()
const conversations = await fetchAllCrispConversations({
urlParams: `filter_resolved=1&filter_date_start=${startISODatePreviousMonth}&filter_date_end=${endISODatePreviousMonth}`,
})
return conversations?.length ?? 0
}
async function fetchZammadUserAnswersStats() {
const ticketLists = await fetch(
'https://mon-entreprise.zammad.com/api/v1/ticket_overviews?view=tickets_repondus_le_mois_dernier',
{
@ -354,20 +426,21 @@ async function fetchUserAnswersStats() {
return answer.index.count
}
async function fetchUserFeedbackIssues() {
const tags = await fetch(
'https://mon-entreprise.zammad.com/api/v1/tag_list',
{
headers: new Headers({
Authorization: `Token token=${process.env.ZAMMAD_API_SECRET_KEY}`,
}),
}
).then((r) => r.json())
const sortedTags = tags
.sort((t1, t2) => t2.count - t1.count)
.filter(({ name }) => /#[\d]+/.exec(name))
async function fetchAllUserAnswerStats() {
const zammadAnswersCount = await fetchZammadUserAnswersStats()
const cripsAnswersCount = await fetchCrispAnsweredConversationsLastMonth()
return zammadAnswersCount + cripsAnswersCount
}
async function fetchGithubIssuesFromTags(tags) {
if (!tags || tags?.length === 0) {
console.error(`❌ Error: no tags to fetch issues from`)
return []
}
const query = `query {
repository(owner:"betagouv", name:"mon-entreprise") {${sortedTags
repository(owner:"betagouv", name:"mon-entreprise") {${tags
.map(
({ name, count }, i) =>
`
@ -402,13 +475,79 @@ async function fetchUserFeedbackIssues() {
.filter(([, value]) => !!value)
.map(([k, value]) => ({ ...value, count: +/[\d]+$/.exec(k)[0] }))
return issues
}
async function fetchCrispUserFeedbackIssues() {
const conversationsResolved = await fetchAllCrispConversations({
urlParams: 'filter_resolved=1&search_query=issue&search_type=segment',
})
const sortedSegments = conversationsResolved
?.reduce((acc, conversation) => {
const newAcc = [...acc]
const conversationSegments = conversation.meta.segments
conversationSegments
.filter((segment) => /#[\d]+/.exec(segment))
.forEach((segment) => {
const segmentObjectIndex = newAcc.findIndex(
(segmentObject) => segmentObject.name === segment
)
if (segmentObjectIndex < 0) {
newAcc.push({ name: segment, count: 1 })
} else {
newAcc[segmentObjectIndex].count += 1
}
})
return newAcc
}, [])
?.sort((t1, t2) => t2.count - t1.count)
return fetchGithubIssuesFromTags(sortedSegments)
}
async function fetchZammadUserFeedbackIssues() {
const tags = await fetch(
'https://mon-entreprise.zammad.com/api/v1/tag_list',
{
headers: new Headers({
Authorization: `Token token=${process.env.ZAMMAD_API_SECRET_KEY}`,
}),
}
).then((r) => r.json())
const sortedTags = tags
.sort((t1, t2) => t2.count - t1.count)
.filter(({ name }) => /#[\d]+/.exec(name))
return fetchGithubIssuesFromTags(sortedTags)
}
async function fetchAllUserFeedbackIssues() {
const crispFeedbackIssues = await fetchCrispUserFeedbackIssues()
const zammadFeedbackIssues = await fetchZammadUserFeedbackIssues()
const allIssues = [...(crispFeedbackIssues || [])]
zammadFeedbackIssues.forEach((zammadIssue) => {
const issueIndex = allIssues.findIndex(
(issue) => issue.number === zammadIssue.number
)
if (issueIndex > 0) {
allIssues[issueIndex].count += zammadIssue.count
} else {
allIssues.push(zammadIssue)
}
})
return {
open: issues.filter((s) => !s.closedAt),
closed: issues
open: allIssues.filter((s) => !s.closedAt),
closed: allIssues
.filter((s) => s.closedAt)
.sort((i1, i2) => new Date(i2.closedAt) - new Date(i1.closedAt)),
}
}
async function main() {
createDataDir()
// In case we cannot fetch the release (the API is down or the Authorization
@ -443,8 +582,8 @@ async function main() {
fetchDailyVisits(),
fetchMonthlyVisits(),
fetchApi(buildSatisfactionQuery()),
fetchUserFeedbackIssues(),
fetchUserAnswersStats(),
fetchAllUserFeedbackIssues(),
fetchAllUserAnswerStats(),
])
const satisfaction = uniformiseData(flattenPage(await rawSatisfaction)).map(
(page) => {

View File

@ -1,77 +1,97 @@
import { ScrollToElement } from '@/components/utils/Scroll'
import { useEffect } from 'react'
import { TextAreaField, TextField } from '@/design-system'
import { Button } from '@/design-system/buttons'
import { Body } from '@/design-system/typography/paragraphs'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation } from 'react-router-dom'
import styled from 'styled-components'
declare global {
interface JQuery {
ZammadForm(options: any): void
}
}
const SERVER_URL = import.meta.env.DEV
? 'http://localhost:4000'
: 'https://mon-entreprise-server.osc-fr1.scalingo.io'
const SHORT_MAX_LENGTH = 254
// TODO: we could implement the form logic ourselves to avoid including
// https://mon-entreprise.zammad.com and https://code.jquery.com scripts
export default function FeedbackForm() {
// const tracker = useContext(TrackerContext)
const [isSubmittedSuccessfully, setIsSubmittedSuccessfully] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const pathname = useLocation().pathname
const page = pathname.split('/').slice(-1)[0]
const isSimulateur = pathname.includes('simulateurs')
const lang = useTranslation().i18n.language
useEffect(() => {
const script = document.createElement('script')
script.src = 'https://code.jquery.com/jquery-2.1.4.min.js'
const { t } = useTranslation()
document.body.appendChild(script)
setTimeout(() => {
const script = document.createElement('script')
script.id = 'zammad_form_script'
script.async = true
script.onload = () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
$('#feedback-form').ZammadForm({
messageTitle: `Remarque sur ${
isSimulateur ? 'le simulateur' : 'la page'
} ${page}`,
messageSubmit: 'Envoyer',
messageThankYou: 'Merci de votre retour !',
lang,
attributes: [
{
display:
"Que pouvons-nous améliorer afin de mieux répondre à vos attentes ? (ne pas mettre d'informations personnelles)",
name: 'body',
tag: 'textarea',
placeholder: 'Your Message...',
defaultValue: '',
rows: 7,
},
{
display: 'Nom',
name: 'name',
tag: 'input',
type: 'text',
defaultValue: '-',
},
{
display: 'Email (pour recevoir une réponse)',
name: 'email',
tag: 'input',
type: 'email',
placeholder: 'Your Email',
},
],
})
}
script.src = 'https://mon-entreprise.zammad.com/assets/form/form.js'
document.body.appendChild(script)
}, 100)
}, [])
const sendMessage = async () => {
setIsLoading(true)
const messageValue = (
document.getElementById('message') as HTMLTextAreaElement
)?.value
const emailValue = (document.getElementById('email') as HTMLInputElement)
?.value
try {
await fetch(`${SERVER_URL}/send-crisp-message`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
subject: `Suggestion sur la page : ${page}`,
message: messageValue,
email: emailValue,
}),
})
setIsSubmittedSuccessfully(true)
} catch (e) {
// Show error message
}
}
return (
<ScrollToElement onlyIfNotVisible>
<StyledFeedback id="feedback-form"></StyledFeedback>
{isSubmittedSuccessfully && (
<StyledBody>Merci de votre retour !</StyledBody>
)}
{!isSubmittedSuccessfully && (
<StyledFeedback>
<form
onSubmit={(e) => {
e.preventDefault()
void sendMessage()
}}
>
<Body>
Que pouvons-nous améliorer pour mieux répondre à vos attentes ?
</Body>
<StyledTextArea
name="message"
label={t('Votre message')}
description={t(
'Éviter de communiquer des informations personnelles'
)}
id="message"
rows={7}
isDisabled={isLoading}
/>
<StyledDiv>
<StyledTextField
id="email"
name="email"
type="email"
label={t('Votre adresse e-mail')}
description={t(
'Renseigner une adresse e-mail pour recevoir une réponse'
)}
isDisabled={isLoading}
maxLength={SHORT_MAX_LENGTH}
/>
</StyledDiv>
<StyledButton isDisabled={isLoading} type="submit">
{t('Envoyer')}
</StyledButton>
</form>
</StyledFeedback>
)}
</ScrollToElement>
)
}
@ -82,15 +102,39 @@ const StyledFeedback = styled.div`
font-family: ${({ theme }) => theme.fonts.main};
text-align: left;
textarea,
input {
width: 100%;
font-family: inherit;
font-size: inherit;
line-height: inherit;
padding: ${({ theme }) => theme.spacings.sm};
margin-top: ${({ theme }) => theme.spacings.xs};
border-radius: ${({ theme }) => theme.box.borderRadius};
border: 1px solid ${({ theme }) => theme.colors.extended.grey[500]};
label {
margin-bottom: 0.5rem;
display: block;
}
`
const StyledTextArea = styled(TextAreaField)`
width: 100%;
font-size: 1rem;
line-height: 1.5rem;
padding: ${({ theme }) => theme.spacings.sm};
border-radius: ${({ theme }) => theme.box.borderRadius};
font-family: ${({ theme }) => theme.fonts.main};
`
const StyledTextField = styled(TextField)`
font-size: 1rem;
line-height: 1.5rem;
padding: ${({ theme }) => theme.spacings.sm};
font-family: ${({ theme }) => theme.fonts.main};
`
const StyledButton = styled(Button)`
margin-top: 1rem;
`
const StyledBody = styled(Body)`
font-size: 1.25rem;
font-family: ${({ theme }) => theme.fonts.main};
text-align: center;
padding: 1rem 0;
`
const StyledDiv = styled.div`
margin-top: 1rem;
`

View File

@ -145,7 +145,7 @@ export default function PageFeedback({ customMessage }: PageFeedbackProps) {
{state.showForm && (
<Popover
isOpen
title="Votre avis nous interesse"
title="Votre avis nous intéresse"
isDismissable
onClose={() => setState({ showThanks: true, showForm: false })}
small

View File

@ -0,0 +1,214 @@
import { AriaTextFieldOptions, useTextField } from '@react-aria/textfield'
import { ExtraSmallBody } from '@/design-system/typography/paragraphs'
import { HTMLAttributes, RefObject, useRef } from 'react'
import styled, { css } from 'styled-components'
const LABEL_HEIGHT = '1rem'
type TextAreaFieldProps = AriaTextFieldOptions<'textarea'> & {
inputRef?: RefObject<HTMLTextAreaElement>
small?: boolean
rows?: number | undefined
}
export default function TextAreaField(props: TextAreaFieldProps) {
const ref = useRef<HTMLTextAreaElement>(null)
const { labelProps, inputProps, descriptionProps, errorMessageProps } =
useTextField(
{ ...props, inputElementType: 'textarea' },
props.inputRef || ref
)
return (
<StyledContainer>
<StyledTextAreaContainer
hasError={!!props.errorMessage || props.validationState === 'invalid'}
hasLabel={!!props.label && !props.small}
>
<StyledTextArea
{...(props as HTMLAttributes<HTMLTextAreaElement>)}
{...(inputProps as HTMLAttributes<HTMLTextAreaElement>)}
placeholder={inputProps.placeholder ?? ''}
ref={props.inputRef || ref}
/>
{props.label && (
<StyledLabel className={props.small ? 'sr-only' : ''} {...labelProps}>
{props.label}
</StyledLabel>
)}
</StyledTextAreaContainer>
{props.errorMessage && (
<StyledErrorMessage {...errorMessageProps}>
{props.errorMessage}
</StyledErrorMessage>
)}
{props.description && (
<StyledDescription {...descriptionProps}>
{props.description}
</StyledDescription>
)}
</StyledContainer>
)
}
export const StyledContainer = styled.div`
width: 100%;
`
export const StyledTextArea = styled.textarea`
font-size: 1rem;
line-height: 1.5rem;
border: none;
width: 100%;
background: none;
font-family: ${({ theme }) => theme.fonts.main};
height: 100%;
outline: none;
transition: color 0.2s;
::placeholder {
${({ theme }) =>
theme.darkMode &&
css`
opacity: 0.6;
`}
color: ${({ theme }) =>
theme.colors.extended.grey[theme.darkMode ? 200 : 600]};
}
${({ theme }) =>
theme.darkMode &&
css`
@media not print {
color: ${theme.colors.extended.grey[100]} !important;
}
`}
`
export const StyledLabel = styled.label`
top: 0%;
left: 0;
pointer-events: none;
transform: translateY(0%);
font-size: 0.75rem;
line-height: ${LABEL_HEIGHT};
font-family: ${({ theme }) => theme.fonts.main};
padding: ${({ theme }) => `${theme.spacings.xxs} ${theme.spacings.sm}`};
position: absolute;
will-change: transform top font-size line-height color;
transition: all 0.1s;
${({ theme }) =>
theme.darkMode &&
css`
@media not print {
color: ${theme.colors.extended.grey[100]} !important;
}
`}
`
export const StyledDescription = styled(ExtraSmallBody)`
padding: ${({ theme }) => `${theme.spacings.xxs} ${theme.spacings.sm}`};
will-change: color;
transition: color 0.2s;
margin-top: 0;
`
export const StyledErrorMessage = styled(StyledDescription)`
color: ${({ theme }) => theme.colors.extended.error[400]} !important;
`
export const StyledSuffix = styled.span`
font-size: 1rem;
line-height: 1.5rem;
font-family: ${({ theme }) => theme.fonts.main};
`
export const StyledTextAreaContainer = styled.div<{
hasError: boolean
hasLabel: boolean
small?: boolean
}>`
border-radius: ${({ theme }) => theme.box.borderRadius};
border: ${({ theme }) =>
`${theme.box.borderWidth} solid ${
theme.darkMode
? theme.colors.extended.grey[100]
: theme.colors.extended.grey[700]
}`};
outline: transparent solid 1px;
position: relative;
display: flex;
background-color: ${({ theme }) =>
theme.darkMode
? 'rgba(255, 255, 255, 20%)'
: theme.colors.extended.grey[100]};
align-items: center;
transition: all 0.2s;
:focus-within {
outline-color: ${({ theme, hasError }) =>
hasError
? theme.colors.extended.error[400]
: theme.darkMode
? theme.colors.bases.primary[100]
: theme.colors.bases.primary[700]};
}
:focus-within ${StyledLabel} {
color: ${({ theme }) => theme.colors.bases.primary[800]};
}
:focus-within + ${StyledDescription} {
${({ theme }) =>
!theme.darkMode &&
css`
color: ${theme.colors.bases.primary[800]};
`}
}
${({ hasLabel }) =>
hasLabel &&
css`
${StyledTextArea}:not(:focus):placeholder-shown {
color: transparent;
}
${StyledTextArea}:not(:focus):placeholder-shown + ${StyledSuffix} {
color: transparent;
}
`}
${StyledTextArea}:not(:focus):placeholder-shown:not(:empty) + ${StyledLabel} {
font-size: 1rem;
line-height: 1.5rem;
top: 50%;
transform: translateY(-50%);
}
${({ theme, hasError }) =>
hasError &&
css`
&& {
border-color: ${theme.colors.extended.error[400]};
}
&&& label {
color: ${theme.colors.extended.error[400]};
}
`}
${StyledTextArea}, ${StyledSuffix} {
padding: ${({ hasLabel, theme, small }) =>
small
? css`
${theme.spacings.xxs} ${theme.spacings.xs}
`
: css`calc(${hasLabel ? LABEL_HEIGHT : '0rem'} + ${
theme.spacings.xs
}) ${theme.spacings.sm} ${theme.spacings.xs}`};
}
${({ small }) =>
small &&
css`
${StyledSuffix}, ${StyledTextArea} {
font-size: 1rem;
line-height: 1.25rem;
}
`}
`

View File

@ -104,6 +104,7 @@ export const StyledDescription = styled(ExtraSmallBody)`
padding: ${({ theme }) => `${theme.spacings.xxs} ${theme.spacings.sm}`};
will-change: color;
transition: color 0.2s;
margin-top: 0;
`
export const StyledErrorMessage = styled(StyledDescription)`

View File

@ -4,3 +4,4 @@ export { default as DateField } from './DateField'
export { default as NumberField } from './NumberField'
export { default as SearchField } from './SearchField'
export { default as TextField } from './TextField'
export { default as TextAreaField } from './TextAreaField'

262
yarn.lock
View File

@ -6481,6 +6481,13 @@ __metadata:
languageName: node
linkType: hard
"@socket.io/component-emitter@npm:~3.0.0":
version: 3.0.0
resolution: "@socket.io/component-emitter@npm:3.0.0"
checksum: b5e909dbb16bcf27958d1bfb8319f3255f3a50f62fde78ecf9a584f39f916b928fdc5661519892eea912da082c6413d671c1e67bde70725c75ee62956aa67c26
languageName: node
linkType: hard
"@storybook/addon-actions@npm:6.5.0-alpha.55, @storybook/addon-actions@npm:^6.5.0-alpha.49":
version: 6.5.0-alpha.55
resolution: "@storybook/addon-actions@npm:6.5.0-alpha.55"
@ -10419,7 +10426,7 @@ __metadata:
languageName: node
linkType: hard
"asap@npm:^2.0.0":
"asap@npm:^2.0.0, asap@npm:~2.0.3":
version: 2.0.6
resolution: "asap@npm:2.0.6"
checksum: b296c92c4b969e973260e47523207cd5769abd27c245a68c26dc7a0fe8053c55bb04360237cb51cab1df52be939da77150ace99ad331fb7fb13b3423ed73ff3d
@ -10843,6 +10850,13 @@ __metadata:
languageName: node
linkType: hard
"backo2@npm:~1.0.2":
version: 1.0.2
resolution: "backo2@npm:1.0.2"
checksum: fda8d0a0f4810068d23715f2f45153146d6ee8f62dd827ce1e0b6cc3c8328e84ad61e11399a83931705cef702fe7cbb457856bf99b9bd10c4ed57b0786252385
languageName: node
linkType: hard
"backoff@npm:^2.5.0":
version: 2.5.0
resolution: "backoff@npm:2.5.0"
@ -12960,6 +12974,17 @@ __metadata:
languageName: node
linkType: hard
"crisp-api@npm:6.3.0":
version: 6.3.0
resolution: "crisp-api@npm:6.3.0"
dependencies:
fbemitter: 3.0.0
got: 9.6.0
socket.io-client: 4.4.1
checksum: 684000db238933c59f752aed87833ca27f1da7a3fe23b1f46d9c8b166cd8a7f99f35417d5fa48bb3aa23f39206527de91b26568d5147ccbe238419189de99cb3
languageName: node
linkType: hard
"cron-parser@npm:^4.1.0, cron-parser@npm:^4.2.1":
version: 4.5.0
resolution: "cron-parser@npm:4.5.0"
@ -13408,7 +13433,7 @@ __metadata:
languageName: node
linkType: hard
"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.0.1, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4":
"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.0.1, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:~4.3.1, debug@npm:~4.3.2":
version: 4.3.4
resolution: "debug@npm:4.3.4"
dependencies:
@ -14354,6 +14379,30 @@ __metadata:
languageName: node
linkType: hard
"engine.io-client@npm:~6.1.1":
version: 6.1.1
resolution: "engine.io-client@npm:6.1.1"
dependencies:
"@socket.io/component-emitter": ~3.0.0
debug: ~4.3.1
engine.io-parser: ~5.0.0
has-cors: 1.1.0
parseqs: 0.0.6
parseuri: 0.0.6
ws: ~8.2.3
xmlhttprequest-ssl: ~2.0.0
yeast: 0.1.2
checksum: c2e1cec87ac8cf45842527bd072d1b2c5f14fbf9e57f110b4120335ed7bf5310a86da0d33b5906dd4774094ee499d534a498db467d3c1cb53c7a1109a593b05d
languageName: node
linkType: hard
"engine.io-parser@npm:~5.0.0":
version: 5.0.4
resolution: "engine.io-parser@npm:5.0.4"
checksum: d4ad0cef6ff63c350e35696da9bb3dbd180f67b56e93e90375010cc40393e6c0639b780d5680807e1d93a7e2e3d7b4a1c3b27cf75db28eb8cbf605bc1497da03
languageName: node
linkType: hard
"enhanced-resolve@npm:^4.5.0":
version: 4.5.0
resolution: "enhanced-resolve@npm:4.5.0"
@ -16103,6 +16152,37 @@ __metadata:
languageName: node
linkType: hard
"fbemitter@npm:3.0.0":
version: 3.0.0
resolution: "fbemitter@npm:3.0.0"
dependencies:
fbjs: ^3.0.0
checksum: 069690b8cdff3521ade3c9beb92ba0a38d818a86ef36dff8690e66749aef58809db4ac0d6938eb1cacea2dbef5f2a508952d455669590264cdc146bbe839f605
languageName: node
linkType: hard
"fbjs-css-vars@npm:^1.0.0":
version: 1.0.2
resolution: "fbjs-css-vars@npm:1.0.2"
checksum: 72baf6d22c45b75109118b4daecb6c8016d4c83c8c0f23f683f22e9d7c21f32fff6201d288df46eb561e3c7d4bb4489b8ad140b7f56444c453ba407e8bd28511
languageName: node
linkType: hard
"fbjs@npm:^3.0.0":
version: 3.0.4
resolution: "fbjs@npm:3.0.4"
dependencies:
cross-fetch: ^3.1.5
fbjs-css-vars: ^1.0.0
loose-envify: ^1.0.0
object-assign: ^4.1.0
promise: ^7.1.1
setimmediate: ^1.0.5
ua-parser-js: ^0.7.30
checksum: 8b23a3550fcda8a9109fca9475a3416590c18bb6825ea884192864ed686f67fcd618e308a140c9e5444fbd0168732e1ff3c092ba3d0c0ae1768969f32ba280c7
languageName: node
linkType: hard
"fd-slicer@npm:~1.1.0":
version: 1.1.0
resolution: "fd-slicer@npm:1.1.0"
@ -17335,6 +17415,25 @@ __metadata:
languageName: node
linkType: hard
"got@npm:9.6.0, got@npm:^9.6.0":
version: 9.6.0
resolution: "got@npm:9.6.0"
dependencies:
"@sindresorhus/is": ^0.14.0
"@szmarczak/http-timer": ^1.1.2
cacheable-request: ^6.0.0
decompress-response: ^3.3.0
duplexer3: ^0.1.4
get-stream: ^4.1.0
lowercase-keys: ^1.0.1
mimic-response: ^1.0.1
p-cancelable: ^1.0.0
to-readable-stream: ^1.0.0
url-parse-lax: ^3.0.0
checksum: 941807bd9704bacf5eb401f0cc1212ffa1f67c6642f2d028fd75900471c221b1da2b8527f4553d2558f3faeda62ea1cf31665f8b002c6137f5de8732f07370b0
languageName: node
linkType: hard
"got@npm:^10.0.0, got@npm:^10.7.0":
version: 10.7.0
resolution: "got@npm:10.7.0"
@ -17402,25 +17501,6 @@ __metadata:
languageName: node
linkType: hard
"got@npm:^9.6.0":
version: 9.6.0
resolution: "got@npm:9.6.0"
dependencies:
"@sindresorhus/is": ^0.14.0
"@szmarczak/http-timer": ^1.1.2
cacheable-request: ^6.0.0
decompress-response: ^3.3.0
duplexer3: ^0.1.4
get-stream: ^4.1.0
lowercase-keys: ^1.0.1
mimic-response: ^1.0.1
p-cancelable: ^1.0.0
to-readable-stream: ^1.0.0
url-parse-lax: ^3.0.0
checksum: 941807bd9704bacf5eb401f0cc1212ffa1f67c6642f2d028fd75900471c221b1da2b8527f4553d2558f3faeda62ea1cf31665f8b002c6137f5de8732f07370b0
languageName: node
linkType: hard
"graceful-fs@npm:^4.0.0, graceful-fs@npm:^4.1.10, graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.15, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.3, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9":
version: 4.2.10
resolution: "graceful-fs@npm:4.2.10"
@ -17489,6 +17569,13 @@ __metadata:
languageName: node
linkType: hard
"has-cors@npm:1.1.0":
version: 1.1.0
resolution: "has-cors@npm:1.1.0"
checksum: 549ce94113fd23895b22d71ade9809918577b8558cd4d701fe79045d8b1d58d87eba870260b28f6a3229be933a691c55653afd496d0fc52e98fd2ff577f01197
languageName: node
linkType: hard
"has-flag@npm:^3.0.0":
version: 3.0.0
resolution: "has-flag@npm:3.0.0"
@ -23256,6 +23343,20 @@ __metadata:
languageName: node
linkType: hard
"parseqs@npm:0.0.6":
version: 0.0.6
resolution: "parseqs@npm:0.0.6"
checksum: 7fc4ff4ba59764060bb8529875f6d4313056ea6939ff579b22dd7bd6f6033035e1fd2d6a559ab48ef0a7fa29a9d7731c982bfd1594e9115141fe1c328485ce9e
languageName: node
linkType: hard
"parseuri@npm:0.0.6":
version: 0.0.6
resolution: "parseuri@npm:0.0.6"
checksum: fa430e40f0c75293a28e5f1023da5f51a5038d5e34c48c517b0d5187143f6bcc67d3091a062b68765db4a22757e488c7d15854f9d1921f2c2b9afa5ca0629a84
languageName: node
linkType: hard
"parseurl@npm:^1.3.2, parseurl@npm:~1.3.2, parseurl@npm:~1.3.3":
version: 1.3.3
resolution: "parseurl@npm:1.3.3"
@ -23929,6 +24030,15 @@ __metadata:
languageName: node
linkType: hard
"promise@npm:^7.1.1":
version: 7.3.1
resolution: "promise@npm:7.3.1"
dependencies:
asap: ~2.0.3
checksum: 475bb069130179fbd27ed2ab45f26d8862376a137a57314cf53310bdd85cc986a826fd585829be97ebc0aaf10e9d8e68be1bfe5a4a0364144b1f9eedfa940cf1
languageName: node
linkType: hard
"prompts@npm:^2.4.0":
version: 2.4.2
resolution: "prompts@npm:2.4.2"
@ -25951,6 +26061,31 @@ __metadata:
languageName: node
linkType: hard
"server@workspace:server":
version: 0.0.0-use.local
resolution: "server@workspace:server"
dependencies:
"@breejs/later": ^4.1.0
"@koa/cors": ^3.4.1
"@koa/router": ^12.0.0
"@types/koa": ^2.13.5
"@types/koa__cors": ^3.3.0
"@types/koa__router": ^12.0.0
"@types/node": ^18.8.3
"@types/safe-timers": ^1.1.0
bree: ^9.1.2
crisp-api: 6.3.0
dotenv: ^16.0.3
got: ^12.5.1
koa: ^2.13.4
koa-body: ^5.0.0
mongodb: ^4.10.0
nodemon: ^2.0.20
ts-node: ^10.9.1
typescript: ^4.8.4
languageName: unknown
linkType: soft
"set-blocking@npm:^2.0.0":
version: 2.0.0
resolution: "set-blocking@npm:2.0.0"
@ -25970,7 +26105,7 @@ __metadata:
languageName: node
linkType: hard
"setimmediate@npm:^1.0.4":
"setimmediate@npm:^1.0.4, setimmediate@npm:^1.0.5":
version: 1.0.5
resolution: "setimmediate@npm:1.0.5"
checksum: c9a6f2c5b51a2dabdc0247db9c46460152ffc62ee139f3157440bd48e7c59425093f42719ac1d7931f054f153e2d26cf37dfeb8da17a794a58198a2705e527fd
@ -26312,6 +26447,30 @@ __metadata:
languageName: node
linkType: hard
"socket.io-client@npm:4.4.1":
version: 4.4.1
resolution: "socket.io-client@npm:4.4.1"
dependencies:
"@socket.io/component-emitter": ~3.0.0
backo2: ~1.0.2
debug: ~4.3.2
engine.io-client: ~6.1.1
parseuri: 0.0.6
socket.io-parser: ~4.1.1
checksum: 4c7f77f439b72f851fa162603b45bdabc94154ad6bd7b1d1c1658b45cd25d7f495293c08f71cd3ce96bccc477045d99840c10a92e1da9c13e9a5ff939edde30d
languageName: node
linkType: hard
"socket.io-parser@npm:~4.1.1":
version: 4.1.2
resolution: "socket.io-parser@npm:4.1.2"
dependencies:
"@socket.io/component-emitter": ~3.0.0
debug: ~4.3.1
checksum: cd13cdbda929cce610b39fbf7f2c6aa59e55cfc58f13b38c592d7eb45b19d5110bcb81150607a88f8644959f5d0a384467a2083d29c12e224c010a406377649b
languageName: node
linkType: hard
"socks-proxy-agent@npm:^7.0.0":
version: 7.0.0
resolution: "socks-proxy-agent@npm:7.0.0"
@ -26636,29 +26795,6 @@ __metadata:
languageName: node
linkType: hard
"standup-mattermost-bot@workspace:standup-mattermost-bot":
version: 0.0.0-use.local
resolution: "standup-mattermost-bot@workspace:standup-mattermost-bot"
dependencies:
"@breejs/later": ^4.1.0
"@koa/cors": ^3.4.1
"@koa/router": ^12.0.0
"@types/koa": ^2.13.5
"@types/koa__cors": ^3.3.0
"@types/koa__router": ^12.0.0
"@types/node": ^18.8.3
"@types/safe-timers": ^1.1.0
bree: ^9.1.2
dotenv: ^16.0.3
got: ^12.5.1
koa: ^2.13.4
mongodb: ^4.10.0
nodemon: ^2.0.20
ts-node: ^10.9.1
typescript: ^4.8.4
languageName: unknown
linkType: soft
"state-toggle@npm:^1.0.0":
version: 1.0.3
resolution: "state-toggle@npm:1.0.3"
@ -28270,6 +28406,13 @@ __metadata:
languageName: node
linkType: hard
"ua-parser-js@npm:^0.7.30":
version: 0.7.31
resolution: "ua-parser-js@npm:0.7.31"
checksum: e2f8324a83d1715601576af85b2b6c03890699aaa7272950fc77ea925c70c5e4f75060ae147dc92124e49f7f0e3d6dd2b0a91e7f40d267e92df8894be967ba8b
languageName: node
linkType: hard
"uglify-js@npm:^3.1.4":
version: 3.16.2
resolution: "uglify-js@npm:3.16.2"
@ -29937,6 +30080,21 @@ __metadata:
languageName: node
linkType: hard
"ws@npm:~8.2.3":
version: 8.2.3
resolution: "ws@npm:8.2.3"
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ^5.0.2
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
checksum: c869296ccb45f218ac6d32f8f614cd85b50a21fd434caf11646008eef92173be53490810c5c23aea31bc527902261fbfd7b062197eea341b26128d4be56a85e4
languageName: node
linkType: hard
"x-default-browser@npm:^0.4.0":
version: 0.4.0
resolution: "x-default-browser@npm:0.4.0"
@ -29975,6 +30133,13 @@ __metadata:
languageName: node
linkType: hard
"xmlhttprequest-ssl@npm:~2.0.0":
version: 2.0.0
resolution: "xmlhttprequest-ssl@npm:2.0.0"
checksum: 1e98df67f004fec15754392a131343ea92e6ab5ac4d77e842378c5c4e4fd5b6a9134b169d96842cc19422d77b1606b8df84a5685562b3b698cb68441636f827e
languageName: node
linkType: hard
"xtend@npm:^4.0.0, xtend@npm:^4.0.1, xtend@npm:~4.0.0, xtend@npm:~4.0.1":
version: 4.0.2
resolution: "xtend@npm:4.0.2"
@ -30107,6 +30272,13 @@ __metadata:
languageName: node
linkType: hard
"yeast@npm:0.1.2":
version: 0.1.2
resolution: "yeast@npm:0.1.2"
checksum: 81a250b69f601fed541e9518eb2972e75631dd81231689503d7f288612d4eec793b29c208d6807fd6bfc4c2a43614d0c6db233739a4ae6223e244aaed6a885c0
languageName: node
linkType: hard
"ylru@npm:^1.2.0":
version: 1.3.2
resolution: "ylru@npm:1.3.2"