Skip to content

Commit ef2d823

Browse files
committed
Merge branch 'main' of github.com:icon-project/sodax-frontend into release/sdk
2 parents ec71886 + 4199ebf commit ef2d823

File tree

11 files changed

+654
-171
lines changed

11 files changed

+654
-171
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ public/dist
1313
.idea
1414
**/build/**
1515

16+
# Claude Code local settings
17+
.claude/settings.local.json
18+
1619
# Agent skills (local to VSCode)
1720
.agents/
1821
.github/skills/

apps/web/app/globals.css

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,3 +469,50 @@ textarea[readonly] {
469469
.scrollbar-hide::-webkit-scrollbar {
470470
display: none; /* Chrome, Safari and Opera */
471471
}
472+
473+
/* ===== Cookie Consent Banner (vanilla-cookieconsent) ===== */
474+
#cc-main {
475+
--cc-font-family: "InterRegular", "Inter", system-ui, sans-serif;
476+
--cc-modal-border-radius: 16px;
477+
--cc-btn-border-radius: 8px;
478+
479+
/* Banner background & text */
480+
--cc-bg: var(--almost-white);
481+
--cc-primary-color: var(--espresso);
482+
--cc-secondary-color: var(--clay-dark);
483+
484+
/* Primary button (Accept all) */
485+
--cc-btn-primary-bg: var(--cherry-dark);
486+
--cc-btn-primary-color: #fff;
487+
--cc-btn-primary-border-color: var(--cherry-dark);
488+
--cc-btn-primary-hover-bg: var(--cherry-soda);
489+
--cc-btn-primary-hover-color: #fff;
490+
--cc-btn-primary-hover-border-color: var(--cherry-soda);
491+
492+
/* Secondary button (Reject / Manage) */
493+
--cc-btn-secondary-bg: transparent;
494+
--cc-btn-secondary-color: var(--cherry-dark);
495+
--cc-btn-secondary-border-color: var(--cherry-dark);
496+
--cc-btn-secondary-hover-bg: var(--cherry-brighter);
497+
--cc-btn-secondary-hover-color: var(--espresso);
498+
--cc-btn-secondary-hover-border-color: var(--cherry-bright);
499+
500+
/* Toggle (preferences modal) */
501+
--cc-toggle-on-bg: var(--cherry-dark);
502+
--cc-toggle-off-bg: var(--clay-light);
503+
--cc-toggle-on-knob-bg: #fff;
504+
--cc-toggle-off-knob-bg: #fff;
505+
--cc-toggle-readonly-bg: var(--cherry-bright);
506+
--cc-toggle-readonly-knob-bg: #fff;
507+
508+
/* Separators & category blocks */
509+
--cc-separator-border-color: var(--cream);
510+
--cc-cookie-category-block-bg: var(--cream-white);
511+
--cc-cookie-category-block-border: var(--cream);
512+
513+
/* Overlay */
514+
--cc-overlay-bg: rgba(72, 53, 52, 0.7);
515+
516+
/* Links */
517+
--cc-link-color: var(--cherry-dark);
518+
}

apps/web/app/layout.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import AppSidebar from '@/components/landing/sidebar';
77
import { SidebarProvider } from '@/components/ui/sidebar';
88
import { AppStoreProvider } from '@/stores/app-store-provider';
99
import { GoogleTagManager } from '@next/third-parties/google';
10+
import { CookieConsentBanner } from '@/components/cookie-consent/cookie-consent-banner';
1011

