@phygitallabs/phygital-consent 1.0.5 → 1.0.6

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.
@@ -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
+ }
@@ -1 +1,2 @@
1
1
  export * from "./CookieConsentBanner";
2
+ export * from "./PolicyPopup";
@@ -2,7 +2,9 @@
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 {
@@ -11,6 +13,11 @@ export interface PhygitalConsentContextValue {
11
13
  hasConsentPreference: boolean;
12
14
  /** Re-read cookie and update hasConsentPreference. Call after storing preference (e.g. from CookieConsentBanner onSuccess). */
13
15
  refreshConsentPreference: () => void;
16
+ /**
17
+ * Open policy popup if user has no latest consent. Receives userId; calls useLatestConsentByUserId.
18
+ * If data exists, popup is not shown. If no data, policy popup is shown.
19
+ */
20
+ openPolicyPopup: (userId: string) => void;
14
21
  }
15
22
 
16
23
  const PhygitalConsentContext = createContext<PhygitalConsentContextValue | undefined>(undefined);
@@ -35,12 +42,40 @@ export interface PhygitalConsentProviderProps extends ConsentServiceProviderProp
35
42
  export function PhygitalConsentProvider(props: PhygitalConsentProviderProps) {
36
43
  const { children, deviceId, showBannerWhenNoPreference = true, ...consentServiceProps } = props;
37
44
  const [hasConsentPreference, setHasConsentPreference] = useState(false);
45
+ const [pendingUserId, setPendingUserId] = useState<string | null>(null);
46
+ const [showPolicyPopup, setShowPolicyPopup] = useState(false);
47
+ const [policyUserId, setPolicyUserId] = useState<string | null>(null);
48
+
49
+ const { data: latestConsent, isFetched } = useLatestConsentByUserId({
50
+ user_id: pendingUserId ?? "",
51
+ });
52
+
53
+ useEffect(() => {
54
+ if (!pendingUserId || !isFetched) return;
55
+ if (latestConsent != null) {
56
+ setPendingUserId(null);
57
+ return;
58
+ }
59
+ setPolicyUserId(pendingUserId);
60
+ setShowPolicyPopup(true);
61
+ setPendingUserId(null);
62
+ }, [pendingUserId, isFetched, latestConsent]);
38
63
 
39
64
  const refreshConsentPreference = useCallback(() => {
40
65
  const stored = getConsentPreferenceCookie();
41
66
  setHasConsentPreference(!!stored);
42
67
  }, []);
43
68
 
69
+ const openPolicyPopup = useCallback((userId: string) => {
70
+ if (!userId) return;
71
+ setPendingUserId(userId);
72
+ }, []);
73
+
74
+ const closePolicyPopup = useCallback(() => {
75
+ setShowPolicyPopup(false);
76
+ setPolicyUserId(null);
77
+ }, []);
78
+
44
79
  useEffect(() => {
45
80
  refreshConsentPreference();
46
81
  }, [refreshConsentPreference]);
@@ -49,6 +84,7 @@ export function PhygitalConsentProvider(props: PhygitalConsentProviderProps) {
49
84
  deviceId,
50
85
  hasConsentPreference,
51
86
  refreshConsentPreference,
87
+ openPolicyPopup,
52
88
  };
53
89
 
54
90
  return (
@@ -58,6 +94,13 @@ export function PhygitalConsentProvider(props: PhygitalConsentProviderProps) {
58
94
  {showBannerWhenNoPreference && !hasConsentPreference && (
59
95
  <CookieConsentBanner onSubmitted={refreshConsentPreference} />
60
96
  )}
97
+ {showPolicyPopup && policyUserId && (
98
+ <PolicyPopup
99
+ open={showPolicyPopup}
100
+ onClose={closePolicyPopup}
101
+ userId={policyUserId}
102
+ />
103
+ )}
61
104
  </PhygitalConsentContext.Provider>
62
105
  </ConsentServiceProvider>
63
106
  );