feat: add coolify deployment and meta tracking

This commit is contained in:
9a0ffedc5b31823b
2026-05-02 22:41:33 +00:00
parent 5c47bdecb6
commit 6b325702b1
13 changed files with 308 additions and 32 deletions

View File

@@ -9,6 +9,7 @@ npm install
npm run dev npm run dev
npm test npm test
npm run build npm run build
npm run start
``` ```
## Add A New Car ## Add A New Car
@@ -40,13 +41,41 @@ Sold cars are hidden by default. To show a sold section on the homepage, set `sh
## Meta Pixel ## Meta Pixel
Set `META_PIXEL_ID` in your Vercel environment variables. Leave it empty locally if the pixel should not load. Set `PUBLIC_META_PIXEL_ID` in Coolify or Vercel environment variables. `META_PIXEL_ID` is also supported for build-time compatibility. Leave both empty locally if the pixel should not load.
Phone and WhatsApp buttons fire a `Contact` event when the pixel is configured. `src/pages/api/meta-capi.ts` is only a static stub for a future server-side Conversions API function. Tracking setup:
1. `PageView` fires on every route with a unique `eventID` and page context.
2. Phone and WhatsApp buttons fire `Contact` with `content_name`, `content_ids`, `contact_channel`, `currency`, and `value` when listing data is available.
3. The sell-car form fires `Lead` on submit when a form endpoint is configured.
4. Events also push to `window.dataLayer` for future Google Tag Manager or debugging.
5. A `<noscript>` fallback is included for `PageView`.
For Meta Conversions API, set `PUBLIC_META_CAPI_ENDPOINT` to a separate server-side endpoint or worker. The browser sends CAPI-ready payloads with matching `event_id`, `_fbp`, `_fbc`, user agent, source URL, and custom data. Keep Meta access tokens server-side only. `src/pages/api/meta-capi.ts` is a static explanatory stub because this site intentionally builds as static output.
## Contact Form Endpoint ## Contact Form Endpoint
Set `PUBLIC_FORMSPREE_ENDPOINT` in Vercel when the Formspree form is ready. Until then, the form is visible but disabled so visitors use phone or WhatsApp instead of submitting to a placeholder endpoint. Set `PUBLIC_FORMSPREE_ENDPOINT` in Coolify or Vercel when the Formspree form is ready. Until then, the form is visible but disabled so visitors use phone or WhatsApp instead of submitting to a placeholder endpoint.
## Deploy To Coolify With Nixpacks
The repository includes `nixpacks.toml` for Coolify.
Coolify settings:
1. Build pack: Nixpacks.
2. Install command: handled by `nixpacks.toml` as `npm ci`.
3. Build command: handled by `nixpacks.toml` as `npm run build`.
4. Start command: handled by `nixpacks.toml` as `npm run start`.
5. Port: Coolify should provide `PORT`; the app falls back to `4321`.
Recommended Coolify environment variables:
```text
PUBLIC_META_PIXEL_ID=<your-pixel-id>
PUBLIC_META_CAPI_ENDPOINT=<optional-server-side-capi-endpoint>
PUBLIC_FORMSPREE_ENDPOINT=<optional-formspree-endpoint>
```
## Deploy To Vercel ## Deploy To Vercel
@@ -54,7 +83,7 @@ Set `PUBLIC_FORMSPREE_ENDPOINT` in Vercel when the Formspree form is ready. Unti
2. Use the default framework detection for Astro. 2. Use the default framework detection for Astro.
3. Build command: `npm run build`. 3. Build command: `npm run build`.
4. Output directory: `dist`. 4. Output directory: `dist`.
5. Add `META_PIXEL_ID` if needed. 5. Add `PUBLIC_META_PIXEL_ID` if needed.
6. Deploy. 6. Deploy.
## Domain Setup ## Domain Setup

12
nixpacks.toml Normal file
View File

@@ -0,0 +1,12 @@
[variables]
NODE_ENV = "production"
NPM_CONFIG_PRODUCTION = "false"
[phases.install]
cmds = ["npm ci"]
[phases.build]
cmds = ["npm run build"]
[start]
cmd = "npm run start"

View File

@@ -7,6 +7,7 @@
"dev": "astro dev", "dev": "astro dev",
"build": "astro check && astro build", "build": "astro check && astro build",
"preview": "astro preview", "preview": "astro preview",
"start": "astro preview --host 0.0.0.0 --port ${PORT:-4321}",
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {

View File

@@ -1,15 +1,20 @@
--- ---
import { lt } from '../i18n/lt'; import { lt } from '../i18n/lt';
import { PHONE_E164, PHONE_WA, telUrl, whatsappUrl } from '../lib/contact'; import { PHONE_E164, PHONE_WA, telUrl, whatsappUrl } from '../lib/contact';
import { contactEventParams } from '../lib/meta';
type Props = { type Props = {
make?: string; make?: string;
model?: string; model?: string;
slug?: string;
price?: number;
compact?: boolean; compact?: boolean;
}; };
const { make, model, compact = false } = Astro.props; const { make, model, slug, price, compact = false } = Astro.props;
const baseClass = compact ? 'px-4 py-3 text-sm' : 'px-5 py-4 text-base'; const baseClass = compact ? 'px-4 py-3 text-sm' : 'px-5 py-4 text-base';
const phoneParams = contactEventParams({ channel: 'phone', make, model, slug, price });
const whatsappParams = contactEventParams({ channel: 'whatsapp', make, model, slug, price });
--- ---
<div class="grid w-full max-w-full grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]" data-contact-buttons> <div class="grid w-full max-w-full grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]" data-contact-buttons>
@@ -19,7 +24,8 @@ const baseClass = compact ? 'px-4 py-3 text-sm' : 'px-5 py-4 text-base';
baseClass, baseClass,
]} ]}
href={telUrl(PHONE_E164)} href={telUrl(PHONE_E164)}
data-contact-event data-meta-event="Contact"
data-meta-params={JSON.stringify(phoneParams)}
> >
{lt.actions.call} {lt.actions.call}
</a> </a>
@@ -29,19 +35,9 @@ const baseClass = compact ? 'px-4 py-3 text-sm' : 'px-5 py-4 text-base';
baseClass, baseClass,
]} ]}
href={whatsappUrl(PHONE_WA, make, model)} href={whatsappUrl(PHONE_WA, make, model)}
data-contact-event data-meta-event="Contact"
data-meta-params={JSON.stringify(whatsappParams)}
> >
{lt.actions.whatsapp} {lt.actions.whatsapp}
</a> </a>
</div> </div>
<script is:inline>
if (!window.__juozasContactEventsBound) {
window.__juozasContactEventsBound = true;
document.addEventListener('click', (event) => {
const link = event.target.closest('[data-contact-event]');
if (!link) return;
if (window.fbq) window.fbq('track', 'Contact');
});
}
</script>

View File

@@ -1,9 +1,64 @@
--- ---
const pixelId = import.meta.env.META_PIXEL_ID; import { pageViewEventParams } from '../lib/meta';
const pixelId = import.meta.env.PUBLIC_META_PIXEL_ID ?? import.meta.env.META_PIXEL_ID;
const capiEndpoint = import.meta.env.PUBLIC_META_CAPI_ENDPOINT ?? '';
const pageParams = pageViewEventParams(Astro.url.pathname);
--- ---
{pixelId && ( {pixelId && (
<script is:inline define:vars={{ pixelId }}> <script is:inline define:vars={{ pixelId, capiEndpoint, pageParams }}>
function juozasCookie(name) {
return document.cookie
.split('; ')
.find((row) => row.startsWith(`${name}=`))
?.split('=')[1];
}
function juozasEventId(eventName) {
return `${eventName.toLowerCase()}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
}
function juozasSendCapi(eventName, eventID, customData) {
if (!capiEndpoint) return;
const payload = {
event_name: eventName,
event_time: Math.floor(Date.now() / 1000),
event_id: eventID,
action_source: 'website',
event_source_url: window.location.href,
user_data: {
client_user_agent: navigator.userAgent,
fbp: juozasCookie('_fbp'),
fbc: juozasCookie('_fbc'),
},
custom_data: customData,
};
const body = JSON.stringify(payload);
if (navigator.sendBeacon) {
navigator.sendBeacon(capiEndpoint, new Blob([body], { type: 'application/json' }));
return;
}
fetch(capiEndpoint, {
method: 'POST',
body,
headers: { 'Content-Type': 'application/json' },
keepalive: true,
}).catch(() => {});
}
window.juozasTrackMeta = function juozasTrackMeta(eventName, customData, options) {
const eventID = juozasEventId(eventName);
if (window.fbq) window.fbq('track', eventName, customData, { eventID });
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ event: `meta_${eventName}`, eventID, ...customData });
if (options && options.capiDelayMs) {
window.setTimeout(() => juozasSendCapi(eventName, eventID, customData), options.capiDelayMs);
return;
}
juozasSendCapi(eventName, eventID, customData);
};
!(function (f, b, e, v, n, t, s) { !(function (f, b, e, v, n, t, s) {
if (f.fbq) return; if (f.fbq) return;
n = f.fbq = function () { n = f.fbq = function () {
@@ -21,6 +76,20 @@ const pixelId = import.meta.env.META_PIXEL_ID;
s.parentNode.insertBefore(t, s); s.parentNode.insertBefore(t, s);
})(window, document, 'script', 'https://connect.facebook.net/en_US/fbevents.js'); })(window, document, 'script', 'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', pixelId); fbq('init', pixelId);
fbq('track', 'PageView'); window.juozasTrackMeta('PageView', pageParams, { capiDelayMs: 500 });
if (!window.__juozasMetaEventsBound) {
window.__juozasMetaEventsBound = true;
document.addEventListener('click', (event) => {
const link = event.target.closest('[data-meta-event]');
if (!link) return;
const eventName = link.dataset.metaEvent || 'Contact';
const params = JSON.parse(link.dataset.metaParams || '{}');
window.juozasTrackMeta(eventName, params);
});
}
</script> </script>
<noscript>
<img height="1" width="1" style="display:none" src={`https://www.facebook.com/tr?id=${pixelId}&ev=PageView&noscript=1`} alt="" />
</noscript>
)} )}

