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,114 @@
---
name: adding-juozas-auto-listing
description: Use when adding, improving, publishing, or replacing a Juozas Auto car listing, especially when photos, vehicle details, Lithuanian copy, content collection fields, or post-add verification are involved.
---
# Adding Juozas Auto Listing
## Overview
Add listings as conversion assets, not data entry. A superb listing has complete facts, strong real photos, calm Lithuanian copy, valid content schema, correct slug, and a verified build before commit or push.
## Before Editing
Ask only for missing facts. Do not invent vehicle details.
Required facts:
- Make, model, year, city, price, mileage.
- Fuel, transmission, body type, color.
- At least one photo, with cover photo identified.
- Publish date. Use today's date only if the user agrees or asks to publish now.
Allowed schema values:
- Fuel: `benzinas`, `dyzelinas`, `hibridas`, `elektra`, `dujos`.
- Transmission: `mechaninė`, `automatinė`.
- Body type: `sedanas`, `universalas`, `hečbekas`, `visureigis`, `kupė`, `kabrioletas`, `vienatūris`.
- Drivetrain: `priekiniai`, `galiniai`, `visi`.
High-value facts to ask when missing:
- Power in kW, engine size in liters, drivetrain.
- First registration month, VIN visibility, service history, known defects.
- Recent maintenance, tire set, ownership/import history.
- What should be emphasized in the first 2 sentences.
- Whether plates, faces, documents, or location clues need blurring before publish.
If the user has photos but they are not in the workspace, ask where they are. If the photos are poor, say what is missing: exterior front three-quarter, rear, interior, odometer, tires, service book, visible defects.
## File Locations
Use this exact structure:
```text
src/content/cars/<slug>.md
src/content/cars/_photos/<slug>/01.jpg
src/content/cars/_photos/<slug>/02.jpg
```
Slug format:
```text
${make}-${model}-${year}-${city}
```
Lowercase, ASCII-folded Lithuanian diacritics, words separated by hyphens. Example: `skoda-octavia-2020-siauliai`.
Before creating files, check whether `src/content/cars/<slug>.md` or `src/content/cars/_photos/<slug>/` already exists. If it does, ask whether this is an update or create a collision-free slug only with user approval.
For trim/body naming, keep the model as the buyer would search it, for example `A6 Avant` may use model `A6 Avant` and body type `universalas`. Do not hide meaningful trim names just to simplify the slug.
## Listing Quality Bar
Frontmatter must satisfy `src/content/config.ts` exactly. `photos` must contain at least one local image path. Keep optional fields absent if unknown rather than guessing.
Description pattern:
- Paragraph 1: the buyer-facing summary in Lithuanian, 1-2 calm sentences.
- Paragraph 2: condition, service, ownership, or usage context.
- Paragraph 3: transparent caveats or viewing/contact note if useful.
Tone: quiet, premium, honest. No hype, emojis, fake urgency, or all-caps selling.
## Photo Workflow
1. Put originals in `src/content/cars/_photos/<slug>/`.
2. Name the best cover image `01.jpg` when the source is JPEG. If the user provides PNG, WebP, HEIC, or another format, either preserve the real extension in frontmatter or convert intentionally with a normal image tool. Do not rename a non-JPEG file to `.jpg` without conversion.
3. Use meaningful order: exterior, interior, dashboard/odometer, wheels, defects.
4. Prefer landscape photos. Do not add synthetic stock photos for real listings unless the user explicitly requests placeholders.
5. If editing photos is needed, preserve originals outside the final committed set or use clearly named processed files.
## Verification
Run after adding or changing a listing:
```sh
npm test
npm run build
```
Also inspect the generated or dev listing at 375px width. Confirm:
- Price is visible quickly.
- Call or WhatsApp is reachable without hunting.
- Photos look credible and premium.
- Markdown renders cleanly.
- SEO title, OG image, and JSON-LD still build.
## Commit And Push
Only commit and push when explicitly requested. Stage only intended listing, photo, and related documentation changes unless the user asked to include everything.
Use a conventional message:
```sh
git commit -m "feat: add <make> <model> listing"
```
If pushing directly to `main`, first verify the branch and remote state with `git status --short --branch`.
## Common Mistakes
| Mistake | Fix |
|---|---|
| Creating a slug without city | Include city to match route convention. |
| Guessing specs | Ask or omit optional fields. |
| Referencing photos outside `src/content/cars/_photos` | Move photos into the content photo folder so Astro can optimize them. |
| Using loud sales copy | Rewrite as calm Lithuanian buyer guidance. |
| Skipping build | Build catches schema and image path failures. |

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
.astro/
.vercel/
*.log
!.gitkeep

26
AGENTS.md Normal file
View File