1112
const geistSans = localFont({
1213
src: './fonts/GeistVF.woff',
@@ -105,13 +106,34 @@ export default function RootLayout({
105106
<body className={`${geistSans.variable} ${geistMono.variable}`}>
106107
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: JSON-LD structured data for SEO */}
107108
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} />
109+
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: Consent Mode v2 defaults must execute synchronously before GTM */}
110+
<script dangerouslySetInnerHTML={{
111+
__html: `
112+
try{
113+
window.dataLayer=window.dataLayer||[];
114+
function gtag(){dataLayer.push(arguments);}
115+
var m=document.cookie.match(/cookie_consent_region=([^;]+)/);
116+
var r=m?m[1]:'other';
117+
if(r==='eu'){
118+
gtag('consent','default',{'ad_storage':'denied','ad_user_data':'denied','ad_personalization':'denied','analytics_storage':'granted','wait_for_update':500});
119+
}else{
120+
gtag('consent','default',{'ad_storage':'granted','ad_user_data':'granted','ad_personalization':'granted','analytics_storage':'granted'});
121+
}
122+
}catch(e){
123+
window.dataLayer=window.dataLayer||[];
124+
function gtag(){window.dataLayer.push(arguments);}
125+
gtag('consent','default',{'ad_storage':'denied','ad_user_data':'denied','ad_personalization':'denied','analytics_storage':'denied'});
126+
}`,
127+
}}
128+
/>
108129
<GoogleTagManager gtmId="GTM-W355PCS6" />
109130
<SidebarProvider>
110131
<AppSidebar />
111132
<Providers>
112133
<AppStoreProvider>{children}</AppStoreProvider>
113134
</Providers>
114135
</SidebarProvider>
136+
<CookieConsentBanner />
115137
</body>
116138
</html>
117139
);
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
'use client';
2+
3+
import { useEffect } from 'react';
4+
import * as CookieConsent from 'vanilla-cookieconsent';
5+
import 'vanilla-cookieconsent/dist/cookieconsent.css';
6+
import { getCookieConsentConfig } from './cookie-consent-config';
7+
8+
function getCookie(name: string): string | undefined {
9+
if (typeof document === 'undefined') return undefined;
10+
for (const cookie of document.cookie.split(';')) {
11+
const [rawKey, ...rest] = cookie.split('=');
12+
if (rawKey?.trim() === name) {
13+
return rest.join('=');
14+
}
15+
}
16+
return undefined;
17+
}
18+
19+
let initialized = false;
20+
21+
function ensureInitialized() {
22+
if (initialized) return;
23+
initialized = true;
24+
CookieConsent.run(getCookieConsentConfig());
25+
}
26+
27+
export function CookieConsentBanner() {
28+
useEffect(() => {
29+
const region = getCookie('cookie_consent_region');
30+
31+
// Only initialize cookie consent automatically for EU/EEA/UK visitors
32+
if (region !== 'eu') return;
33+
34+
try {
35+
ensureInitialized();
36+
} catch (error) {
37+
console.error('Failed to initialize cookie consent banner:', error);
38+
}
39+
}, []);
40+
41+
return null;
42+
}
43+
44+
/**
45+
* Opens the cookie preferences modal.
46+
* Initializes the consent library on-demand if it hasn't been initialized yet
47+
* (e.g. when a non-EU user clicks "Cookie Settings" in the footer).
48+
*/
49+
export function showCookiePreferences() {
50+
try {
51+
ensureInitialized();
52+
CookieConsent.showPreferences();
53+
} catch (error) {
54+
console.error('Failed to open cookie preferences:', error);
55+
}
56+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import type { CookieConsentConfig } from 'vanilla-cookieconsent';
2+
3+
/**
4+
* Updates Google Consent Mode v2 signals via gtag().
5+
* Called from vanilla-cookieconsent callbacks when the user makes a consent choice.
6+
*/
7+
function updateGtagConsent(categories: string[]) {
8+
if (typeof window === 'undefined' || typeof window.gtag !== 'function') return;
9+
10+
const hasMarketing = categories.includes('marketing');
11+
12+
window.gtag('consent', 'update', {
13+
ad_storage: hasMarketing ? 'granted' : 'denied',
14+
ad_user_data: hasMarketing ? 'granted' : 'denied',
15+
ad_personalization: hasMarketing ? 'granted' : 'denied',
16+
});
17+
}
18+
19+
export function getCookieConsentConfig(): CookieConsentConfig {
20+
return {
21+
cookie: {
22+
name: 'cc_cookie',
23+
expiresAfterDays: 365,
24+
},
25+
26+
guiOptions: {
27+
consentModal: {
28+
layout: 'box wide',
29+
position: 'bottom center',
30+
equalWeightButtons: true,
31+
flipButtons: false,
32+
},
33+
preferencesModal: {
34+
layout: 'box',
35+
position: 'right',
36+
equalWeightButtons: true,
37+
flipButtons: false,
38+
},
39+
},
40+
41+
categories: {
42+
necessary: {
43+
enabled: true,
44+
readOnly: true,
45+
},
46+
analytics: {
47+
enabled: true,
48+
readOnly: true, // Cookieless GA4 — always enabled, no user toggle needed
49+
},
50+
marketing: {
51+
enabled: false, // Denied by default for EU — gates X pixel and Reddit pixel
52+
readOnly: false,
53+
},
54+
},
55+
56+
onFirstConsent: ({ cookie }) => {
57+
updateGtagConsent(cookie.categories);
58+
},
59+
onChange: ({ cookie }) => {
60+
updateGtagConsent(cookie.categories);
61+
},
62+
63+
language: {
64+
default: 'en',
65+
translations: {
66+
en: {
67+
consentModal: {
68+
title: 'We use cookies',
69+
description:
70+
'We use essential cookies for site functionality and optional marketing cookies for personalized ads. You can accept all or customize your preferences.',
71+
acceptAllBtn: 'Accept all',
72+
acceptNecessaryBtn: 'Reject non-essential',
73+
showPreferencesBtn: 'Manage preferences',
74+
},
75+
preferencesModal: {
76+
title: 'Cookie preferences',
77+
acceptAllBtn: 'Accept all',
78+
acceptNecessaryBtn: 'Reject non-essential',
79+
savePreferencesBtn: 'Save preferences',
80+
closeIconLabel: 'Close',
81+
sections: [
82+
{
83+
title: 'Cookie usage',
84+
description:
85+
'We use cookies to ensure basic site functionality and to enhance your experience. You can choose to opt in or out of each category.',
86+
},
87+
{
88+
title: 'Strictly necessary cookies',
89+
description:
90+
'These cookies are essential for the website to function and cannot be switched off. They are set in response to your actions, such as setting privacy preferences or connecting a wallet.',
91+
linkedCategory: 'necessary',
92+
},
93+
{
94+
title: 'Analytics cookies',
95+
description:
96+
'We use cookieless analytics (Google Analytics 4) that do not store personal data on your device. This category is always enabled.',
97+
linkedCategory: 'analytics',
98+
},
99+
{
100+
title: 'Marketing cookies',
101+
description:
102+
'These cookies are used by advertising partners (such as X/Twitter and Reddit) to build a profile of your interests and show you relevant ads on other sites.',
103+
linkedCategory: 'marketing',
104+
},
105+
],
106+
},
107+
},
108+
},
109+
},
110+
};
111+
}

apps/web/components/landing/footer.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
SWAP_ROUTE,
2121
X_ROUTE,
2222
} from '@/constants/routes';
23+
import { showCookiePreferences } from '@/components/cookie-consent/cookie-consent-banner';
2324