View File

@@ -43,9 +43,9 @@ const canonicalUrl = new URL(canonicalPath, site.url).toString();
<meta name="twitter:title" content={ogTitle} /> <meta name="twitter:title" content={ogTitle} />
<meta name="twitter:description" content={description} /> <meta name="twitter:description" content={description} />
{image && <meta name="twitter:image" content={image} />} {image && <meta name="twitter:image" content={image} />}
<MetaPixel />
</head> </head>
<body class="min-h-screen text-ink antialiased"> <body class="min-h-screen text-ink antialiased">
<MetaPixel />
<slot /> <slot />
</body> </body>
</html> </html>

54
src/lib/meta.ts Normal file
View File

@@ -0,0 +1,54 @@
export type ContactChannel = 'phone' | 'whatsapp';
type ContactEventInput = {
channel: ContactChannel;
make?: string;
model?: string;
slug?: string;
price?: number;
};
type CapiPayloadInput = {
eventName: 'PageView' | 'Contact' | 'Lead';
eventId: string;
eventSourceUrl: string;
customData?: Record<string, unknown>;
userAgent?: string;
fbp?: string;
fbc?: string;
};
export const pageViewEventParams = (pathname: string) => ({
content_category: pathname.startsWith('/automobiliai/') ? 'VehicleListing' : 'SitePage',
page_path: pathname,
});
export const contactEventParams = ({ channel, make, model, slug, price }: ContactEventInput) => ({
content_category: slug || make || model ? 'Vehicle' : 'SiteContact',
...(make || model ? { content_name: [make, model].filter(Boolean).join(' ') } : {}),
...(slug ? { content_ids: [slug] } : {}),
contact_channel: channel,
...(price ? { currency: 'EUR', value: price } : {}),
});
export const buildMetaCapiPayload = ({
eventName,
eventId,
eventSourceUrl,
customData = {},
userAgent,
fbp,
fbc,
}: CapiPayloadInput) => ({
event_name: eventName,
event_time: Math.floor(Date.now() / 1000),
event_id: eventId,
action_source: 'website',
event_source_url: eventSourceUrl,
user_data: {
...(userAgent ? { client_user_agent: userAgent } : {}),
...(fbp ? { fbp } : {}),
...(fbc ? { fbc } : {}),
},
custom_data: customData,
});