@@ -0,0 +1,26 @@
# Agent Instructions
## Pushing Changes
1. Check the working tree before editing:
`git status --short --branch`
2. Make the requested changes without modifying unrelated files.
3. Review what changed:
`git diff`
4. Stage only intended files:
`git add <path>`
5. Commit with a conventional commit message:
`git commit -m "type: short description"`
6. Push to the tracked remote branch:
`git push`
If the branch is not tracking a remote yet, push with:
```sh
git push -u origin main
```
## Credentials
Do not commit credentials, tokens, `.netrc` files, or generated askpass scripts.
Use credentials stored outside this repository when authentication is required.

33
PRODUCT.md Normal file
View File

@@ -0,0 +1,33 @@
# Product
## Register
brand
## Users
Lithuanian car buyers evaluating one of 1-10 manually listed used cars from a single seller. Many arrive from Meta ads on a specific listing, likely on mobile, comparing trust, price, photos, and contact friction before calling or sending a WhatsApp message.
## Product Purpose
Juozas Auto is a small used car listing website for a single seller in Lithuania. Its job is to make each car feel credible, easy to evaluate, and easy to inquire about without buyer accounts, payments, search, filters, or marketplace clutter. Success is measured by qualified phone calls and WhatsApp messages from listing pages.
## Brand Personality
Quiet, premium, honest. The tone should feel calm and direct: confident enough to sell a 15 000 EUR car, restrained enough to avoid shouting, and practical enough that placeholders can ship without embarrassment.
## Anti-references
Avoid classifieds-board patterns, especially Autoplius or Skelbiu visual density, loud badges, crowded filters, screaming promotional copy, fake urgency, animated gimmicks, stock-photo dealership gloss, and generic SaaS hero sections. The site should not feel like a marketplace, admin tool, financing funnel, or template dealer site.
## Design Principles
- Photos carry the sale: give car photography space, hierarchy, and stable aspect ratios.
- Contact is the conversion: every listing should make calling or WhatsApp obvious without visual pressure.
- Calm earns trust: use measured copy, generous rhythm, and quiet details instead of persuasion tricks.
- Small inventory stays small: optimize for 1-10 cars and manual markdown updates, not marketplace scale.
- Lithuanian first: labels, formatting, and buyer expectations should feel native to Lithuania.
## Accessibility & Inclusion
Target WCAG AA with readable Lithuanian text, keyboard-accessible navigation and forms, visible focus states, reduced-motion support, strong color contrast, and touch targets suitable for mobile buyers.

View File

@@ -1 +1,72 @@
# auto.juozas.lt # Juozas Auto
Static Astro website for a small Lithuanian used-car seller at `auto.juozas.lt`.
## Commands
```sh
npm install
npm run dev
npm test
npm run build
```
## Add A New Car
1. Create a folder for photos: `src/content/cars/_photos/<make-model-year-city>/`.
2. Add photos named `01.jpg`, `02.jpg`, and so on. Use real car photos, landscape crop preferred. Astro will generate optimized AVIF output during build.
3. Create `src/content/cars/<make-model-year-city>.md`.
4. Use lowercase ASCII slugs, for example `skoda-octavia-2020-siauliai.md`.
5. Add frontmatter matching `src/content/config.ts`, then write the Lithuanian description below it.
6. Run `npm run build` before publishing.
Example photo reference:
```yaml
photos:
- ./_photos/skoda-octavia-2020-siauliai/01.jpg
- ./_photos/skoda-octavia-2020-siauliai/02.jpg
```
## Mark A Car As Sold
Open the car markdown file and set:
```yaml
sold: true
```
Sold cars are hidden by default. To show a sold section on the homepage, set `showSoldCars: true` in `src/site.ts`.
## Meta Pixel
Set `META_PIXEL_ID` in your Vercel environment variables. Leave it 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.
## 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.
## Deploy To Vercel
1. Import the repository into Vercel.
2. Use the default framework detection for Astro.
3. Build command: `npm run build`.
4. Output directory: `dist`.
5. Add `META_PIXEL_ID` if needed.
6. Deploy.
## Domain Setup
In Vercel, add `auto.juozas.lt` under Project Settings, Domains. Then point the DNS record for `auto.juozas.lt` to the value Vercel provides.
## Example Listings
The repository includes three sample listings:
- `bmw-320d-2018-vilnius`
- `vw-passat-2016-kaunas`
- `toyota-rav4-2020-vilnius`
Sample photos are Unsplash placeholders and should be replaced with real vehicle photography before launch.

12
astro.config.mjs Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import tailwind from '@astrojs/tailwind';
export default defineConfig({
site: 'https://auto.juozas.lt',
output: 'static',
integrations: [tailwind(), sitemap()],
image: {
domains: ['images.unsplash.com'],
},
});

View File

