404 lines
16 KiB
TypeScript
404 lines
16 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { Shield, SlidersHorizontal, Info, Save, Globe } from 'lucide-react';
|
|
import { toast } from 'react-toastify';
|
|
import adminPrivacyService, {
|
|
CookieIntegrationSettings,
|
|
CookieIntegrationSettingsResponse,
|
|
CookiePolicySettings,
|
|
CookiePolicySettingsResponse,
|
|
} from '../../services/api/adminPrivacyService';
|
|
import { Loading } from '../../components/common';
|
|
|
|
const CookieSettingsPage: React.FC = () => {
|
|
const [policy, setPolicy] = useState<CookiePolicySettings>({
|
|
analytics_enabled: true,
|
|
marketing_enabled: true,
|
|
preferences_enabled: true,
|
|
});
|
|
const [integrations, setIntegrations] = useState<CookieIntegrationSettings>({
|
|
ga_measurement_id: '',
|
|
fb_pixel_id: '',
|
|
});
|
|
const [policyMeta, setPolicyMeta] = useState<
|
|
Pick<CookiePolicySettingsResponse, 'updated_at' | 'updated_by'> | null
|
|
>(null);
|
|
const [integrationMeta, setIntegrationMeta] = useState<
|
|
Pick<CookieIntegrationSettingsResponse, 'updated_at' | 'updated_by'> | null
|
|
>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const loadSettings = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const [policyRes, integrationRes] = await Promise.all([
|
|
adminPrivacyService.getCookiePolicy(),
|
|
adminPrivacyService.getIntegrations(),
|
|
]);
|
|
setPolicy(policyRes.data);
|
|
setPolicyMeta({
|
|
updated_at: policyRes.updated_at,
|
|
updated_by: policyRes.updated_by,
|
|
});
|
|
setIntegrations(integrationRes.data || {});
|
|
setIntegrationMeta({
|
|
updated_at: integrationRes.updated_at,
|
|
updated_by: integrationRes.updated_by,
|
|
});
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to load cookie & integration settings');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
void loadSettings();
|
|
}, []);
|
|
|
|
const handleToggle = (key: keyof CookiePolicySettings) => {
|
|
setPolicy((prev) => ({
|
|
...prev,
|
|
[key]: !prev[key],
|
|
}));
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
try {
|
|
setSaving(true);
|
|
const [policyRes, integrationRes] = await Promise.all([
|
|
adminPrivacyService.updateCookiePolicy(policy),
|
|
adminPrivacyService.updateIntegrations(integrations),
|
|
]);
|
|
setPolicy(policyRes.data);
|
|
setPolicyMeta({
|
|
updated_at: policyRes.updated_at,
|
|
updated_by: policyRes.updated_by,
|
|
});
|
|
setIntegrations(integrationRes.data || {});
|
|
setIntegrationMeta({
|
|
updated_at: integrationRes.updated_at,
|
|
updated_by: integrationRes.updated_by,
|
|
});
|
|
toast.success('Cookie policy and integrations updated successfully');
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to update cookie settings');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleSaveIntegrations = async () => {
|
|
try {
|
|
setSaving(true);
|
|
const integrationRes = await adminPrivacyService.updateIntegrations(
|
|
integrations
|
|
);
|
|
setIntegrations(integrationRes.data || {});
|
|
setIntegrationMeta({
|
|
updated_at: integrationRes.updated_at,
|
|
updated_by: integrationRes.updated_by,
|
|
});
|
|
toast.success('Integration IDs updated successfully');
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to update integration IDs');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return <Loading fullScreen={false} text="Loading cookie policy..." />;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-10 pb-8 animate-fade-in">
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-6 pb-6 border-b border-gray-200/60">
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2.5 rounded-lg bg-gradient-to-br from-[#d4af37]/10 to-[#d4af37]/5 border border-[#d4af37]/20 shadow-sm">
|
|
<Shield className="w-6 h-6 text-[#d4af37]" />
|
|
</div>
|
|
<h1 className="enterprise-section-title">Cookie & Privacy Controls</h1>
|
|
</div>
|
|
<p className="enterprise-section-subtitle max-w-2xl text-gray-600">
|
|
Define which cookie categories are allowed in the application. These
|
|
settings control which types of cookies your users can consent to.
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
className="btn-enterprise-primary inline-flex items-center gap-2 whitespace-nowrap"
|
|
>
|
|
<Save className={`w-4 h-4 ${saving ? 'animate-pulse' : ''}`} />
|
|
{saving ? 'Saving...' : 'Save All Changes'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Info card */}
|
|
<div className="enterprise-card flex gap-5 p-6 bg-gradient-to-br from-blue-50/50 to-indigo-50/30 border-blue-100/60">
|
|
<div className="mt-0.5 flex-shrink-0">
|
|
<div className="p-2.5 rounded-lg bg-gradient-to-br from-blue-500/10 to-indigo-500/10 border border-blue-200/40">
|
|
<Info className="w-5 h-5 text-blue-600" />
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2.5 flex-1">
|
|
<p className="font-semibold text-gray-900 text-base">
|
|
How these settings affect the guest experience
|
|
</p>
|
|
<p className="text-sm text-gray-700 leading-relaxed">
|
|
Disabling a category here prevents it from being offered to guests as
|
|
part of the cookie consent flow. For example, if marketing cookies are
|
|
disabled, the website should not load marketing pixels even if a guest
|
|
previously opted in.
|
|
</p>
|
|
{policyMeta?.updated_at && (
|
|
<div className="pt-2 border-t border-gray-200/60">
|
|
<p className="text-xs text-gray-500 font-medium">
|
|
Last updated on{' '}
|
|
<span className="text-gray-700">
|
|
{new Date(policyMeta.updated_at).toLocaleString(undefined, {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
})}
|
|
</span>
|
|
{policyMeta.updated_by && (
|
|
<>
|
|
{' '}by <span className="text-gray-700 font-semibold">{policyMeta.updated_by}</span>
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Toggles */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div className="enterprise-card p-6 space-y-4 group">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex-1 space-y-2">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="p-1.5 rounded-md bg-emerald-50 border border-emerald-100">
|
|
<SlidersHorizontal className="w-4 h-4 text-emerald-600" />
|
|
</div>
|
|
<p className="font-bold text-gray-900 text-base">
|
|
Analytics Cookies
|
|
</p>
|
|
</div>
|
|
<p className="text-sm text-gray-600 leading-relaxed">
|
|
Anonymous traffic and performance measurement (e.g. page views,
|
|
conversion funnels).
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleToggle('analytics_enabled')}
|
|
className={`relative inline-flex h-8 w-14 items-center rounded-full transition-all duration-300 shadow-lg ${
|
|
policy.analytics_enabled
|
|
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 shadow-emerald-500/30'
|
|
: 'bg-gray-300 shadow-gray-300/20'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-6 w-6 transform rounded-full bg-white shadow-lg transition-all duration-300 ${
|
|
policy.analytics_enabled ? 'translate-x-7' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
<div className="pt-3 border-t border-gray-100">
|
|
<p className="text-xs text-gray-500 leading-relaxed">
|
|
When disabled, analytics tracking scripts should not be executed,
|
|
regardless of user consent.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="enterprise-card p-6 space-y-4 group">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex-1 space-y-2">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="p-1.5 rounded-md bg-pink-50 border border-pink-100">
|
|
<SlidersHorizontal className="w-4 h-4 text-pink-600" />
|
|
</div>
|
|
<p className="font-bold text-gray-900 text-base">
|
|
Marketing Cookies
|
|
</p>
|
|
</div>
|
|
<p className="text-sm text-gray-600 leading-relaxed">
|
|
Personalised offers, remarketing campaigns, and external ad
|
|
networks.
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleToggle('marketing_enabled')}
|
|
className={`relative inline-flex h-8 w-14 items-center rounded-full transition-all duration-300 shadow-lg ${
|
|
policy.marketing_enabled
|
|
? 'bg-gradient-to-r from-pink-500 to-pink-600 shadow-pink-500/30'
|
|
: 'bg-gray-300 shadow-gray-300/20'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-6 w-6 transform rounded-full bg-white shadow-lg transition-all duration-300 ${
|
|
policy.marketing_enabled ? 'translate-x-7' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
<div className="pt-3 border-t border-gray-100">
|
|
<p className="text-xs text-gray-500 leading-relaxed">
|
|
When disabled, do not load any marketing pixels or share data with ad
|
|
platforms.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="enterprise-card p-6 space-y-4 group">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex-1 space-y-2">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="p-1.5 rounded-md bg-indigo-50 border border-indigo-100">
|
|
<SlidersHorizontal className="w-4 h-4 text-indigo-600" />
|
|
</div>
|
|
<p className="font-bold text-gray-900 text-base">
|
|
Preference Cookies
|
|
</p>
|
|
</div>
|
|
<p className="text-sm text-gray-600 leading-relaxed">
|
|
Remember guest choices like language, currency, and layout.
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleToggle('preferences_enabled')}
|
|
className={`relative inline-flex h-8 w-14 items-center rounded-full transition-all duration-300 shadow-lg ${
|
|
policy.preferences_enabled
|
|
? 'bg-gradient-to-r from-indigo-500 to-indigo-600 shadow-indigo-500/30'
|
|
: 'bg-gray-300 shadow-gray-300/20'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-6 w-6 transform rounded-full bg-white shadow-lg transition-all duration-300 ${
|
|
policy.preferences_enabled ? 'translate-x-7' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
<div className="pt-3 border-t border-gray-100">
|
|
<p className="text-xs text-gray-500 leading-relaxed">
|
|
When disabled, the application should avoid persisting non-essential
|
|
preferences client-side.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Integration IDs */}
|
|
<div className="enterprise-card p-6 space-y-6">
|
|
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between pb-4 border-b border-gray-200/60">
|
|
<div className="flex items-start gap-3">
|
|
<div className="p-2 rounded-lg bg-gradient-to-br from-blue-500/10 to-indigo-500/10 border border-blue-200/40 mt-0.5">
|
|
<Globe className="w-5 h-5 text-blue-600" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<p className="font-bold text-gray-900 text-lg">
|
|
Third-Party Integrations
|
|
</p>
|
|
<p className="text-sm text-gray-600 leading-relaxed max-w-xl">
|
|
Configure IDs for supported analytics and marketing platforms. The
|
|
application will only load these when both the policy and user consent
|
|
allow it.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-start gap-3 md:items-end md:min-w-[200px]">
|
|
{integrationMeta?.updated_at && (
|
|
<div className="text-right">
|
|
<p className="text-xs text-gray-500 font-medium">
|
|
Last changed
|
|
</p>
|
|
<p className="text-xs text-gray-700 mt-0.5">
|
|
{new Date(integrationMeta.updated_at).toLocaleString(undefined, {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
})}
|
|
</p>
|
|
{integrationMeta.updated_by && (
|
|
<p className="text-xs text-gray-600 mt-0.5">
|
|
by <span className="font-semibold">{integrationMeta.updated_by}</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={handleSaveIntegrations}
|
|
disabled={saving}
|
|
className="btn-enterprise-secondary inline-flex items-center gap-2 whitespace-nowrap"
|
|
>
|
|
<Save className="w-3.5 h-3.5" />
|
|
{saving ? 'Saving...' : 'Save Integration IDs'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="space-y-3">
|
|
<label className="block text-sm font-semibold text-gray-900 tracking-wide">
|
|
Google Analytics 4 Measurement ID
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={integrations.ga_measurement_id || ''}
|
|
onChange={(e) =>
|
|
setIntegrations((prev) => ({
|
|
...prev,
|
|
ga_measurement_id: e.target.value || undefined,
|
|
}))
|
|
}
|
|
placeholder="G-XXXXXXXXXX"
|
|
className="enterprise-input text-sm"
|
|
/>
|
|
<p className="text-xs text-gray-500 leading-relaxed">
|
|
Example: <code className="font-mono text-gray-700 bg-gray-50 px-1.5 py-0.5 rounded border border-gray-200">G-ABCDE12345</code>. This is used to
|
|
load GA4 via gtag.js when analytics cookies are allowed.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<label className="block text-sm font-semibold text-gray-900 tracking-wide">
|
|
Meta (Facebook) Pixel ID
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={integrations.fb_pixel_id || ''}
|
|
onChange={(e) =>
|
|
setIntegrations((prev) => ({
|
|
...prev,
|
|
fb_pixel_id: e.target.value || undefined,
|
|
}))
|
|
}
|
|
placeholder="123456789012345"
|
|
className="enterprise-input text-sm"
|
|
/>
|
|
<p className="text-xs text-gray-500 leading-relaxed">
|
|
Numeric ID from your Meta Pixel. The application will only fire pixel
|
|
events when marketing cookies are allowed.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CookieSettingsPage;
|
|
|
|
|