View File

@@ -1,10 +1,11 @@
// TODO: Add Meta Conversions API forwarding when the site moves beyond static-only output. // Meta CAPI integration note:
// Keep the public site static for now. A future Vercel Function can accept Contact events, // This app is deployed as a static Astro site. Browser events can POST CAPI-ready
// hash user data server-side, and forward them to Meta with a server-only access token. // payloads to PUBLIC_META_CAPI_ENDPOINT, which should be a separate server-side
// endpoint or worker holding the Meta access token. Keep tokens out of this repo.
export function GET() { export function GET() {
return new Response('Meta Conversions API stub. Configure a serverless function before use.\n', { return new Response(JSON.stringify({ ok: false, message: 'Configure PUBLIC_META_CAPI_ENDPOINT to a server-side Meta CAPI receiver.' }), {
status: 501, status: 501,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }, headers: { 'Content-Type': 'application/json; charset=utf-8' },
}); });
} }

View File

@@ -63,13 +63,13 @@ const jsonLd = vehicleJsonLd({ car: car.data, canonicalUrl, imageUrl: ogImageUrl
<h1 class="mt-2 text-2xl font-semibold tracking-[-0.06em] text-ink sm:text-3xl">{carName}</h1> <h1 class="mt-2 text-2xl font-semibold tracking-[-0.06em] text-ink sm:text-3xl">{carName}</h1>
<p class="mt-5 text-3xl font-semibold tracking-[-0.07em] text-burgundy-700 tabular">{formatPrice(car.data.price)}</p> <p class="mt-5 text-3xl font-semibold tracking-[-0.07em] text-burgundy-700 tabular">{formatPrice(car.data.price)}</p>
<div class="mt-6 lg:hidden"> <div class="mt-6 lg:hidden">
<ContactButtons make={car.data.make} model={car.data.model} compact /> <ContactButtons make={car.data.make} model={car.data.model} slug={car.slug} price={car.data.price} compact />
</div> </div>
<div class="mt-7"> <div class="mt-7">
<SpecStrip year={car.data.year} mileage={car.data.mileage} fuel={car.data.fuel} transmission={car.data.transmission} /> <SpecStrip year={car.data.year} mileage={car.data.mileage} fuel={car.data.fuel} transmission={car.data.transmission} />
</div> </div>
<div class="mt-7 hidden lg:block"> <div class="mt-7 hidden lg:block">
<ContactButtons make={car.data.make} model={car.data.model} /> <ContactButtons make={car.data.make} model={car.data.model} slug={car.slug} price={car.data.price} />
<p class="mt-4 text-sm text-muted">Tiesioginis kontaktas: {PHONE_DISPLAY}, {EMAIL}</p> <p class="mt-4 text-sm text-muted">Tiesioginis kontaktas: {PHONE_DISPLAY}, {EMAIL}</p>
</div> </div>
</aside> </aside>
@@ -111,7 +111,7 @@ const jsonLd = vehicleJsonLd({ car: car.data, canonicalUrl, imageUrl: ogImageUrl
</main> </main>
<div class="fixed inset-x-0 bottom-0 z-40 translate-y-full border-t border-line bg-paper/95 px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-3 shadow-soft lg:hidden" data-sticky-contact> <div class="fixed inset-x-0 bottom-0 z-40 translate-y-full border-t border-line bg-paper/95 px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-3 shadow-soft lg:hidden" data-sticky-contact>
<ContactButtons make={car.data.make} model={car.data.model} compact /> <ContactButtons make={car.data.make} model={car.data.model} slug={car.slug} price={car.data.price} compact />
</div> </div>
<Footer /> <Footer />

View File

@@ -32,15 +32,45 @@ import { site } from '../site';
Forma paruošta prijungimui. Iki tol greičiausias kontaktas yra telefonas arba WhatsApp. Forma paruošta prijungimui. Iki tol greičiausias kontaktas yra telefonas arba WhatsApp.
</p> </p>
)} )}
<form class="mt-7 grid gap-4" action={site.formAction || undefined} method="post"> <form class="mt-7 grid gap-4" action={site.formAction || undefined} method="post" data-sell-form data-meta-params={JSON.stringify({ content_category: 'SellerLead', content_name: 'Sell car inquiry' })}>
<label class="grid gap-2 text-sm font-semibold">Vardas<input class="rounded-2xl border border-line bg-paper px-4 py-3 font-normal" name="name" autocomplete="name" required /></label> <label class="grid gap-2 text-sm font-semibold">Vardas<input class="rounded-2xl border border-line bg-paper px-4 py-3 font-normal" name="name" autocomplete="name" required /></label>
<label class="grid gap-2 text-sm font-semibold">Telefonas<input class="rounded-2xl border border-line bg-paper px-4 py-3 font-normal" name="phone" autocomplete="tel" required /></label> <label class="grid gap-2 text-sm font-semibold">Telefonas<input class="rounded-2xl border border-line bg-paper px-4 py-3 font-normal" name="phone" autocomplete="tel" required /></label>
<label class="grid gap-2 text-sm font-semibold">Automobilis<input class="rounded-2xl border border-line bg-paper px-4 py-3 font-normal" name="car" placeholder="BMW 320d, 2018" required /></label> <label class="grid gap-2 text-sm font-semibold">Automobilis<input class="rounded-2xl border border-line bg-paper px-4 py-3 font-normal" name="car" placeholder="BMW 320d, 2018" required /></label>
<label class="grid gap-2 text-sm font-semibold">Pastaba<textarea class="min-h-32 rounded-2xl border border-line bg-paper px-4 py-3 font-normal" name="message" placeholder="Trumpai apie komplektaciją, ridą, būklę ir nuotraukas." /></label> <label class="grid gap-2 text-sm font-semibold">Pastaba<textarea class="min-h-32 rounded-2xl border border-line bg-paper px-4 py-3 font-normal" name="message" placeholder="Trumpai apie komplektaciją, ridą, būklę ir nuotraukas." /></label>
<p class="text-sm text-muted">Nuotraukų įkėlimas šiame etape nenaudojamas dėl paprasto ir greito statinio puslapio.</p> <p class="text-sm text-muted">Nuotraukų įkėlimas šiame etape nenaudojamas dėl paprasto ir greito statinio puslapio.</p>
<button class="rounded-full bg-burgundy-700 px-5 py-4 font-semibold text-paper transition-transform duration-200 ease-out-quart hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-60" type="submit" disabled={!site.formAction}>Siųsti užklausą</button> <button class="rounded-full bg-burgundy-700 px-5 py-4 font-semibold text-paper transition-transform duration-200 ease-out-quart hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-60" type="submit" disabled={!site.formAction}>Siųsti užklausą</button>
<p class="text-sm text-muted" role="status" data-form-status></p>
</form> </form>
</section> </section>
</main> </main>
<Footer /> <Footer />
</BaseLayout> </BaseLayout>
<script is:inline>
const form = document.querySelector('[data-sell-form]');
const status = document.querySelector('[data-form-status]');
if (form && form.action) {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const submit = form.querySelector('button[type="submit"]');
if (submit) submit.disabled = true;
if (status) status.textContent = 'Siunčiama...';
try {
const response = await fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { Accept: 'application/json' },
});
if (!response.ok) throw new Error('Form submit failed');
if (window.juozasTrackMeta) {
window.juozasTrackMeta('Lead', JSON.parse(form.dataset.metaParams || '{}'));
}
form.reset();
if (status) status.textContent = 'Užklausa išsiųsta. Susisieksime artimiausiu metu.';
} catch {
if (status) status.textContent = 'Nepavyko išsiųsti. Paskambinkite arba parašykite per WhatsApp.';
if (submit) submit.disabled = false;
}
});
}
</script>