@@ -0,0 +1,21 @@
# Juozas Auto Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a production-ready Astro and Tailwind static website for Juozas Auto.
**Architecture:** Use Astro content collections for cars, small server-rendered components for layout and SEO, and one lightweight client gallery script. Keep configuration, Lithuanian strings, formatting utilities, and schema helpers separate so listing pages stay simple.
**Tech Stack:** Astro 4, TypeScript, Tailwind CSS, Astro Image, content collections, Vitest, static Vercel output.
---
## Tasks
- [ ] Scaffold Astro, Tailwind, sitemap, TypeScript, Vitest, and base config.
- [ ] Add formatting, slug, excerpt, contact, and JSON-LD utilities with tests.
- [ ] Add car content schema and three example listings with local photos.
- [ ] Build shared layout, header, footer, SEO, Meta Pixel, JSON-LD, and contact components.
- [ ] Build homepage, listing pages, about, contact, robots, and Meta CAPI stub.
- [ ] Add styling tokens, responsive layout, gallery behavior, and accessibility polish.
- [ ] Update README and run full verification: tests, build, and 375px browser inspection.

View File

@@ -0,0 +1,64 @@
# Juozas Auto Design Brief
## Feature Summary
Build the full Juozas Auto static website for a Lithuanian single-seller used-car inventory. Listing pages are the highest-priority surface because each one doubles as a Meta ad landing page and must convert mobile visitors into calls or WhatsApp messages.
## Primary User Action
A buyer should quickly understand the car, see the price, trust the presentation, and contact the seller without friction.
## Design Direction
Color strategy: Restrained burgundy. Use tinted off-white and charcoal neutrals, with deep burgundy reserved for primary contact actions, prices, focus states, and a few editorial details.
Theme scene: A buyer opens a Meta ad on a phone in daylight or near a car, wants quick trust and contact, so the site uses a light theme with high readability and quiet contrast.
Anchor references: boutique automotive retail, restrained fashion ecommerce, and calm Swiss-style inventory presentation. The site must avoid Autoplius, Skelbiu, marketplace density, and generic SaaS hero sections.
Image gate: skipped because this harness has no native image-generation tool.
## Scope
Fidelity: production-ready.
Breadth: full site, including homepage, listing detail pages, about, contact, content collection, SEO metadata, Meta Pixel stub, README, and responsive behavior.
Interactivity: shipped-quality static site with minimal client JavaScript only where it directly supports conversion or gallery use.
Time intent: polish until build verification passes and the 375px experience answers the conversion checklist.
## Layout Strategy
Mobile-first. Listing pages put the photo gallery, title, price, and contact actions at the top so buyers can evaluate and act within seconds. Desktop uses a sticky gallery on the left and a sticky information/contact column on the right. The homepage stays sparse and image-led, with inventory cards only where they help comparison.
## Key States
- Current inventory with 1-10 cars.
- Sold inventory hidden by default behind a config flag.
- Empty inventory with a calm contact prompt.
- Optional specification fields missing.
- Mobile sticky contact bar after initial scroll.
- Desktop sticky contact area in the right column.
- Reduced-motion mode with no spatial animation dependency.
## Interaction Model
Car cards are full-link targets with subtle lift on hover-capable devices and no image zoom. The gallery is swipeable with a counter and minimal controls. Phone and WhatsApp links fire the Meta `Contact` event when a pixel ID is configured. Contact remains reachable without requiring the buyer to hunt through the page.
## Content Requirements
All UI strings are Lithuanian and centralized in `src/i18n/lt.ts`. Copy should be confident, honest, and quiet. Formatting must match Lithuanian expectations: `12 500 €`, `145 000 km`, `140 kW (190 AG)`, and Lithuanian WhatsApp text.
## Recommended References
- `brand.md` for brand-register distinctiveness and anti-slop checks.
- `spatial-design.md` for mobile-first hierarchy and avoiding unnecessary cards.
- `typography.md` for fluid display type, readable measure, and tabular figures.
- `responsive-design.md` for 375px and touch-target behavior.
- `color-and-contrast.md` for OKLCH burgundy and tinted neutrals.
- `motion-design.md` for restrained micro-interactions and reduced motion.
## Open Questions
None blocking. Use placeholder seller contact details and form endpoint values that are easy to replace in config.

7530
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "juozas-auto",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"test": "vitest run"
},
"dependencies": {
"@astrojs/sitemap": "3.2.1",
"@astrojs/tailwind": "^5.1.2",
"@fontsource/geist-sans": "^5.1.0",
"astro": "^4.16.18",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2"
},
"devDependencies": {
"@astrojs/check": "^0.9.4",
"vitest": "^2.1.8"
}
}

5
postcss.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
tailwindcss: {},
},
};

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;
}
}

41
tailwind.config.mjs Normal file
View File

