feat: add coolify deployment and meta tracking
This commit is contained in:
37
README.md
37
README.md
@@ -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
12
nixpacks.toml
Normal 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"
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
54
src/lib/meta.ts
Normal 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,
|
||||||
|
});
|
||||||
@@ -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' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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
20
tests/deploy.test.ts
Normal 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
63
tests/meta.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,5 +5,6 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"exclude": ["dist", ".astro", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user