20
tests/deploy.test.ts Normal file
View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
describe('Coolify and Nixpacks deployment config', () => {
it('defines a production start script that binds to all interfaces and PORT', () => {
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
expect(packageJson.scripts.start).toContain('astro preview');
expect(packageJson.scripts.start).toContain('--host 0.0.0.0');
expect(packageJson.scripts.start).toContain('${PORT:-4321}');
});
it('pins Nixpacks install, build, and start commands for Coolify', () => {
const nixpacks = readFileSync('nixpacks.toml', 'utf8');
expect(nixpacks).toContain('npm ci');
expect(nixpacks).toContain('npm run build');
expect(nixpacks).toContain('npm run start');
});
});

63
tests/meta.test.ts Normal file
View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { buildMetaCapiPayload, contactEventParams, pageViewEventParams } from '../src/lib/meta';
describe('Meta tracking helpers', () => {
it('builds rich Contact event params for vehicle inquiries', () => {
expect(
contactEventParams({
channel: 'whatsapp',
make: 'BMW',
model: '320d',
slug: 'bmw-320d-2018-vilnius',
price: 12500,
}),
).toEqual({
content_category: 'Vehicle',
content_name: 'BMW 320d',
content_ids: ['bmw-320d-2018-vilnius'],
contact_channel: 'whatsapp',
currency: 'EUR',
value: 12500,
});
});
it('builds PageView params with content context', () => {
expect(pageViewEventParams('/automobiliai/bmw-320d-2018-vilnius')).toEqual({
content_category: 'VehicleListing',
page_path: '/automobiliai/bmw-320d-2018-vilnius',
});
});
it('keeps generic contact page inquiries separate from vehicle listing contacts', () => {
expect(contactEventParams({ channel: 'phone' })).toEqual({
content_category: 'SiteContact',
contact_channel: 'phone',
});
});
it('builds a CAPI-ready browser payload with dedupe event id', () => {
const payload = buildMetaCapiPayload({
eventName: 'Contact',
eventId: 'contact-123',
eventSourceUrl: 'https://auto.juozas.lt/automobiliai/bmw-320d-2018-vilnius',
userAgent: 'Mozilla/5.0',
fbp: 'fb.1.123',
fbc: 'fb.1.456',
customData: { content_name: 'BMW 320d' },
});
expect(payload).toMatchObject({
event_name: 'Contact',
event_id: 'contact-123',
action_source: 'website',
event_source_url: 'https://auto.juozas.lt/automobiliai/bmw-320d-2018-vilnius',
user_data: {
client_user_agent: 'Mozilla/5.0',
fbp: 'fb.1.123',
fbc: 'fb.1.456',
},
custom_data: { content_name: 'BMW 320d' },
});
expect(payload.event_time).toBeTypeOf('number');
});
});

View File

@@ -5,5 +5,6 @@
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"]
} }
} },
"exclude": ["dist", ".astro", "node_modules"]
} }