Skip to content

Commit e845404

Browse files
committed
Serve the fleet from the admin panel, and make the site indexable
Two pieces: a public read-only endpoint so the panel becomes the single place the fleet is managed, and the SEO work needed for a client-rendered site on a static host. ## The endpoint api/public-vehicles.php returns vehicles whose status is Available, with rates read from the dated rate card the same way the admin reads them, so a price scheduled for next month does not leak out early. Adding a car in the panel puts it on the site; setting one to Maintenance takes it off. It deliberately omits columns the admin's own endpoint returns: reg_number, a plate is not the public's business; current_km, which reveals how hard a car has been worked; created_by and created_at, internal; colour, a swatch for the panel's UI. A vehicle with no rate card is skipped rather than listed with a blank price, since that would invite an enquiry nobody can answer. CORS uses the existing public_site_origin allowlist, echoed rather than wildcarded, matching enquiry-submit.php. GET only. No session, no writes. The site falls back to src/data/cars.ts when the request fails -- offline, a CORS rejection, or the endpoint not yet uploaded. That file is empty, so the page degrades to its "ask us what's available" state instead of an error. ## SEO The app renders in the browser, so every route was served the same index.html carrying the home page's title. Google runs JavaScript and would eventually see the right tags, but WhatsApp, Facebook and X do not run it at all -- they read the HTML as served and stop. Sharing niteshacars.in/tariff previewed as the home page. scripts/prerender-seo.mjs now writes a directory per route with that route's title, description, canonical and og: tags baked in. Pages serves /tariff/ from /tariff/index.html. dist/404.html keeps the root page's tags, since it is what Pages serves for unmatched paths. src/data/seo.json is the single source for those strings and is also read by src/lib/useSeo.ts, which updates the same tags on client-side navigation -- the app never re-requests HTML after boot, so without it the title would stay on whichever page loaded first. Also added: sitemap.xml generated from the same file, robots.txt pointing at it, and AutoRental structured data in index.html carrying the business name, phone and email, which is what local search results are built from. This replaces scripts/spa-fallback.mjs; the 404 copy now happens here. Verified: php -l passes on the endpoint; all six routes build with their own title, canonical and og:url; 404.html carries the root tags; the sitemap lists every route. In a browser with the API unreachable, the listing falls back to the empty state rather than hanging, the title changes on navigation, and no page errors are raised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
1 parent c386472 commit e845404

16 files changed

Lines changed: 534 additions & 45 deletions

File tree

‎my-app/README.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,35 @@ sent and enquiries will link to the vehicle record.
7676

7777
A honeypot field named `website` is included and kept visually hidden, matching
7878
what the endpoint expects.
79+
80+
## Where the fleet comes from
81+
82+
The site fetches `https://admin.niteshacars.in/admin/api/public-vehicles.php`,
83+
which returns only vehicles whose status is `Available`, and only public-safe
84+
columns. Adding a car in the admin panel puts it on the site; setting one to
85+
Maintenance takes it off. No code change, no deploy.
86+
87+
`src/data/cars.ts` is the fallback used when that request fails — offline, a
88+
CORS rejection, or the endpoint not uploaded yet. It is empty, so the site
89+
degrades to its "ask us what's available" state rather than showing an error.
90+
91+
The endpoint must be uploaded to the server; it lives in this repo at
92+
`public_html/admin.niteshacars.in/admin/api/public-vehicles.php`.
93+
94+
## SEO
95+
96+
`src/data/seo.json` is the single source of per-route titles and descriptions.
97+
It feeds two things:
98+
99+
- `scripts/prerender-seo.mjs`, which runs after each build and writes a
100+
directory per route (`dist/about/index.html` and so on) with that route's
101+
title, description, canonical and `og:` tags baked into the HTML. This is
102+
what crawlers and link previews read — WhatsApp, Facebook and X do not run
103+
JavaScript, so tags set at runtime never reach them.
104+
- `src/lib/useSeo.ts`, which updates the same tags on client-side navigation,
105+
since the app never re-requests HTML after boot.
106+
107+
The script also writes `sitemap.xml`. `public/robots.txt` points at it.
108+
109+
`index.html` carries `AutoRental` structured data — the business name, phone
110+
and email — which is what local search results are built from.