@@ -0,0 +1,41 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {
colors: {
paper: 'oklch(98.4% 0.008 28)',
ink: 'oklch(19% 0.008 28)',
muted: 'oklch(48% 0.012 28)',
line: 'oklch(88% 0.011 28)',
wash: 'oklch(94.8% 0.012 28)',
burgundy: {
50: 'oklch(96% 0.018 24)',
100: 'oklch(91% 0.035 24)',
600: 'oklch(43% 0.12 24)',
700: 'oklch(36% 0.105 24)',
900: 'oklch(24% 0.07 24)',
},
},
fontFamily: {
sans: ['Geist Sans', 'Geist Sans Fallback', 'system-ui', 'sans-serif'],
},
fontSize: {
xs: ['0.75rem', { lineHeight: '1rem', letterSpacing: '0.02em' }],
sm: ['0.875rem', { lineHeight: '1.25rem' }],
base: ['1rem', { lineHeight: '1.625rem' }],
lg: ['1.25rem', { lineHeight: '1.875rem' }],
xl: ['1.625rem', { lineHeight: '2rem', letterSpacing: '-0.025em' }],
'2xl': ['2.25rem', { lineHeight: '2.5rem', letterSpacing: '-0.045em' }],
'3xl': ['clamp(2.75rem, 8vw, 5.75rem)', { lineHeight: '0.95', letterSpacing: '-0.07em' }],
},
boxShadow: {
soft: '0 18px 50px rgb(62 25 20 / 0.09)',
},
transitionTimingFunction: {
'out-quart': 'cubic-bezier(0.25, 1, 0.5, 1)',
},
},
},
plugins: [],
};

10
tests/contact.test.ts Normal file
View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';
import { whatsappUrl } from '../src/lib/contact';
describe('contact links', () => {
it('builds a Lithuanian WhatsApp inquiry link for a car', () => {
expect(whatsappUrl('37061234567', 'BMW', '320d')).toBe(
'https://wa.me/37061234567?text=Sveiki%2C%20domina%20BMW%20320d',
);
});
});

32
tests/format.test.ts Normal file
View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { excerpt, formatEngineSize, formatMileage, formatPower, formatPrice, slugifyCar } from '../src/lib/format';
describe('Lithuanian listing formatters', () => {
it('formats prices with spaces and a non-breaking euro separator', () => {
expect(formatPrice(12500)).toBe('12 500\u00a0€');
});
it('formats mileage with grouped spaces', () => {
expect(formatMileage(145000)).toBe('145 000 km');
});
it('formats power in kW and AG', () => {
expect(formatPower(140)).toBe('140 kW (190 AG)');
});
it('formats engine size with a Lithuanian decimal separator', () => {
expect(formatEngineSize(2.5)).toBe('2,5 l');
});
it('creates ascii car slugs from Lithuanian city names', () => {
expect(slugifyCar({ make: 'Škoda', model: 'Octavia RS', year: 2020, city: 'Šiauliai' })).toBe(
'skoda-octavia-rs-2020-siauliai',
);
});
it('creates plain text excerpts capped at 155 characters', () => {
const text = '**Patikrintas automobilis.** '.repeat(12);
expect(excerpt(text)).toHaveLength(155);
expect(excerpt(text)).not.toContain('*');
});
});

51
tests/jsonld.test.ts Normal file
View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { vehicleJsonLd } from '../src/lib/jsonLd';
describe('Vehicle JSON-LD', () => {
it('maps listing fields to Vehicle schema', () => {
const schema = vehicleJsonLd({
canonicalUrl: 'https://auto.juozas.lt/automobiliai/bmw-320d-2018-vilnius',
imageUrl: 'https://auto.juozas.lt/_astro/01.jpg',
car: {
make: 'BMW',
model: '320d',
year: 2018,
price: 12500,
mileage: 145000,
fuel: 'dyzelinas',
transmission: 'automatinė',
bodyType: 'sedanas',
color: 'Pilka',
sold: false,
vin: 'WBA8C91010A000000',
},
});
expect(schema['@type']).toBe('Vehicle');
expect(schema.vehicleModelDate).toBe(2018);
expect(schema.mileageFromOdometer.value).toBe(145000);
expect(schema.offers.price).toBe(12500);
expect(schema.offers.priceCurrency).toBe('EUR');
});
it('marks sold listings as sold out', () => {
const schema = vehicleJsonLd({
canonicalUrl: 'https://auto.juozas.lt/automobiliai/bmw-320d-2018-vilnius',
imageUrl: 'https://auto.juozas.lt/_astro/01.jpg',
car: {
make: 'BMW',
model: '320d',
year: 2018,
price: 12500,
mileage: 145000,
fuel: 'dyzelinas',
transmission: 'automatinė',
bodyType: 'sedanas',
color: 'Pilka',
sold: true,
},
});
expect(schema.offers.availability).toBe('https://schema.org/SoldOut');
});
});

9
tsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}