@phygitallabs/phygital-consent 1.0.0
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/README.md +63 -0
- package/bun.lock +465 -0
- package/dist/index.cjs +3 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.mts +278 -0
- package/dist/index.d.ts +278 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/phygital-consent.css +430 -0
- package/index.ts +1 -0
- package/package.json +44 -0
- package/postcss.config.mjs +6 -0
- package/src/api/consent.ts +112 -0
- package/src/api/index.ts +1 -0
- package/src/components/CookieConsentBanner.tsx +163 -0
- package/src/components/index.ts +1 -0
- package/src/env/constant.ts +1 -0
- package/src/helpers/cookie.ts +93 -0
- package/src/helpers/index.ts +1 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useConsent.ts +179 -0
- package/src/index.ts +17 -0
- package/src/provider/ConsentServiceProvider.tsx +144 -0
- package/src/provider/PhygitalConsentProvider.tsx +58 -0
- package/src/provider/index.ts +1 -0
- package/src/styles.css +7 -0
- package/src/types/common.ts +26 -0
- package/src/types/consent.ts +103 -0
- package/src/types/index.ts +3 -0
- package/src/types/pagination.ts +30 -0
- package/tsconfig.json +34 -0
- package/tsup.config.ts +20 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { setConsentPreferenceCookie } from "../helpers/cookie";
|
|
5
|
+
import { useCreateDeviceCookieConsent } from "../hooks/useConsent";
|
|
6
|
+
|
|
7
|
+
const PREFERENCES = ["essential", "analytics", "advertising"] as const;
|
|
8
|
+
|
|
9
|
+
const MOCK_CONTENT = {
|
|
10
|
+
title: "Cookie preferences",
|
|
11
|
+
description:
|
|
12
|
+
"We use cookies to improve your experience, remember your settings, and understand how you use our site. You can accept all, or reject non-essential cookies.",
|
|
13
|
+
essentialLabel: "Essential",
|
|
14
|
+
essentialDesc: "Required for the site to work (e.g. security, preferences).",
|
|
15
|
+
analyticsLabel: "Analytics",
|
|
16
|
+
analyticsDesc: "Help us improve by collecting anonymous usage data.",
|
|
17
|
+
advertisingLabel: "Advertising",
|
|
18
|
+
advertisingDesc: "Used to show you relevant ads and measure campaigns.",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export interface CookieConsentBannerProps {
|
|
22
|
+
/** Device ID (consumer app must provide; will be hashed by app before API if required). */
|
|
23
|
+
deviceId: string;
|
|
24
|
+
/** Called after consent is submitted successfully (Reject or Accept). */
|
|
25
|
+
onSubmitted?: () => void;
|
|
26
|
+
/** Called when the banner is dismissed (e.g. hide from UI). */
|
|
27
|
+
onDismiss?: () => void;
|
|
28
|
+
/** Optional class name for the root container. */
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function CookieConsentBanner({
|
|
33
|
+
deviceId,
|
|
34
|
+
onSubmitted,
|
|
35
|
+
onDismiss,
|
|
36
|
+
className = "",
|
|
37
|
+
}: CookieConsentBannerProps) {
|
|
38
|
+
const [dismissed, setDismissed] = useState(false);
|
|
39
|
+
|
|
40
|
+
const createConsent = useCreateDeviceCookieConsent({
|
|
41
|
+
onSuccess: (_data, variables) => {
|
|
42
|
+
setConsentPreferenceCookie(deviceId, variables.selected_preferences ?? []);
|
|
43
|
+
onSubmitted?.();
|
|
44
|
+
onDismiss?.();
|
|
45
|
+
setDismissed(true);
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const handleReject = () => {
|
|
50
|
+
createConsent.mutate({
|
|
51
|
+
device_id: deviceId,
|
|
52
|
+
status: "REJECTED",
|
|
53
|
+
selected_preferences: ["essential"],
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const handleAccept = () => {
|
|
58
|
+
createConsent.mutate({
|
|
59
|
+
device_id: deviceId,
|
|
60
|
+
status: "ACCEPTED",
|
|
61
|
+
selected_preferences: [...PREFERENCES],
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (dismissed) return null;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div
|
|
69
|
+
role="dialog"
|
|
70
|
+
aria-label={MOCK_CONTENT.title}
|
|
71
|
+
className={`consent:fixed consent:bottom-0 consent:left-0 consent:right-0 consent:z-50 consent:flex consent:flex-col consent:gap-4 consent:rounded-t-xl consent:border consent:border-b-0 consent:border-gray-200 consent:bg-white consent:p-4 consent:shadow-lg consent:md:left-4 consent:md:right-auto consent:md:bottom-4 consent:md:max-w-md consent:md:rounded-xl consent:md:border consent:md:border-gray-200 ${className}`}
|
|
72
|
+
style={{
|
|
73
|
+
position: "fixed",
|
|
74
|
+
bottom: 0,
|
|
75
|
+
left: 0,
|
|
76
|
+
right: 0,
|
|
77
|
+
zIndex: 50,
|
|
78
|
+
display: "flex",
|
|
79
|
+
flexDirection: "column",
|
|
80
|
+
gap: "1rem",
|
|
81
|
+
borderRadius: "0.75rem 0.75rem 0 0",
|
|
82
|
+
borderWidth: "1px 1px 0 1px",
|
|
83
|
+
borderColor: "#e5e7eb",
|
|
84
|
+
backgroundColor: "#fff",
|
|
85
|
+
padding: "1rem",
|
|
86
|
+
boxShadow: "0 -4px 6px -1px rgb(0 0 0 / 0.1)",
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<h2
|
|
90
|
+
className="consent:text-lg consent:font-semibold consent:text-gray-900"
|
|
91
|
+
style={{ fontSize: "1.125rem", fontWeight: 600, color: "#111827" }}
|
|
92
|
+
>
|
|
93
|
+
{MOCK_CONTENT.title}
|
|
94
|
+
</h2>
|
|
95
|
+
<p
|
|
96
|
+
className="consent:text-sm consent:text-gray-600"
|
|
97
|
+
style={{ fontSize: "0.875rem", color: "#4b5563" }}
|
|
98
|
+
>
|
|
99
|
+
{MOCK_CONTENT.description}
|
|
100
|
+
</p>
|
|
101
|
+
<ul
|
|
102
|
+
className="consent:text-sm consent:text-gray-600 consent:space-y-2"
|
|
103
|
+
style={{ fontSize: "0.875rem", color: "#4b5563", margin: 0, paddingLeft: "1.25rem" }}
|
|
104
|
+
>
|
|
105
|
+
<li>
|
|
106
|
+
<strong>{MOCK_CONTENT.essentialLabel}</strong> — {MOCK_CONTENT.essentialDesc}
|
|
107
|
+
</li>
|
|
108
|
+
<li>
|
|
109
|
+
<strong>{MOCK_CONTENT.analyticsLabel}</strong> — {MOCK_CONTENT.analyticsDesc}
|
|
110
|
+
</li>
|
|
111
|
+
<li>
|
|
112
|
+
<strong>{MOCK_CONTENT.advertisingLabel}</strong> — {MOCK_CONTENT.advertisingDesc}
|
|
113
|
+
</li>
|
|
114
|
+
</ul>
|
|
115
|
+
<div
|
|
116
|
+
className="consent:flex consent:flex-wrap consent:gap-2"
|
|
117
|
+
style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}
|
|
118
|
+
>
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
onClick={handleReject}
|
|
122
|
+
disabled={createConsent.isPending}
|
|
123
|
+
className="consent:rounded-lg consent:border consent:border-gray-300 consent:bg-white consent:px-4 consent:py-2 consent:text-sm consent:font-medium consent:text-gray-700 consent:shadow-sm hover:consent:bg-gray-50 disabled:consent:opacity-50"
|
|
124
|
+
style={{
|
|
125
|
+
borderRadius: "0.5rem",
|
|
126
|
+
border: "1px solid #d1d5db",
|
|
127
|
+
background: "#fff",
|
|
128
|
+
padding: "0.5rem 1rem",
|
|
129
|
+
fontSize: "0.875rem",
|
|
130
|
+
fontWeight: 500,
|
|
131
|
+
color: "#374151",
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
Reject
|
|
135
|
+
</button>
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
onClick={handleAccept}
|
|
139
|
+
disabled={createConsent.isPending}
|
|
140
|
+
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"
|
|
141
|
+
style={{
|
|
142
|
+
borderRadius: "0.5rem",
|
|
143
|
+
background: "#111827",
|
|
144
|
+
padding: "0.5rem 1rem",
|
|
145
|
+
fontSize: "0.875rem",
|
|
146
|
+
fontWeight: 500,
|
|
147
|
+
color: "#fff",
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
150
|
+
Accept
|
|
151
|
+
</button>
|
|
152
|
+
</div>
|
|
153
|
+
{createConsent.isError && (
|
|
154
|
+
<p
|
|
155
|
+
className="consent:text-sm consent:text-red-600"
|
|
156
|
+
style={{ fontSize: "0.875rem", color: "#dc2626" }}
|
|
157
|
+
>
|
|
158
|
+
Something went wrong. Please try again.
|
|
159
|
+
</p>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./CookieConsentBanner";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const CONSENT_API_BASE_URL = "";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser cookie helpers for phygital-consent.
|
|
3
|
+
* Cookie name for consent preference: phygital_cookie_consent.
|
|
4
|
+
* Stored value: { deviceId: string, selected_preferences: string[] } (JSON).
|
|
5
|
+
* Default: 1 year, path /, SameSite Lax.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const COOKIE_CONSENT_NAME = "phygital_cookie_consent";
|
|
9
|
+
|
|
10
|
+
const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
|
|
11
|
+
|
|
12
|
+
export interface SetCookieOptions {
|
|
13
|
+
maxAge?: number;
|
|
14
|
+
path?: string;
|
|
15
|
+
sameSite?: "Strict" | "Lax" | "None";
|
|
16
|
+
secure?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get a cookie value by name.
|
|
21
|
+
*/
|
|
22
|
+
export function getCookie(name: string): string | null {
|
|
23
|
+
if (typeof document === "undefined") return null;
|
|
24
|
+
const match = document.cookie.match(new RegExp("(?:^|; )" + encodeURIComponent(name) + "=([^;]*)"));
|
|
25
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Set a cookie.
|
|
30
|
+
*/
|
|
31
|
+
export function setCookie(
|
|
32
|
+
name: string,
|
|
33
|
+
value: string,
|
|
34
|
+
options: SetCookieOptions = {}
|
|
35
|
+
): void {
|
|
36
|
+
if (typeof document === "undefined") return;
|
|
37
|
+
const {
|
|
38
|
+
maxAge = ONE_YEAR_SECONDS,
|
|
39
|
+
path = "/",
|
|
40
|
+
sameSite = "Lax",
|
|
41
|
+
secure = false,
|
|
42
|
+
} = options;
|
|
43
|
+
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}; path=${path}; max-age=${maxAge}; SameSite=${sameSite}`;
|
|
44
|
+
if (secure) cookie += "; Secure";
|
|
45
|
+
document.cookie = cookie;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ConsentPreferenceValue {
|
|
49
|
+
deviceId: string;
|
|
50
|
+
selected_preferences: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read consent preference from cookie. Returns null if missing or invalid.
|
|
55
|
+
*/
|
|
56
|
+
export function getConsentPreferenceCookie(): ConsentPreferenceValue | null {
|
|
57
|
+
const raw = getCookie(COOKIE_CONSENT_NAME);
|
|
58
|
+
if (!raw) return null;
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
61
|
+
if (
|
|
62
|
+
parsed &&
|
|
63
|
+
typeof parsed === "object" &&
|
|
64
|
+
"deviceId" in parsed &&
|
|
65
|
+
"selected_preferences" in parsed &&
|
|
66
|
+
typeof (parsed as ConsentPreferenceValue).deviceId === "string" &&
|
|
67
|
+
Array.isArray((parsed as ConsentPreferenceValue).selected_preferences)
|
|
68
|
+
) {
|
|
69
|
+
return parsed as ConsentPreferenceValue;
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// ignore
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Store consent preference in cookie (hashed device id + selected_preferences).
|
|
79
|
+
* Uses 1 year max-age and SameSite Lax.
|
|
80
|
+
*/
|
|
81
|
+
export function setConsentPreferenceCookie(
|
|
82
|
+
deviceId: string,
|
|
83
|
+
selected_preferences: string[],
|
|
84
|
+
options?: Partial<SetCookieOptions>
|
|
85
|
+
): void {
|
|
86
|
+
const value: ConsentPreferenceValue = { deviceId, selected_preferences };
|
|
87
|
+
setCookie(COOKIE_CONSENT_NAME, JSON.stringify(value), {
|
|
88
|
+
maxAge: ONE_YEAR_SECONDS,
|
|
89
|
+
path: "/",
|
|
90
|
+
sameSite: "Lax",
|
|
91
|
+
...options,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./cookie";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./useConsent";
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useQuery,
|
|
3
|
+
useMutation,
|
|
4
|
+
useQueryClient,
|
|
5
|
+
UseQueryOptions,
|
|
6
|
+
UseMutationOptions,
|
|
7
|
+
} from "@tanstack/react-query";
|
|
8
|
+
import { useConsentService } from "../provider/ConsentServiceProvider";
|
|
9
|
+
import { consentService } from "../api/consent";
|
|
10
|
+
import type {
|
|
11
|
+
GetManyConsentParams,
|
|
12
|
+
GetManyConsentResponse,
|
|
13
|
+
GetConsentByIdParams,
|
|
14
|
+
GetConsentByIdResponse,
|
|
15
|
+
GetLatestConsentByUserIdParams,
|
|
16
|
+
GetLatestConsentByUserIdResponse,
|
|
17
|
+
GetLatestCookieConsentByDeviceIdParams,
|
|
18
|
+
GetLatestCookieConsentByDeviceIdResponse,
|
|
19
|
+
CreateUserConsentUpsertDTO,
|
|
20
|
+
CreateUserConsentResponse,
|
|
21
|
+
CreateDeviceCookieConsentUpsertDTO,
|
|
22
|
+
CreateDeviceCookieConsentResponse,
|
|
23
|
+
HealthResponse,
|
|
24
|
+
} from "../types";
|
|
25
|
+
|
|
26
|
+
/** Query keys for consent API */
|
|
27
|
+
export const consentQueryKeys = {
|
|
28
|
+
all: ["consent"] as const,
|
|
29
|
+
health: () => [...consentQueryKeys.all, "health"] as const,
|
|
30
|
+
list: (params?: GetManyConsentParams) =>
|
|
31
|
+
[...consentQueryKeys.all, "list", params] as const,
|
|
32
|
+
detail: (id: string) => [...consentQueryKeys.all, "detail", id] as const,
|
|
33
|
+
latestByUserId: (user_id: string) =>
|
|
34
|
+
[...consentQueryKeys.all, "user-id", user_id] as const,
|
|
35
|
+
latestCookieByDeviceId: (device_id: string) =>
|
|
36
|
+
[...consentQueryKeys.all, "cookies", "device-id", device_id] as const,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// --- Queries ---
|
|
40
|
+
|
|
41
|
+
/** Health check – GET /health */
|
|
42
|
+
export const useHealth = (
|
|
43
|
+
options?: UseQueryOptions<HealthResponse>
|
|
44
|
+
) => {
|
|
45
|
+
const { consentApi } = useConsentService();
|
|
46
|
+
const service = consentService(consentApi);
|
|
47
|
+
|
|
48
|
+
return useQuery<HealthResponse>({
|
|
49
|
+
queryKey: consentQueryKeys.health(),
|
|
50
|
+
queryFn: () => service.getHealth(),
|
|
51
|
+
...options,
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Get all consent logs – GET /consent/v1/consent */
|
|
56
|
+
export const useManyConsents = (
|
|
57
|
+
params?: GetManyConsentParams,
|
|
58
|
+
options?: UseQueryOptions<GetManyConsentResponse>
|
|
59
|
+
) => {
|
|
60
|
+
const { consentApi } = useConsentService();
|
|
61
|
+
const service = consentService(consentApi);
|
|
62
|
+
|
|
63
|
+
return useQuery<GetManyConsentResponse>({
|
|
64
|
+
queryKey: consentQueryKeys.list(params),
|
|
65
|
+
queryFn: () => service.getManyConsents(params),
|
|
66
|
+
...options,
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Get consent log by ID – GET /consent/v1/consent/{id} */
|
|
71
|
+
export const useConsentById = (
|
|
72
|
+
params: GetConsentByIdParams,
|
|
73
|
+
options?: UseQueryOptions<GetConsentByIdResponse>
|
|
74
|
+
) => {
|
|
75
|
+
const { consentApi } = useConsentService();
|
|
76
|
+
const service = consentService(consentApi);
|
|
77
|
+
|
|
78
|
+
return useQuery<GetConsentByIdResponse>({
|
|
79
|
+
queryKey: consentQueryKeys.detail(params.id),
|
|
80
|
+
queryFn: () => service.getConsentById(params),
|
|
81
|
+
enabled: !!params.id,
|
|
82
|
+
...options,
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Get latest consent for a user – GET /consent/v1/user-id */
|
|
87
|
+
export const useLatestConsentByUserId = (
|
|
88
|
+
params: GetLatestConsentByUserIdParams,
|
|
89
|
+
options?: UseQueryOptions<GetLatestConsentByUserIdResponse>
|
|
90
|
+
) => {
|
|
91
|
+
const { consentApi } = useConsentService();
|
|
92
|
+
const service = consentService(consentApi);
|
|
93
|
+
|
|
94
|
+
return useQuery<GetLatestConsentByUserIdResponse>({
|
|
95
|
+
queryKey: consentQueryKeys.latestByUserId(params.user_id),
|
|
96
|
+
queryFn: () => service.getLatestConsentByUserId(params),
|
|
97
|
+
enabled: !!params.user_id,
|
|
98
|
+
...options,
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Get latest cookie consent for a device – GET /consent/v1/cookies/device-id */
|
|
103
|
+
export const useLatestCookieConsentByDeviceId = (
|
|
104
|
+
params: GetLatestCookieConsentByDeviceIdParams,
|
|
105
|
+
options?: UseQueryOptions<GetLatestCookieConsentByDeviceIdResponse>
|
|
106
|
+
) => {
|
|
107
|
+
const { consentApi } = useConsentService();
|
|
108
|
+
const service = consentService(consentApi);
|
|
109
|
+
|
|
110
|
+
return useQuery<GetLatestCookieConsentByDeviceIdResponse>({
|
|
111
|
+
queryKey: consentQueryKeys.latestCookieByDeviceId(params.device_id),
|
|
112
|
+
queryFn: () => service.getLatestCookieConsentByDeviceId(params),
|
|
113
|
+
enabled: !!params.device_id,
|
|
114
|
+
...options,
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// --- Mutations ---
|
|
119
|
+
|
|
120
|
+
/** Create user consent (account register) – POST /consent/v1/user-id */
|
|
121
|
+
export const useCreateUserConsent = (
|
|
122
|
+
options?: UseMutationOptions<
|
|
123
|
+
CreateUserConsentResponse,
|
|
124
|
+
Error,
|
|
125
|
+
CreateUserConsentUpsertDTO
|
|
126
|
+
>
|
|
127
|
+
) => {
|
|
128
|
+
const { consentApi } = useConsentService();
|
|
129
|
+
const queryClient = useQueryClient();
|
|
130
|
+
const service = consentService(consentApi);
|
|
131
|
+
|
|
132
|
+
return useMutation<CreateUserConsentResponse, Error, CreateUserConsentUpsertDTO>({
|
|
133
|
+
mutationFn: (body: CreateUserConsentUpsertDTO) => service.createUserConsent(body),
|
|
134
|
+
onSuccess: (
|
|
135
|
+
_data: CreateUserConsentResponse,
|
|
136
|
+
variables: CreateUserConsentUpsertDTO
|
|
137
|
+
) => {
|
|
138
|
+
if (variables.user_id) {
|
|
139
|
+
queryClient.invalidateQueries({
|
|
140
|
+
queryKey: consentQueryKeys.latestByUserId(variables.user_id),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
queryClient.invalidateQueries({ queryKey: consentQueryKeys.list() });
|
|
144
|
+
},
|
|
145
|
+
...options,
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Create device cookie consent – POST /consent/v1/cookies/device-id */
|
|
150
|
+
export const useCreateDeviceCookieConsent = (
|
|
151
|
+
options?: UseMutationOptions<
|
|
152
|
+
CreateDeviceCookieConsentResponse,
|
|
153
|
+
Error,
|
|
154
|
+
CreateDeviceCookieConsentUpsertDTO
|
|
155
|
+
>
|
|
156
|
+
) => {
|
|
157
|
+
const { consentApi } = useConsentService();
|
|
158
|
+
const queryClient = useQueryClient();
|
|
159
|
+
const service = consentService(consentApi);
|
|
160
|
+
|
|
161
|
+
return useMutation<
|
|
162
|
+
CreateDeviceCookieConsentResponse,
|
|
163
|
+
Error,
|
|
164
|
+
CreateDeviceCookieConsentUpsertDTO
|
|
165
|
+
>({
|
|
166
|
+
mutationFn: (body: CreateDeviceCookieConsentUpsertDTO) =>
|
|
167
|
+
service.createDeviceCookieConsent(body),
|
|
168
|
+
onSuccess: (
|
|
169
|
+
_data: CreateDeviceCookieConsentResponse,
|
|
170
|
+
variables: CreateDeviceCookieConsentUpsertDTO
|
|
171
|
+
) => {
|
|
172
|
+
queryClient.invalidateQueries({
|
|
173
|
+
queryKey: consentQueryKeys.latestCookieByDeviceId(variables.device_id),
|
|
174
|
+
});
|
|
175
|
+
queryClient.invalidateQueries({ queryKey: consentQueryKeys.list() });
|
|
176
|
+
},
|
|
177
|
+
...options,
|
|
178
|
+
});
|
|
179
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Export types
|
|
2
|
+
export * from "./types";
|
|
3
|
+
|
|
4
|
+
// Export provider
|
|
5
|
+
export * from "./provider";
|
|
6
|
+
|
|
7
|
+
// Export api
|
|
8
|
+
export * from "./api";
|
|
9
|
+
|
|
10
|
+
// Export hooks
|
|
11
|
+
export * from "./hooks";
|
|
12
|
+
|
|
13
|
+
// Export helpers
|
|
14
|
+
export * from "./helpers";
|
|
15
|
+
|
|
16
|
+
// Export components
|
|
17
|
+
export * from "./components";
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { QueryClient, QueryClientConfig, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import axios, {
|
|
5
|
+
AxiosInstance,
|
|
6
|
+
AxiosRequestConfig,
|
|
7
|
+
AxiosResponse,
|
|
8
|
+
InternalAxiosRequestConfig,
|
|
9
|
+
} from "axios";
|
|
10
|
+
import React, { createContext, useContext, useMemo } from "react";
|
|
11
|
+
|
|
12
|
+
export interface ConsentService {
|
|
13
|
+
consentApi: AxiosInstance;
|
|
14
|
+
queryClient: QueryClient;
|
|
15
|
+
updateHeaders: (headers: Record<string, string>) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ConsentServiceContext = createContext<ConsentService | undefined>(undefined);
|
|
19
|
+
|
|
20
|
+
interface RequestInterceptor {
|
|
21
|
+
onFulfilled?: (
|
|
22
|
+
config: InternalAxiosRequestConfig
|
|
23
|
+
) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>;
|
|
24
|
+
onRejected?: (error: unknown) => unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ResponseInterceptor {
|
|
28
|
+
onFulfilled?: (
|
|
29
|
+
response: AxiosResponse
|
|
30
|
+
) => AxiosResponse | Promise<AxiosResponse>;
|
|
31
|
+
onRejected?: (error: unknown) => unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ConsentServiceProviderProps {
|
|
35
|
+
children: React.ReactNode;
|
|
36
|
+
baseURL?: string;
|
|
37
|
+
axiosConfig?: AxiosRequestConfig;
|
|
38
|
+
queryClient?: QueryClient;
|
|
39
|
+
queryClientConfig?: Partial<QueryClientConfig>;
|
|
40
|
+
requestInterceptors?: RequestInterceptor;
|
|
41
|
+
responseInterceptors?: ResponseInterceptor;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface CreateConsentServiceParams {
|
|
45
|
+
baseURL?: string;
|
|
46
|
+
axiosConfig?: AxiosRequestConfig;
|
|
47
|
+
requestInterceptors?: RequestInterceptor;
|
|
48
|
+
responseInterceptors?: ResponseInterceptor;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const createConsentService = ({
|
|
52
|
+
baseURL = "",
|
|
53
|
+
axiosConfig = {},
|
|
54
|
+
requestInterceptors = {},
|
|
55
|
+
responseInterceptors = {},
|
|
56
|
+
}: CreateConsentServiceParams) => {
|
|
57
|
+
const instance = axios.create({
|
|
58
|
+
baseURL,
|
|
59
|
+
...axiosConfig,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
instance.interceptors.request.use(
|
|
63
|
+
requestInterceptors.onFulfilled,
|
|
64
|
+
requestInterceptors.onRejected
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
instance.interceptors.response.use(
|
|
68
|
+
responseInterceptors.onFulfilled,
|
|
69
|
+
responseInterceptors.onRejected
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const updateHeaders = (headers: Record<string, string>) => {
|
|
73
|
+
Object.entries(headers).forEach(([key, value]) => {
|
|
74
|
+
instance.defaults.headers.common[key] = value;
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
consentApi: instance,
|
|
80
|
+
updateHeaders,
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const ConsentServiceProvider: React.FC<ConsentServiceProviderProps> = ({
|
|
85
|
+
children,
|
|
86
|
+
baseURL = "",
|
|
87
|
+
axiosConfig = {},
|
|
88
|
+
queryClient,
|
|
89
|
+
queryClientConfig = {},
|
|
90
|
+
requestInterceptors = {},
|
|
91
|
+
responseInterceptors = {},
|
|
92
|
+
}) => {
|
|
93
|
+
const queryClientInstance = useMemo(
|
|
94
|
+
() =>
|
|
95
|
+
queryClient ||
|
|
96
|
+
new QueryClient({
|
|
97
|
+
defaultOptions: {
|
|
98
|
+
queries: {
|
|
99
|
+
refetchOnWindowFocus: false,
|
|
100
|
+
refetchOnMount: false,
|
|
101
|
+
refetchOnReconnect: false,
|
|
102
|
+
...queryClientConfig?.defaultOptions?.queries,
|
|
103
|
+
},
|
|
104
|
+
mutations: {
|
|
105
|
+
...queryClientConfig?.defaultOptions?.mutations,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
queryCache: queryClientConfig?.queryCache,
|
|
109
|
+
mutationCache: queryClientConfig?.mutationCache,
|
|
110
|
+
}),
|
|
111
|
+
[queryClient]
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const consentService = useMemo(() => {
|
|
115
|
+
const service = createConsentService({
|
|
116
|
+
baseURL,
|
|
117
|
+
axiosConfig,
|
|
118
|
+
requestInterceptors,
|
|
119
|
+
responseInterceptors,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
consentApi: service.consentApi,
|
|
124
|
+
updateHeaders: service.updateHeaders,
|
|
125
|
+
queryClient: queryClientInstance,
|
|
126
|
+
};
|
|
127
|
+
}, [baseURL, queryClientInstance, axiosConfig, requestInterceptors, responseInterceptors]);
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<ConsentServiceContext.Provider value={consentService}>
|
|
131
|
+
<QueryClientProvider client={queryClientInstance}>
|
|
132
|
+
{children}
|
|
133
|
+
</QueryClientProvider>
|
|
134
|
+
</ConsentServiceContext.Provider>
|
|
135
|
+
);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const useConsentService = () => {
|
|
139
|
+
const context = useContext(ConsentServiceContext);
|
|
140
|
+
if (!context) {
|
|
141
|
+
throw new Error("useConsentService must be used within a ConsentServiceProvider");
|
|
142
|
+
}
|
|
143
|
+
return context;
|
|
144
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
|
|
4
|
+
import { getConsentPreferenceCookie } from "../helpers/cookie";
|
|
5
|
+
import { ConsentServiceProvider, ConsentServiceProviderProps } from "./ConsentServiceProvider";
|
|
6
|
+
|
|
7
|
+
export interface PhygitalConsentContextValue {
|
|
8
|
+
/** True when cookie phygital_cookie_consent exists and has valid deviceId + selected_preferences. Use to show/hide cookie banner. */
|
|
9
|
+
hasConsentPreference: boolean;
|
|
10
|
+
/** Re-read cookie and update hasConsentPreference. Call after storing preference (e.g. from CookieConsentBanner onSuccess). */
|
|
11
|
+
refreshConsentPreference: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const PhygitalConsentContext = createContext<PhygitalConsentContextValue | undefined>(undefined);
|
|
15
|
+
|
|
16
|
+
export interface PhygitalConsentProviderProps extends ConsentServiceProviderProps {
|
|
17
|
+
children: React.ReactNode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Main provider for consumer apps. Wraps ConsentServiceProvider and exposes
|
|
22
|
+
* hasConsentPreference + refreshConsentPreference so the app can show/hide
|
|
23
|
+
* the cookie banner based on whether the user has already chosen a preference.
|
|
24
|
+
*/
|
|
25
|
+
export function PhygitalConsentProvider(props: PhygitalConsentProviderProps) {
|
|
26
|
+
const { children, ...consentServiceProps } = props;
|
|
27
|
+
const [hasConsentPreference, setHasConsentPreference] = useState(false);
|
|
28
|
+
|
|
29
|
+
const refreshConsentPreference = useCallback(() => {
|
|
30
|
+
const stored = getConsentPreferenceCookie();
|
|
31
|
+
setHasConsentPreference(!!stored);
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
refreshConsentPreference();
|
|
36
|
+
}, [refreshConsentPreference]);
|
|
37
|
+
|
|
38
|
+
const value: PhygitalConsentContextValue = {
|
|
39
|
+
hasConsentPreference,
|
|
40
|
+
refreshConsentPreference,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<ConsentServiceProvider {...consentServiceProps}>
|
|
45
|
+
<PhygitalConsentContext.Provider value={value}>
|
|
46
|
+
{children}
|
|
47
|
+
</PhygitalConsentContext.Provider>
|
|
48
|
+
</ConsentServiceProvider>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function usePhygitalConsent() {
|
|
53
|
+
const context = useContext(PhygitalConsentContext);
|
|
54
|
+
if (!context) {
|
|
55
|
+
throw new Error("usePhygitalConsent must be used within a PhygitalConsentProvider");
|
|
56
|
+
}
|
|
57
|
+
return context;
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./PhygitalConsentProvider";
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phygital Consent package styles.
|
|
3
|
+
* Uses prefix "consent:" so consumer app Tailwind does not override these utilities.
|
|
4
|
+
* Consumer: import "@phygitallabs/phygital-consent/styles";
|
|
5
|
+
* In components, use consent: utilities (e.g. consent:flex consent:p-4).
|
|
6
|
+
*/
|
|
7
|
+
@import "tailwindcss" prefix(consent) source("./");
|