@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.
@@ -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,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
- * Main provider for consumer apps. Wraps ConsentServiceProvider and exposes
30
- * hasConsentPreference + refreshConsentPreference so the app can show/hide
31
- * the cookie banner based on whether the user has already chosen a preference.
32
- * When showBannerWhenNoPreference is true (default), the consent banner is
33
- * rendered automatically on app start when the user has no stored preference.
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
- <PhygitalConsentContext.Provider value={value}>
99
+ <PhygitalConsentProviderInner
100
+ deviceId={deviceId}
101
+ showBannerWhenNoPreference={showBannerWhenNoPreference}
102
+ >
57
103
  {children}
58
- {showBannerWhenNoPreference && !hasConsentPreference && (
59
- <CookieConsentBanner onSubmitted={refreshConsentPreference} />
60
- )}
61
- </PhygitalConsentContext.Provider>
104
+ </PhygitalConsentProviderInner>
62
105
  </ConsentServiceProvider>
63
106
  );
64
107
  }