‎my-app/index.html‎

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,51 @@
11
<!doctype html>
2-
<html lang="en">
2+
<html lang="en-IN">
33
<head>
44
<meta charset="UTF-8" />
55
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7-
<title>Nitesha Cars — Self-drive car rental in Tamil Nadu</title>
7+
8+
<title>Nitesha Cars — Self-drive car rental</title>
89
<meta
910
name="description"
10-
content="Self-drive car rentals from Nitesha Cars. Hatchbacks, sedans and SUVs on daily, weekly and monthly rates, with transparent deposits and KM limits."
11+
content="Self-drive car hire by the day, week or month. Clear KM limits, transparent extra-KM rates and refundable deposits, stated upfront."
1112
/>
13+
<link rel="canonical" href="https://niteshacars.in/" />
14+
15+
<!-- Link previews. WhatsApp, Facebook and X do not run JavaScript, so
16+
these have to be in the served HTML; the build writes per-route
17+
values into each page's copy. -->
18+
<meta property="og:type" content="website" />
19+
<meta property="og:site_name" content="Nitesha Cars" />
20+
<meta property="og:locale" content="en_IN" />
21+
<meta property="og:title" content="Nitesha Cars — Self-drive car rental" />
22+
<meta
23+
property="og:description"
24+
content="Self-drive car hire by the day, week or month. Clear KM limits, transparent extra-KM rates and refundable deposits, stated upfront."
25+
/>
26+
<meta property="og:url" content="https://niteshacars.in/" />
27+
<meta property="og:image" content="https://niteshacars.in/hero-car.webp" />
28+
<meta name="twitter:card" content="summary_large_image" />
29+
30+
<!-- Local search: tells Google this is a car rental business, what it is
31+
called, and how to reach it. -->
32+
<script type="application/ld+json">
33+
{
34+
"@context": "https://schema.org",
35+
"@type": "AutoRental",
36+
"name": "Nitesha Cars",
37+
"url": "https://niteshacars.in",
38+
"telephone": "+916374942976",
39+
"email": "niteshacars045@gmail.com",
40+
"image": "https://niteshacars.in/hero-car.webp",
41+
"priceRange": "₹₹",
42+
"address": {
43+
"@type": "PostalAddress",
44+
"addressCountry": "IN"
45+
}
46+
}
47+
</script>
48+
1249
<link rel="preconnect" href="https://fonts.googleapis.com" />
1350
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
1451
<link

‎my-app/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"type": "module",
66
"scripts": {
77
"dev": "vite",
8-
"build": "tsc -b && vite build && node scripts/spa-fallback.mjs",
8+
"build": "tsc -b && vite build && node scripts/prerender-seo.mjs",
99
"lint": "oxlint",
1010
"preview": "vite preview"
1111
},

‎my-app/public/robots.txt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
User-agent: *
2+
Allow: /
3+
4+
Sitemap: https://niteshacars.in/sitemap.xml

