Skip to content

Commit 5446099

Browse files
committed
feat: add NPS on transport
1 parent 4d468e9 commit 5446099

14 files changed

Lines changed: 648 additions & 15 deletions

File tree

src/components/layout/Footer.module.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
border-radius: 12.5rem 12.5rem 0 0;
1818
background-color: var(--neutral-00);
1919
border: 2px solid var(--primary-30);
20-
width: 19.25rem;
20+
width: min(19.25rem, 100%);
2121
height: 5rem;
2222
top: 0;
2323
left: 50%;

src/components/nps/NPS.module.css

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
.parent {
2+
position: absolute;
3+
height: 100%;
4+
right: 1.5rem;
5+
bottom: 1.5rem;
6+
display: flex;
7+
flex-direction: column;
8+
justify-content: flex-end;
9+
10+
@media screen and (max-width: 40rem) {
11+
right: 0;
12+
bottom: 0;
13+
width: 100%;
14+
}
15+
}
16+
17+
.container {
18+
background-color: var(--neutral-00);
19+
border: 2px solid var(--primary-30);
20+
border-radius: 0.5rem;
21+
position: sticky;
22+
bottom: 1.5rem;
23+
width: 27.25rem;
24+
min-height: 14.75rem;
25+
z-index: 1000;
26+
padding: 1.5rem 2rem;
27+
display: flex;
28+
flex-direction: column;
29+
gap: 0.75rem;
30+
31+
@media screen and (max-width: 40rem) {
32+
width: 100%;
33+
border: none;
34+
border-radius: 0 0 1rem 1rem;
35+
border-top: 2px solid var(--primary-40);
36+
align-items: center;
37+
text-align: center;
38+
bottom: 0;
39+
padding: 1.5rem 0.75rem;
40+
}
41+
}
42+
43+
.closeButton {
44+
position: absolute;
45+
top: -1rem;
46+
right: -1rem;
47+
height: 2.5rem;
48+
width: 2.5rem;
49+
svg {
50+
width: 1.25rem;
51+
height: auto;
52+
}
53+
54+
border: 2px solid var(--primary-30);
55+
background-color: var(--neutral-00);
56+
border-radius: 50%;
57+
color: var(--primary-50);
58+
cursor: pointer;
59+
display: flex;
60+
justify-content: center;
61+
align-items: center;
62+
63+
&:hover {
64+
color: var(--primary-60);
65+
background-color: var(--primary-10);
66+
}
67+
68+
&:active {
69+
color: var(--primary-70);
70+
background-color: var(--primary-20);
71+
}
72+
73+
@media screen and (max-width: 40rem) {
74+
top: 0.5rem;
75+
right: 0.5rem;
76+
}
77+
}
78+
79+
.question {
80+
color: var(--neutral-80);
81+
font-weight: 500;
82+
@media screen and (max-width: 40rem) {
83+
padding: 0 2.75rem;
84+
}
85+
}
86+
87+
.input {
88+
width: 100%;
89+
}
90+
91+
.buttonContainer {
92+
flex: 1;
93+
display: flex;
94+
align-items: flex-end;
95+
width: 100%;
96+
}
97+
98+
.button {
99+
width: 100%;
100+
}
101+
102+
.thanks {
103+
text-align: center;
104+
}
105+
106+
.subtitle {
107+
color: var(--neutral-50);
108+
margin-top: 0.75rem;
109+
}
110+
111+
.errorAlert {
112+
width: 100%;
113+
color: var(--critical-50);
114+
font-size: 0.75rem;
115+
}

