Skip to content

Commit 4ddeddb

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

14 files changed

Lines changed: 646 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: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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) : km
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+
timeoutRef.current = setTimeout(() => {
162+
localStorage.setItem(NPS_SEEN_STORAGE_KEY, Date.now().toString())
163+
setDisplay(true)
164+
}, timeout)
165+
166+
return () => {
167+
if (timeoutRef.current) {
168+
clearTimeout(timeoutRef.current)
169+
}
170+
}
171+
}, [display, tracking, searchParams, km, start, end, blockedBySeen])
172+
173+
if (closed) {
174+
return null
175+
}
176+
177+
if (blockedBySeen) {
178+
return null
179+
}
180+
181+
if (!display) {
182+
return null
183+
}
184+
185+
return (
186+
<div className={styles.parent}>
187+
<div className={styles.container}>
188+
<button className={styles.closeButton} onClick={() => setClosed(true)} title={t('close')}>
189+
<CloseIcon />
190+
</button>
191+
{step === 0 && (
192+
<>
193+
<p className={styles.question}>{t('question')}</p>
194+
<div className={styles.input}>
195+
<ScoreInput selected={selected} setSelected={setSelected} />
196+
</div>
197+
</>
198+
)}
199+
{step === 1 && (
200+
<>
201+
<p className={styles.question}>{t('amelioration')}</p>
202+
<div className={styles.input}>
203+
<TextArea
204+
id='nps-improvement'
205+
value={text}
206+
onChange={(e) => setText(e.target.value)}
207+
placeholder={t('ameliorationPlaceholder')}
208+
rows={2}
209+
/>
210+
</div>
211+
</>
212+
)}
213+
{step === 2 && (
214+
<div className={styles.thanks}>
215+
<p className={styles.question}>{t('thanksTitle')}</p>
216+
<p className={styles.subtitle}>{t('thanks')}</p>
217+
</div>
218+
)}
219+
{error && (
220+
<div className={styles.errorAlert} role='alert' aria-live='polite'>
221+
{t('error')}
222+
</div>
223+
)}
224+
<div className={styles.buttonContainer}>
225+
<Button className={styles.button} onClick={send} disabled={sending}>
226+
{step === 2 ? t('close') : t('send')}
227+
</Button>
228+
</div>
229+
</div>
230+
</div>
231+
)
232+
}
233+
234+
export default NPS

0 commit comments

Comments
 (0)