feat: build juozas auto site

This commit is contained in:
9a0ffedc5b31823b
2026-05-02 22:32:02 +00:00
parent c44b6fa229
commit 5c47bdecb6
48 changed files with 9005 additions and 1 deletions

View File

@@ -0,0 +1,43 @@
---
import { Image } from 'astro:assets';
import type { CollectionEntry } from 'astro:content';
import { lt } from '../i18n/lt';
import { formatMileage, formatPrice } from '../lib/format';
type Props = {
car: CollectionEntry<'cars'>;
compact?: boolean;
};
const { car, compact = false } = Astro.props;
const title = `${car.data.year} ${car.data.make} ${car.data.model}`;
---
<a
href={`/automobiliai/${car.slug}`}
class:list={[
'group block rounded-[2rem] bg-paper transition-transform duration-300 ease-out-quart focus-visible:outline-burgundy-600',
compact ? 'opacity-75' : 'hover:-translate-y-1',
]}
aria-label={`${lt.actions.viewCar}: ${title}`}
>
<div class="overflow-hidden rounded-[1.5rem] bg-wash">
<Image
src={car.data.photos[0]}
alt={`${car.data.make} ${car.data.model} automobilio nuotrauka`}
width={720}
height={540}
loading="lazy"
format="avif"
class="aspect-[4/3] w-full object-cover"
/>
</div>
<div class="pt-4">
<p class="text-sm text-muted">{car.data.city}</p>
<h3 class="mt-1 text-xl font-semibold tracking-[-0.04em] text-ink">{title}</h3>
<p class="mt-3 text-2xl font-semibold tracking-[-0.05em] text-burgundy-700 tabular">{formatPrice(car.data.price)}</p>
<p class="mt-2 text-sm text-muted tabular">
{formatMileage(car.data.mileage)} · {car.data.fuel} · {car.data.transmission}
</p>
</div>
</a>

View File

@@ -0,0 +1,46 @@
---
import { Image } from 'astro:assets';
type Props = {
photos: ImageMetadata[];
title: string;
};
const { photos, title } = Astro.props;
---
<section class="relative" aria-label="Automobilio nuotraukos" data-gallery>
<div class="no-scrollbar flex snap-x snap-mandatory gap-3 overflow-x-auto rounded-[2rem] scroll-smooth bg-wash" data-gallery-track>
{photos.map((photo, index) => (
<Image
src={photo}
alt={`${title} nuotrauka ${index + 1}`}
width={960}
height={720}
loading={index < 2 ? 'eager' : 'lazy'}
format="avif"
class="aspect-[4/3] min-w-full snap-center object-cover"
/>
))}
</div>
<p class="absolute bottom-4 right-4 rounded-full bg-ink/80 px-3 py-1 text-xs font-semibold text-paper tabular" data-gallery-counter>
1 / {photos.length}
</p>
</section>
<script is:inline>
document.querySelectorAll('[data-gallery]').forEach((gallery) => {
const track = gallery.querySelector('[data-gallery-track]');
const counter = gallery.querySelector('[data-gallery-counter]');
if (!track || !counter) return;
const total = track.children.length;
track.addEventListener(
'scroll',
() => {
const index = Math.round(track.scrollLeft / track.clientWidth) + 1;
counter.textContent = `${Math.min(Math.max(index, 1), total)} / ${total}`;
},
{ passive: true },
);
});
</script>

View File

@@ -0,0 +1,47 @@
---
import { lt } from '../i18n/lt';
import { PHONE_E164, PHONE_WA, telUrl, whatsappUrl } from '../lib/contact';
type Props = {
make?: string;
model?: string;
compact?: boolean;
};
const { make, model, compact = false } = Astro.props;
const baseClass = compact ? 'px-4 py-3 text-sm' : 'px-5 py-4 text-base';
---
<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>
<a
class:list={[
'min-w-0 rounded-full bg-burgundy-700 text-center font-semibold text-paper shadow-soft transition-transform duration-200 ease-out-quart hover:-translate-y-0.5 active:translate-y-0 tabular',
baseClass,
]}
href={telUrl(PHONE_E164)}
data-contact-event
>
{lt.actions.call}
</a>
<a
class:list={[
'min-w-0 rounded-full border border-line bg-paper text-center font-semibold text-ink transition-colors duration-200 ease-out-quart hover:border-burgundy-600 hover:text-burgundy-700 tabular',
baseClass,
]}
href={whatsappUrl(PHONE_WA, make, model)}
data-contact-event
>
{lt.actions.whatsapp}
</a>
</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