src/components/nps/NPS.tsx

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
'use client'
2+
3+
import { useTranslations } from 'next-intl'
4+
import { useSearchParams } from 'next/navigation'
5+
import { useEffect, useRef, useState } from 'react'
6+
import useParamContext from 'src/providers/ParamProvider'
7+
import { addNpsNote, updateNpsRetour } from 'src/serverFunctions/nps'
8+
import { getSource } from 'src/utils/matomo'
9+
import { Point } from 'src/hooks/useItineraries'
10+
import Button from '../base/buttons/Button'
11+
import CloseIcon from '../base/icons/close'
12+
import TextArea from '../form/TextArea'
13+
import ScoreInput from './ScoreInput'
14+
import styles from './NPS.module.css'
15+
16+
const NPS_SEEN_STORAGE_KEY = 'impactco2_nps_seen_at'
17+
const NPS_SEEN_TTL =
18+
(process.env.NEXT_PUBLIC_NPS_RESET_TIME_SECONDS
19+
? parseInt(process.env.NEXT_PUBLIC_NPS_RESET_TIME_SECONDS, 10)
20+
: 30 * 24 * 60 * 60) * 1000
21+
22+
const getTimeout = (
23+
pathname: string,
24+
params: URLSearchParams,
25+
{ km, start, end }: { km: number; start?: Point; end?: Point }
26+
) => {
27+
const initialKm = params.get('km') ? parseInt(params.get('km') || '0', 10) : 10
28+
if (km && initialKm !== km) {
29+
// L'utilisateur a rempli une distance
30+
return 20000
31+
}
32+
33+
if (start && end) {
34+
// L'utilisateur a rempli un itinéraire
35+
return 20000
36+
}
37+
38+
// L'utilisateur n'a pas encore engagé avec l'outil, timeout en fonction de l'outil
39+
const tabs = params.get('tabs')
40+
if (tabs === 'itineraire') {
41+
return 15000
42+
} else if (tabs === 'distance') {
43+
return 45000
44+
}
45+
46+
if (pathname.includes('itineraire')) {
47+
return 15000
48+
}
49+
50+
return 45000
51+
}
52+
53+
const NPS = ({ tracking }: { tracking: string }) => {
54+
const t = useTranslations('nps')
55+
const searchParams = useSearchParams()
56+
const {
57+
distance: { km },
58+
itineraire: { start, end },
59+
transport: { selected: transportTabSelected },
60+
} = useParamContext()
61+
62+
const [display, setDisplay] = useState(false)
63+
const [blockedBySeen, setBlockedBySeen] = useState(false)
64+
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
65+
66+
const [selected, setSelected] = useState<number | null>(null)
67+
const [text, setText] = useState('')
68+
const [step, setStep] = useState(0)
69+
const [id, setId] = useState('')
70+
const [closed, setClosed] = useState(false)
71+
const [sending, setSending] = useState(false)
72+
const [error, setError] = useState(false)
73+
74+
const send = async () => {
75+
if (sending) {
76+
return
77+
}
78+
79+
setError(false)
80+
setSending(true)
81+
try {
82+
switch (step) {
83+
case 0:
84+
if (selected === null) {
85+
return
86+
}
87+
const tabs = searchParams.get('tabs')
88+
const start = searchParams.get('itineraireStart')
89+
const end = searchParams.get('itineraireEnd')
90+
91+
const createdId = await addNpsNote({
92+
note: selected,
93+
tracking: `${tracking} ${transportTabSelected}`,
94+
source: getSource(),
95+
params: [tabs ? `tabs=${tabs}` : null, start ? 'start' : null, end ? 'end' : null]
96+
.filter(Boolean)
97+
.join(','),
98+
})
99+
if (!createdId) {
100+
setError(true)
101+
return
102+
}
103+
setId(createdId)
104+
setStep(1)
105+
return
106+
case 1:
107+
if (!id || !text.trim()) {
108+
return
109+
}
110+
const updated = await updateNpsRetour(id, text.trim())
111+
if (!updated) {
112+
setError(true)
113+
return
114+
}
115+
setStep(2)
116+
return
117+
case 2:
118+
setClosed(true)
119+
return
120+
default:
121+
return
122+
}
123+
} finally {
124+
setSending(false)
125+
}
126+
}
127+
128+
useEffect(() => {
129+
const seenAtRaw = localStorage.getItem(NPS_SEEN_STORAGE_KEY)
130+
if (!seenAtRaw) {
131+
return
132+
}
133+
134+
const seenAt = Number.parseInt(seenAtRaw, 10)
135+
if (Number.isNaN(seenAt)) {
136+
localStorage.removeItem(NPS_SEEN_STORAGE_KEY)
137+
return
138+
}
139+
140+
if (Date.now() - seenAt < NPS_SEEN_TTL) {
141+
setBlockedBySeen(true)
142+
return
143+
}
144+
145+
localStorage.removeItem(NPS_SEEN_STORAGE_KEY)
146+
}, [])
147+
148+
useEffect(() => {
149+
if (timeoutRef.current) {
150+
clearTimeout(timeoutRef.current)
151+
}
152+
153+
if (blockedBySeen) {
154+
return
155+
}
156+
157+
if (display) {
158+
return
159+
}
160+
161+
const timeout = getTimeout(window.location.pathname, searchParams, { km, start, end })
162+
console.log('NPS timeout set to', timeout)
163+
timeoutRef.current = setTimeout(() => {
164+
localStorage.setItem(NPS_SEEN_STORAGE_KEY, Date.now().toString())
165+
setDisplay(true)
166+
}, timeout)
167+
168+
return () => {
169+
if (timeoutRef.current) {
170+
clearTimeout(timeoutRef.current)
171+
}
172+
}
173+
}, [display, tracking, searchParams, km, start, end, blockedBySeen])
174+
175+
if (closed) {
176+
return null
177+
}
178+
179+
if (blockedBySeen) {
180+
return null
181+
}
182+
183+
if (!display) {
184+
return null
185+
}
186+
187+
return (
188+
<div className={styles.parent}>
189+
<div className={styles.container}>
190+
<button className={styles.closeButton} onClick={() => setClosed(true)} title={t('close')}>
191+
<CloseIcon />
192+
</button>
193+
{step === 0 && (
194+
<>
195+
<p className={styles.question}>{t('question')}</p>
196+
<div className={styles.input}>
197+
<ScoreInput selected={selected} setSelected={setSelected} />
198+
</div>
199+
</>
200+
)}
201+
{step === 1 && (
202+
<>
203+
<p className={styles.question}>{t('amelioration')}</p>
204+
<div className={styles.input}>
205+
<TextArea
206+
id='nps-improvement'
207+
value={text}
208+
onChange={(e) => setText(e.target.value)}
209+
placeholder={t('ameliorationPlaceholder')}
210+
rows={2}
211+
/>
212+
</div>
213+
</>
214+
)}
215+
{step === 2 && (
216+
<div className={styles.thanks}>
217+
<p className={styles.question}>{t('thanksTitle')}</p>
218+
<p className={styles.subtitle}>{t('thanks')}</p>
219+
</div>
220+
)}
221+
{error && (
222+
<div className={styles.errorAlert} role='alert' aria-live='polite'>
223+
{t('error')}
224+
</div>
225+
)}
226+
<div className={styles.buttonContainer}>
227+
<Button className={styles.button} onClick={send} disabled={sending}>
228+
{step === 2 ? t('close') : t('send')}
229+
</Button>
230+
</div>
231+
</div>
232+
</div>
233+
)
234+
}
235+
236+
export default NPS

0 commit comments

Comments
 (0)