‎my-app/scripts/prerender-seo.mjs‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Per-route HTML and a sitemap, written after the Vite build.
3+
*
4+
* The app renders in the browser, so every route would otherwise be served the
5+
* same index.html carrying the home page's title and description. Google
6+
* executes JavaScript and would eventually see the right ones, but WhatsApp,
7+
* Facebook, X and most other link unfurlers do not run it at all -- they read
8+
* the HTML as served and stop. Sharing niteshacars.in/tariff would preview as
9+
* the home page.
10+
*
11+
* So each route gets its own directory with its own index.html: same bundle,
12+
* same markup, but title, description, canonical and og: tags rewritten. Pages
13+
* serves /tariff/ from /tariff/index.html, and a request for /tariff without
14+
* the slash is redirected to it.
15+
*
16+
* dist/404.html stays a copy of the root page: it is what Pages serves for
17+
* anything unmatched, and the router resolves the URL once the app boots.
18+
*/
19+
import { readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs';
20+
import { join } from 'node:path';
21+
22+
const root = join(import.meta.dirname, '..');
23+
const dist = join(root, 'dist');
24+
const seo = JSON.parse(readFileSync(join(root, 'src/data/seo.json'), 'utf8'));
25+
const template = readFileSync(join(dist, 'index.html'), 'utf8');
26+
27+
/** Replace the content of a meta/title/canonical tag, leaving the rest alone. */
28+
function rewrite(html, { title, description, url }) {
29+
return html
30+
.replace(/<title>[\s\S]*?<\/title>/, `<title>${title}</title>`)
31+
.replace(
32+
/(<meta\s+name="description"\s+content=")[\s\S]*?(")/,
33+
`$1${description}$2`,
34+
)
35+
.replace(
36+
/(<link\s+rel="canonical"\s+href=")[^"]*(")/,
37+
`$1${url}$2`,
38+
)
39+
.replace(
40+
/(<meta\s+property="og:title"\s+content=")[\s\S]*?(")/,
41+
`$1${title}$2`,
42+
)
43+
.replace(
44+
/(<meta\s+property="og:description"\s+content=")[\s\S]*?(")/,
45+
`$1${description}$2`,
46+
)
47+
.replace(
48+
/(<meta\s+property="og:url"\s+content=")[^"]*(")/,
49+
`$1${url}$2`,
50+
);
51+
}
52+
53+
const escape = (s) =>
54+
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
55+
56+
let written = 0;
57+
for (const route of seo.routes) {
58+
const url = seo.site.origin + route.path;
59+
const html = rewrite(template, {
60+
title: escape(route.title),
61+
description: escape(route.description),
62+
url,
63+
});
64+
65+
if (route.path === '/') {
66+
writeFileSync(join(dist, 'index.html'), html);
67+
} else {
68+
const dir = join(dist, route.path.replace(/^\//, ''));
69+
mkdirSync(dir, { recursive: true });
70+
writeFileSync(join(dir, 'index.html'), html);
71+
}
72+
written++;
73+
}
74+
75+
// The SPA fallback must carry the root page's tags, not the last route's.
76+
copyFileSync(join(dist, 'index.html'), join(dist, '404.html'));
77+
78+
const today = new Date().toISOString().slice(0, 10);
79+
const sitemap =
80+
'<?xml version="1.0" encoding="UTF-8"?>\n' +
81+
'<urlset xmlns="http://www.sitemap.org/schemas/sitemap/0.9">\n'.replace(
82+
'www.sitemap.org',
83+
'www.sitemaps.org',
84+
) +
85+
seo.routes
86+
.map(
87+
(r) =>
88+
` <url>\n <loc>${seo.site.origin}${r.path}</loc>\n` +
89+
` <lastmod>${today}</lastmod>\n` +
90+
` <priority>${r.priority}</priority>\n </url>`,
91+
)
92+
.join('\n') +
93+
'\n</urlset>\n';
94+
95+
writeFileSync(join(dist, 'sitemap.xml'), sitemap);
96+
97+
console.log(
98+
`prerender-seo: ${written} routes, plus sitemap.xml and the 404 fallback`,
99+
);

‎my-app/scripts/spa-fallback.mjs‎

Lines changed: 0 additions & 24 deletions
This file was deleted.

‎my-app/src/App.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useEffect } from 'react';
22
import { Routes, Route, useLocation } from 'react-router-dom';
3+
import { useSeo } from './lib/useSeo';
34
import { Header } from './components/Header';
45
import { Footer } from './components/Footer';
56
import { Home } from './pages/Home';
@@ -24,6 +25,8 @@ function ScrollToTop() {
2425
}
2526

2627
function App() {
28+
useSeo();
29+
2730
return (
2831
<>
2932
<ScrollToTop />

‎my-app/src/components/Enquiry.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState, type FormEvent } from 'react';
22
import { useSearchParams } from 'react-router-dom';
33
import { submitEnquiry } from '../lib/enquiry';
4-
import { cars } from '../data/cars';
4+
import { useFleet } from '../lib/useFleet';
55

66
type Status =
77
| { kind: 'idle' }
@@ -18,6 +18,9 @@ export function Enquiry() {
1818

1919
// A car chosen on the fleet page arrives as ?car=..., so the choice
2020
// survives the navigation here.
21+
const fleet = useFleet();
22+
const cars = fleet.status === 'ready' ? fleet.cars : [];
23+
2124
const [searchParams] = useSearchParams();
2225
const [selectedCar, setSelectedCar] = useState(searchParams.get('car') ?? '');
2326

‎my-app/src/components/Fleet.tsx‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import { useState } from 'react';
22
import { Link } from 'react-router-dom';
3-
import { cars, inr, type BodyType } from '../data/cars';
3+
import { inr, type BodyType } from '../data/cars';
4+
import { useFleet } from '../lib/useFleet';
45

56
type Filter = 'All' | BodyType;
67
const filters: Filter[] = ['All', 'Hatchback', 'Sedan', 'SUV', 'MUV'];
78

89
export function Fleet() {
910
const [filter, setFilter] = useState<Filter>('All');
10-
const empty = cars.length === 0;
11+
const fleet = useFleet();
12+
13+
const cars = fleet.status === 'ready' ? fleet.cars : [];
14+
const loading = fleet.status === 'loading';
15+
const empty = !loading && cars.length === 0;
1116
const shown = filter === 'All' ? cars : cars.filter((c) => c.bodyType === filter);
1217

1318
return (
@@ -18,13 +23,13 @@ export function Fleet() {
1823
Our fleet
1924
</h2>
2025
<p className="mt-2 max-w-lg text-ink-dim">
21-
{empty
26+
{loading ? 'Fetching the current fleet…' : empty
2227
? 'We are updating our vehicle listing. Call us or send an enquiry and we will tell you what is free for your dates.'
2328
: 'Rates shown are per day. Longer hires bring the daily rate down.'}
2429
</p>
2530
</div>
2631

27-
{empty ? null : (
32+
{loading || empty ? null : (
2833
<div className="flex flex-wrap gap-2" role="group" aria-label="Filter by body type">
2934
{filters.map((f) => {
3035
const active = f === filter;
@@ -48,7 +53,9 @@ export function Fleet() {
4853
)}
4954
</div>
5055

51-
{empty ? (
56+
{loading ? (
57+
<p className="mt-10 text-center text-ink-faint">Loading our cars…</p>
58+
) : empty ? (
5259
<div className="mt-10 rounded-[14px] border border-line bg-white p-10 text-center shadow-[0_10px_30px_rgba(16,24,40,0.08)]">
5360
<p className="text-lg font-semibold text-navy">
5461
Ask us what's available

‎my-app/src/data/seo.json‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"site": {
3+
"name": "Nitesha Cars",
4+
"origin": "https://niteshacars.in",
5+
"phone": "+916374942976",
6+
"email": "niteshacars045@gmail.com",
7+
"locale": "en_IN"
8+
},
9+
"routes": [
10+
{
11+
"path": "/",
12+
"title": "Nitesha Cars — Self-drive car rental",
13+
"description": "Self-drive car hire by the day, week or month. Clear KM limits, transparent extra-KM rates and refundable deposits, stated upfront.",
14+
"priority": "1.0"
15+
},
16+
{
17+
"path": "/about",
18+
"title": "About us — Nitesha Cars",
19+
"description": "Who we are and how self-drive hire works with us: what you need to bring, how deposits are handled, and how rates are set.",
20+
"priority": "0.6"
21+
},
22+
{
23+
"path": "/cars",
24+
"title": "Our cars — Nitesha Cars",
25+
"description": "Hatchbacks, sedans and SUVs available for self-drive hire. Every car lists its daily rate, KM allowance, extra-KM rate and deposit.",
26+
"priority": "0.9"
27+
},
28+
{
29+
"path": "/tariff",
30+
"title": "Tariff and rates — Nitesha Cars",
31+
"description": "Daily, weekly and monthly rates for every car, with KM limits, extra-KM charges and refundable deposits. No booking fee.",
32+
"priority": "0.9"
33+
},
34+
{
35+
"path": "/blog",
36+
"title": "Blog — Nitesha Cars",
37+
"description": "Road trip routes, driving tips and news about our fleet.",
38+
"priority": "0.4"
39+
},
40+
{
41+
"path": "/contact",
42+
"title": "Contact us — Nitesha Cars",
43+
"description": "Call, email or send an enquiry. Tell us your dates and we will confirm what is available and what it costs.",
44+
"priority": "0.8"
45+
}
46+
]
47+
}

0 commit comments

Comments
 (0)