@@ -0,0 +1,18 @@
---
import { EMAIL, HOURS, PHONE_DISPLAY } from '../lib/contact';
---
<footer class="mt-24 border-t border-line bg-wash/60">
<div class="mx-auto grid max-w-7xl gap-8 px-5 py-10 text-sm text-muted sm:px-8 md:grid-cols-[1fr_auto] lg:px-10">
<div>
<p class="font-semibold text-ink">Juozas Auto</p>
<p class="mt-2 max-w-md">Atrinkti automobiliai Lietuvoje. Sąžiningos kainos, tikri automobiliai, be paslėptų mokesčių.</p>
</div>
<address class="not-italic md:text-right">
<a class="block font-semibold text-ink" href="tel:+37061234567">{PHONE_DISPLAY}</a>
<a class="mt-1 block" href={`mailto:${EMAIL}`}>{EMAIL}</a>
<p class="mt-1">{HOURS}</p>
</address>
<p class="text-xs text-muted md:col-span-2">© 2026 Juozas Auto. Informacija svetainėje nėra viešas pasiūlymas.</p>
</div>
</footer>

View File

@@ -0,0 +1,14 @@
---
import { lt } from '../i18n/lt';
---
<header class="mx-auto flex w-full max-w-7xl flex-col items-start gap-4 px-5 py-5 sm:flex-row sm:items-center sm:justify-between sm:px-8 lg:px-10">
<a href="/" class="text-lg font-semibold tracking-[-0.04em]" aria-label="Juozas Auto pradžia">Juozas Auto</a>
<nav aria-label="Pagrindinė navigacija">
<ul class="flex items-center gap-5 text-sm text-muted sm:gap-7">
<li><a class="transition-colors duration-200 ease-out-quart hover:text-ink" href="/#automobiliai">{lt.nav.cars}</a></li>
<li><a class="transition-colors duration-200 ease-out-quart hover:text-ink" href="/apie">{lt.nav.about}</a></li>
<li><a class="transition-colors duration-200 ease-out-quart hover:text-ink" href="/kontaktai">{lt.nav.contact}</a></li>
</ul>
</nav>
</header>

View File

@@ -0,0 +1,9 @@
---
type Props = {
data: Record<string, unknown>;
};
const { data } = Astro.props;
---
<script is:inline type="application/ld+json" set:html={JSON.stringify(data)} />

View File

@@ -0,0 +1,26 @@
---
const pixelId = import.meta.env.META_PIXEL_ID;
---
{pixelId && (
<script is:inline define:vars={{ pixelId }}>
!(function (f, b, e, v, n, t, s) {
if (f.fbq) return;
n = f.fbq = function () {
n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments);
};
if (!f._fbq) f._fbq = n;
n.push = n;
n.loaded = true;
n.version = '2.0';
n.queue = [];
t = b.createElement(e);
t.async = true;
t.src = v;
s = b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t, s);
})(window, document, 'script', 'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', pixelId);
fbq('track', 'PageView');
</script>
)}

View File

@@ -0,0 +1,28 @@
---
import { lt } from '../i18n/lt';
import { formatMileage } from '../lib/format';
type Props = {
year: number;
mileage: number;
fuel: string;
transmission: string;
};
const { year, mileage, fuel, transmission } = Astro.props;
const specs = [
[lt.labels.year, String(year)],
[lt.labels.mileage, formatMileage(mileage)],
[lt.labels.fuel, fuel],
[lt.labels.gearbox, transmission],
];
---
<dl class="grid w-full min-w-0 grid-cols-2 overflow-hidden rounded-[1.75rem] border border-line bg-paper sm:grid-cols-4">
{specs.map(([label, value]) => (
<div class="min-w-0 border-line px-4 py-4 odd:border-r sm:border-r sm:last:border-r-0">
<dt class="text-xs uppercase tracking-[0.12em] text-muted">{label}</dt>
<dd class="mt-1 font-semibold text-ink tabular">{value}</dd>
</div>
))}
</dl>

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -0,0 +1,29 @@
---
make: BMW
model: 320d
year: 2018
price: 12500
mileage: 145000
fuel: dyzelinas
transmission: automatinė
bodyType: sedanas
color: Pilka
city: Vilnius
drivetrain: galiniai
power: 140
engineSize: 2.0
firstRegistration: 2018-05
vin: WBA8C91010A000000
photos:
- ./_photos/bmw-320d-2018-vilnius/01.jpg
- ./_photos/bmw-320d-2018-vilnius/02.jpg
sold: false
featured: true
publishedAt: 2026-04-15
---
Tvarkingas BMW 320d su automatine pavarų dėže, aiškia istorija ir prižiūrėtu salonu. Automobilis paruoštas apžiūrai Vilniuje.
Pakeisti pagrindiniai eksploataciniai mazgai, važiuoklė standi, variklis dirba tolygiai. Kaina nurodyta be paslėptų mokesčių.
Nuotraukos: Unsplash placeholder, savininkas pakeis realiomis automobilio nuotraukomis.

