@phygitallabs/phygital-consent 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/index.cjs +3 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +11 -14
- package/dist/index.d.ts +11 -14
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/phygital-consent.css +3 -0
- package/package.json +1 -1
- package/src/components/CookieConsentBanner.tsx +2 -2
- package/src/components/PolicyPopup.tsx +276 -0
- package/src/components/index.ts +1 -0
- package/src/provider/PhygitalConsentProvider.tsx +63 -20
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useCreateUserConsent } from "../hooks/useConsent";
|
|
5
|
+
|
|
6
|
+
const POLICY_PLACEHOLDER = `
|
|
7
|
+
Tanta petere igitur et tam longe abesse non opus est. Quodsi haberent magnalia inter potentiam et divitias, et tamen ista philosophia et disciplina vivendi. Quam ob rem ut illi superiores, nos usque ad hanc aetatem. Sed ut iis bonis erudiamur, quae gignuntur ex tempore. Et tamen rerum necessitatibus saepe vincimur. Quam ob rem ut illi superiores, nos usque ad hanc aetatem.
|
|
8
|
+
`.trim();
|
|
9
|
+
|
|
10
|
+
const REFUSAL_MESSAGE =
|
|
11
|
+
"Vì bạn không đồng ý với thỏa thuận này. Chúng tôi sẽ xóa tài khoản trong vòng x ngày";
|
|
12
|
+
|
|
13
|
+
export interface PolicyPopupProps {
|
|
14
|
+
open: boolean;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
userId: string;
|
|
17
|
+
/** Called after user agrees and consent is created successfully. */
|
|
18
|
+
onAgreeSuccess?: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function PolicyPopup({
|
|
22
|
+
open,
|
|
23
|
+
onClose,
|
|
24
|
+
userId,
|
|
25
|
+
onAgreeSuccess,
|
|
26
|
+
}: PolicyPopupProps) {
|
|
27
|
+
const [agreed, setAgreed] = useState(false);
|
|
28
|
+
const [showRefusalAlert, setShowRefusalAlert] = useState(false);
|
|
29
|
+
|
|
30
|
+
const createUserConsent = useCreateUserConsent({
|
|
31
|
+
onSuccess: () => {
|
|
32
|
+
onAgreeSuccess?.();
|
|
33
|
+
handleClose();
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const handleClose = () => {
|
|
38
|
+
setAgreed(false);
|
|
39
|
+
setShowRefusalAlert(false);
|
|
40
|
+
onClose();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handleRefuse = () => {
|
|
44
|
+
setShowRefusalAlert(true);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const handleRefusalAck = () => {
|
|
48
|
+
setShowRefusalAlert(false);
|
|
49
|
+
onClose();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const handleAgree = () => {
|
|
53
|
+
if (!agreed) return;
|
|
54
|
+
createUserConsent.mutate({
|
|
55
|
+
user_id: userId,
|
|
56
|
+
status: "ACCEPTED"
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (!open) return null;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<>
|
|
64
|
+
{/* Policy modal */}
|
|
65
|
+
<div
|
|
66
|
+
role="dialog"
|
|
67
|
+
aria-modal="true"
|
|
68
|
+
aria-label="Chính sách bảo mật"
|
|
69
|
+
style={{
|
|
70
|
+
position: "fixed",
|
|
71
|
+
inset: 0,
|
|
72
|
+
zIndex: 60,
|
|
73
|
+
display: "flex",
|
|
74
|
+
alignItems: "center",
|
|
75
|
+
justifyContent: "center",
|
|
76
|
+
backgroundColor: "rgba(0,0,0,0.5)",
|
|
77
|
+
padding: "1rem",
|
|
78
|
+
}}
|
|
79
|
+
onClick={(e) => e.target === e.currentTarget && handleClose()}
|
|
80
|
+
>
|
|
81
|
+
<div
|
|
82
|
+
className="consent:bg-white consent:rounded-xl consent:border consent:border-gray-200 consent:shadow-lg consent:max-h-[90vh] consent:overflow-hidden consent:flex consent:flex-col"
|
|
83
|
+
style={{
|
|
84
|
+
backgroundColor: "#fff",
|
|
85
|
+
borderRadius: "0.75rem",
|
|
86
|
+
border: "1px solid #e5e7eb",
|
|
87
|
+
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1)",
|
|
88
|
+
maxWidth: "28rem",
|
|
89
|
+
width: "100%",
|
|
90
|
+
maxHeight: "90vh",
|
|
91
|
+
overflow: "hidden",
|
|
92
|
+
display: "flex",
|
|
93
|
+
flexDirection: "column",
|
|
94
|
+
}}
|
|
95
|
+
onClick={(e) => e.stopPropagation()}
|
|
96
|
+
>
|
|
97
|
+
<h2
|
|
98
|
+
className="consent:text-lg consent:font-semibold consent:text-gray-900"
|
|
99
|
+
style={{
|
|
100
|
+
fontSize: "1.125rem",
|
|
101
|
+
fontWeight: 600,
|
|
102
|
+
color: "#2563eb",
|
|
103
|
+
padding: "1rem 1.25rem",
|
|
104
|
+
borderBottom: "1px solid #e5e7eb",
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
Chính sách bảo mật
|
|
108
|
+
</h2>
|
|
109
|
+
<div
|
|
110
|
+
style={{
|
|
111
|
+
flex: 1,
|
|
112
|
+
overflowY: "auto",
|
|
113
|
+
padding: "1rem 1.25rem",
|
|
114
|
+
fontSize: "0.875rem",
|
|
115
|
+
color: "#111827",
|
|
116
|
+
lineHeight: 1.6,
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
<p style={{ margin: 0, whiteSpace: "pre-wrap" }}>
|
|
120
|
+
{POLICY_PLACEHOLDER}
|
|
121
|
+
</p>
|
|
122
|
+
</div>
|
|
123
|
+
<div
|
|
124
|
+
style={{
|
|
125
|
+
padding: "1rem 1.25rem",
|
|
126
|
+
borderTop: "1px solid #e5e7eb",
|
|
127
|
+
display: "flex",
|
|
128
|
+
flexDirection: "column",
|
|
129
|
+
gap: "1rem",
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
<label
|
|
133
|
+
style={{
|
|
134
|
+
display: "flex",
|
|
135
|
+
alignItems: "center",
|
|
136
|
+
gap: "0.5rem",
|
|
137
|
+
fontSize: "0.875rem",
|
|
138
|
+
color: "#111827",
|
|
139
|
+
cursor: "pointer",
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
<input
|
|
143
|
+
type="checkbox"
|
|
144
|
+
checked={agreed}
|
|
145
|
+
onChange={(e) => setAgreed(e.target.checked)}
|
|
146
|
+
style={{ width: "1rem", height: "1rem", accentColor: "#111827" }}
|
|
147
|
+
aria-label="Tôi đồng ý với chính sách bảo mật trên"
|
|
148
|
+
/>
|
|
149
|
+
<span>Tôi đồng ý với chính sách bảo mật trên</span>
|
|
150
|
+
</label>
|
|
151
|
+
<div
|
|
152
|
+
style={{
|
|
153
|
+
display: "flex",
|
|
154
|
+
gap: "0.5rem",
|
|
155
|
+
flexWrap: "wrap",
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
onClick={handleRefuse}
|
|
161
|
+
disabled={createUserConsent.isPending}
|
|
162
|
+
className="consent:rounded-lg consent:bg-gray-900 consent:px-4 consent:py-2 consent:text-sm consent:font-medium consent:text-white consent:shadow-sm hover:consent:bg-gray-800 disabled:consent:opacity-50"
|
|
163
|
+
style={{
|
|
164
|
+
flex: 1,
|
|
165
|
+
minWidth: 0,
|
|
166
|
+
borderRadius: "0.5rem",
|
|
167
|
+
background: "#111827",
|
|
168
|
+
padding: "0.5rem 1rem",
|
|
169
|
+
fontSize: "0.875rem",
|
|
170
|
+
fontWeight: 500,
|
|
171
|
+
color: "#fff",
|
|
172
|
+
border: "none",
|
|
173
|
+
cursor: "pointer",
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
176
|
+
Bỏ qua
|
|
177
|
+
</button>
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
onClick={handleAgree}
|
|
181
|
+
disabled={!agreed || createUserConsent.isPending}
|
|
182
|
+
className="consent:rounded-lg consent:px-4 consent:py-2 consent:text-sm consent:font-medium consent:shadow-sm disabled:consent:opacity-50 disabled:consent:cursor-not-allowed"
|
|
183
|
+
style={{
|
|
184
|
+
flex: 1,
|
|
185
|
+
minWidth: 0,
|
|
186
|
+
borderRadius: "0.5rem",
|
|
187
|
+
background: agreed ? "#111827" : "#e5e7eb",
|
|
188
|
+
padding: "0.5rem 1rem",
|
|
189
|
+
fontSize: "0.875rem",
|
|
190
|
+
fontWeight: 500,
|
|
191
|
+
color: agreed ? "#fff" : "#9ca3af",
|
|
192
|
+
border: "none",
|
|
193
|
+
cursor: agreed ? "pointer" : "not-allowed",
|
|
194
|
+
}}
|
|
195
|
+
>
|
|
196
|
+
Tiếp tục
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
{createUserConsent.isError && (
|
|
200
|
+
<p
|
|
201
|
+
className="consent:text-sm consent:text-red-600"
|
|
202
|
+
style={{ fontSize: "0.875rem", color: "#dc2626", margin: 0 }}
|
|
203
|
+
>
|
|
204
|
+
Đã xảy ra lỗi. Vui lòng thử lại.
|
|
205
|
+
</p>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
{/* Refusal alert modal */}
|
|
212
|
+
{showRefusalAlert && (
|
|
213
|
+
<div
|
|
214
|
+
role="alertdialog"
|
|
215
|
+
aria-modal="true"
|
|
216
|
+
aria-label="Thông báo từ chối"
|
|
217
|
+
style={{
|
|
218
|
+
position: "fixed",
|
|
219
|
+
inset: 0,
|
|
220
|
+
zIndex: 70,
|
|
221
|
+
display: "flex",
|
|
222
|
+
alignItems: "center",
|
|
223
|
+
justifyContent: "center",
|
|
224
|
+
backgroundColor: "rgba(0,0,0,0.5)",
|
|
225
|
+
padding: "1rem",
|
|
226
|
+
}}
|
|
227
|
+
onClick={(e) => e.target === e.currentTarget && handleRefusalAck()}
|
|
228
|
+
>
|
|
229
|
+
<div
|
|
230
|
+
className="consent:bg-white consent:rounded-xl consent:border consent:border-gray-200 consent:shadow-lg"
|
|
231
|
+
style={{
|
|
232
|
+
backgroundColor: "#fff",
|
|
233
|
+
borderRadius: "0.75rem",
|
|
234
|
+
border: "1px solid #e5e7eb",
|
|
235
|
+
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1)",
|
|
236
|
+
maxWidth: "22rem",
|
|
237
|
+
width: "100%",
|
|
238
|
+
padding: "1.25rem",
|
|
239
|
+
}}
|
|
240
|
+
onClick={(e) => e.stopPropagation()}
|
|
241
|
+
>
|
|
242
|
+
<p
|
|
243
|
+
className="consent:text-sm consent:text-gray-700"
|
|
244
|
+
style={{
|
|
245
|
+
fontSize: "0.875rem",
|
|
246
|
+
color: "#374151",
|
|
247
|
+
margin: "0 0 1rem 0",
|
|
248
|
+
lineHeight: 1.5,
|
|
249
|
+
}}
|
|
250
|
+
>
|
|
251
|
+
{REFUSAL_MESSAGE}
|
|
252
|
+
</p>
|
|
253
|
+
<button
|
|
254
|
+
type="button"
|
|
255
|
+
onClick={handleRefusalAck}
|
|
256
|
+
className="consent:rounded-lg consent:bg-gray-900 consent:px-4 consent:py-2 consent:text-sm consent:font-medium consent:text-white consent:w-full"
|
|
257
|
+
style={{
|
|
258
|
+
borderRadius: "0.5rem",
|
|
259
|
+
background: "#111827",
|
|
260
|
+
padding: "0.5rem 1rem",
|
|
261
|
+
fontSize: "0.875rem",
|
|
262
|
+
fontWeight: 500,
|
|
263
|
+
color: "#fff",
|
|
264
|
+
border: "none",
|
|
265
|
+
cursor: "pointer",
|
|
266
|
+
width: "100%",
|
|
267
|
+
}}
|
|
268
|
+
>
|
|
269
|
+
Tôi đã hiểu
|
|
270
|
+
</button>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
)}
|
|
274
|
+
</>
|
|
275
|
+
);
|
|
276
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
|
|
4
4
|
import { CookieConsentBanner } from "../components/CookieConsentBanner";
|
|
5
|
+
import { PolicyPopup } from "../components/PolicyPopup";
|
|
5
6
|
import { getConsentPreferenceCookie } from "../helpers/cookie";
|
|
7
|
+
import { useLatestConsentByUserId } from "../hooks/useConsent";
|
|
6
8
|
import { ConsentServiceProvider, ConsentServiceProviderProps } from "./ConsentServiceProvider";
|
|
7
9
|
|
|
8
10
|
export interface PhygitalConsentContextValue {
|
|
9
11
|
deviceId: string;
|
|
10
|
-
/** True when cookie phygital_cookie_consent exists and has valid deviceId + selected_preferences. Use to show/hide cookie banner. */
|
|
11
12
|
hasConsentPreference: boolean;
|
|
12
|
-
/** Re-read cookie and update hasConsentPreference. Call after storing preference (e.g. from CookieConsentBanner onSuccess). */
|
|
13
13
|
refreshConsentPreference: () => void;
|
|
14
|
+
openPolicyPopup: (userId: string) => void;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
const PhygitalConsentContext = createContext<PhygitalConsentContextValue | undefined>(undefined);
|
|
@@ -18,29 +19,50 @@ const PhygitalConsentContext = createContext<PhygitalConsentContextValue | undef
|
|
|
18
19
|
export interface PhygitalConsentProviderProps extends ConsentServiceProviderProps {
|
|
19
20
|
deviceId: string;
|
|
20
21
|
children: React.ReactNode;
|
|
21
|
-
/**
|
|
22
|
-
* When true (default), render CookieConsentBanner automatically when hasConsentPreference is false.
|
|
23
|
-
* Set to false if you want to render the banner yourself (e.g. in a layout) using usePhygitalConsent().
|
|
24
|
-
*/
|
|
25
22
|
showBannerWhenNoPreference?: boolean;
|
|
26
23
|
}
|
|
27
24
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
*/
|
|
35
|
-
export function PhygitalConsentProvider(props: PhygitalConsentProviderProps) {
|
|
36
|
-
const { children, deviceId, showBannerWhenNoPreference = true, ...consentServiceProps } = props;
|
|
25
|
+
/** Inner provider: runs inside ConsentServiceProvider so useLatestConsentByUserId has context. */
|
|
26
|
+
function PhygitalConsentProviderInner({
|
|
27
|
+
children,
|
|
28
|
+
deviceId,
|
|
29
|
+
showBannerWhenNoPreference = true,
|
|
30
|
+
}: Pick<PhygitalConsentProviderProps, "children" | "deviceId" | "showBannerWhenNoPreference">) {
|
|
37
31
|
const [hasConsentPreference, setHasConsentPreference] = useState(false);
|
|
32
|
+
const [pendingUserId, setPendingUserId] = useState<string | null>(null);
|
|
33
|
+
const [showPolicyPopup, setShowPolicyPopup] = useState(false);
|
|
34
|
+
const [policyUserId, setPolicyUserId] = useState<string | null>(null);
|
|
35
|
+
|
|
36
|
+
const { data: latestConsent, isFetched } = useLatestConsentByUserId({
|
|
37
|
+
user_id: pendingUserId ?? "",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!pendingUserId || !isFetched) return;
|
|
42
|
+
if (latestConsent != null) {
|
|
43
|
+
setPendingUserId(null);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
setPolicyUserId(pendingUserId);
|
|
47
|
+
setShowPolicyPopup(true);
|
|
48
|
+
setPendingUserId(null);
|
|
49
|
+
}, [pendingUserId, isFetched, latestConsent]);
|
|
38
50
|
|
|
39
51
|
const refreshConsentPreference = useCallback(() => {
|
|
40
52
|
const stored = getConsentPreferenceCookie();
|
|
41
53
|
setHasConsentPreference(!!stored);
|
|
42
54
|
}, []);
|
|
43
55
|
|
|
56
|
+
const openPolicyPopup = useCallback((userId: string) => {
|
|
57
|
+
if (!userId) return;
|
|
58
|
+
setPendingUserId(userId);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
const closePolicyPopup = useCallback(() => {
|
|
62
|
+
setShowPolicyPopup(false);
|
|
63
|
+
setPolicyUserId(null);
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
44
66
|
useEffect(() => {
|
|
45
67
|
refreshConsentPreference();
|
|
46
68
|
}, [refreshConsentPreference]);
|
|
@@ -49,16 +71,37 @@ export function PhygitalConsentProvider(props: PhygitalConsentProviderProps) {
|
|
|
49
71
|
deviceId,
|
|
50
72
|
hasConsentPreference,
|
|
51
73
|
refreshConsentPreference,
|
|
74
|
+
openPolicyPopup,
|
|
52
75
|
};
|
|
53
76
|
|
|
77
|
+
return (
|
|
78
|
+
<PhygitalConsentContext.Provider value={value}>
|
|
79
|
+
{children}
|
|
80
|
+
{showBannerWhenNoPreference && !hasConsentPreference && (
|
|
81
|
+
<CookieConsentBanner onSubmitted={refreshConsentPreference} />
|
|
82
|
+
)}
|
|
83
|
+
{showPolicyPopup && policyUserId && (
|
|
84
|
+
<PolicyPopup
|
|
85
|
+
open={showPolicyPopup}
|
|
86
|
+
onClose={closePolicyPopup}
|
|
87
|
+
userId={policyUserId}
|
|
88
|
+
/>
|
|
89
|
+
)}
|
|
90
|
+
</PhygitalConsentContext.Provider>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function PhygitalConsentProvider(props: PhygitalConsentProviderProps) {
|
|
95
|
+
const { children, deviceId, showBannerWhenNoPreference = true, ...consentServiceProps } = props;
|
|
96
|
+
|
|
54
97
|
return (
|
|
55
98
|
<ConsentServiceProvider {...consentServiceProps}>
|
|
56
|
-
<
|
|
99
|
+
<PhygitalConsentProviderInner
|
|
100
|
+
deviceId={deviceId}
|
|
101
|
+
showBannerWhenNoPreference={showBannerWhenNoPreference}
|
|
102
|
+
>
|
|
57
103
|
{children}
|
|
58
|
-
|
|
59
|
-
<CookieConsentBanner onSubmitted={refreshConsentPreference} />
|
|
60
|
-
)}
|
|
61
|
-
</PhygitalConsentContext.Provider>
|
|
104
|
+
</PhygitalConsentProviderInner>
|
|
62
105
|
</ConsentServiceProvider>
|
|
63
106
|
);
|
|
64
107
|
}
|