Skip to content

Commit 5683e15

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

14 files changed

Lines changed: 647 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: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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+
} = useParamContext()
60+
61+
const [display, setDisplay] = useState(false)
62+
const [blockedBySeen, setBlockedBySeen] = useState(false)
63+
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
64+
65+
const [selected, setSelected] = useState<number | null>(null)
66+
const [text, setText] = useState('')
67+
const [step, setStep] = useState(0)
68+
const [id, setId] = useState('')
69+
const [closed, setClosed] = useState(false)
70+
const [sending, setSending] = useState(false)
71+
const [error, setError] = useState(false)
72+
73+
const send = async () => {
74+
if (sending) {
75+
return
76+
}
77+
78+
setError(false)
79+
setSending(true)
80+
try {
81+
switch (step) {
82+
case 0:
83+
if (selected === null) {
84+
return
85+
}
86+
const tabs = searchParams.get('tabs')
87+
const start = searchParams.get('itineraireStart')
88+
const end = searchParams.get('itineraireEnd')
89+
90+
const createdId = await addNpsNote({
91+
note: selected,
92+
tracking,
93+
source: getSource(),
94+
params: [tabs ? `tabs=${tabs}` : null, start ? 'start' : null, end ? 'end' : null]
95+
.filter(Boolean)
96+
.join(','),
97+
})
98+
if (!createdId) {
99+
setError(true)
100+
return
101+
}
102+
setId(createdId)
103+
setStep(1)
104+
return
105+
case 1:
106+
if (!id || !text.trim()) {
107+
return
108+
}
109+
const updated = await updateNpsRetour(id, text.trim())
110+
if (!updated) {
111+
setError(true)
112+
return
113+
}
114+
setStep(2)
115+
return
116+
case 2:
117+
setClosed(true)
118+
return
119+
default:
120+
return
121+
}
122+
} finally {
123+
setSending(false)
124+
}
125+
}
126+
127+
useEffect(() => {
128+
const seenAtRaw = localStorage.getItem(NPS_SEEN_STORAGE_KEY)
129+
if (!seenAtRaw) {
130+
return
131+
}
132+
133+
const seenAt = Number.parseInt(seenAtRaw, 10)
134+
if (Number.isNaN(seenAt)) {
135+
localStorage.removeItem(NPS_SEEN_STORAGE_KEY)
136+
return
137+
}
138+
139+
if (Date.now() - seenAt < NPS_SEEN_TTL) {
140+
setBlockedBySeen(true)
141+
return
142+
}
143+
144+
localStorage.removeItem(NPS_SEEN_STORAGE_KEY)
145+
}, [])
146+
147+
useEffect(() => {
148+
if (timeoutRef.current) {
149+
clearTimeout(timeoutRef.current)
150+
}
151+
152+
if (blockedBySeen) {
153+
return
154+
}
155+
156+
if (display) {
157+
return
158+
}
159+
160+
const timeout = getTimeout(window.location.pathname, searchParams, { km, start, end })
161+
console.log('NPS timeout set to', timeout)
162+
timeoutRef.current = setTimeout(() => {
163+
localStorage.setItem(NPS_SEEN_STORAGE_KEY, Date.now().toString())
164+
setDisplay(true)
165+
}, timeout)
166+
167+
return () => {
168+
if (timeoutRef.current) {
169+
clearTimeout(timeoutRef.current)
170+
}
171+
}
172+
}, [display, tracking, searchParams, km, start, end, blockedBySeen])
173+
174+
if (closed) {
175+
return null
176+
}
177+
178+
if (blockedBySeen) {
179+
return null
180+
}
181+
182+
if (!display) {
183+
return null
184+
}
185+
186+
return (
187+
<div className={styles.parent}>
188+
<div className={styles.container}>
189+
<button className={styles.closeButton} onClick={() => setClosed(true)} title={t('close')}>
190+
<CloseIcon />
191+
</button>
192+
{step === 0 && (
193+
<>
194+
<p className={styles.question}>{t('question')}</p>
195+
<div className={styles.input}>
196+
<ScoreInput selected={selected} setSelected={setSelected} />
197+
</div>
198+
</>
199+
)}
200+
{step === 1 && (
201+
<>
202+
<p className={styles.question}>{t('amelioration')}</p>
203+
<div className={styles.input}>
204+
<TextArea
205+
id='nps-improvement'
206+
value={text}
207+
onChange={(e) => setText(e.target.value)}
208+
placeholder={t('ameliorationPlaceholder')}
209+
rows={2}
210+
/>
211+
</div>
212+
</>
213+
)}
214+
{step === 2 && (
215+
<div className={styles.thanks}>
216+
<p className={styles.question}>{t('thanksTitle')}</p>
217+
<p className={styles.subtitle}>{t('thanks')}</p>
218+
</div>
219+
)}
220+
{error && (
221+
<div className={styles.errorAlert} role='alert' aria-live='polite'>
222+
{t('error')}
223+
</div>
224+
)}
225+
<div className={styles.buttonContainer}>
226+
<Button className={styles.button} onClick={send} disabled={sending}>
227+
{step === 2 ? t('close') : t('send')}
228+
</Button>
229+
</div>
230+
</div>
231+
</div>
232+
)
233+
}
234+
235+
export default NPS

0 commit comments

Comments
 (0)