View File

@@ -0,0 +1,28 @@
---
make: Toyota
model: RAV4
year: 2020
price: 23900
mileage: 82000
fuel: hibridas
transmission: automatinė
bodyType: visureigis
color: Balta
city: Vilnius
drivetrain: visi
power: 160
engineSize: 2.5
firstRegistration: 2020-03
photos:
- ./_photos/toyota-rav4-2020-vilnius/01.jpg
- ./_photos/toyota-rav4-2020-vilnius/02.jpg
sold: false
featured: true
publishedAt: 2026-04-18
---
Erdvus hibridinis RAV4 su automatine pavarų dėže ir visais varomais ratais. Tinka šeimai, miestui ir ilgesniems savaitgalio maršrutams.
Automobilis parduodamas su aiškia komplektacija ir be papildomų administravimo mokesčių.
Nuotraukos: Unsplash placeholder, savininkas pakeis realiomis automobilio nuotraukomis.

View File

@@ -0,0 +1,28 @@
---
make: Volkswagen
model: Passat
year: 2016
price: 9800
mileage: 178000
fuel: dyzelinas
transmission: mechaninė
bodyType: universalas
color: Juoda
city: Kaunas
drivetrain: priekiniai
power: 110
engineSize: 2.0
firstRegistration: 2016-09
photos:
- ./_photos/vw-passat-2016-kaunas/01.jpg
- ./_photos/vw-passat-2016-kaunas/02.jpg
sold: false
featured: false
publishedAt: 2026-04-10
---
Praktiškas Passat universalas kasdienai ir ilgesnėms kelionėms. Ekonomiškas dyzelinis variklis, tvarkingas kėbulas ir švarus salonas.
Automobilį galima apžiūrėti Kaune iš anksto suderinus laiką telefonu arba WhatsApp žinute.
Nuotraukos: Unsplash placeholder, savininkas pakeis realiomis automobilio nuotraukomis.

29
src/content/config.ts Normal file
View File

@@ -0,0 +1,29 @@
import { defineCollection, z } from 'astro:content';
const cars = defineCollection({
type: 'content',
schema: ({ image }) =>
z.object({
make: z.string(),
model: z.string(),
year: z.number().int().min(1990).max(2030),
price: z.number().int().positive(),
mileage: z.number().int().nonnegative(),
fuel: z.enum(['benzinas', 'dyzelinas', 'hibridas', 'elektra', 'dujos']),
transmission: z.enum(['mechaninė', 'automatinė']),
bodyType: z.enum(['sedanas', 'universalas', 'hečbekas', 'visureigis', 'kupė', 'kabrioletas', 'vienatūris']),
color: z.string(),
city: z.string(),
drivetrain: z.enum(['priekiniai', 'galiniai', 'visi']).optional(),
power: z.number().int().optional(),
engineSize: z.number().optional(),
firstRegistration: z.string().optional(),
vin: z.string().optional(),
photos: z.array(image()).min(1),
sold: z.boolean().default(false),
featured: z.boolean().default(false),
publishedAt: z.date(),
}),
});
export const collections = { cars };

1
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />

35
src/i18n/lt.ts Normal file
View File

@@ -0,0 +1,35 @@
export const lt = {
nav: {
cars: 'Automobiliai',
about: 'Apie',
contact: 'Kontaktai',
},
actions: {
call: 'Skambinti',
whatsapp: 'WhatsApp',
viewCar: 'Peržiūrėti automobilį',
send: 'Siųsti užklausą',
},
labels: {
year: 'Metai',
mileage: 'Rida',
fuel: 'Kuras',
gearbox: 'Pavaros',
city: 'Miestas',
bodyType: 'Kėbulas',
color: 'Spalva',
drivetrain: 'Varantieji ratai',
power: 'Galia',
engineSize: 'Variklis',
firstRegistration: 'Pirma registracija',
vin: 'VIN',
},
sections: {
currentCars: 'Automobiliai',
soldCars: 'Parduoti automobiliai',
specs: 'Specifikacija',
location: 'Vieta',
similar: 'Panašūs automobiliai',
description: 'Aprašymas',
},
};

