@consentx/angular 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/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@consentx/angular",
3
+ "version": "1.0.0",
4
+ "description": "ConsentX cookie consent & CMP for Angular — GDPR, CCPA, DPDPA. Inject the ConsentX embed and read consent state from a service. Site-key model, no server handshake.",
5
+ "keywords": [
6
+ "consentx",
7
+ "angular",
8
+ "cookie-consent",
9
+ "cmp",
10
+ "gdpr",
11
+ "ccpa",
12
+ "dpdpa",
13
+ "consent-mode",
14
+ "privacy"
15
+ ],
16
+ "homepage": "https://consentx.io/integrations/angular",
17
+ "bugs": {
18
+ "url": "https://github.com/consentx/consentx-plugins/issues"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/consentx/consentx-plugins.git",
23
+ "directory": "packages/angular"
24
+ },
25
+ "license": "MIT",
26
+ "author": "ConsentX <hello@consentx.io> (https://consentx.io)",
27
+ "sideEffects": false,
28
+ "scripts": {
29
+ "build": "ng-packagr -p ng-package.json",
30
+ "prepublishOnly": "npm run build"
31
+ },
32
+ "peerDependencies": {
33
+ "@angular/common": ">=15.0.0",
34
+ "@angular/core": ">=15.0.0",
35
+ "rxjs": ">=7.0.0"
36
+ },
37
+ "dependencies": {
38
+ "tslib": "^2.6.0"
39
+ },
40
+ "devDependencies": {
41
+ "@angular/common": "^17.3.0",
42
+ "@angular/compiler": "^17.3.0",
43
+ "@angular/compiler-cli": "^17.3.0",
44
+ "@angular/core": "^17.3.0",
45
+ "ng-packagr": "^17.3.0",
46
+ "rxjs": "^7.8.0",
47
+ "typescript": "~5.4.0",
48
+ "zone.js": "~0.14.0"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
53
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './public-api';
@@ -0,0 +1,53 @@
1
+ import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
2
+
3
+ import { ConsentXService } from './consentx.service';
4
+ import { consentxRootProviders } from './consentx.providers';
5
+ import { ConsentXConfig } from './consentx.types';
6
+
7
+ /**
8
+ * The ConsentX module for classic NgModule-based applications.
9
+ *
10
+ * Import once at the application root via `forRoot`:
11
+ *
12
+ * ```ts
13
+ * @NgModule({
14
+ * imports: [
15
+ * BrowserModule,
16
+ * ConsentXModule.forRoot({ siteKey: 'YOUR_SITE_KEY' }),
17
+ * ],
18
+ * bootstrap: [AppComponent],
19
+ * })
20
+ * export class AppModule {}
21
+ * ```
22
+ *
23
+ * On app init the {@link ConsentXService} injects the ConsentX embed
24
+ * (`/api/SITE_KEY/embed.js`) into `<head>` and starts tracking `cx:consent`.
25
+ *
26
+ * For standalone (`bootstrapApplication`) apps use `provideConsentX()` instead.
27
+ */
28
+ @NgModule()
29
+ export class ConsentXModule {
30
+ /**
31
+ * Guard against importing `forRoot()` more than once — it would register the
32
+ * embed initialiser twice.
33
+ */
34
+ constructor(@Optional() @SkipSelf() parent?: ConsentXModule) {
35
+ if (parent) {
36
+ throw new Error(
37
+ 'ConsentXModule.forRoot() was imported more than once. Import it only in ' +
38
+ 'your root AppModule.',
39
+ );
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Configure ConsentX with your Site Key (and optional overrides). Returns the
45
+ * module plus the root providers (config token, service, APP_INITIALIZER).
46
+ */
47
+ static forRoot(config: ConsentXConfig): ModuleWithProviders<ConsentXModule> {
48
+ return {
49
+ ngModule: ConsentXModule,
50
+ providers: consentxRootProviders(config),
51
+ };
52
+ }
53
+ }
@@ -0,0 +1,60 @@
1
+ import {
2
+ APP_INITIALIZER,
3
+ EnvironmentProviders,
4
+ Provider,
5
+ makeEnvironmentProviders,
6
+ } from '@angular/core';
7
+
8
+ import { CONSENTX_CONFIG } from './consentx.tokens';
9
+ import { ConsentXService } from './consentx.service';
10
+ import { ConsentXConfig } from './consentx.types';
11
+
12
+ /**
13
+ * Factory used by APP_INITIALIZER. Initialising on app boot means the embed is
14
+ * injected once the Angular application is ready (i.e. after hydration on SSR),
15
+ * which is the SPA-safe load point the Connect spec recommends so the
16
+ * framework's DOM reconciliation does not strip the appended
17
+ * `#consentx-cookie-consent` div.
18
+ */
19
+ export function consentxInitFactory(service: ConsentXService): () => void {
20
+ return () => service.init();
21
+ }
22
+
23
+ /**
24
+ * Standalone-app provider. Use in `bootstrapApplication(...)`:
25
+ *
26
+ * ```ts
27
+ * bootstrapApplication(AppComponent, {
28
+ * providers: [provideConsentX({ siteKey: 'YOUR_SITE_KEY' })],
29
+ * });
30
+ * ```
31
+ */
32
+ export function provideConsentX(config: ConsentXConfig): EnvironmentProviders {
33
+ return makeEnvironmentProviders([
34
+ { provide: CONSENTX_CONFIG, useValue: config },
35
+ ConsentXService,
36
+ {
37
+ provide: APP_INITIALIZER,
38
+ multi: true,
39
+ useFactory: consentxInitFactory,
40
+ deps: [ConsentXService],
41
+ },
42
+ ]);
43
+ }
44
+
45
+ /**
46
+ * Internal: the providers `ConsentXModule.forRoot()` contributes. Kept separate
47
+ * so both the NgModule and the standalone API share one source of truth.
48
+ */
49
+ export function consentxRootProviders(config: ConsentXConfig): Provider[] {
50
+ return [
51
+ { provide: CONSENTX_CONFIG, useValue: config },
52
+ ConsentXService,
53
+ {
54
+ provide: APP_INITIALIZER,
55
+ multi: true,
56
+ useFactory: consentxInitFactory,
57
+ deps: [ConsentXService],
58
+ },
59
+ ];
60
+ }
@@ -0,0 +1,262 @@
1
+ import {
2
+ Inject,
3
+ Injectable,
4
+ NgZone,
5
+ OnDestroy,
6
+ Optional,
7
+ PLATFORM_ID,
8
+ } from '@angular/core';
9
+ import { DOCUMENT, isPlatformBrowser } from '@angular/common';
10
+ import { BehaviorSubject, Observable } from 'rxjs';
11
+ import { distinctUntilChanged, map } from 'rxjs/operators';
12
+
13
+ import { CONSENTX_CONFIG } from './consentx.tokens';
14
+ import {
15
+ CONSENTX_DEFAULT_APP_URL,
16
+ ConsentGranted,
17
+ ConsentXConfig,
18
+ ConsentXConsentDetail,
19
+ ConsentXState,
20
+ } from './consentx.types';
21
+
22
+ const SCRIPT_ID = 'consentx-embed';
23
+ const CONSENT_MODE_ID = 'consentx-consent-mode';
24
+
25
+ /**
26
+ * Injects the ConsentX embed script (`/api/SITE_KEY/embed.js`) into `<head>`
27
+ * and exposes the visitor's consent state, derived from the widget's
28
+ * `cx:consent` events.
29
+ *
30
+ * Site-key model (Connect spec, Model C): no redirect handshake. The Site Key
31
+ * is supplied via configuration; this service only performs clean embed
32
+ * injection plus a consent hook.
33
+ *
34
+ * The service is `providedIn: 'root'` so it is a singleton across the app. It
35
+ * is SSR-safe: all DOM access is guarded behind `isPlatformBrowser`, so it is a
36
+ * no-op on the server and runs once the app boots in the browser.
37
+ */
38
+ @Injectable({ providedIn: 'root' })
39
+ export class ConsentXService implements OnDestroy {
40
+ private readonly appUrl: string;
41
+ private readonly isBrowser: boolean;
42
+ private listenerBound = false;
43
+ private boundConsentListener?: (event: Event) => void;
44
+
45
+ private readonly stateSubject = new BehaviorSubject<ConsentXState>({
46
+ loaded: false,
47
+ decided: false,
48
+ granted: [],
49
+ detail: null,
50
+ });
51
+
52
+ /** Full consent state as an observable. Emits an initial snapshot. */
53
+ readonly state$: Observable<ConsentXState> = this.stateSubject.asObservable();
54
+
55
+ /** Convenience stream of the granted category slugs. */
56
+ readonly granted$: Observable<ConsentGranted> = this.state$.pipe(
57
+ map((s) => s.granted),
58
+ distinctUntilChanged((a, b) => a.length === b.length && a.every((v, i) => v === b[i])),
59
+ );
60
+
61
+ constructor(
62
+ @Optional() @Inject(CONSENTX_CONFIG) private readonly config: ConsentXConfig | null,
63
+ @Inject(PLATFORM_ID) platformId: object,
64
+ @Inject(DOCUMENT) private readonly document: Document,
65
+ private readonly zone: NgZone,
66
+ ) {
67
+ this.isBrowser = isPlatformBrowser(platformId);
68
+ this.appUrl = this.normalizeAppUrl(this.config?.appUrl);
69
+
70
+ if (!this.config?.siteKey) {
71
+ // Misconfiguration is loud but non-fatal: the app keeps working without
72
+ // the banner instead of crashing bootstrap.
73
+ // eslint-disable-next-line no-console
74
+ console.error(
75
+ '[ConsentX] No siteKey provided. Pass { siteKey } to ConsentXModule.forRoot() ' +
76
+ 'or provideConsentX(). Copy the Site Key from the ConsentX dashboard -> ' +
77
+ 'Websites -> your site.',
78
+ );
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Auto-init entry point. Called once during app initialisation (via an
84
+ * APP_INITIALIZER registered by the module/provider) unless `manualLoad` is
85
+ * set. Binds the consent listener and injects the embed.
86
+ */
87
+ init(): void {
88
+ if (!this.isBrowser) {
89
+ return;
90
+ }
91
+ this.bindConsentListener();
92
+ if (!this.config?.manualLoad) {
93
+ this.load();
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Inject the ConsentX embed into `<head>`. Idempotent: a second call is a
99
+ * no-op if the script is already present. Safe to call manually when
100
+ * `manualLoad` is enabled (e.g. after a cookie-policy route is reached).
101
+ */
102
+ load(): void {
103
+ if (!this.isBrowser) {
104
+ return;
105
+ }
106
+ const siteKey = this.config?.siteKey?.trim();
107
+ if (!siteKey) {
108
+ return;
109
+ }
110
+
111
+ this.bindConsentListener();
112
+
113
+ if (this.config?.consentMode) {
114
+ this.injectConsentModeDefaults();
115
+ }
116
+
117
+ // Expose the key the way embed.js expects (window.consentx_key), matching
118
+ // the canonical embed contract.
119
+ this.document.defaultView!.consentx_key = siteKey;
120
+
121
+ if (this.document.getElementById(SCRIPT_ID)) {
122
+ // Already injected — make sure the loaded flag reflects reality.
123
+ this.patchState({ loaded: true });
124
+ return;
125
+ }
126
+
127
+ const head = this.document.head || this.document.getElementsByTagName('head')[0];
128
+ if (!head) {
129
+ return;
130
+ }
131
+
132
+ const script = this.document.createElement('script');
133
+ script.id = SCRIPT_ID;
134
+ script.type = 'module';
135
+ script.src = `${this.appUrl}/api/${encodeURIComponent(siteKey)}/embed.js`;
136
+ script.setAttribute('data-consentx', siteKey);
137
+ script.async = true;
138
+
139
+ script.addEventListener('load', () => {
140
+ this.zone.run(() => this.patchState({ loaded: true }));
141
+ });
142
+ script.addEventListener('error', () => {
143
+ // eslint-disable-next-line no-console
144
+ console.error(`[ConsentX] Failed to load embed from ${script.src}`);
145
+ });
146
+
147
+ head.appendChild(script);
148
+ }
149
+
150
+ /**
151
+ * Open the ConsentX preferences dialog. Requires the embed to have loaded and
152
+ * exposed `window.ConsentX`. Returns `true` if the call was dispatched.
153
+ */
154
+ openPreferences(): boolean {
155
+ if (!this.isBrowser) {
156
+ return false;
157
+ }
158
+ const api = this.document.defaultView?.ConsentX;
159
+ if (api && typeof api.open === 'function') {
160
+ api.open();
161
+ return true;
162
+ }
163
+ return false;
164
+ }
165
+
166
+ /** Latest granted category slugs (synchronous snapshot). */
167
+ getGranted(): ConsentGranted {
168
+ return this.stateSubject.value.granted;
169
+ }
170
+
171
+ /**
172
+ * Whether the visitor has granted a specific consent category slug, e.g.
173
+ * `hasConsent('analytics')`.
174
+ */
175
+ hasConsent(slug: string): boolean {
176
+ return this.stateSubject.value.granted.includes(slug);
177
+ }
178
+
179
+ /** Current full state snapshot (synchronous). */
180
+ getState(): ConsentXState {
181
+ return this.stateSubject.value;
182
+ }
183
+
184
+ ngOnDestroy(): void {
185
+ if (this.isBrowser && this.boundConsentListener) {
186
+ this.document.defaultView?.removeEventListener(
187
+ 'cx:consent',
188
+ this.boundConsentListener as EventListener,
189
+ );
190
+ }
191
+ }
192
+
193
+ // --- internals -----------------------------------------------------------
194
+
195
+ /**
196
+ * Print the Google Consent Mode v2 denied-by-default stub before any
197
+ * analytics tag fires. Idempotent. Mirrors the canonical embed contract.
198
+ */
199
+ private injectConsentModeDefaults(): void {
200
+ const win = this.document.defaultView;
201
+ if (!win) {
202
+ return;
203
+ }
204
+ if (this.document.getElementById(CONSENT_MODE_ID)) {
205
+ return;
206
+ }
207
+ win.dataLayer = win.dataLayer || [];
208
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
209
+ const gtag = (...args: unknown[]) => (win.dataLayer as any[]).push(args);
210
+ gtag('consent', 'default', {
211
+ ad_storage: 'denied',
212
+ analytics_storage: 'denied',
213
+ ad_user_data: 'denied',
214
+ ad_personalization: 'denied',
215
+ functionality_storage: 'denied',
216
+ personalization_storage: 'denied',
217
+ security_storage: 'granted',
218
+ wait_for_update: 500,
219
+ });
220
+ // Marker so a second call (or a duplicate inline tag) is a no-op.
221
+ const marker = this.document.createElement('script');
222
+ marker.id = CONSENT_MODE_ID;
223
+ marker.type = 'application/json';
224
+ marker.textContent = '{"consentx":"consent-mode-defaults"}';
225
+ (this.document.head || this.document.documentElement).appendChild(marker);
226
+ }
227
+
228
+ /** Subscribe to the widget's `cx:consent` events exactly once. */
229
+ private bindConsentListener(): void {
230
+ if (this.listenerBound || !this.isBrowser) {
231
+ return;
232
+ }
233
+ const win = this.document.defaultView;
234
+ if (!win) {
235
+ return;
236
+ }
237
+ this.boundConsentListener = (event: Event) => {
238
+ const detail = (event as CustomEvent<ConsentXConsentDetail>).detail;
239
+ const granted = Array.isArray(detail?.granted) ? detail!.granted : [];
240
+ // Re-enter Angular so bound templates/observables update.
241
+ this.zone.run(() =>
242
+ this.patchState({
243
+ decided: true,
244
+ granted,
245
+ detail: detail ?? null,
246
+ }),
247
+ );
248
+ };
249
+ win.addEventListener('cx:consent', this.boundConsentListener as EventListener);
250
+ this.listenerBound = true;
251
+ }
252
+
253
+ private patchState(partial: Partial<ConsentXState>): void {
254
+ this.stateSubject.next({ ...this.stateSubject.value, ...partial });
255
+ }
256
+
257
+ /** Resolve + sanitise the app host (strip trailing slash). */
258
+ private normalizeAppUrl(url?: string): string {
259
+ const raw = (url || CONSENTX_DEFAULT_APP_URL).trim();
260
+ return raw.replace(/\/+$/, '');
261
+ }
262
+ }
@@ -0,0 +1,8 @@
1
+ import { InjectionToken } from '@angular/core';
2
+ import { ConsentXConfig } from './consentx.types';
3
+
4
+ /**
5
+ * DI token carrying the resolved {@link ConsentXConfig}. Provided by
6
+ * `ConsentXModule.forRoot(...)` or `provideConsentX(...)`.
7
+ */
8
+ export const CONSENTX_CONFIG = new InjectionToken<ConsentXConfig>('CONSENTX_CONFIG');
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Public types for @consentx/angular.
3
+ *
4
+ * ConsentX runs on the site-key model (Model C in the Connect spec): the user
5
+ * pastes the Site Key copied from the ConsentX dashboard (Websites -> their
6
+ * site) and ensures their domain is on that site's allowlist. The embed then
7
+ * loads with no server-side handshake.
8
+ */
9
+
10
+ /** Default ConsentX application host (no trailing slash). */
11
+ export const CONSENTX_DEFAULT_APP_URL = 'https://app.consentx.io';
12
+
13
+ /**
14
+ * Configuration passed to `ConsentXModule.forRoot(...)` (NgModule apps) or
15
+ * `provideConsentX(...)` (standalone apps).
16
+ */
17
+ export interface ConsentXConfig {
18
+ /**
19
+ * The ConsentX Site Key for this website. Copy it from the ConsentX
20
+ * dashboard -> Websites -> your site. Required.
21
+ */
22
+ siteKey: string;
23
+
24
+ /**
25
+ * Override the ConsentX app host (scheme + host, no trailing slash).
26
+ * Defaults to `https://app.consentx.io`. Use this for staging hosts such as
27
+ * `https://consentx1.satyamrastogi.com`.
28
+ */
29
+ appUrl?: string;
30
+
31
+ /**
32
+ * When `true`, the service prints the Google Consent Mode v2
33
+ * denied-by-default stub into `<head>` before the embed loads, so any
34
+ * analytics tag that fires before consent starts denied. The ConsentX
35
+ * widget emits the `gtag('consent','update',...)` calls on the visitor's
36
+ * choice. Defaults to `false`.
37
+ */
38
+ consentMode?: boolean;
39
+
40
+ /**
41
+ * Disable automatic embed injection on app init. Set this to `true` if you
42
+ * want to call `ConsentXService.load()` yourself at a custom point.
43
+ * Defaults to `false` (auto-inject on init).
44
+ */
45
+ manualLoad?: boolean;
46
+ }
47
+
48
+ /** The category slugs a visitor has granted, e.g. `['analytics','marketing']`. */
49
+ export type ConsentGranted = string[];
50
+
51
+ /**
52
+ * The detail payload of the `cx:consent` browser event emitted by the ConsentX
53
+ * widget. `granted` is the array of granted category slugs.
54
+ */
55
+ export interface ConsentXConsentDetail {
56
+ granted: ConsentGranted;
57
+ [key: string]: unknown;
58
+ }
59
+
60
+ /** Snapshot of consent state exposed by `ConsentXService.state$`. */
61
+ export interface ConsentXState {
62
+ /** Whether the embed script has loaded into the page. */
63
+ loaded: boolean;
64
+ /** Whether a `cx:consent` event has been received this session. */
65
+ decided: boolean;
66
+ /** Granted category slugs from the latest `cx:consent` event. */
67
+ granted: ConsentGranted;
68
+ /** Raw detail of the latest `cx:consent` event, if any. */
69
+ detail: ConsentXConsentDetail | null;
70
+ }
71
+
72
+ /**
73
+ * The `window.ConsentX` API the widget exposes once loaded. Currently used to
74
+ * re-open the preferences dialog.
75
+ */
76
+ export interface ConsentXWidgetApi {
77
+ open?: () => void;
78
+ [key: string]: unknown;
79
+ }
80
+
81
+ declare global {
82
+ interface Window {
83
+ /** Carries the connected Site Key for the embed (read by embed.js). */
84
+ consentx_key?: string;
85
+ /** The widget API, present once embed.js has initialised. */
86
+ ConsentX?: ConsentXWidgetApi;
87
+ dataLayer?: unknown[];
88
+ }
89
+
90
+ interface WindowEventMap {
91
+ 'cx:consent': CustomEvent<ConsentXConsentDetail>;
92
+ }
93
+ }
@@ -0,0 +1,20 @@
1
+ /*
2
+ * Public API surface of @consentx/angular.
3
+ */
4
+
5
+ export { ConsentXModule } from './lib/consentx.module';
6
+ export { ConsentXService } from './lib/consentx.service';
7
+ export {
8
+ provideConsentX,
9
+ consentxInitFactory,
10
+ consentxRootProviders,
11
+ } from './lib/consentx.providers';
12
+ export { CONSENTX_CONFIG } from './lib/consentx.tokens';
13
+ export {
14
+ CONSENTX_DEFAULT_APP_URL,
15
+ ConsentXConfig,
16
+ ConsentXState,
17
+ ConsentGranted,
18
+ ConsentXConsentDetail,
19
+ ConsentXWidgetApi,
20
+ } from './lib/consentx.types';
package/tsconfig.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "compilerOptions": {
3
+ "baseUrl": ".",
4
+ "outDir": "./dist/out-tsc",
5
+ "declaration": true,
6
+ "declarationMap": true,
7
+ "experimentalDecorators": true,
8
+ "emitDecoratorMetadata": true,
9
+ "moduleResolution": "node",
10
+ "importHelpers": true,
11
+ "strict": true,
12
+ "noImplicitOverride": true,
13
+ "noImplicitReturns": true,
14
+ "noFallthroughCasesInSwitch": true,
15
+ "noPropertyAccessFromIndexSignature": false,
16
+ "forceConsistentCasingInFileNames": true,
17
+ "skipLibCheck": true,
18
+ "esModuleInterop": true,
19
+ "module": "ES2022",
20
+ "target": "ES2022",
21
+ "useDefineForClassFields": false,
22
+ "lib": ["ES2022", "dom"]
23
+ },
24
+ "angularCompilerOptions": {
25
+ "enableI18nLegacyMessageIdFormat": false,
26
+ "strictInjectionParameters": true,
27
+ "strictInputAccessModifiers": true,
28
+ "strictTemplates": true,
29
+ "compilationMode": "partial"
30
+ },
31
+ "files": ["src/public-api.ts"]
32
+ }