feat: build juozas auto site
This commit is contained in:
114
.claude/skills/adding-juozas-auto-listing/SKILL.md
Normal file
114
.claude/skills/adding-juozas-auto-listing/SKILL.md
Normal 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
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
|
.vercel/
|
||||||
|
*.log
|
||||||
|
!.gitkeep
|
||||||
26
AGENTS.md
Normal file
26
AGENTS.md
Normal 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
33
PRODUCT.md
Normal 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.
|
||||||
73
README.md
73
README.md
@@ -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
12
astro.config.mjs
Normal 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'],
|
||||||
|
},
|
||||||
|
});
|
||||||
21
docs/superpowers/plans/2026-05-02-juozas-auto.md
Normal file
21
docs/superpowers/plans/2026-05-02-juozas-auto.md
Normal 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.
|
||||||
64
docs/superpowers/specs/2026-05-02-juozas-auto-design.md
Normal file
64
docs/superpowers/specs/2026-05-02-juozas-auto-design.md
Normal 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
7530
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal 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
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
43
src/components/CarCard.astro
Normal file
43
src/components/CarCard.astro
Normal 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>
|
||||||
46
src/components/CarGallery.astro
Normal file
46
src/components/CarGallery.astro
Normal 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>
|
||||||
47
src/components/ContactButtons.astro
Normal file
47
src/components/ContactButtons.astro
Normal 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>
|
||||||
18
src/components/Footer.astro
Normal file
18
src/components/Footer.astro
Normal 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>
|
||||||
14
src/components/Header.astro
Normal file
14
src/components/Header.astro
Normal 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>
|
||||||
9
src/components/JsonLd.astro
Normal file
9
src/components/JsonLd.astro
Normal 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)} />
|
||||||
26
src/components/MetaPixel.astro
Normal file
26
src/components/MetaPixel.astro
Normal 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>
|
||||||
|
)}
|
||||||
28
src/components/SpecStrip.astro
Normal file
28
src/components/SpecStrip.astro
Normal 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>
|
||||||
BIN
src/content/cars/_photos/bmw-320d-2018-vilnius/01.jpg
Normal file
BIN
src/content/cars/_photos/bmw-320d-2018-vilnius/01.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 255 KiB |
BIN
src/content/cars/_photos/bmw-320d-2018-vilnius/02.jpg
Normal file
BIN
src/content/cars/_photos/bmw-320d-2018-vilnius/02.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 398 KiB |
BIN
src/content/cars/_photos/toyota-rav4-2020-vilnius/01.jpg
Normal file
BIN
src/content/cars/_photos/toyota-rav4-2020-vilnius/01.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 331 KiB |
BIN
src/content/cars/_photos/toyota-rav4-2020-vilnius/02.jpg
Normal file
BIN
src/content/cars/_photos/toyota-rav4-2020-vilnius/02.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 349 KiB |
BIN
src/content/cars/_photos/vw-passat-2016-kaunas/01.jpg
Normal file
BIN
src/content/cars/_photos/vw-passat-2016-kaunas/01.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
BIN
src/content/cars/_photos/vw-passat-2016-kaunas/02.jpg
Normal file
BIN
src/content/cars/_photos/vw-passat-2016-kaunas/02.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
29
src/content/cars/bmw-320d-2018-vilnius.md
Normal file
29
src/content/cars/bmw-320d-2018-vilnius.md
Normal 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.
|
||||||
28
src/content/cars/toyota-rav4-2020-vilnius.md
Normal file
28
src/content/cars/toyota-rav4-2020-vilnius.md
Normal 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.
|
||||||
28
src/content/cars/vw-passat-2016-kaunas.md
Normal file
28
src/content/cars/vw-passat-2016-kaunas.md
Normal 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
29
src/content/config.ts
Normal 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
1
src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference path="../.astro/types.d.ts" />
|
||||||
35
src/i18n/lt.ts
Normal file
35
src/i18n/lt.ts
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
51
src/layouts/BaseLayout.astro
Normal file
51
src/layouts/BaseLayout.astro
Normal 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
14
src/lib/contact.ts
Normal 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
52
src/lib/format.ts
Normal 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
50
src/lib/jsonLd.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
10
src/pages/api/meta-capi.ts
Normal file
10
src/pages/api/meta-capi.ts
Normal 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
45
src/pages/apie.astro
Normal 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>
|
||||||
128
src/pages/automobiliai/[slug].astro
Normal file
128
src/pages/automobiliai/[slug].astro
Normal 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
81
src/pages/index.astro
Normal 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
46
src/pages/kontaktai.astro
Normal 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
7
src/pages/robots.txt.ts
Normal 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
7
src/site.ts
Normal 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
83
src/styles/global.css
Normal 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
41
tailwind.config.mjs
Normal 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
10
tests/contact.test.ts
Normal 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
32
tests/format.test.ts
Normal 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
51
tests/jsonld.test.ts
Normal 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
9
tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user