@koehler8/cms-ext-compliance 1.0.0-beta.4

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Chris Koehler
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # Compliance Extension
2
+
3
+ First-party bundle that keeps the legal surfaces (cookies, terms of service, and privacy policy) out of the core component registry. Sites can mount these documents wherever needed without copying the SFCs or duplicating schema logic.
4
+
5
+ ## Quick Start
6
+ - Add the components to a page with the object syntax so the loader can resolve the extension:
7
+ ```json
8
+ "components": [
9
+ "Header",
10
+ { "name": "Terms", "source": "compliance" },
11
+ { "name": "Cookies", "source": "compliance" },
12
+ { "name": "Privacy", "source": "compliance" },
13
+ "Footer"
14
+ ]
15
+ ```
16
+ - Each component reads its content block from the matching config key (`terms`, `cookies`, `privacy`). Define those fields under `pages.<page>.content` or `shared.content` in your site JSON.
17
+
18
+ ## Component Catalog
19
+
20
+ | Component | Location | Config Key | Allowed Pages | Notes |
21
+ |-----------|----------|------------|---------------|-------|
22
+ | `Terms` | `extensions/compliance/components/Terms.vue` | `terms` | `terms` | Renders rich-text body copy for the Terms of Service route. Supports HTML/markdown mixed content plus optional metadata (headings, updated date) defined inside the config block. |
23
+ | `Cookies` | `extensions/compliance/components/Cookies.vue` | `cookies` | `cookies` | Cookie policy surface with sections, ordered lists, and callouts. Reads the `cookieConsent` helpers so buttons stay in sync with `CookieConsent.vue`. |
24
+ | `Privacy` | `extensions/compliance/components/Privacy.vue` | `privacy` | `privacy` | Privacy policy layout with title, intro, and repeating section groups. Mirrored across locales by defining per-locale overrides for `privacy`. |
25
+
26
+ > **Tip:** The manifest already maps each component to the config key above, so you only need to supply `configKey` in page configs when you want the component to pull data from an alternate field.
27
+
28
+ ## Content Expectations
29
+ - Store legal copy under the matching config key:
30
+ ```json
31
+ {
32
+ "pages": {
33
+ "privacy": {
34
+ "components": ["Header", { "name": "Privacy", "source": "compliance" }, "Footer"]
35
+ }
36
+ },
37
+ "shared": {
38
+ "content": {
39
+ "privacy": {
40
+ "title": "Privacy Policy",
41
+ "subtitle": "Updated January 2025",
42
+ "sections": [
43
+ { "heading": "Data We Collect", "body": "<p>...</p>" }
44
+ ]
45
+ }
46
+ }
47
+ }
48
+ }
49
+ ```
50
+ - Sections accept HTML strings and simple arrays (`bullets`, `listItems`) so CMS tooling can render rich copy without new Vue components.
51
+ - Route metadata (`pages.privacy.meta.title`, etc.) still lives beside the page definition just like any core component page.
52
+
53
+ ## Localization
54
+ - Follow the normal locale override pattern described in `docs/configuration.md`. For example, `sites/<token>/config/<token>-es.json` can override just the `privacy` block for `/es/privacy` while inheriting the base English copy for any undefined fields.
55
+ - Because legal copy often changes per market, keep locale files scoped to the page-level `content` to avoid accidental overwrites across sites.
56
+
57
+ ## Related References
58
+ - Manifest schema and validation rules: `docs/extensions.md`
59
+ - Site configuration fundamentals: `docs/configuration.md`
60
+ - Routing behaviour for `/privacy`, `/terms`, `/cookies`: `docs/architecture.md`
61
+
62
+ ## Contributing
63
+
64
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
65
+
66
+ ## License
67
+
68
+ [MIT](./LICENSE)
@@ -0,0 +1,264 @@
1
+ <template>
2
+ <div
3
+ v-if="showBanner"
4
+ class="cookie-consent-banner"
5
+ role="dialog"
6
+ aria-live="polite"
7
+ aria-label="Cookie consent notice"
8
+ >
9
+ <div class="cookie-consent-content">
10
+ <h3 class="cookie-consent-title">Cookies &amp; Analytics</h3>
11
+ <p class="cookie-consent-message">
12
+ We use cookies to measure traffic and improve your experience. Accept to enable analytics; decline to continue with essential cookies only.
13
+ </p>
14
+ <div class="cookie-consent-links">
15
+ <a href="/privacy" target="_blank" rel="noopener">Privacy Policy</a>
16
+ <span class="separator" aria-hidden="true">•</span>
17
+ <a href="/cookies" target="_blank" rel="noopener">Cookie Policy</a>
18
+ </div>
19
+ <div class="cookie-consent-actions">
20
+ <button @click="handleAccept" class="btn btn-accept">Accept</button>
21
+ <button @click="handleDecline" class="btn btn-decline">Decline</button>
22
+ </div>
23
+ </div>
24
+ </div>
25
+ </template>
26
+
27
+ <script setup>
28
+ import { ref, onMounted, onUnmounted } from 'vue';
29
+ import {
30
+ isConsentPending,
31
+ acceptConsent,
32
+ declineConsent,
33
+ scheduleAnalyticsLoad,
34
+ isCookieBannerEnabled
35
+ } from '@koehler8/cms/utils/cookieConsent';
36
+ import { loadConfigData } from '@koehler8/cms/utils/loadConfig';
37
+ import { trackEvent } from '@koehler8/cms/utils/analytics';
38
+
39
+ const showBanner = ref(false);
40
+ const bannerEnabled = isCookieBannerEnabled();
41
+ let siteData;
42
+ let consentListener;
43
+ let siteDataPromise;
44
+ let initializeTrackingPromise;
45
+ onMounted(() => {
46
+ if (!bannerEnabled) {
47
+ return;
48
+ }
49
+
50
+ siteDataPromise = loadConfigData()
51
+ .then((data) => {
52
+ siteData = data;
53
+ return data;
54
+ });
55
+
56
+ siteDataPromise.catch((error) => {
57
+ console.error('Unable to load site configuration for cookie consent:', error);
58
+ });
59
+
60
+ if (isConsentPending()) {
61
+ showBanner.value = true;
62
+ trackEvent('cookie_consent_banner_shown', {
63
+ source: 'auto'
64
+ });
65
+ }
66
+
67
+ consentListener = (e) => {
68
+ if (e.detail.status === 'accepted') {
69
+ ensureTrackingInitialized();
70
+ }
71
+ };
72
+
73
+ // Listen for consent changes to load scripts
74
+ window.addEventListener('consentChanged', consentListener);
75
+ });
76
+
77
+ onUnmounted(() => {
78
+ if (consentListener) {
79
+ window.removeEventListener('consentChanged', consentListener);
80
+ }
81
+ });
82
+
83
+ function ensureTrackingInitialized() {
84
+ if (!bannerEnabled) {
85
+ return Promise.resolve();
86
+ }
87
+
88
+ if (!initializeTrackingPromise) {
89
+ initializeTrackingPromise = (async () => {
90
+ try {
91
+ const data = siteData || (siteDataPromise && await siteDataPromise);
92
+ if (!data) {
93
+ console.warn('Site configuration unavailable; skipping analytics initialization.');
94
+ return;
95
+ }
96
+
97
+ const googleId = data?.site?.googleId;
98
+
99
+ if (googleId) {
100
+ await scheduleAnalyticsLoad(googleId);
101
+ }
102
+ } catch (error) {
103
+ console.error('Unable to initialize tracking after consent:', error);
104
+ initializeTrackingPromise = undefined;
105
+ }
106
+ })();
107
+ }
108
+
109
+ return initializeTrackingPromise;
110
+ }
111
+
112
+ function handleAccept() {
113
+ acceptConsent();
114
+ showBanner.value = false;
115
+ trackEvent('cookie_consent_choice', {
116
+ choice: 'accept',
117
+ source: 'banner'
118
+ });
119
+ ensureTrackingInitialized();
120
+ }
121
+
122
+ function handleDecline() {
123
+ declineConsent();
124
+ showBanner.value = false;
125
+ trackEvent('cookie_consent_choice', {
126
+ choice: 'decline',
127
+ source: 'banner'
128
+ });
129
+ }
130
+ </script>
131
+
132
+ <style scoped>
133
+ .cookie-consent-banner {
134
+ position: fixed;
135
+ bottom: 24px;
136
+ right: 24px;
137
+ width: min(340px, calc(100% - 48px));
138
+ background-color: rgba(10, 10, 13, 0.96);
139
+ color: var(--brand-fg-100, #f0eaf3);
140
+ padding: 20px;
141
+ border-radius: var(--brand-card-radius, 24px);
142
+ box-shadow: var(--brand-surface-card-shadow, 0 8px 24px rgba(217, 22, 75, 0.18));
143
+ border: 1px solid rgba(39, 243, 255, 0.22);
144
+ z-index: 9999;
145
+ animation: fadeIn 0.25s ease-out;
146
+ }
147
+
148
+ @keyframes fadeIn {
149
+ from {
150
+ opacity: 0;
151
+ transform: translateY(10px);
152
+ }
153
+ to {
154
+ opacity: 1;
155
+ transform: translateY(0);
156
+ }
157
+ }
158
+
159
+ @media (prefers-reduced-motion: reduce) {
160
+ .cookie-consent-banner {
161
+ animation: none;
162
+ }
163
+ }
164
+
165
+ .cookie-consent-content {
166
+ display: flex;
167
+ flex-direction: column;
168
+ gap: 12px;
169
+ }
170
+
171
+ .cookie-consent-title {
172
+ margin: 0;
173
+ font-size: 16px;
174
+ font-weight: 600;
175
+ }
176
+
177
+ .cookie-consent-message {
178
+ margin: 0;
179
+ font-size: 14px;
180
+ line-height: 1.5;
181
+ color: rgba(240, 234, 243, 0.85);
182
+ }
183
+
184
+ .cookie-consent-links {
185
+ display: flex;
186
+ align-items: center;
187
+ gap: 6px;
188
+ font-size: 13px;
189
+ color: rgba(201, 191, 208, 0.85);
190
+ }
191
+
192
+ .cookie-consent-links a {
193
+ color: var(--brand-electric-blue, #27f3ff);
194
+ text-decoration: underline;
195
+ }
196
+
197
+ .cookie-consent-links a:hover {
198
+ color: var(--brand-neon-pink, #ff2d86);
199
+ }
200
+
201
+ .cookie-consent-actions {
202
+ display: flex;
203
+ gap: 8px;
204
+ justify-content: flex-start;
205
+ }
206
+
207
+ .btn {
208
+ padding: 10px 18px;
209
+ font-size: 14px;
210
+ font-weight: 500;
211
+ border: none;
212
+ border-radius: var(--brand-button-radius, 14px);
213
+ cursor: pointer;
214
+ transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
215
+ white-space: nowrap;
216
+ min-width: 96px;
217
+ }
218
+
219
+ .btn-accept {
220
+ background: var(
221
+ --brand-primary-cta-gradient,
222
+ linear-gradient(135deg, #ff2d86 0%, #9a2eff 55%, #27f3ff 100%)
223
+ );
224
+ color: var(--brand-primary-cta-text, #0a0a0d);
225
+ box-shadow: var(--brand-primary-cta-shadow, 0 18px 40px rgba(255, 45, 134, 0.45));
226
+ }
227
+
228
+ .btn-accept:hover {
229
+ transform: translateY(-1px);
230
+ box-shadow: var(--brand-primary-cta-hover-shadow, 0 20px 44px rgba(255, 45, 134, 0.55));
231
+ }
232
+
233
+ .btn-accept:focus-visible {
234
+ outline: 2px solid rgba(39, 243, 255, 0.6);
235
+ outline-offset: 3px;
236
+ }
237
+
238
+ .btn-decline {
239
+ background: rgba(39, 243, 255, 0.12);
240
+ color: var(--brand-fg-100, #f0eaf3);
241
+ border: 1px solid rgba(39, 243, 255, 0.35);
242
+ }
243
+
244
+ .btn-decline:hover {
245
+ background: rgba(39, 243, 255, 0.22);
246
+ border-color: rgba(39, 243, 255, 0.55);
247
+ transform: translateY(-1px);
248
+ box-shadow: 0 12px 24px rgba(39, 243, 255, 0.18);
249
+ }
250
+
251
+ .btn-decline:focus-visible {
252
+ outline: 2px solid rgba(39, 243, 255, 0.6);
253
+ outline-offset: 3px;
254
+ }
255
+
256
+ @media (max-width: 480px) {
257
+ .cookie-consent-banner {
258
+ right: 16px;
259
+ left: 16px;
260
+ bottom: 16px;
261
+ width: auto;
262
+ }
263
+ }
264
+ </style>