Add Google Analytics integration with consent-gated loading

Adds a "Statistik" cookie category (alongside the existing external
media one), an admin-configurable Measurement ID field in Website
Settings, and a GoogleAnalytics component that only injects gtag.js
after the visitor has consented - mirrors the existing ExternalEmbed
pattern instead of loading tracking scripts unconditionally.

Also documents Google Analytics in the Datenschutzerklärung (new
section 8, remaining sections renumbered) since introducing a new
data processing purpose requires disclosure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Maaxxs 2026-07-07 01:32:44 +02:00
parent 0de35ef692
commit b5de375d7f
13 changed files with 130 additions and 18 deletions

View file

@ -6,6 +6,7 @@ import { useLanguage } from '../context/LanguageContext.jsx';
function SettingsModal({ onClose }) { function SettingsModal({ onClose }) {
const { consent, saveCustom } = useCookieConsent(); const { consent, saveCustom } = useCookieConsent();
const [external, setExternal] = useState(consent.external); const [external, setExternal] = useState(consent.external);
const [analytics, setAnalytics] = useState(consent.analytics);
const { t } = useLanguage(); const { t } = useLanguage();
return ( return (
@ -49,6 +50,20 @@ function SettingsModal({ onClose }) {
className="mt-1 h-4 w-4 shrink-0" className="mt-1 h-4 w-4 shrink-0"
/> />
</div> </div>
<div className="flex items-start justify-between gap-3 rounded-lg border border-neutral-200 p-3 dark:border-neutral-700">
<div>
<p className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">{t('cookieConsent.analyticsTitle')}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('cookieConsent.analyticsDesc')}
</p>
</div>
<input
type="checkbox"
checked={analytics}
onChange={(e) => setAnalytics(e.target.checked)}
className="mt-1 h-4 w-4 shrink-0"
/>
</div>
</div> </div>
<div className="mt-5 flex justify-end gap-2"> <div className="mt-5 flex justify-end gap-2">
<button <button
@ -60,7 +75,7 @@ function SettingsModal({ onClose }) {
</button> </button>
<button <button
type="button" type="button"
onClick={() => saveCustom({ external })} onClick={() => saveCustom({ external, analytics })}
className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600" className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600"
> >
{t('cookieConsent.saveSelection')} {t('cookieConsent.saveSelection')}

View file

@ -0,0 +1,32 @@
import { useEffect, useRef } from 'react';
import { useCookieConsent } from '../context/CookieConsentContext.jsx';
import { useSiteSettings } from '../context/SiteSettingsContext.jsx';
// Laedt gtag.js erst, nachdem der Besucher der Statistik-Kategorie im Cookie-Banner
// zugestimmt hat (wie Google Maps/YouTube ueber ExternalEmbed) - keine Anfrage an
// Google, keine Cookies, solange keine Einwilligung vorliegt.
export default function GoogleAnalytics() {
const { consent } = useCookieConsent();
const { ga_measurement_id: measurementId } = useSiteSettings();
const loadedRef = useRef(false);
useEffect(() => {
if (!measurementId || !consent.analytics || loadedRef.current) return;
loadedRef.current = true;
const script = document.createElement('script');
script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`;
script.async = true;
document.head.appendChild(script);
window.dataLayer = window.dataLayer || [];
function gtag(...args) {
window.dataLayer.push(args);
}
window.gtag = gtag;
gtag('js', new Date());
gtag('config', measurementId, { anonymize_ip: true });
}, [measurementId, consent.analytics]);
return null;
}

View file

@ -6,6 +6,7 @@ import ScrollProgress from './ScrollProgress.jsx';
import MaintenanceNotice from './MaintenanceNotice.jsx'; import MaintenanceNotice from './MaintenanceNotice.jsx';
import MaintenanceBypassBanner from './MaintenanceBypassBanner.jsx'; import MaintenanceBypassBanner from './MaintenanceBypassBanner.jsx';
import CookieConsentBanner from './CookieConsentBanner.jsx'; import CookieConsentBanner from './CookieConsentBanner.jsx';
import GoogleAnalytics from './GoogleAnalytics.jsx';
import { MaintenanceProvider } from '../context/MaintenanceContext.jsx'; import { MaintenanceProvider } from '../context/MaintenanceContext.jsx';
import { SiteSettingsProvider } from '../context/SiteSettingsContext.jsx'; import { SiteSettingsProvider } from '../context/SiteSettingsContext.jsx';
import { LanguageProvider } from '../context/LanguageContext.jsx'; import { LanguageProvider } from '../context/LanguageContext.jsx';
@ -23,6 +24,7 @@ const DEFAULT_SITE_SETTINGS = {
whatsapp: null, whatsapp: null,
contact_email: 'info@hifi-planet-amorbach.de', contact_email: 'info@hifi-planet-amorbach.de',
hero_image_path: null, hero_image_path: null,
ga_measurement_id: null,
}; };
// Das Impressum (und die anderen rechtlichen Pflichtseiten) müssen laut § 5 DDG // Das Impressum (und die anderen rechtlichen Pflichtseiten) müssen laut § 5 DDG
@ -71,6 +73,7 @@ export default function PublicLayout() {
</main> </main>
<Footer /> <Footer />
<CookieConsentBanner /> <CookieConsentBanner />
<GoogleAnalytics />
</div> </div>
</SiteSettingsProvider> </SiteSettingsProvider>
</MaintenanceProvider> </MaintenanceProvider>

View file

@ -2,7 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
const STORAGE_KEY = 'hifiplanet-cookie-consent'; const STORAGE_KEY = 'hifiplanet-cookie-consent';
const defaultConsent = { necessary: true, external: false }; const defaultConsent = { necessary: true, external: false, analytics: false };
const CookieConsentContext = createContext({ const CookieConsentContext = createContext({
consent: defaultConsent, consent: defaultConsent,
@ -25,7 +25,7 @@ export function CookieConsentProvider({ children }) {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (raw) { if (raw) {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
setConsent({ necessary: true, external: !!parsed.external }); setConsent({ necessary: true, external: !!parsed.external, analytics: !!parsed.analytics });
setDecided(true); setDecided(true);
} }
} catch { } catch {
@ -40,9 +40,12 @@ export function CookieConsentProvider({ children }) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...next, decidedAt: new Date().toISOString() })); localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...next, decidedAt: new Date().toISOString() }));
}, []); }, []);
const acceptAll = useCallback(() => persist({ necessary: true, external: true }), [persist]); const acceptAll = useCallback(() => persist({ necessary: true, external: true, analytics: true }), [persist]);
const rejectNonEssential = useCallback(() => persist({ necessary: true, external: false }), [persist]); const rejectNonEssential = useCallback(() => persist({ necessary: true, external: false, analytics: false }), [persist]);
const saveCustom = useCallback((partial) => persist({ necessary: true, external: !!partial.external }), [persist]); const saveCustom = useCallback(
(partial) => persist({ necessary: true, external: !!partial.external, analytics: !!partial.analytics }),
[persist]
);
const openSettings = useCallback(() => setSettingsOpen(true), []); const openSettings = useCallback(() => setSettingsOpen(true), []);
const closeSettings = useCallback(() => setSettingsOpen(false), []); const closeSettings = useCallback(() => setSettingsOpen(false), []);

View file

@ -7,6 +7,7 @@ const SiteSettingsContext = createContext({
whatsapp: null, whatsapp: null,
contact_email: 'info@hifi-planet-amorbach.de', contact_email: 'info@hifi-planet-amorbach.de',
hero_image_path: null, hero_image_path: null,
ga_measurement_id: null,
}); });
export const SiteSettingsProvider = SiteSettingsContext.Provider; export const SiteSettingsProvider = SiteSettingsContext.Provider;

View file

@ -36,7 +36,7 @@ export default {
cookieSettings: 'Cookie-Einstellungen', cookieSettings: 'Cookie-Einstellungen',
}, },
cookieConsent: { cookieConsent: {
bannerText: 'Wir verwenden nur technisch notwendige Cookies. Für Google Maps und YouTube-Videos benötigen wir zusätzlich deine Zustimmung, da dabei Daten an Google übertragen werden.', bannerText: 'Wir verwenden nur technisch notwendige Cookies. Für Google Maps, YouTube-Videos und Statistik-Cookies benötigen wir zusätzlich deine Zustimmung, da dabei Daten an Google übertragen werden.',
moreInfo: 'Mehr dazu in unserer', moreInfo: 'Mehr dazu in unserer',
privacyPolicy: 'Datenschutzerklärung', privacyPolicy: 'Datenschutzerklärung',
settings: 'Einstellungen', settings: 'Einstellungen',
@ -48,6 +48,8 @@ export default {
necessaryDesc: 'Für den Betrieb der Website erforderlich (z. B. Login-Session, Theme- und Cookie-Einstellung). Kann nicht deaktiviert werden.', necessaryDesc: 'Für den Betrieb der Website erforderlich (z. B. Login-Session, Theme- und Cookie-Einstellung). Kann nicht deaktiviert werden.',
externalTitle: 'Externe Medien', externalTitle: 'Externe Medien',
externalDesc: 'Google Maps und YouTube-Videos. Beim Laden werden Daten (u. a. deine IP-Adresse) an Google übertragen.', externalDesc: 'Google Maps und YouTube-Videos. Beim Laden werden Daten (u. a. deine IP-Adresse) an Google übertragen.',
analyticsTitle: 'Statistik',
analyticsDesc: 'Google Analytics hilft uns zu verstehen, wie die Website genutzt wird. Dabei werden Daten an Google übertragen.',
cancel: 'Abbrechen', cancel: 'Abbrechen',
saveSelection: 'Auswahl speichern', saveSelection: 'Auswahl speichern',
}, },

View file

@ -36,7 +36,7 @@ export default {
cookieSettings: 'Cookie settings', cookieSettings: 'Cookie settings',
}, },
cookieConsent: { cookieConsent: {
bannerText: 'We only use technically necessary cookies. For Google Maps and YouTube videos we need your additional consent, since data is transferred to Google in that case.', bannerText: 'We only use technically necessary cookies. For Google Maps, YouTube videos and analytics cookies we need your additional consent, since data is transferred to Google in that case.',
moreInfo: 'More details in our', moreInfo: 'More details in our',
privacyPolicy: 'privacy policy', privacyPolicy: 'privacy policy',
settings: 'Settings', settings: 'Settings',
@ -48,6 +48,8 @@ export default {
necessaryDesc: 'Required to operate the website (e.g. login session, theme and cookie preference). Cannot be disabled.', necessaryDesc: 'Required to operate the website (e.g. login session, theme and cookie preference). Cannot be disabled.',
externalTitle: 'External media', externalTitle: 'External media',
externalDesc: 'Google Maps and YouTube videos. Loading them transfers data (including your IP address) to Google.', externalDesc: 'Google Maps and YouTube videos. Loading them transfers data (including your IP address) to Google.',
analyticsTitle: 'Analytics',
analyticsDesc: 'Google Analytics helps us understand how the website is used. This transfers data to Google.',
cancel: 'Cancel', cancel: 'Cancel',
saveSelection: 'Save selection', saveSelection: 'Save selection',
}, },

View file

@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { api } from '../../../api/client.js'; import { api } from '../../../api/client.js';
import ImageUploadField from '../../../components/ImageUploadField.jsx'; import ImageUploadField from '../../../components/ImageUploadField.jsx';
const emptyForm = { phone: '', whatsapp: '', contact_email: '', hero_image_path: '' }; const emptyForm = { phone: '', whatsapp: '', contact_email: '', hero_image_path: '', ga_measurement_id: '' };
export default function WebsiteSettings() { export default function WebsiteSettings() {
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
@ -20,6 +20,7 @@ export default function WebsiteSettings() {
whatsapp: res.whatsapp || '', whatsapp: res.whatsapp || '',
contact_email: res.contact_email || '', contact_email: res.contact_email || '',
hero_image_path: res.hero_image_path || '', hero_image_path: res.hero_image_path || '',
ga_measurement_id: res.ga_measurement_id || '',
}) })
) )
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@ -103,6 +104,23 @@ export default function WebsiteSettings() {
</div> </div>
</section> </section>
<section className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Google Analytics</h2>
<p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
Wird erst geladen, nachdem ein Besucher der Statistik-Kategorie im Cookie-Banner zugestimmt hat. Leer
lassen, um Google Analytics zu deaktivieren.
</p>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Measurement-ID</label>
<input
value={form.ga_measurement_id}
onChange={(e) => update('ga_measurement_id', e.target.value)}
placeholder="G-XXXXXXXXXX"
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
/>
</div>
</section>
{error && <p className="text-sm text-red-600">{error}</p>} {error && <p className="text-sm text-red-600">{error}</p>}
{saved && <p className="text-sm text-green-600 dark:text-green-400">Gespeichert.</p>} {saved && <p className="text-sm text-green-600 dark:text-green-400">Gespeichert.</p>}

View file

@ -93,9 +93,10 @@ export default function Datenschutz() {
<p className="mt-2"> <p className="mt-2">
Für diese technisch notwendigen Cookies ist gemäß § 25 Abs. 2 Nr. 2 TTDSG keine Einwilligung Für diese technisch notwendigen Cookies ist gemäß § 25 Abs. 2 Nr. 2 TTDSG keine Einwilligung
erforderlich. Rechtsgrundlage ist unser berechtigtes Interesse am technischen Betrieb der Website (Art. erforderlich. Rechtsgrundlage ist unser berechtigtes Interesse am technischen Betrieb der Website (Art.
6 Abs. 1 lit. f DSGVO). Für alle darüber hinausgehenden Inhalte aktuell die eingebundenen Google 6 Abs. 1 lit. f DSGVO). Für alle darüber hinausgehenden Inhalte und Dienste aktuell die eingebundenen
Maps- und YouTube-Elemente fragen wir Sie beim ersten Besuch aktiv nach Ihrer Einwilligung. Sie Google Maps- und YouTube-Elemente sowie Google Analytics fragen wir Sie beim ersten Besuch aktiv nach
können Ihre Auswahl jederzeit über den Link Cookie-Einstellungen" im Footer ändern. Ihrer Einwilligung. Sie können Ihre Auswahl jederzeit über den Link Cookie-Einstellungen" im Footer
ändern.
</p> </p>
</section> </section>
@ -145,7 +146,31 @@ export default function Datenschutz() {
</section> </section>
<section> <section>
<h2 className="mb-2 font-semibold text-neutral-900 dark:text-white">8. Empfänger der Daten</h2> <h2 className="mb-2 font-semibold text-neutral-900 dark:text-white">8. Google Analytics</h2>
<p>
Sofern Sie der Statistik-Kategorie im Cookie-Banner zugestimmt haben, setzen wir Google Analytics" ein,
einen Webanalysedienst der Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, Irland. Google
Analytics verwendet Cookies und ähnliche Technologien, die eine Analyse der Benutzung dieser Website
durch Sie ermöglichen. Die dabei erzeugten Informationen (u. a. Ihre IP-Adresse, die vor der Verarbeitung
durch die Aktivierung der IP-Anonymisierung gekürzt wird) werden an einen Server von Google übertragen
und dort gespeichert; eine Verarbeitung kann dabei auch auf Servern außerhalb der EU/des EWR erfolgen.
Die Verarbeitung erfolgt ausschließlich auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO),
die Sie jederzeit über die Cookie-Einstellungen mit Wirkung für die Zukunft widerrufen können. Weitere
Informationen finden Sie in der{' '}
<a
href="https://policies.google.com/privacy"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-brand-500"
>
Datenschutzerklärung von Google
</a>
.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-neutral-900 dark:text-white">9. Empfänger der Daten</h2>
<p> <p>
Ihre Daten werden grundsätzlich nur innerhalb unseres Unternehmens verarbeitet. Eine Weitergabe an Ihre Daten werden grundsätzlich nur innerhalb unseres Unternehmens verarbeitet. Eine Weitergabe an
Dritte erfolgt nur, soweit dies zur Bearbeitung Ihrer Anfrage erforderlich ist (z. B. an unseren Dritte erfolgt nur, soweit dies zur Bearbeitung Ihrer Anfrage erforderlich ist (z. B. an unseren
@ -155,7 +180,7 @@ export default function Datenschutz() {
</section> </section>
<section> <section>
<h2 className="mb-2 font-semibold text-neutral-900 dark:text-white">9. Speicherdauer</h2> <h2 className="mb-2 font-semibold text-neutral-900 dark:text-white">10. Speicherdauer</h2>
<p> <p>
Sofern in dieser Erklärung keine speziellere Speicherdauer genannt wurde, verbleiben Ihre Sofern in dieser Erklärung keine speziellere Speicherdauer genannt wurde, verbleiben Ihre
personenbezogenen Daten bei uns, bis der Zweck für die Datenspeicherung entfällt. Gesetzliche personenbezogenen Daten bei uns, bis der Zweck für die Datenspeicherung entfällt. Gesetzliche
@ -165,7 +190,7 @@ export default function Datenschutz() {
</section> </section>
<section> <section>
<h2 className="mb-2 font-semibold text-neutral-900 dark:text-white">10. SSL-/TLS-Verschlüsselung</h2> <h2 className="mb-2 font-semibold text-neutral-900 dark:text-white">11. SSL-/TLS-Verschlüsselung</h2>
<p> <p>
Diese Seite nutzt aus Sicherheitsgründen eine SSL-/TLS-Verschlüsselung zur Übertragung Ihrer Daten. Eine Diese Seite nutzt aus Sicherheitsgründen eine SSL-/TLS-Verschlüsselung zur Übertragung Ihrer Daten. Eine
verschlüsselte Verbindung erkennen Sie an dem Schloss-Symbol in der Adresszeile Ihres Browsers. verschlüsselte Verbindung erkennen Sie an dem Schloss-Symbol in der Adresszeile Ihres Browsers.
@ -173,7 +198,7 @@ export default function Datenschutz() {
</section> </section>
<section> <section>
<h2 className="mb-2 font-semibold text-neutral-900 dark:text-white">11. Änderung dieser Datenschutzerklärung</h2> <h2 className="mb-2 font-semibold text-neutral-900 dark:text-white">12. Änderung dieser Datenschutzerklärung</h2>
<p> <p>
Wir passen diese Datenschutzerklärung an, sobald sich die von uns eingesetzten Datenverarbeitungen Wir passen diese Datenschutzerklärung an, sobald sich die von uns eingesetzten Datenverarbeitungen
ändern. Es gilt jeweils die zum Zeitpunkt Ihres Besuchs aktuelle, auf dieser Seite veröffentlichte ändern. Es gilt jeweils die zum Zeitpunkt Ihres Besuchs aktuelle, auf dieser Seite veröffentlichte

View file

@ -0,0 +1,6 @@
-- Google Analytics Measurement ID admin-konfigurierbar machen. Additiv, nichts wird geloescht.
-- Hinweis: Auf der Live-Seite reicht stattdessen ein Klick auf "Datenbankstruktur aktualisieren"
-- unter Admin-Panel -> Einstellungen -> Datenbank.
ALTER TABLE app_settings
ADD COLUMN IF NOT EXISTS ga_measurement_id VARCHAR(20) NULL;

View file

@ -24,6 +24,7 @@ CREATE TABLE app_settings (
whatsapp VARCHAR(50) NULL, whatsapp VARCHAR(50) NULL,
contact_email VARCHAR(150) NULL, contact_email VARCHAR(150) NULL,
hero_image_path VARCHAR(255) NULL, hero_image_path VARCHAR(255) NULL,
ga_measurement_id VARCHAR(20) NULL,
mail_host VARCHAR(255) NULL, mail_host VARCHAR(255) NULL,
mail_port INT NULL, mail_port INT NULL,
mail_username VARCHAR(255) NULL, mail_username VARCHAR(255) NULL,

View file

@ -14,13 +14,14 @@ class SiteSettingsController
'whatsapp' => null, 'whatsapp' => null,
'contact_email' => 'info@hifi-planet-amorbach.de', 'contact_email' => 'info@hifi-planet-amorbach.de',
'hero_image_path' => null, 'hero_image_path' => null,
'ga_measurement_id' => null,
]; ];
public static function show(): void public static function show(): void
{ {
try { try {
$stmt = Database::connection()->query( $stmt = Database::connection()->query(
'SELECT phone, whatsapp, contact_email, hero_image_path FROM app_settings WHERE id = 1' 'SELECT phone, whatsapp, contact_email, hero_image_path, ga_measurement_id FROM app_settings WHERE id = 1'
); );
$row = $stmt->fetch(); $row = $stmt->fetch();
} catch (\Throwable $e) { } catch (\Throwable $e) {
@ -43,13 +44,14 @@ class SiteSettingsController
$db->exec('INSERT IGNORE INTO app_settings (id) VALUES (1)'); $db->exec('INSERT IGNORE INTO app_settings (id) VALUES (1)');
$stmt = $db->prepare( $stmt = $db->prepare(
'UPDATE app_settings SET phone = ?, whatsapp = ?, contact_email = ?, hero_image_path = ? WHERE id = 1' 'UPDATE app_settings SET phone = ?, whatsapp = ?, contact_email = ?, hero_image_path = ?, ga_measurement_id = ? WHERE id = 1'
); );
$stmt->execute([ $stmt->execute([
trim($body['phone'] ?? '') ?: null, trim($body['phone'] ?? '') ?: null,
trim($body['whatsapp'] ?? '') ?: null, trim($body['whatsapp'] ?? '') ?: null,
trim($body['contact_email'] ?? '') ?: null, trim($body['contact_email'] ?? '') ?: null,
trim($body['hero_image_path'] ?? '') ?: null, trim($body['hero_image_path'] ?? '') ?: null,
trim($body['ga_measurement_id'] ?? '') ?: null,
]); ]);
self::show(); self::show();

View file

@ -35,6 +35,7 @@ class Schema
whatsapp VARCHAR(50) NULL, whatsapp VARCHAR(50) NULL,
contact_email VARCHAR(150) NULL, contact_email VARCHAR(150) NULL,
hero_image_path VARCHAR(255) NULL, hero_image_path VARCHAR(255) NULL,
ga_measurement_id VARCHAR(20) NULL,
mail_host VARCHAR(255) NULL, mail_host VARCHAR(255) NULL,
mail_port INT NULL, mail_port INT NULL,
mail_username VARCHAR(255) NULL, mail_username VARCHAR(255) NULL,
@ -242,6 +243,7 @@ class Schema
'whatsapp' => 'VARCHAR(50) NULL', 'whatsapp' => 'VARCHAR(50) NULL',
'contact_email' => 'VARCHAR(150) NULL', 'contact_email' => 'VARCHAR(150) NULL',
'hero_image_path' => 'VARCHAR(255) NULL', 'hero_image_path' => 'VARCHAR(255) NULL',
'ga_measurement_id' => 'VARCHAR(20) NULL',
'mail_host' => 'VARCHAR(255) NULL', 'mail_host' => 'VARCHAR(255) NULL',
'mail_port' => 'INT NULL', 'mail_port' => 'INT NULL',
'mail_username' => 'VARCHAR(255) NULL', 'mail_username' => 'VARCHAR(255) NULL',