2425
interface FooterProps {
2526
onTermsClick?: () => void;
@@ -109,6 +110,15 @@ const Footer: React.FC<FooterProps> = ({ onTermsClick }) => {
109110
<FooterLink href="#" onClick={handleTermsClick}>
110111
Terms
111112
</FooterLink>
113+
<FooterLink
114+
href="#"
115+
onClick={(e) => {
116+
e.preventDefault();
117+
showCookiePreferences();
118+
}}
119+
>
120+
Cookie Settings
121+
</FooterLink>
112122
</div>
113123
</div>
114124
<TermsModal open={isTermsModalOpen} onOpenChange={setIsTermsModalOpen} />

apps/web/lib/analytics.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ export interface SwapCompletedEvent {
1919
// Extend Window interface for dataLayer
2020
declare global {
2121
interface Window {
22-
dataLayer?: Object[];
22+
dataLayer?: Record<string, unknown>[];
23+
gtag?: (...args: unknown[]) => void;
2324
}
2425
}
2526

apps/web/middleware.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { type NextRequest, NextResponse } from 'next/server';
2+
3+
/**
4+
* EU/EEA/UK country codes (31 total).
5+
* Used to determine if the cookie consent banner should be shown.
6+
*/
7+
const EU_EEA_UK_COUNTRY_CODES = new Set([
8+
// EU (27)
9+
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR',
10+
'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL',
11+
'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE',
12+
// EEA non-EU (3)
13+
'IS', 'LI', 'NO',
14+
// UK (1)
15+
'GB',
16+
]);
17+
18+
export function middleware(request: NextRequest) {
19+
const response = NextResponse.next();
20+
21+
// Only set the cookie if it hasn't been set yet
22+
if (request.cookies.has('cookie_consent_region')) {
23+
return response;
24+
}
25+
26+
// Vercel injects geo data as request headers; undefined in local dev (defaults to 'other' → no banner)
27+
const country = request.headers.get('x-vercel-ip-country');
28+
const region = country && EU_EEA_UK_COUNTRY_CODES.has(country) ? 'eu' : 'other';
29+
30+
response.cookies.set('cookie_consent_region', region, {
31+
httpOnly: false, // Client JS must read this cookie
32+
secure: process.env.NODE_ENV === 'production',
33+
sameSite: 'lax',
34+
path: '/',
35+
maxAge: 60 * 60 * 24 * 365, // 1 year
36+
});
37+
38+
return response;
39+
}
40+
41+
export const config = {
42+
matcher: [
43+
'/((?!_next/static|_next/image|favicon.ico|fonts|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|zip|toml)).*)',
44+
],
45+
};

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
"tailwind-merge": "^3.0.2",
8282
"tailwindcss": "^4.0.8",
8383
"tailwindcss-animate": "^1.0.7",
84+
"vanilla-cookieconsent": "^3.1.0",
8485
"vaul": "^1.1.2",
8586
"viem": "catalog:",
8687
"wagmi": "catalog:",
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1-
export { IconSpokeProvider, IconRawSpokeProvider, IconBaseSpokeProvider } from './IconSpokeProvider.js';
1+
export {
2+
IconSpokeProvider,
3+
IconRawSpokeProvider,
4+
IconBaseSpokeProvider,
5+
type IconRawSpokeProviderConfig,
6+
} from './IconSpokeProvider.js';
27
export * from './utils.js';
38
export * from './HanaWalletConnector.js';

0 commit comments

Comments
 (0)