diff --git a/coverage/summary.json b/coverage/summary.json
index b266de8..19f9e25 100644
--- a/coverage/summary.json
+++ b/coverage/summary.json
@@ -1,18 +1,18 @@
{
- "generatedAt": "2026-05-19T13:18:49.389Z",
- "commit": "ae4e0f2",
+ "generatedAt": "2026-05-19T19:19:27.597Z",
+ "commit": "33e3c09",
"repo": {
- "statements": 96.72,
- "branches": 92.06,
- "functions": 96.83,
- "lines": 96.72,
+ "statements": 96.74,
+ "branches": 92.11,
+ "functions": 96.86,
+ "lines": 96.74,
"counts": {
- "lf": 4607,
- "lh": 4456,
- "brf": 907,
- "brh": 835,
- "fnf": 284,
- "fnh": 275
+ "lf": 4632,
+ "lh": 4481,
+ "brf": 912,
+ "brh": 840,
+ "fnf": 287,
+ "fnh": 278
}
},
"byPackage": {
@@ -59,17 +59,17 @@
}
},
"@repo/core-consent": {
- "statements": 99.69,
- "branches": 94.57,
- "functions": 96.77,
- "lines": 99.69,
+ "statements": 99.72,
+ "branches": 94.85,
+ "functions": 97.06,
+ "lines": 99.72,
"counts": {
- "lf": 326,
- "lh": 325,
- "brf": 92,
- "brh": 87,
- "fnf": 31,
- "fnh": 30
+ "lf": 351,
+ "lh": 350,
+ "brf": 97,
+ "brh": 92,
+ "fnf": 34,
+ "fnh": 33
}
},
"@repo/core-shared": {
diff --git a/packages/core-consent/package.json b/packages/core-consent/package.json
index f499c80..3be1938 100644
--- a/packages/core-consent/package.json
+++ b/packages/core-consent/package.json
@@ -5,7 +5,8 @@
"type": "module",
"exports": {
".": "./src/index.ts",
- "./di": "./src/di/bind-production.ts"
+ "./di": "./src/di/bind-production.ts",
+ "./react": "./src/react/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
@@ -19,19 +20,27 @@
"zod": "^3.24.0"
},
"peerDependencies": {
- "payload": "^3.0.0"
+ "payload": "^3.0.0",
+ "react": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"payload": {
"optional": true
+ },
+ "react": {
+ "optional": true
}
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
+ "@testing-library/react": "^16.0.0",
+ "@types/react": "^19.0.0",
"@vitest/coverage-v8": "^3.0.0",
+ "jsdom": "^25.0.0",
"payload": "^3.14.0",
+ "react": "^19.0.0",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
}
diff --git a/packages/core-consent/src/react/consent-provider.test.tsx b/packages/core-consent/src/react/consent-provider.test.tsx
new file mode 100644
index 0000000..b6bd374
--- /dev/null
+++ b/packages/core-consent/src/react/consent-provider.test.tsx
@@ -0,0 +1,91 @@
+import { describe, expect, it, vi } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { RecordingConsent } from "@repo/core-testing";
+import {
+ ConsentContextError,
+ ConsentProvider,
+ useConsent,
+} from "@/react/index";
+
+function makeWrapper(consent: RecordingConsent) {
+ return function Wrapper({ children }: { children: React.ReactNode }) {
+ return {children};
+ };
+}
+
+describe("ConsentProvider / useConsent", () => {
+ it("returns the injected IConsent instance", () => {
+ const recording = new RecordingConsent();
+ const { result } = renderHook(() => useConsent(), {
+ wrapper: makeWrapper(recording),
+ });
+ expect(result.current).toBe(recording);
+ });
+
+ it("propagates grant() to the injected IConsent", async () => {
+ const recording = new RecordingConsent();
+ const { result } = renderHook(() => useConsent(), {
+ wrapper: makeWrapper(recording),
+ });
+
+ await act(async () => {
+ await result.current.grant("analytics");
+ });
+
+ expect(recording.grants).toContainEqual({
+ category: "analytics",
+ meta: undefined,
+ });
+ });
+
+ it("propagates withdraw() to the injected IConsent", async () => {
+ const recording = new RecordingConsent();
+ await recording.grant("marketing");
+
+ const { result } = renderHook(() => useConsent(), {
+ wrapper: makeWrapper(recording),
+ });
+
+ await act(async () => {
+ await result.current.withdraw("marketing");
+ });
+
+ expect(recording.withdrawals).toContain("marketing");
+ });
+
+ it("isGranted() reflects the injected instance state", async () => {
+ const recording = new RecordingConsent();
+ await recording.grant("functional");
+
+ const { result } = renderHook(() => useConsent(), {
+ wrapper: makeWrapper(recording),
+ });
+
+ expect(result.current.isGranted("functional")).toBe(true);
+ expect(result.current.isGranted("marketing")).toBe(false);
+ });
+
+ it("getCategories() returns categories from the injected IConsent", async () => {
+ const recording = new RecordingConsent();
+ await recording.grant("necessary");
+
+ const { result } = renderHook(() => useConsent(), {
+ wrapper: makeWrapper(recording),
+ });
+
+ const cats = result.current.getCategories();
+ expect(cats).toHaveLength(1);
+ expect(cats[0]).toMatchObject({ category: "necessary", state: "granted" });
+ });
+});
+
+describe("useConsent without provider", () => {
+ it("throws ConsentContextError when called outside a provider", () => {
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ expect(() => renderHook(() => useConsent())).toThrow(ConsentContextError);
+ } finally {
+ spy.mockRestore();
+ }
+ });
+});
diff --git a/packages/core-consent/src/react/consent-provider.tsx b/packages/core-consent/src/react/consent-provider.tsx
new file mode 100644
index 0000000..bc09e52
--- /dev/null
+++ b/packages/core-consent/src/react/consent-provider.tsx
@@ -0,0 +1,31 @@
+import { createContext, useContext, type ReactNode } from "react";
+import type { IConsent } from "../consent.interface";
+
+const ConsentContext = createContext(null);
+
+export class ConsentContextError extends Error {
+ constructor() {
+ super("useConsent() must be called within a .");
+ this.name = "ConsentContextError";
+ }
+}
+
+export function ConsentProvider({
+ value,
+ children,
+}: {
+ value: IConsent;
+ children: ReactNode;
+}) {
+ return (
+ {children}
+ );
+}
+
+export function useConsent(): IConsent {
+ const consent = useContext(ConsentContext);
+ if (consent === null) {
+ throw new ConsentContextError();
+ }
+ return consent;
+}
diff --git a/packages/core-consent/src/react/index.ts b/packages/core-consent/src/react/index.ts
new file mode 100644
index 0000000..39aa1d4
--- /dev/null
+++ b/packages/core-consent/src/react/index.ts
@@ -0,0 +1,5 @@
+export {
+ ConsentProvider,
+ useConsent,
+ ConsentContextError,
+} from "./consent-provider";
diff --git a/packages/core-consent/tsconfig.json b/packages/core-consent/tsconfig.json
index 8facef5..bfb5a39 100644
--- a/packages/core-consent/tsconfig.json
+++ b/packages/core-consent/tsconfig.json
@@ -1,5 +1,5 @@
{
- "extends": "@repo/core-typescript/base.json",
+ "extends": "@repo/core-typescript/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
@@ -7,6 +7,6 @@
"@/*": ["./src/*"]
}
},
- "include": ["**/*.ts"],
+ "include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "dist"]
}
diff --git a/packages/core-consent/vitest.config.ts b/packages/core-consent/vitest.config.ts
index 2ee07c1..79a7ee6 100644
--- a/packages/core-consent/vitest.config.ts
+++ b/packages/core-consent/vitest.config.ts
@@ -1,9 +1,17 @@
import path from "node:path";
-import { mergeConfig } from "vitest/config";
+import { defineConfig, mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
-export default mergeConfig(nodeVitestConfig, {
- resolve: {
- alias: { "@": path.resolve(__dirname, "./src") },
- },
-});
+export default mergeConfig(
+ nodeVitestConfig,
+ defineConfig({
+ test: {
+ include: ["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"],
+ environmentMatchGlobs: [["**/*.test.tsx", "jsdom"]],
+ setupFiles: ["@repo/core-testing/setup/jsdom"],
+ },
+ resolve: {
+ alias: { "@": path.resolve(__dirname, "./src") },
+ },
+ }),
+);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9c74a26..4e7e811 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -608,12 +608,24 @@ importers:
"@repo/core-typescript":
specifier: workspace:*
version: link:../core-typescript
+ "@testing-library/react":
+ specifier: ^16.0.0
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ "@types/react":
+ specifier: ^19.0.0
+ version: 19.2.14
"@vitest/coverage-v8":
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
+ jsdom:
+ specifier: ^25.0.0
+ version: 25.0.1
payload:
specifier: ^3.14.0
version: 3.81.0(graphql@16.13.2)(typescript@5.9.3)
+ react:
+ specifier: ^19.0.0
+ version: 19.2.4
typescript:
specifier: ^5.8.0
version: 5.9.3