View File

@@ -0,0 +1,51 @@
---
import MetaPixel from '../components/MetaPixel.astro';
import { site } from '../site';
import '../styles/global.css';
type Props = {
title?: string;
ogTitle?: string;
description?: string;
canonicalPath?: string;
image?: string;
type?: string;
};
const {
title = site.name,
ogTitle = title,
description = site.description,
canonicalPath = Astro.url.pathname,
image,
type = 'website',
} = Astro.props;
const canonicalUrl = new URL(canonicalPath, site.url).toString();
---
<!doctype html>
<html lang="lt">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalUrl} />
<meta property="og:type" content={type} />
<meta property="og:title" content={ogTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:locale" content="lt_LT" />
{image && <meta property="og:image" content={image} />}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={ogTitle} />
<meta name="twitter:description" content={description} />
{image && <meta name="twitter:image" content={image} />}
<MetaPixel />
</head>
<body class="min-h-screen text-ink antialiased">
<slot />
</body>
</html>

14
src/lib/contact.ts Normal file
View File

@@ -0,0 +1,14 @@
export const PHONE_DISPLAY = '+370 612 34 567';
export const PHONE_E164 = '+37061234567';
export const PHONE_WA = '37061234567';
export const EMAIL = 'info@juozas.lt';
export const CITY = 'Vilnius';
export const HOURS = 'I-V 9:00-18:00, VI 10:00-14:00';
export const telUrl = (phone = PHONE_E164) => `tel:${phone.replace(/\s/g, '')}`;
export const whatsappUrl = (phone: string, make?: string, model?: string) => {
const subject = [make, model].filter(Boolean).join(' ').trim();
const text = subject ? `Sveiki, domina ${subject}` : 'Sveiki, domina automobilis';
return `https://wa.me/${phone.replace(/\D/g, '')}?text=${encodeURIComponent(text)}`;
};

52
src/lib/format.ts Normal file
View File

@@ -0,0 +1,52 @@
const lithuanianMap: Record<string, string> = {
ą: 'a',
č: 'c',
ę: 'e',
ė: 'e',
į: 'i',
š: 's',
ų: 'u',
ū: 'u',
ž: 'z',
};
export type SlugCar = {
make: string;
model: string;
year: number;
city: string;
};
export const groupNumber = (value: number) => new Intl.NumberFormat('lt-LT').format(value).replace(/\u00a0/g, ' ');
export const formatPrice = (price: number) => `${groupNumber(price)}\u00a0€`;
export const formatMileage = (mileage: number) => `${groupNumber(mileage)} km`;
export const formatPower = (kw: number) => `${kw} kW (${Math.round(kw * 1.35962)} AG)`;
export const formatEngineSize = (liters: number) => `${liters.toFixed(1).replace('.', ',')} l`;
export const slugify = (value: string) =>
value
.trim()
.toLowerCase()
.replace(/[ąčęėįšųūž]/g, (letter) => lithuanianMap[letter] ?? letter)
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
export const slugifyCar = (car: SlugCar) => slugify(`${car.make}-${car.model}-${car.year}-${car.city}`);
export const stripMarkdown = (value: string) =>
value
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[*_>#~-]/g, '')
.replace(/\s+/g, ' ')
.trim();
export const excerpt = (value: string, limit = 155) => stripMarkdown(value).slice(0, limit);

50
src/lib/jsonLd.ts Normal file
View File

@@ -0,0 +1,50 @@
export type VehicleSchemaCar = {
make: string;
model: string;
year: number;
price: number;
mileage: number;
fuel: string;
transmission: string;
bodyType: string;
color: string;
sold?: boolean;
vin?: string;
};
type VehicleJsonLdInput = {
car: VehicleSchemaCar;
canonicalUrl: string;
imageUrl: string;
};
export const vehicleJsonLd = ({ car, canonicalUrl, imageUrl }: VehicleJsonLdInput) => ({
'@context': 'https://schema.org',
'@type': 'Vehicle',
name: `${car.year} ${car.make} ${car.model}`,
brand: {
'@type': 'Brand',
name: car.make,
},
model: car.model,
vehicleModelDate: car.year,
mileageFromOdometer: {
'@type': 'QuantitativeValue',
value: car.mileage,
unitCode: 'KMT',
},
fuelType: car.fuel,
vehicleTransmission: car.transmission,
bodyType: car.bodyType,
color: car.color,
...(car.vin ? { vehicleIdentificationNumber: car.vin } : {}),
image: imageUrl,
url: canonicalUrl,
offers: {
'@type': 'Offer',
price: car.price,
priceCurrency: 'EUR',
availability: car.sold ? 'https://schema.org/SoldOut' : 'https://schema.org/InStock',
url: canonicalUrl,
},
});

View File

@@ -0,0 +1,10 @@
// TODO: Add Meta Conversions API forwarding when the site moves beyond static-only output.
// Keep the public site static for now. A future Vercel Function can accept Contact events,
// hash user data server-side, and forward them to Meta with a server-only access token.
export function GET() {
return new Response('Meta Conversions API stub. Configure a serverless function before use.\n', {
status: 501,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}

45
src/pages/apie.astro Normal file
View File

@@ -0,0 +1,45 @@
---
import Footer from '../components/Footer.astro';
import Header from '../components/Header.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="Apie - Juozas Auto" description="Trumpai apie Juozas Auto principus, automobilio pardavimo pagalbą ir darbo būdą." canonicalPath="/apie">
<Header />
<main class="mx-auto max-w-5xl px-5 py-10 sm:px-8 lg:px-10">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-burgundy-700">Apie</p>
<h1 class="mt-4 max-w-3xl text-3xl font-semibold tracking-[-0.06em]">Mažas inventorius, daugiau dėmesio kiekvienam automobiliui.</h1>
<p class="mt-7 max-w-2xl text-lg leading-8 text-muted">
Juozas Auto yra vieno pardavėjo projektas. Čia nėra pirkėjų paskyrų, paslėptų mokesčių ar triukšmingų skelbimų blokų. Tik keli automobiliai ir aiški informacija sprendimui priimti.
</p>
<section class="mt-16 grid gap-8 md:grid-cols-3" aria-labelledby="process-heading">
<h2 id="process-heading" class="sr-only">Kaip tai veikia</h2>
<div>
<p class="text-sm font-semibold text-burgundy-700">01</p>
<h3 class="mt-3 text-xl font-semibold tracking-[-0.04em]">Atranka</h3>
<p class="mt-3 text-muted">Į svetainę patenka tik keli automobiliai, kuriuos galima ramiai apžiūrėti ir palyginti.</p>
</div>
<div>
<p class="text-sm font-semibold text-burgundy-700">02</p>
<h3 class="mt-3 text-xl font-semibold tracking-[-0.04em]">Pateikimas</h3>
<p class="mt-3 text-muted">Nuotraukos, kaina ir specifikacija pateikiami be perteklinių blokų ar dirbtinės skubos.</p>
</div>
<div>
<p class="text-sm font-semibold text-burgundy-700">03</p>
<h3 class="mt-3 text-xl font-semibold tracking-[-0.04em]">Kontaktas</h3>
<p class="mt-3 text-muted">Skambutis arba WhatsApp žinutė veda tiesiai pas pardavėją. Savininkas tekstą pakeis tikra istorija.</p>
</div>
</section>
<section class="mt-16 rounded-[2rem] bg-wash p-8">
<p class="text-sm uppercase tracking-[0.18em] text-muted">Pasitikėjimas</p>
<div class="mt-6 grid gap-8 sm:grid-cols-3">
<div><p class="text-3xl font-semibold tabular">8+</p><p class="mt-1 text-sm text-muted">metai patirties</p></div>
<div><p class="text-3xl font-semibold tabular">120+</p><p class="mt-1 text-sm text-muted">parduotų automobilių</p></div>
<div><p class="text-3xl font-semibold tabular">1:1</p><p class="mt-1 text-sm text-muted">tiesioginis bendravimas</p></div>
</div>
</section>
</main>
<Footer />
</BaseLayout>

View File

@@ -0,0 +1,128 @@
---
import { getImage } from 'astro:assets';
import { getCollection, type CollectionEntry } from 'astro:content';
import CarCard from '../../components/CarCard.astro';
import CarGallery from '../../components/CarGallery.astro';
import ContactButtons from '../../components/ContactButtons.astro';
import Footer from '../../components/Footer.astro';
import Header from '../../components/Header.astro';
import JsonLd from '../../components/JsonLd.astro';
import SpecStrip from '../../components/SpecStrip.astro';
import { lt } from '../../i18n/lt';
import { EMAIL, PHONE_DISPLAY } from '../../lib/contact';
import { excerpt, formatEngineSize, formatMileage, formatPower, formatPrice } from '../../lib/format';
import { vehicleJsonLd } from '../../lib/jsonLd';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { site } from '../../site';
export async function getStaticPaths() {
const cars = await getCollection('cars');
return cars.map((car) => ({ params: { slug: car.slug }, props: { car } }));
}
const { car } = Astro.props as { car: CollectionEntry<'cars'> };
const allCars = await getCollection('cars');
const similarCars = allCars.filter((item) => item.id !== car.id && !item.data.sold).slice(0, 3);
const { Content } = await car.render();
const carName = `${car.data.make} ${car.data.model}`;
const title = `${car.data.year} ${car.data.make} ${car.data.model} — ${formatPrice(car.data.price)} — Juozas Auto`;
const ogTitle = `${car.data.year} ${car.data.make} ${car.data.model} — ${formatPrice(car.data.price)}`;
const description = excerpt(car.body);
const canonicalPath = `/automobiliai/${car.slug}`;
const canonicalUrl = new URL(canonicalPath, site.url).toString();
const ogImage = await getImage({ src: car.data.photos[0], width: 1200, height: 630, format: 'jpg' });
const ogImageUrl = new URL(ogImage.src, site.url).toString();
const specs = [
[lt.labels.year, String(car.data.year)],
[lt.labels.mileage, formatMileage(car.data.mileage)],
[lt.labels.fuel, car.data.fuel],
[lt.labels.gearbox, car.data.transmission],
[lt.labels.bodyType, car.data.bodyType],
[lt.labels.color, car.data.color],
car.data.drivetrain ? [lt.labels.drivetrain, car.data.drivetrain] : null,
car.data.power ? [lt.labels.power, formatPower(car.data.power)] : null,
car.data.engineSize ? [lt.labels.engineSize, formatEngineSize(car.data.engineSize)] : null,
car.data.firstRegistration ? [lt.labels.firstRegistration, car.data.firstRegistration] : null,
car.data.vin ? [lt.labels.vin, car.data.vin] : null,
].filter(Boolean) as [string, string][];
const jsonLd = vehicleJsonLd({ car: car.data, canonicalUrl, imageUrl: ogImageUrl });
---
<BaseLayout title={title} ogTitle={ogTitle} description={description} canonicalPath={canonicalPath} image={ogImageUrl} type="product">
<Header />
<JsonLd data={jsonLd} />
<main class="mx-auto max-w-7xl px-5 pb-8 sm:px-8 lg:px-10">
<div class="grid min-w-0 gap-10 lg:grid-cols-[minmax(0,1.08fr)_minmax(380px,0.72fr)] lg:items-start">
<div class="min-w-0 lg:sticky lg:top-6">
<CarGallery photos={car.data.photos} title={carName} />
</div>
<aside class="w-full max-w-full min-w-0 overflow-hidden lg:sticky lg:top-6">
<p class="text-sm text-muted">{car.data.year} · {car.data.city}</p>
<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>
<div class="mt-6 lg:hidden">
<ContactButtons make={car.data.make} model={car.data.model} compact />
</div>
<div class="mt-7">
<SpecStrip year={car.data.year} mileage={car.data.mileage} fuel={car.data.fuel} transmission={car.data.transmission} />
</div>
<div class="mt-7 hidden lg:block">
<ContactButtons make={car.data.make} model={car.data.model} />
<p class="mt-4 text-sm text-muted">Tiesioginis kontaktas: {PHONE_DISPLAY}, {EMAIL}</p>
</div>
</aside>
</div>
<div class="mt-12 grid gap-12 lg:grid-cols-[minmax(0,1fr)_380px]">
<article class="prose-lite measure">
<h2 class="mb-5 text-2xl font-semibold tracking-[-0.05em] text-ink">{lt.sections.description}</h2>
<Content />
</article>
<section aria-labelledby="specs-heading">
<h2 id="specs-heading" class="text-2xl font-semibold tracking-[-0.05em]">{lt.sections.specs}</h2>
<dl class="mt-5 divide-y divide-line rounded-[1.75rem] border border-line bg-paper">
{specs.map(([label, value]) => (
<div class="grid grid-cols-[1fr_1.25fr] gap-4 px-5 py-4 text-sm">
<dt class="text-muted">{label}</dt>
<dd class="font-semibold text-ink tabular">{value}</dd>
</div>
))}
</dl>
</section>
</div>
<section class="mt-16 rounded-[2rem] bg-wash p-6 sm:p-8" aria-labelledby="location-heading">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{lt.sections.location}</p>
<h2 id="location-heading" class="mt-2 text-2xl font-semibold tracking-[-0.05em]">{car.data.city}</h2>
<p class="mt-3 max-w-2xl text-muted">Apžiūra derinama telefonu. Tikslus adresas pateikiamas susitarus dėl laiko.</p>
</section>
{similarCars.length > 0 && (
<section class="mt-20" aria-labelledby="similar-heading">
<h2 id="similar-heading" class="text-2xl font-semibold tracking-[-0.05em]">{lt.sections.similar}</h2>
<div class="mt-7 grid gap-x-7 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
{similarCars.map((item) => <CarCard car={item} />)}
</div>
</section>
)}
</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>
<ContactButtons make={car.data.make} model={car.data.model} compact />
</div>
<Footer />
</BaseLayout>
<script is:inline>
const sticky = document.querySelector('[data-sticky-contact]');
if (sticky) {
const toggle = () => sticky.classList.toggle('translate-y-full', window.scrollY < 200);
sticky.classList.add('transition-transform', 'duration-300', 'ease-out-quart');
toggle();
window.addEventListener('scroll', toggle, { passive: true });
}
</script>

81
src/pages/index.astro Normal file
View File

@@ -0,0 +1,81 @@
---
import { Image } from 'astro:assets';
import { getCollection } from 'astro:content';
import CarCard from '../components/CarCard.astro';
import Footer from '../components/Footer.astro';
import Header from '../components/Header.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import { lt } from '../i18n/lt';
import { site } from '../site';
const allCars = await getCollection('cars');
const currentCars = allCars.filter((car) => !car.data.sold).sort((a, b) => b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf());
const soldCars = allCars.filter((car) => car.data.sold);
const heroCar = currentCars.find((car) => car.data.featured) ?? currentCars[0];
---
<BaseLayout title="Juozas Auto" description={site.description} canonicalPath="/">
<Header />
<main>
<section class="mx-auto grid max-w-7xl gap-10 px-5 pb-16 pt-8 sm:px-8 lg:grid-cols-[0.9fr_1.1fr] lg:items-end lg:px-10 lg:pb-24 lg:pt-14">
<div class="max-w-3xl">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-burgundy-700">Naudoti automobiliai Lietuvoje</p>
<h1 class="mt-5 text-3xl font-semibold text-ink sm:text-3xl">
Atrinkti automobiliai. Be triukšmo, be paslėptų mokesčių.
</h1>
<p class="mt-6 max-w-xl text-lg text-muted">
Mažas inventorius, aiškios kainos ir tiesioginis kontaktas su pardavėju. Kiekvienas automobilis pateiktas taip, kad sprendimą priimtumėte ramiai.
</p>
</div>
{heroCar && (
<a href={`/automobiliai/${heroCar.slug}`} class="group block" aria-label={`Peržiūrėti ${heroCar.data.make} ${heroCar.data.model}`}>
<div class="overflow-hidden rounded-[2.5rem] bg-wash shadow-soft">
<Image
src={heroCar.data.photos[0]}
alt={`${heroCar.data.make} ${heroCar.data.model} automobilio nuotrauka`}
width={1040}
height={780}
loading="eager"
format="avif"
class="aspect-[4/3] w-full object-cover"
/>
</div>
<p class="mt-4 flex items-center justify-between text-sm text-muted">
<span>Naujausias pasirinkimas</span>
<span class="font-semibold text-burgundy-700">{heroCar.data.year} {heroCar.data.make} {heroCar.data.model}</span>
</p>
</a>
)}
</section>
<section id="automobiliai" class="mx-auto max-w-7xl px-5 sm:px-8 lg:px-10">
<div class="mb-8 flex items-end justify-between gap-6">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{currentCars.length} vnt.</p>
<h2 class="mt-2 text-2xl font-semibold tracking-[-0.05em]">{lt.sections.currentCars}</h2>
</div>
<a class="hidden text-sm font-semibold text-burgundy-700 sm:block" href="/kontaktai">Norite parduoti automobilį?</a>
</div>
{currentCars.length > 0 ? (
<div class="grid gap-x-7 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
{currentCars.map((car) => <CarCard car={car} />)}
</div>
) : (
<div class="rounded-[2rem] border border-line bg-paper p-8">
<p class="text-xl font-semibold">Šiuo metu aktyvių automobilių nėra.</p>
<p class="mt-2 text-muted">Susisiekite, jei ieškote konkretaus modelio arba norite parduoti savo automobilį.</p>
</div>
)}
</section>
{site.showSoldCars && soldCars.length > 0 && (
<section class="mx-auto mt-20 max-w-7xl px-5 sm:px-8 lg:px-10">
<h2 class="text-xl font-semibold tracking-[-0.04em]">{lt.sections.soldCars}</h2>
<div class="mt-6 grid gap-x-7 gap-y-10 sm:grid-cols-2 lg:grid-cols-4">
{soldCars.map((car) => <CarCard car={car} compact />)}
</div>
</section>
)}
</main>
<Footer />
</BaseLayout>

46
src/pages/kontaktai.astro Normal file
View File

@@ -0,0 +1,46 @@
---
import ContactButtons from '../components/ContactButtons.astro';
import Footer from '../components/Footer.astro';
import Header from '../components/Header.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import { EMAIL, HOURS, PHONE_DISPLAY } from '../lib/contact';
import { site } from '../site';
---
<BaseLayout title="Kontaktai - Juozas Auto" description="Susisiekite telefonu, el. paštu arba WhatsApp dėl automobilio apžiūros Lietuvoje." canonicalPath="/kontaktai">
<Header />
<main class="mx-auto grid max-w-7xl gap-12 px-5 py-10 sm:px-8 lg:grid-cols-[0.8fr_1.2fr] lg:px-10">
<section>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-burgundy-700">Kontaktai</p>
<h1 class="mt-4 text-3xl font-semibold tracking-[-0.06em]">Susitarkime dėl apžiūros.</h1>
<p class="mt-5 max-w-xl text-lg text-muted">Greičiausias būdas susisiekti yra skambutis arba WhatsApp žinutė. Atsakome darbo valandomis.</p>
<div class="mt-8 max-w-md">
<ContactButtons />
</div>
<dl class="mt-10 space-y-4 text-sm">
<div><dt class="text-muted">Telefonas</dt><dd class="mt-1 font-semibold tabular">{PHONE_DISPLAY}</dd></div>
<div><dt class="text-muted">El. paštas</dt><dd class="mt-1 font-semibold">{EMAIL}</dd></div>
<div><dt class="text-muted">Darbo laikas</dt><dd class="mt-1 font-semibold">{HOURS}</dd></div>
</dl>
</section>
<section class="rounded-[2rem] border border-line bg-paper p-6 shadow-soft sm:p-8" aria-labelledby="sell-heading">
<h2 id="sell-heading" class="text-2xl font-semibold tracking-[-0.05em]">Norite parduoti automobilį?</h2>
<p class="mt-3 text-muted">Palikite kontaktą ir trumpą informaciją. Nuotraukas galėsite atsiųsti atsakius į užklausą.</p>
{!site.formAction && (
<p class="mt-5 rounded-2xl bg-burgundy-50 px-4 py-3 text-sm text-burgundy-900">
Forma paruošta prijungimui. Iki tol greičiausias kontaktas yra telefonas arba WhatsApp.
</p>
)}
<form class="mt-7 grid gap-4" action={site.formAction || undefined} method="post">
<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">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>
<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>
</form>
</section>
</main>
<Footer />
</BaseLayout>

7
src/pages/robots.txt.ts Normal file
View File

@@ -0,0 +1,7 @@
import { site } from '../site';
export function GET() {
return new Response(`User-agent: *\nAllow: /\nSitemap: ${site.url}/sitemap-index.xml\n`, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}

7
src/site.ts Normal file
View File

@@ -0,0 +1,7 @@
export const site = {
name: 'Juozas Auto',
url: 'https://auto.juozas.lt',
description: 'Atrinkti naudoti automobiliai Lietuvoje. Be triukšmo, be paslėptų mokesčių.',
showSoldCars: false,
formAction: import.meta.env.PUBLIC_FORMSPREE_ENDPOINT ?? '',
};

83
src/styles/global.css Normal file
View File

@@ -0,0 +1,83 @@
@import '@fontsource/geist-sans/400.css';
@import '@fontsource/geist-sans/600.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
}
* {
box-sizing: border-box;
}
html {
background: theme('colors.paper');
color: theme('colors.ink');
font-kerning: normal;
scroll-behavior: smooth;
}
body {
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at top left, oklch(93% 0.03 24), transparent 34rem),
theme('colors.paper');
font-family: theme('fontFamily.sans');
text-rendering: optimizeLegibility;
}
a {
color: inherit;
text-decoration: none;
}
img {
max-width: 100%;
height: auto;
}
:focus-visible {
outline: 3px solid theme('colors.burgundy.600');
outline-offset: 4px;
}
.measure {
max-width: 68ch;
}
.tabular {
font-variant-numeric: tabular-nums;
}
.prose-lite {
color: theme('colors.ink');
font-size: 1.05rem;
line-height: 1.75;
}
.prose-lite p + p {
margin-top: 1.25rem;
}
.no-scrollbar {
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}