Skip to content

MAIN - #17280

Open
niteeshkanna-sh wants to merge 170 commits into
react:mainfrom
niteeshkanna-sh:main
Open

MAIN#17280
niteeshkanna-sh wants to merge 170 commits into
react:mainfrom
niteeshkanna-sh:main

Conversation

@niteeshkanna-sh

Copy link
Copy Markdown

No description provided.

This workflow sets up a CI pipeline for Node.js projects, including installation, caching, building, and testing across multiple Node.js versions.
@meta-cla

meta-cla Bot commented Aug 27, 2026

Copy link
Copy Markdown

Hi @niteeshkanna-sh!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

niteeshkanna-sh and others added 27 commits September 1, 2026 16:46
Scaffold a new standalone single-page app alongside the existing project:

- Vite 8 with @vitejs/plugin-react, TypeScript, React 19
- Tailwind CSS v4 wired through @tailwindcss/vite
- Replace the Vite demo page with a minimal starter component
- Pin an inline (empty) PostCSS config so Vite does not walk up and load
  the parent directory's postcss.config.js, which broke the build
- README covering setup, scripts, and layout

Verified: npm run lint, npm run build, and npm run dev all pass, and the
dev server renders the styled page with working component state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
`npm ci` failed at the install step of every CI job:

    npm ERR! `npm ci` can only install packages when your package.json
    npm ERR! and package-lock.json are in sync.
    npm ERR! Missing: yaml@2.9.1 from lock file

The lockfile predates the current dependency tree: tailwindcss 3.4.19
pulls postcss-load-config, which needs yaml@^2, but the lockfile only
carried the top-level yaml@1.10.2.

Regenerated with `npm install --package-lock-only`. The change adds the
missing nested yaml@2.9.1 and drops some stale `peer` metadata flags; no
dependency version or integrity hash changes.

Verified `npm ci` validates under both npm 10 and npm 8.19.4, the version
the lint workflow pins via Node 16.

This unblocks the install step only. The workflows have further problems
that predate this branch, noted on the pull request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Adds a workflow that builds my-app and publishes it to GitHub Pages, plus
the CNAME for the custom domain.

The workflow installs with `npm ci`, lints, builds, and uploads my-app/dist
as the Pages artifact. It triggers on pushes to main that touch my-app/, and
on manual dispatch. Because it is scoped to main, merging is what deploys;
nothing publishes from a feature branch.

`public/CNAME` is copied verbatim into dist/ by Vite, landing at the site
root where Pages looks for it.

Vite's default base of '/' is correct here: a custom domain serves from the
root, and the built asset paths are root-relative to match.

Verified by running the workflow's exact steps locally -- npm ci, npm run
lint, npm run build -- and confirming dist/ contains CNAME, index.html with
root-relative asset URLs, and the hashed JS/CSS bundles.

Deploying still needs two manual steps that cannot be done from here:
enabling Pages with the GitHub Actions source, and pointing DNS at GitHub.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Scaffolds my-app/ as a standalone Vite + React + TypeScript + Tailwind SPA,
syncs the root package-lock.json with package.json so npm ci works again, and
adds a GitHub Pages deploy workflow with a CNAME for niteshacars.in.

Merged at the repo owner's request. CI is red from pre-existing failures that
reproduce on main and are unrelated to these changes; see the pull request
description for the four causes.
Every workflow run in this repo's history has failed, on main as well as on
every branch. The cause is commit dc079b1, which replaced the monorepo's root
package.json with a create-react-app app's. That dropped the `workspaces` field
and the alex, eslint, prettier and test:integration scripts the inherited CI
depends on, leaving four distinct failures:

    lint              npm run alex        -> Missing script: "alex"
    Build / build     react-scripts build -> no public/index.html
    Integration Tests npm run test:integration -> Missing script
    E2E               tasks/publish.sh    -> No workspaces found

Remove build-and-test.yml, e2e-base.yml and lint.yml. These are upstream
create-react-app CI: they exercise packages/ through lerna, verdaccio and
puppeteer on Node 16. Restoring the old root package.json would not fix them
either -- it runs `prettier --list-different` and `eslint .` across the whole
repo, now including the uploaded public_html/ tree and my-app/, so it trades
four failures for a larger set.

Retarget node.js.yml at my-app, the app this repo actively maintains, running
install, lint and build. Drop the 18.x matrix leg: Vite 8 requires
^20.19.0 || >=22.12.0, so that leg could never pass.

This also closes a real gap. No workflow covered my-app on pull requests --
deploy-my-app.yml only runs on main -- so its code was never built or linted
before reaching the default branch.

packages/, docusaurus/, tasks/ and test/ are left untouched; only CI changes.

Verified by running the workflow's exact steps -- npm ci, npm run lint,
npm run build -- from my-app on Node 22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Removes the inherited upstream create-react-app CI (build-and-test.yml,
e2e-base.yml, lint.yml), which exercised packages/ through lerna, verdaccio and
puppeteer on Node 16 and could not pass after commit dc079b1 replaced the
monorepo root package.json with a create-react-app app's.

Retargets node.js.yml at my-app, running install, lint and build on Node 20.x
and 22.x. The 18.x leg is dropped because Vite 8 requires ^20.19.0 || >=22.12.0.

This also gives my-app its first pull request coverage; deploy-my-app.yml only
runs on main, so its code was previously never built or linted before reaching
the default branch.
This repository forked create-react-app and carried its entire monorepo, none
of which was ever developed here. Commit dc079b1 replaced the root package.json
with an app's, dropping the workspaces field, so the monorepo had already
stopped being buildable; PR #4 then removed the CI that exercised it. What
remained was 116k lines of upstream source that nothing builds, tests, or ships.

Removed:

  packages/       334 files, 7.7M  upstream CRA packages
  docusaurus/      59 files, 508K  upstream documentation site
  test/            78 files, 456K  upstream integration tests
  tasks/           15 files,  88K  upstream release and e2e scripts
  CHANGELOG*.md     6 files        upstream release history
  CONTRIBUTING.md                  upstream contributor guide
  SECURITY.md                      Create React App disclosure policy
  screencast*.svg                  upstream README assets

Also removed, because each only configured the directories above and now
resolves to nothing:

  lerna.json                       monorepo config, no packages left
  netlify.toml                     builds docusaurus/website
  .eslintrc.json, .eslintignore    scoped to packages/, test/, docusaurus/
  .prettierrc, .prettierignore     scoped to packages/, test/
  .alexrc, .alexignore             alex prose linter, upstream prose only
  .github/CODEOWNERS               assigns packages/ and docusaurus/ to
                                   upstream maintainers who do not work here
  .github/FUNDING.yml              upstream funding links
  .github/ISSUE_TEMPLATE/          templates for reporting CRA bugs
  .github/lock.yml, stale.yml      upstream issue-bot config

LICENSE is kept: the fork lineage is real and removing a license is not a
cleanup decision. CODE_OF_CONDUCT.md and the pull request template are generic
and still apply.

README.md described a create-react-app app at the repo root via `npm start`.
No such app exists -- the root has no src/ or public/ -- so it was boilerplate
for something that could not run. Rewritten to describe what is actually here:
my-app and public_html, how each is built and deployed, and this history.

Nothing in my-app/ or public_html/ referenced any removed path. Verified with
the CI steps -- npm ci, npm run lint, npm run build -- from my-app, and dist/
still contains CNAME, index.html and the hashed bundles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Removes ~116,000 lines of upstream create-react-app source that nothing in this
repository builds, tests, or ships: packages/, docusaurus/, test/, tasks/, the
changelogs, CONTRIBUTING.md, SECURITY.md and the screencast assets, along with
the configs that only scoped to those directories -- lerna.json, netlify.toml,
the eslint/prettier/alex configs, and the upstream .github meta files.

LICENSE is kept; the fork lineage is real. CODE_OF_CONDUCT.md and the pull
request template are generic and still apply.

README.md described a create-react-app app at the repo root that does not
exist, since the root has no src/ or public/. Rewritten to describe my-app and
public_html, how each is built and deployed, and this history.

Verified with the CI steps from my-app -- npm ci, npm run lint, npm run build --
and by grepping the tree for references to every removed path.
Replaces the Vite starter with an actual site for the business: hero, filterable
fleet, how-it-works, and an enquiry form that posts to the live admin API.

The old public site's theme is not in this repository. asset-manifest.json
references /static/css/main.0700b630.css and /static/js/main.b3748527.js, and
neither file was ever committed. What did survive is the brand token block in
public_html/admin.niteshacars.in/admin/admin.css, whose comments name the
colours outright -- navy #0A0E20, gold #F5A500, Poppins, 14px radius, warm
#FAF8F4 ground. Those are carried into @theme in index.css, so the public site
and the admin panel now share one palette.

The fleet in src/data/cars.ts is placeholder data shaped to the real vehicles
and vehicle_rates tables: body type, fuel, transmission, seats, model year,
daily/weekly/monthly rates, KM limit, extra-KM rate and deposit. Editing that
file is all it takes to put the real cars up.

The enquiry form is wired to the real endpoint rather than mocked.
api/enquiry-submit.php needs no sign-in and config.sample.php already
allowlists https://niteshacars.in for CORS, so submissions land in the admin
panel. It sends the honeypot field the endpoint expects, surfaces per-field
errors from the API, and shows the returned enquiry number on success.

vehicle_id is deliberately not sent. The endpoint validates it against the
vehicles table and the placeholder ids do not exist there, so sending one would
be rejected; the chosen car goes in `requirements` as free text instead. Once
cars.ts carries real ids the vehicle can be linked properly.

Live inventory is not fetched because api/vehicles.php calls
api_guard('vehicle.view'), which a public page cannot satisfy.

Images reuse the two web-sized files already in public_html: background
car.webp (8.4K) and brezza1.avif (27K). The two PNGs there are 876K and 3.5M,
too heavy to ship without processing.

Verified with npm ci, npm run lint and npm run build, then driven in a real
browser: six cards render, the SUV filter narrows to two, and clicking a card's
enquire button preselects that car in the form. The only console error is the
Google Fonts request, which this sandbox's proxy blocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Replaces the Vite starter with a site for the business: hero, filterable fleet,
how-it-works, and an enquiry form posting to the live admin API.

The theme is rebuilt from the brand tokens in the admin panel's admin.css --
navy #0A0E20, gold #F5A500, Poppins, 14px radius -- because the old public
site's compiled CSS and JS were never committed to this repository.

The fleet in src/data/cars.ts is placeholder data shaped to the real vehicles
and vehicle_rates tables; editing that file puts the real cars up. Live
inventory cannot be fetched because api/vehicles.php requires authentication.

Verified with lint and build on Node 20 and 22, and driven in a browser.
public_html/admin.niteshacars.in/admin/config.php was committed with real
values: a database name, user and password that are in use. This repository is
a fork of facebook/create-react-app and is public, so those credentials are
readable by anyone who finds it.

Untracks the file and adds it to .gitignore. config.sample.php is the template
to copy on a new server, and its values were checked against the live ones --
they differ, so the sample leaks nothing.

The working copy is left in place; `git rm --cached` only stops tracking. The
copy on the server is untouched, and the panel keeps running.

This does NOT remove the file from git history. Every commit that carried it
still does, so the credentials remain readable to anyone who clones. Rotating
the database password is what actually closes this; that has to happen in the
hosting panel.

Scanned the rest of public_html for other committed secrets. Only two other
files matched a credential-shaped pattern, and neither is one:
config.sample.php holds example values, and tools/test-auth.php uses a fixed
throwaway password for a test user it creates and deletes in the same run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
config.php was committed with a real database name, user and password. This
repository is public, so those values were readable by anyone who found it.

Untracks the file and adds it to .gitignore. The working copy and the server's
copy are both untouched, so the admin panel keeps running.

This does not remove the file from git history; rotating the database password
in the hosting panel is what actually closes the exposure.
Replaces the placeholders the site shipped with -- +91 90000 00000 and
hello@niteshacars.in -- which were visible to customers on the live domain.

Four places: the header's Call us button, and the footer's phone and email,
each as both the visible text and the tel:/mailto: href.

The tel: href carries no spaces (+916374942976) so phone dialers parse it,
while the visible text keeps the readable +91 63749 42976 grouping.

Verified in a browser: all four resolve to the new values, and a grep over
my-app confirms no placeholder survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Replaces the placeholders the site shipped with -- +91 90000 00000 and
hello@niteshacars.in -- which were visible to customers on the live domain.

Four places: the header's Call us button, and the footer's phone and email,
each as both the visible text and the tel:/mailto: href.

The tel: href carries no spaces so phone dialers parse it, while the visible
text keeps the readable +91 63749 42976 grouping.

Verified in a browser, and by grepping my-app for surviving placeholders.
The site shipped with six invented vehicles -- a Brezza, Swift, City, Innova,
Baleno and Nexon EV -- with invented rates, KM limits and deposits. On a live
domain that is worse than showing nothing: a customer could read a price for a
car that does not exist and call about it.

cars.ts is now an empty array with a commented template for one entry. Adding
the real fleet is filling it back in; nothing else has to change.

The three places that read the array each handle it being empty, so the page
stays coherent rather than rendering a blank grid:

  Fleet     shows a card inviting an enquiry or a call, and hides the
            body-type filters, which would otherwise be a row of controls
            that filter nothing
  Enquiry   hides the car picker, and relabels the free-text field to
            "What kind of car do you need?" so the visitor can say what the
            dropdown would have asked
  Header    unchanged; it never read the fleet

Verified in a browser: no placeholder name appears anywhere on the page, zero
cards and zero filters render, the empty-state card shows, the car picker is
gone, the enquiry form still works, and there are no page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Replaces the single scrolling page with six routes matching the old site's
menu: Home, About Us, Our Cars, Tariff, Blog, Contact, plus a 404.

  /          Home      hero, how it works, a closing call to action
  /about     About Us  what hiring involves; deliberately generic copy
  /cars      Our Cars  the fleet listing
  /tariff    Tariff    a rate table built from the same fleet data
  /blog      Blog      post list, currently empty
  /contact   Contact   phone and email cards, and the enquiry form
  *          404       a real page rather than a blank screen

Uses react-router-dom. The nav marks the current page with a gold dot above
it, matching the menu on the old site, and collapses to a button-toggled list
below the lg breakpoint so the six items do not crowd a phone.

Two components were written for one page and had to be rewired. Fleet took an
onEnquire callback to scroll down to the form; it now links to
/contact?car=<name>, and Enquiry reads that query parameter, so choosing a car
survives the navigation between pages. Neither takes props any more.

Tariff and Blog handle being empty the same way the fleet listing does: a card
that invites a call or an enquiry, rather than an empty table or an empty list.
About and Blog carry TODO comments -- their copy states only what the booking
flow actually does, because inventing a founding year, a fleet size or posts
would put untrue claims on a live site.

scripts/spa-fallback.mjs copies dist/index.html to dist/404.html after every
build. GitHub Pages serves static files and knows nothing about client-side
routes, so without it a direct visit to /about, or a refresh on /cars, returns
Pages' own 404 and the app never boots. Pages does serve 404.html for anything
it cannot match, so an exact copy loads the app and the router resolves the
URL. Copied rather than symlinked because the Pages artifact upload does not
follow symlinks, and written in Node so the build still works on Windows.

ScrollToTop resets scroll on navigation, since a client-side route change
otherwise keeps the previous scroll position and opens a page halfway down.
Hash links are left alone so #anchors still work.

Verified in a browser: all seven routes render the right heading, clicking a
nav item changes the URL, /contact?car= is handled with the fleet empty, the
enquiry form is present, the mobile menu opens with all seven links, and no
page errors are raised. 404.html is byte-identical to index.html.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
)

Two changes shipped together.

The six invented vehicles are gone. cars.ts is now an empty array with a
commented template; everything reading it handles being empty, so no page
renders a hole. Adding the real fleet is filling the array back in.

The single scrolling page becomes six routes matching the old site's menu --
Home, About Us, Our Cars, Tariff, Blog, Contact -- plus a 404 page. The nav
marks the current page with a gold dot and collapses to a toggle on phones.

Fleet and Enquiry were written for one page; a car chosen on the fleet page
now travels as /contact?car=<name> so the choice survives the navigation.

scripts/spa-fallback.mjs copies dist/index.html to dist/404.html after every
build. GitHub Pages knows nothing about client-side routes, so without it a
direct visit to /about or a refresh on /cars returns Pages' own 404 and the
app never boots.

About and Blog carry TODO comments: their copy states only what the booking
flow actually does, rather than inventing a founding year or posts.
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
api/public-vehicles.php returns Available vehicles with rates from the dated
rate card, so adding a car in the admin panel puts it on the site. It omits
registration numbers, current KM and internal columns, and skips vehicles with
no rate card rather than listing a blank price. CORS reuses the existing
public_site_origin allowlist. The site falls back to an empty cars.ts when the
request fails, so it degrades rather than erroring.

For SEO, every route was served the same index.html carrying the home page's
title. Google runs JavaScript, but WhatsApp, Facebook and X do not -- sharing
/tariff previewed as the home page. The build now writes a directory per route
with its own title, description, canonical and og: tags, plus sitemap.xml,
robots.txt and AutoRental structured data.

The endpoint still has to be uploaded to the server; the repo is not deployed
to Hostinger.
The metadata described the service generically, so it competed with every
self-drive rental in India and matched none of the searches that actually
convert. Someone looking for a car does not search "self-drive car hire" --
they search "car rental Nagercoil".

Titles and descriptions now name the district and its towns, kept inside the
lengths search results truncate at: every title is 42-53 characters against a
~60 limit, and every description 107-150 against ~155, so none is cut mid
sentence.

The structured data declares the service area rather than only the business:
areaServed is Kanyakumari district within Tamil Nadu, and the address carries
Nagercoil and Tamil Nadu. No street address or coordinates are invented --
those have to be real to be worth anything, and a wrong one is worse than none.

Local ranking also reads the visible page, not just the head. AreasServed
lists the twelve main towns as text on the home and contact pages, so a search
for "car rental Marthandam" lands on a page that says Marthandam. The hero now
names the district and four largest towns in its opening paragraph.

Verified in a browser: all six towns checked appear on the home page, the
title is the district one, and the JSON-LD parses with areaServed "Kanyakumari
district", locality Nagercoil, region Tamil Nadu. No page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Search Console shows the site at position 3.2 for its main query, with clicks
down 47% over 28 days. The indexed listing explains part of why: it advertises
"NiteSha Cars & Bikes ... premium cars, wedding rentals, and tourist vehicle
services", and the rebuilt site mentioned none of that. Replacing a page that
ranks with one covering less of what the business does is how a ranking is
lost, and the click drop may already be that.

Three services now have their own pages, because they are separate searches
and a page can only rank for what it is about. Somebody hiring a scooter is
not the person booking a wedding car.

  /bikes             two-wheelers, hourly to weekly
  /wedding-cars      decorated cars, reserved dates, vehicles for the family
  /tourist-vehicles  cars and vans with a driver, sightseeing and temple tours

The brand was wrong throughout: the site said "Nitesha Cars", the indexed
listing and the business are "NiteSha Cars & Bikes". Inconsistent naming costs
local ranking directly, so it now comes from seo.json wherever it appears.

Titles now lead with Nagercoil rather than Kanyakumari. The district name is
what people add after the town, not before it, and the competitor ranking
above us leads with Nagercoil too. All nine titles are 40-51 characters and
all nine descriptions 107-152, inside where results truncate.

The home h1 was "Take the wheel. We'll handle the rest." -- the strongest
on-page heading, carrying no keyword at all. It now reads "Self drive car &
bike rental in Nagercoil and across Kanyakumari district", with the slogan
kept as a tagline beneath it.

Nine nav items do not fit a desktop row, so the four services sit behind one
Services trigger, which keeps the six-item menu shape the old site had. Each
service keeps its own route. The mobile menu lists all nine flat.

Menus now close from the click that navigates rather than an effect watching
the path, which oxlint flagged as cascading an extra render.

Verified in a browser: all nine routes render their own h1, the brand appears
in the header, the dropdown holds four items and closes after navigating, and
the home page links to each service two or three times over -- nav, card grid
and footer. No page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Motion throughout: the hero staggers in, sections and cards fade up as they
are scrolled to, each route change fades the new page in, menus drop open, and
cards lift on hover.

Built on CSS keyframes and one IntersectionObserver rather than a motion
library. Framer Motion is around 34 kB gzipped; this is 0.43 kB, measured
against the previous build. That matters here specifically: the site competes
on local search, Core Web Vitals feed that, and most visitors arrive on a
mid-range phone over mobile data.

Only opacity and transform are animated. Both are composited, so no frame
triggers layout or paint -- animating height, top or margin is what makes a
site judder on the hardware most people actually have. translate3d and
scale3d keep the work on the GPU.

The reveal uses an IntersectionObserver, not a scroll listener: intersections
are reported off the main thread, where a scroll handler would run on every
frame of every scroll. Each element unobserves after firing, so content
animates once rather than re-animating whenever it passes the viewport again.

Route transitions need main to be keyed on the pathname. Without the key React
reuses the DOM node, no mount happens, and the animation never replays.

prefers-reduced-motion is handled in both directions. The whole motion block
is inside a no-preference query, and a reduce query resets [data-reveal] to
full opacity -- without that second rule the reveal would leave every section
permanently invisible for anyone who has asked their system for less motion,
which is worse than having no animation at all.

AreasServed is rewritten rather than patched: wrapping its list items left the
JSX unbalanced, and the structure reads better with the chip as a span inside
the revealed li.

Verified in a browser: a below-fold section measures opacity 0 before scroll
and 1 after, the hero is already at full opacity on load, an animationstart
event for fade-up fires on main at every route change, and under reduced
motion zero elements are left invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Kanyakumari district sends a lot of people to the Gulf, Singapore and
Malaysia, and they come back for weddings, the December holidays and family
occasions. Their problems are not a local customer's: they book months ahead
from another country, land at an airport in a different state, and usually
need a vehicle for weeks rather than days. None of that was addressed
anywhere on the site.

/nri covers the things they actually ask about -- arranging a vehicle before
flying, being met at Trivandrum, which licence works, and dropping the car
back on the way out. Trivandrum is named specifically because it is the
nearest international airport to Nagercoil and where most Gulf and Singapore
flights land, and because "car rental Trivandrum airport Nagercoil" is a
search somebody makes.

WhatsApp is the primary call to action on this page, per the owner. It costs
nothing from abroad and survives the time difference, where a phone call to
India from the Gulf does neither. The link is wa.me with the country code and
no punctuation, which is what the format requires -- a space or a leading plus
fails silently. It carries a prefilled message with blanks for arrival and
return dates, so the first message already has the information we need.

The two offers the owner chose, a long-stay discount and airport pickup, are
described without figures. No percentage, no rate, no "free". They have not
set the terms, and a number on a live page is a promise a customer can hold
them to; inventing one would commit their money. The copy says the daily rate
comes down for longer hires and to ask for the rate, which is true and still
converts. A test asserts no percentage or rupee figure appears on the page.

The licence guidance is the part most worth getting right, since being turned
away at the counter after a night flight is the failure people fear: an Indian
licence works if unexpired, a foreign one needs an International Driving
Permit alongside, passport and visa or OCI as photo ID, and send a photo ahead
so it is confirmed before travelling rather than on arrival.

The services grid now holds five cards and moves to a 3-then-5 column layout
so none is orphaned on its own row.

Verified in a browser: the page renders with its own title, the WhatsApp link
resolves to wa.me/916374942976 with the prefilled text and opens in a new tab
with noopener, Trivandrum, the IDP rule, long-stay and weddings are all
mentioned, no invented figure appears anywhere, and the home page links to it
twice. No page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
"Monthly car rental Nagercoil" is a different search from "self drive car
rental Nagercoil", and the competitor ranking above us has a page for it while
we had nothing. A page can only rank for what it is about.

Their version is a section: a paragraph, four bullets on what affects the
rate, and a button. This goes further, because the way to outrank a thin page
is to answer what it leaves out. Six questions somebody actually has before
handing over a month of hire -- how the KM allowance works across a month
rather than a day, who services the car while they have it, what happens on a
breakdown, whether they can extend, what deposit is held, what to bring -- and
four audiences, since a family back from the Gulf and someone on a work
posting arrive with different worries.

Their page does confirm one thing: it quotes no price either, only what
affects the rate. So the no-figures approach is not a handicap against the
site currently ranking first. This page lists five factors and offers a
same-day quote.

Both spellings of the vehicle class are present. The page said "seven-seater",
but the search is typed "7 seater" -- the numeral now appears here and on the
fleet page, where somebody looking for one would land.

Monthly is linked from the NRI page's long-stay step, which is the natural
path: a visitor reading about staying for weeks is the person who wants this.

Verified in a browser: the page renders with its own h1 and title, mentions
7 seater, NRI, work postings, extra-KM, deposits and servicing, carries six
Q&A entries, and contains no invented figure. All eleven routes still render
an h1. No page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Monthly hire, NRI visitors and weddings were reachable only through the nav or
a small card in the services grid, where they read as four equal options. They
are not equal: a month-long hire, a family flying in for a season and a wedding
booking are each worth many times a weekend rental, and they are the bookings
worth putting in front of someone before they leave the page.

Each is now a band on navy rather than the page's sand, so it reads as an
offer block instead of more body copy. The layout follows what works on the
competitor's monthly section -- icon, heading, a gold line naming the
question in the visitor's head, a paragraph, and a panel of four checked
points ending in a call to action -- because it is a good pattern, not because
it is theirs. Ours carries three of these where they have one.

Still no figures anywhere in the band. The points say what decides the rate,
not what the rate is; a test asserts no percentage or rupee figure appears.

Verified in a browser: three blocks render, each call to action points at its
own route and navigates, the band contains no invented price, and at 390px
the blocks stack with no horizontal overflow. No page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The layout pattern only: two overlapping photographs with a circular badge
across them, and beside it a pill label, a heading, a paragraph and numbered
accordion rows. The content is ours. Nobody's founder story is borrowed, and
nothing is claimed about the fleet that has not been established.

The three rows say what is actually true and worth knowing before hiring:
the rate, KM allowance, extra-KM rate and deposit are all stated before the
vehicle is handed over; we cover the whole district including airport
delivery; and cars, bikes, wedding vehicles and drivers come from one place
rather than four.

The circular badge is an SVG textPath around a circle, not letters rotated
individually with transforms -- that approach drifts at different font sizes
and falls apart when the font falls back. Only the svg spins, so the arrow in
the middle stays upright, and it stops under prefers-reduced-motion.

The accordion is buttons with aria-expanded rather than details and summary,
because only one row should be open at a time and native details has no notion
of a group. Clicking an open row closes it, so the section can be collapsed
entirely.

Reuses the two web-sized images already in the project. No new assets: the
remaining photographs in public_html are 876K and 3.5M, too heavy to ship.

Verified in a browser: three rows, the first open at load, opening the second
closes the first, its panel becomes visible, clicking again closes it, the
badge links to /contact, both images render, and at 390px there is no
horizontal overflow. No page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
claude and others added 30 commits September 18, 2026 06:10
**The grid was ragged because of a control nobody chose the width of.** Each
slot carried a bare <input type="file">, which prints "Choose File — No file
chosen" at whatever width the browser feels like. With nineteen of them on one
page no two rows agreed where anything sat.

The input is still there and still the thing that opens the file chooser; it is
just no longer the thing anyone looks at. Every card is now the same width,
its buttons pinned to the bottom so they line up across a row whatever the
label above them did.

**Every image can be framed before it is uploaded, and re-framed afterwards.**
Drag to move, a slider and a pair of buttons to zoom. Whatever is inside the
frame is what the site shows -- rather than the browser cropping a photograph
of some other proportion and nobody knowing which part survived until the page
loads.

The frame is the shape the site actually lays that slot out in, and each is
saved at a size to match:

  logo, snake          1:1     512x512
  the page banners     16:5    1600x500
  home hero, open road 16:9    1600x900
  the why-us photos    4:3     1200x900
  cards and steps      16:10   1200x750

One crop for all of them would be wrong in both directions: a logo squeezed
into a letterbox loses its top and bottom, a banner squared off loses its
sides.

A slot with an image gets Replace, Edit and Remove; an empty one gets Choose
image. Edit re-frames what is already stored, which works because the panel
serves it from this same origin -- a cross-origin source would taint the canvas
and toBlob would throw.

The framed result goes into the hidden input through a DataTransfer, so this
stays an ordinary form post rather than a fetch with its own error handling.
Remove sets the action on that same form: a form cannot contain another, and a
second one beside it would be a second cell in the grid.

Also fixes a rule of mine from the mobile pass. `input[type="file"] { width:
100% }` outranked the visually-hidden class and stretched every hidden input to
the full width, pushing a 390px page out to 447. It now excludes the one input
that is not a visible control.

Verified: 28 cards, all one width, no native file control drawn, three to a row
on a desktop and one on a phone, nothing over 320px. Choosing a file opens a
frame measuring 1.00 for the logo and 3.20 for a banner; the zoom buttons move
100 to 140; saving puts a webp in the form and posts brand-upload; Edit reopens
the stored image and posts a new one; Remove confirms by name and posts
brand-clear with no file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Dropped into public/photos as home-hero.webp, which is all it takes: the build
manifest picks up whatever is in that folder, and the hero already reads the
home-hero slot. An upload in the panel still wins over it, so this is the
default rather than a fixture.

Converted from PNG to WebP, 423 KB down to 54 KB. It is the largest thing above
the fold and is fetched at high priority, so the size is the difference between
the page painting once and painting twice.

Kept at its own 383x801 rather than upscaled in the file: the browser stretches
it either way, and the extra bytes would buy no detail.

Worth knowing what that means on a desktop. The picture is a portrait and the
hero band is wide, so at 1280px it is stretched 3.3x across and only a slice of
it is in frame -- it reads as a dark texture at 25% opacity rather than as a
photograph. On a phone it fits almost exactly and reads as intended. A wider
original, or the same shot framed landscape, would carry the desktop view too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Site images: a grid that lines up, a frame you can crop and zoom, and a hero photograph
**The right-hand end of the footer is now Find us**: the address, a map of it,
and a directions link. The social icons move up under the business name, which
is where a reader looks for both anyway and leaves the last column free for
the map.

**The map is built here from a place name, not from a URL typed into the
panel.** An <iframe src> is the one field where a pasted address is genuinely
dangerous -- whatever it points at renders inside our page. Encoding a search
term into a URL this code constructs means the panel can only ever move the
pin, never change what is embedded. Leave the box empty and there is no map
rather than a frame pointing nowhere.

It is lazy, and proven to be: nothing is fetched until the footer is scrolled
to. A map at the bottom of the page should cost nothing to anyone who never
reaches it.

**Website content gains a Footer section** -- the line under the business name,
the heading above the map, the address one line at a time, where the map should
point, and the directions link text. The blurb was written into the component,
so the one paragraph describing the business could only be changed by a deploy.

Verified: four columns on a desktop at 84, 370, 656 and 942px and stacked on a
phone, the map 252x160 and 348x160 with loading="lazy", the address three lines,
one directions link, the social icons in the first column, and nothing
overflowing 390px. With the network stood in for, the frame is not fetched
before the footer is scrolled to and paints once it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
"The images I added are not on the site" has two causes that look identical
from a browser and need opposite fixes. Either the panel has not stored the
picture -- nothing uploaded, or site_images never created on this database --
or it has, and the copy of the website published on the server was built
before that slot existed and has nowhere to put it. Nothing anywhere said
which, so the only way through was to guess and check.

Everything in this repository is on the far side of that question already: the
panel returns every slot it holds, and the built site asks for every slot it
is given. Verified by serving the published build against a stub panel -- all
sixteen pictures on the home page, and every one on the seven other pages,
come from the panel when the panel answers. So the remaining failure lives
between the two, and it is a publishing step rather than a fault.

The site check now names it:

- public-content.php reports imagesReady separately from the picture list. An
  empty list means "nobody has uploaded anything" or "the table does not
  exist", and those send someone to two different places.

- publish-site.mjs writes build.json -- when the build was published, the
  commit it came from, and the hashed bundle names. A server holding an older
  publish says so instead of looking current.

- fleet-check.html asks both, loads each stored picture to see that the file
  is really there, and searches the site's own bundle for each slot name. A
  picture stored for a slot the running code has never heard of is exactly a
  stale deploy, and it is invisible from every other angle: the panel is
  right, the file is there, and the page keeps showing the drawing.

Checked against six servers -- healthy, no table, nothing uploaded, a stale
deploy, a missing file, and no build.json -- and each one reports its own
cause.

The Pictures section in the panel also stops describing itself as the logo and
the mark. It has covered every picture on the site since the slots were added,
and a heading that says otherwise is why someone would not think to look there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Footer map, a Footer section in the panel, and a check that says why an uploaded picture is missing
Three things, all on the top of the home page.

The uploaded background was invisible. Two separate dimmers sat on it: the
image itself at 25% opacity, and a navy gradient over that reaching 95% in the
middle. Multiplied, about one part in fifty of the picture survived -- so
uploading a background looked exactly like the upload not saving, which is
what it was reported as. The image is at full strength now and a single scrim
carries the legibility, the same weighted one the page banners use.

It needed its own variant rather than .page-scrim. Four of the hero's five
blocks are a left-hand column, and then the three points at the bottom run the
full width, so a scrim that clears on the right the way the banners do would
leave the last of those three on bare photograph. This one clears less far and
deepens towards the bottom where that row sits.

Measured rather than judged, against a pure white photograph -- the worst case,
and not a rare one, since a bright sky behind a car is the commonest thing an
owner uploads. Every piece of text: 4.98:1 at the tightest, across 1536, 1280,
768 and 390. Below 1024 it goes to even coverage, because there every line runs
the full width and there is nowhere the photograph can show without something
being read over it.

The band of empty navy between the menu and the first word is gone. The hero
had even padding top and bottom, which is right for a section in the middle of
a page and wrong for the one thing at the top that should be read without
scrolling. Tight above, unchanged below.

And the page is wider: 86rem rather than 72rem, with the side gutters growing
at sm and lg instead of staying at 20px. On a 1536 screen the content ran
1152px with 192px of nothing on each side. Seventeen containers, all the same
change, so the header, the sections and the footer still line up. No horizontal
overflow at 320, 390, 768, 1024, 1280, 1536 or 1920 on six routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Let the home page photograph show, and give the page its width back
…rives

The three highlights were full-width rows, each split into a description and
a bulleted panel. Three of them ran to about two and a half screens, and every
row left a column of empty navy beside whichever half had less to say -- which
is what the gap in the screenshot is. Side by side they are one screen, and
three offers next to each other can be compared, which is the only reason to
put three offers next to each other.

The inner panel goes with it: a bordered box inside a bordered box was what
made the emptiness look deliberate. The points sit under a gold hairline in
the same card now, and the button is pushed down with mt-auto so all three
line up however long the copy above them runs. They stagger in on scroll and
lift on hover -- .card-lift as the light cards already use, with a gold glow
instead of a drop shadow, because a dark shadow under a dark card is nothing.

--- and then the speed.

The page was served as an empty <div id="root"> with 365 KB of JavaScript
beside it. Nothing at all was on screen until that had been fetched, parsed
and run: three seconds on a mid-range phone on a rural connection, which is
most of this site's visitors, and everything a page is scored on happens
inside that window. Three changes, largest first.

The site is rendered to HTML at build time. vite build --ssr produces a server
bundle, and prerender-seo.mjs -- which already wrote each route's title and
description -- now also writes its markup. The script still loads and takes
over; it is what makes the fleet list and the enquiry form work. But the words
and the layout are in the file the server sends, so the page is readable while
it arrives. prerenderToNodeStream rather than renderToString, because the
routes suspend now and renderToString would bake the fallback into every page
but the home page.

main.tsx keeps createRoot rather than switching to hydrateRoot, deliberately.
The footer prints the current year and the date picker opens on today's month,
so a page built yesterday and read today would not match and React would tear
it down anyway, loudly. createRoot replaces the markup in the same frame it
paints, which costs one extra render and cannot be wrong.

Every route but the home page is now fetched when it is asked for. One bundle
held all fourteen, so someone landing on the home page downloaded the tariff
table, the blog, the places list and the enquiry form before the first word
appeared. Home stays eager: it is where most people arrive, and splitting it
would add a round trip before the thing they came for.

Poppins is served from this site instead of Google. A third-party stylesheet
is the slowest thing on a cold load -- the browser cannot paint until it has
it, and getting it costs a DNS lookup, a handshake and a round trip, then all
three again to fonts.gstatic.com for the files it names. The rupee sign was
its own tax: Google puts U+20B9 in the devanagari subset, so every page with a
price pulled 38 KB of Hindi per weight. Subset to that one glyph it is 570
bytes, and the four weights together are 2.3 KB instead of 155 KB.

The hero photograph is marked as the priority fetch, since it is what Google
measures this page's loading time by.

Measured on a throttled phone -- 390px, 1.6 Mbps, 150 ms latency, 4x CPU:

              first paint        largest paint
  /           3124 -> 1436 ms    3452 -> 2032 ms
  /cars       2784 -> 1108 ms    3048 -> 1440 ms
  /tariff     2760 -> 1112 ms    3024 -> 1448 ms

All thirteen routes checked after: no console errors, one header and one
footer each, no sideways scrolling. An upload in the panel still replaces the
committed photograph once the panel answers, and the committed one is what
goes into the HTML -- which is the right way round, since a crawler should not
wait on a request to /admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Three offers across, and a page that is readable before the script arrives
Every image on this site is stored above the document root and streamed back
by a small PHP reader, so the address a visitor sees is one this code makes
up. It made up photo.php?f=v12-9f3a1c4b7d2e5a60.jpg. On a business whose
traffic comes from searching for "self drive car rental Nagercoil", the file
name is one of the few things Google has to go on about a picture besides its
alt text and the page around it -- and that one said nothing at all.

They are named for their subject now:

  /admin/images/self-drive-car-rental-nagercoil-9f3a1c4b7d2e5a60.webp
  /admin/photos/maruti-swift-self-drive-car-rental-nagercoil-<hex>.jpg
  /admin/place-photos/vattakottai-fort-places-to-visit-kanyakumari-<hex>.jpg

The words come from the site, never from the uploader. Each site-image slot
has a phrase in SITE_IMAGE_KEYWORDS -- what is in the picture, not where it
goes, because "Cars page banner" is of no use to anyone searching. A vehicle's
photograph takes the car's own make and model; a place's takes the place's
name. The random suffix stays: it is what makes a replaced picture a new
address, which is what lets the reader cache for a year.

A path rather than a query string, so the words are in the address and not
only in a parameter. admin/.htaccess routes /admin/images/, /admin/photos/ and
/admin/place-photos/ to the three readers, which still answer ?f= as well --
every address stored before today keeps working, and so does every file name,
because the one pattern that replaces the three old ones matches v12-<hex>,
p3-<hex> and bcars-hero-<hex> too. No migration, nothing to re-upload.

The pattern is the security, not the rewrite. Lowercase words joined by single
hyphens, sixteen hex, one extension: there is no way to write `..` or a
separator in that. Checked both ways round -- ten kinds of hostile input
reduced to harmless words (../../nitesha-config/config becomes
nitesha-config-config), and twelve shapes that must never be answered,
including name-<hex>.jpg.php and a traversal appended to a valid name.

Two things found while testing it.

admin/src/ had no deny rule, so its files were reachable over HTTP. Nothing
leaks today -- every one of them only defines functions, so a request returns
an empty 200 -- but that is a property of each file continuing to be written
that way rather than a guarantee, and sql/ and tools/ were already denied.

And the readers now answer a conditional request with 304 instead of the file.
These addresses change whenever the picture does, so the year-long cache was
already right; what was missing was the ETag that lets a returning visitor
spend a few hundred bytes instead of a few hundred kilobytes.

Alt text, which is the other half of this. The six service cards and the three
steps passed none, so an uploaded photograph of a wedding car was announced to
nobody and read by nothing. They pass the card's own title now. The drawings
stay decorative, which is still correct -- SectionArt already makes that call.

The site check tells the two new failure modes apart. If a picture will not
load at its new address it tries the old one: if that works, the file is fine
and the rewrite is not, and the page says so and names the remedy rather than
reporting a missing picture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Name every uploaded picture after what is in it
Two things: the site got a lot faster, and the block of structured data every
page carries now describes an actual business rather than a name and a phone
number.

--- speed

Nothing was compressed. That did not matter much while every page was an empty
<div id="root">; it matters a great deal now that each one carries its own
markup, because the home page went from 3 KB to 74 KB in the process. Gzipped
it is 13 KB. Brotli first where the host has it, gzip behind it, and images
and fonts deliberately left alone -- WebP, AVIF, JPEG and WOFF2 are already
compressed and running them through again spends CPU to make the file very
slightly larger.

Measured on a throttled phone -- 390px, 1.6 Mbps, 150 ms latency, 4x CPU:

              first paint       largest paint
  /           1484 -> 692 ms    2124 -> 1344 ms
  /cars       1108 -> 652 ms    1356 ->  908 ms
  /tariff     1108 -> 640 ms    1380 ->  916 ms

Against where this started two changes ago, the home page's first paint has
gone from 3124 ms to 692 ms.

One thing removed rather than added. The first version of this preloaded each
page's banner photograph by hand, and that was a second download of a file the
page already had: React emits a preload for an image marked fetchPriority=high
as it renders, with the exact URL the <img> uses, and mine used the absolute
form of the same address. Found by reading the built HTML rather than by
trusting it.

--- what the structured data says

It said the name, a phone number, a locality and a district. It now says:

  the postal address, including the postcode, read out of the panel's own
    footer fields rather than typed a second time for the crawler -- the two
    disagreeing is exactly how a business loses Google's trust in both;
  opening hours, from a new field in the panel, in the form schema.org wants,
    shown in the footer as well when it is filled in;
  every town served as its own City, not just "Kanyakumari district". Someone
    searching for a car in Marthandam is searching for a town, and a district
    named on its own does not say the town is covered. Twelve of them;
  the social profiles, as sameAs, which is how a page claims an account rather
    than merely linking to one. Only the ones that are filled in;
  a stable @id, so thirteen pages describe one business rather than thirteen
    businesses that share a name;
  hasMap, pointing at the same place the footer's map does.

serviceArea is gone: areaServed now says the same thing town by town, and two
properties describing one thing with different precision is a pair to keep in
step for no gain.

Every inner page also carries a BreadcrumbList, so a result prints
"niteshacars.in > Wedding car rental" instead of a bare URL. The leaf is the
title cut at the business name or the place, because a breadcrumb is read at a
glance and the full title is not.

robots.txt stops sending crawlers into the panel -- a login page can only ever
rank for the business's own name and disappoint whoever clicks it -- while
still allowing the three image readers, because a photograph that cannot be
fetched cannot be in image search.

Checked: all thirteen routes render clean, the structured data parses on every
page, and exactly one preload for the banner rather than two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Compress what is served, and tell Google what this business is
Somebody in Marthandam looking for a car does not search for "Kanyakumari
district" -- they search for Marthandam. The site named twelve towns in a row
of chips on the home page and competed properly for none of them, because a
page that mentions a town in passing has nothing to say about it.

Twelve town pages now, and a hub above them at /car-rental. Each one says what
that town is, what the roads around it are like, what people actually hire
there, and how far the drives worth making from it are.

The risk in doing this is the opposite failure, and it is worse than not doing
it: twelve pages that are one page with the name swapped. Google calls those
doorway pages and treats them as a reason to trust the whole site less. So
every page is written from its own entry in towns.json rather than generated
from a template with a placeholder in it -- Colachel is about a fishing
harbour and a battle in 1741, Marthandam about the rubber belt and the airport
run, Padmanabhapuram about a palace that shuts for a long lunch and catches
people out. Measured rather than assumed: the two most similar pages share 63%
of their vocabulary, and most of that is the header, the nav and the footer
that every page of the site carries.

Distances are approximate road distances and are in the data file, so they are
one edit each rather than a search through markup. They want checking against
what the owner actually drives -- that is the one part of this I cannot verify
from here, and it is flagged rather than left to be discovered.

Structure, because a set of pages nothing links to is a set of orphans:

  the town names on the home page are links now rather than chips that do
    nothing -- that section was the obvious place and it led nowhere;
  every town page links to the other eleven, so a crawler that finds one finds
    all of them;
  "Where we deliver" is in the footer, which puts the hub on every page of the
    site rather than only on the home page;
  all thirteen are in the sitemap, derived from towns.json by the same module
    the app reads, so adding a town cannot produce a page the sitemap does not
    list.

Each town page also carries a Service node naming that one town, pointing at
the business's @id rather than repeating it, and a three-step breadcrumb --
Home > Where we deliver > Marthandam. The leaf was "Self Drive Car Rental" on
all twelve at first: the rule that shortens a title by cutting at " in " threw
away the only word that distinguished them.

An unknown slug renders the 404 page rather than an empty frame with a name in
it, which is what a route matching anything would otherwise turn every typo
into.

readablePhone moved out of the footer into lib/phone.ts. The town pages print
the number too, and the second copy of a formatter is the one that goes out of
step with the first.

Checked: all twenty-six routes render with one h1, one header and one footer,
no sideways scrolling at 390px, and no console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Five boxes in the booking screens take a file: Payment Screenshot / Receipt,
Deposit Proof, Refund Proof, Pickup Photos, Return Photos. They have been
there since those screens were built and were wired to nothing at all. No code
read the input, there was no table to put a file in, and nothing anywhere
displayed one. Choosing a screenshot and pressing Save recorded the payment
and dropped the file on the floor, silently -- which from the outside is
indistinguishable from it having worked.

Three things were missing and all three are here now: a preview so you can see
what you picked, the upload itself, and somewhere on the booking for them to
appear afterwards.

  booking_files, one row per file. kind says which box it came from, and
    ref_id ties a payment screenshot to the payment it proves rather than to
    the booking in general -- four payments would otherwise leave four
    screenshots in a heap nobody can match up;
  api/booking-files.php takes several files in one request, because the pickup
    and return boxes accept several and five round trips over a phone
    connection is how some of them go missing. A file refused for being the
    wrong sort is reported without losing the others;
  the detail screen shows each kind under the section it belongs to, click to
    open full size, with a remove button on the corner of each.

The upload runs after the record it belongs to is saved, not with it. A
booking_files row points at a booking, and it also means a refused image never
costs someone the payment they just typed in: that is already saved, and the
file problem is reported on its own.

These are not like the other images on this site, and the reader is where that
shows. brand.php and photo.php are deliberately public -- a vehicle photograph
is an advertisement. A payment screenshot carries a customer's name, their
bank and an amount; a pickup photograph shows a registration plate. So
booking-file.php requires a signed-in user, sends Cache-Control: private,
no-store so nothing in the middle keeps a copy, and the names are random
rather than descriptive -- a file name should not read "advance-payment-10000".
A random name is not access control; the session is.

Verified rather than assumed. Signed out, the reader answers 302 and zero
bytes. Signed in, the image and no more. Signed in with a name that is not
ours -- a traversal, a .php, a .jpg.php, a short hex, an uppercase one -- 404
every time. In the panel: five inputs wired, two files chosen give two
previews with the right names, reopening the modal clears them, the detail
screen groups by kind and renders nothing for a kind with no files, and a
caption containing <img src=x onerror=...> comes out as text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Keep the screenshots the panel has always asked for
Thirty-nine figures changed. They were written from recollection and flagged
as such; these are checked against the towns' own Wikipedia articles where
those state a distance, and against the road figures the travel sites agree on
where they do not.

Two were badly wrong rather than slightly off:

  Kuzhithurai is 26 km from Nagercoil, not 35;
  Boothapandi to Tirunelveli is about 70 km, not 60 -- Nagercoil to Tirunelveli
    is 81 km by road, and Boothapandi is only 11 km up that road.

Azhagiapandipuram was out by nearly half: 15 km north of Nagercoil rather than
22. Colachel is 20 km, not 27. Marthandam is 26, not 30. Thuckalay 16, not 20.
Eraniel is further than stated rather than nearer -- 20 km, not 17.

The airport runs were both overstated, which on a page about hiring a car for
exactly that run is the number people would have checked: Marthandam to
Trivandrum airport is about 45 km rather than 55, and Kuzhithurai about 42
rather than 48.

Smaller corrections follow from the corrected bases: a trip between two towns
cannot be longer than going through Nagercoil when Nagercoil is on the way, and
several were. Marunthuvazh Malai is 11 km from Nagercoil and 5 from Suchindram,
both verified, and both were out.

One sentence had to go with them. Kuzhithurai's page opened "closer to
Thiruvananthapuram than to Nagercoil", which the corrected figures make false
-- 26 km against 42. A number that is wrong is checkable; a sentence that
sounds like local knowledge and is wrong is worse, because nobody checks it.
The point it was making, that the work up there points north, is made without
the claim.

Two notes were rewritten for the same reason: "the long way round the coast"
for a drive now routed through Nagercoil, and "under an hour and a half" for a
42 km run that takes about an hour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Check every distance on the town pages, and fix the wrong ones
An audit of the three panel pages -- every tab walked, the DOM checked for the
faults that make a control lie about what it does -- and the things it found.

Twenty-two duplicate element ids on Website content. Each section posts its own
form, so "heading" in one and "heading" in another are different fields with
the same name, which is right for the form and wrong for the document. The
consequence was not theoretical: <label for="f_f_heading_"> finds whichever
element the browser reaches first, so clicking the label above one section's
Heading box put the cursor in a different section's. Ids carry the section now.
Checked by clicking every label that reads "Heading" and seeing where the
focus lands: seven sections, seven correct fields.

A booking whose total did not match the rental amount above it. The screen said
Rental Amount 5,000 and Rental Amount Due 6,542 with nothing on it accounting
for the 1,542, because extra KM is charged in the total and shown three
sections further down. The Payments block adds up in the open now -- the
agreed rental, the extra KM with the count, any other charges or discount, and
then the amount due. The field at the top is "Agreed Rental", because two
things called Rental Amount that differ is the confusion itself.

A balance of -3,458. The minus is easy to miss on a phone and reads as money
owed when it is money to give back. It says "₹3,458 to return" now, and
"₹3,458 due" the other way, and "settled" at zero.

install.php and session-check.php were reachable over HTTP. Neither is a way
in -- the installer refuses to run once an account exists -- but both print
server paths, the PHP build and the cookie settings, which is nobody's
business. Denied in admin/.htaccess rather than deleted, because deleting them
is not the fix it sounds like: this host publishes by cloning the repository
into the web root, so a file removed by hand is back on the next deploy. The
rule says which two lines to comment out to install on a new host. That
correction is mine: I asked for them to be deleted from the server several
times, and it would not have held.

The panel's own thumbnails fall back to the older address. They go through
/admin/images/<name> now, which needs the rewrite rule, and the panel is
exactly where someone looks to see whether an upload worked -- a thumbnail
that depends on a server setting is the worst place for one to be missing. If
the new address fails, the image and the crop window both retry brand.php?f=
once.

The enquiry search box had a placeholder and no label. A placeholder vanishes
the moment anyone types and a screen reader need not announce it.

Also checked and left alone: every PHP file and every panel script parses, the
money tests pass 35 of 35, and the two remaining "unlabelled" fields the audit
flagged are wrapped in a label element, which the check does not see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Clear what was actually wrong in the panel
Three things asked for, and one found on the way that was quietly wrong.

--- somebody else's car

The business does two things and the schema described one. Most bookings are
our own car, where the whole rental is ours. Some are a car sourced from
another owner for a customer, and there the rental is mostly passed on and
what the business earns is a commission.

Recording those the same way overstated revenue by the owner's share every
time -- the kind of error that only shows up when the money does not match the
bank.

A car now says whose it is. Ours, or someone else's with the owner's name and
phone, and a "Temporary" tick for one brought in for a hire or two so it can
be found and retired rather than sitting in the fleet forever. Partner cars
carry a badge on the card, because whose car it is decides what a booking on
it earns and nobody should have to open a dialog to find out.

A booking on such a car asks for the commission -- and only such a booking
does. A box that does nothing on our own cars is a box somebody eventually
types into, and a commission entered there would quietly shrink the revenue
that booking reports. The detail screen then shows the three figures that
matter: what the customer pays, what we keep, what is payable to the owner.
The dashboard totals both.

--- extra KM was counted twice

Found while doing that. b.total already includes the extra-KM charge, and Net
Revenue then added extraKmRevenue on top of it, so every booking that ran over
its allowance was counted twice for the overage -- on the dashboard and in the
Revenue Report. The comment above it claimed the opposite.

The extra KM comes out of the rental figure now, so the two cards add up
rather than overlap, and both sums work from what the business earns rather
than from what the customer pays.

--- deleting a cancelled booking

Only cancelled ones, and only once no money is attached. A cancelled booking
still holding a payment or a deposit is not finished -- it is a refund waiting
to be made, and deleting it would take the record of the money owed with it.
The endpoint refuses and says how much is holding it.

Cancelling stays the ordinary ending. This is for the ones that should never
have existed: a test, a duplicate, a booking taken against the wrong car. The
charges, readings, attachments and the files on disk go with it; the audit
entry stays, so what a booking said is gone but that it existed and who
removed it is still readable.

--- part payments

These already worked -- Advance, Balance, Additional, Extra KM, as many as the
booking needs -- so what was missing was not the recording but the expecting.
A monthly hire paid half now and the rest in a few days had no date on it, and
the second half was remembered by whoever took the booking and nobody else.
There is an optional "Balance due by" on a booking now, shown beside the
balance while anything is still owed.

Checked: nineteen browser assertions over the owner fields, the commission
box, the brokered booking's figures, when Delete is offered, and the revenue
sums; fifteen more over the split itself, including a database that has not
run the migration yet, where every car reads as ours and every total is what
it was. The money tests still pass 35 of 35.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Commission on other owners' cars, deletable cancellations, part payments
Most of the nine sections asked for were already here -- payment history with
proofs, deposit collection and refund with deductions, pickup and return
readings, extra-KM maths, a seven-stage lifecycle. What was missing was the
evidence: the licence that was checked, the state the car left in, what came
back damaged, and what was charged for it. Those are what a dispute turns on,
and they were on somebody's phone or nowhere.

--- the customer

A WhatsApp number, because the one that answers a call is often not the one
that answers a message and WhatsApp is how this business talks. A licence
expiry, because a licence that runs out mid-hire is the owner's liability and
worth knowing before the keys move. An ID number. A customer type -- new,
returning, corporate. An estimated KM, which is useful before a hire for
quoting and after it for knowing whose estimates to trust.

--- documents

Driving licence, Aadhaar or ID, passport, and anything else. PDF as well as
photographs, because a licence usually arrives as a PDF -- and a PDF is
recognised by its first five bytes rather than by what the file was called, for
the same reason the images are.

Against the customer rather than the booking. A returning customer's licence is
the same licence, and asking for it again every hire is how a file ends up
attached to three bookings and missing from the fourth. Uploading a licence
replaces the one on file rather than stacking a fourth copy nobody can pick
between; "other" is the exception, being where everything else goes.

An expiry that has passed is marked, in red, on the booking.

These are the most private things the system holds -- a licence carries a name,
an address, a date of birth and a photograph -- so the reader requires a
signed-in user, sends no-store, and the file names carry nothing. Signed out it
answers 302 and zero bytes; a traversal, a .php and a .pdf.php all answer 404.

--- the handover check

Thirteen items on both legs: exterior, interior, tyres, spare, tools, papers in
the car, AC, lights, indicators, horn, mirrors, wipers, toolkit. Stored as JSON
on the reading rather than as thirteen columns, because the list changes -- a
business that starts hiring bikes wants a different one -- and a schema change
per item is how a checklist stops being maintained.

Unticked is stored as false rather than left out. "Not checked" and "checked
and wrong" are different things and only the first is what a blank should mean.

--- charges at return

Cleaning, fuel, late return, other -- each its own line with its own amount and
note, and each feeding the booking's total. Itemised rather than folded into
other_charges, because "₹1,150 of other charges" is not something a customer
accepts and not something anyone can explain a month later. An "other" charge
with no note is refused, and refusing it does not take the fuel charge down
with it.

Raised after the reading is saved, not with it, so a rejected charge never
costs someone the reading they just took.

--- damage

What was damaged, what it is thought to cost, which leg it was noticed at,
notes and photographs. Deliberately not money in itself: an estimate is not a
charge. Some damage is billed, some is held back from the deposit, some is
absorbed -- and a record that assumed the first would make the other two wrong.
A tick on the return form raises the charge; leaving it unticked records the
damage and bills nothing.

--- the lifecycle

Not changed, and this is the one place I did not do as asked. The statuses
already run Enquiry, Confirmed, Ready, Active, Returned, Completed, Cancelled.
The extra ones wanted -- Advance Paid, Payment Pending, Deposit Pending -- are
not stages a booking is moved through: they are facts about the money, true or
false on their own, and the panel already derives and shows them. Making them
statuses would mean somebody remembering to move a booking to "Payment
Pending", and a status nobody remembers to set is worse than none.

Checked: twenty-nine browser assertions over the new fields, the checklist
round trip, the charges going up line by line, the refusal that spares the
rest, and what the detail screen shows; the document validator against a real
multipart upload, where a PHP script renamed .pdf is refused and a genuine PDF
is not; the reader's session guard; and the earlier suites still passing -- 35
money tests, 15 commission-split tests, 19 feature tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The records a rental business is asked for when something goes wrong
A tick says "done". None of these lists are things that have been done: they
are what is included in a hire, what the monthly rate depends on, what to
bring, what we arrange for a wedding. A dot says "item", which is what they
are.

Thirty-one of them across the site. The three offer cards had a gold disc with
a check mark inside it, which was also the heaviest thing in a card whose job
is to be read; the hero, the monthly page, the NRI page and the service pages
had a bare gold check mark. All of them are the same bullet now, and it is the
same bullet the areas list and the town pages were already using -- there were
two bullets on this site and now there is one.

Sized and positioned in CSS rather than with a margin at each call site,
because the one thing a bullet has to get right is sitting on the optical
centre of the first line, and that depends on the line height beside it. The
hero's list is 14px text and the service pages' is 16px, and a fixed margin
cannot be right for both. lh gets it exactly, with an em fallback that is close
enough everywhere it is used. Measured: the dot's centre lands at 10px against
a first line whose centre is 10px.

The one check mark left is the "your enquiry was sent" mark on the contact
form. That one genuinely does mean done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Four of the eleven points were already built, so this is the other seven --
and of those, the ones with a deadline.

--- already there, checked rather than rebuilt

Double booking is already refused before saving, naming the booking that
clashes and its dates. The activity log already exists: every change is
audited and the booking shows its own timeline. An enquiry already converts
into a booking carrying the customer's details, and can be rejected. Reports
already export to CSV.

--- what a car needs, and when

Insurance, the pollution certificate and the fitness certificate all expire,
and a service falls due by distance or by date. Every one of those was
somebody remembering. They get remembered until the day one does not, and that
day the car is off the road or is out with a customer uninsured.

A vehicle now carries all five dates, the next-service distance, and the
tracker: provider, device id and a link. Per car rather than per fleet,
because the trackers are from different suppliers. A link rather than an API
key -- what is wanted today is the provider's own page for this car, and a key
stored here would be a credential in a database that does not need one.

Service history is its own record, separate from expenses. Expenses say money
left the business; this says what was done to a car and when the next one is
due -- the same event from the workshop rather than the ledger, and only one
of them can answer "what needs servicing this month". Saving a service moves
the car's next-due markers and its odometer, so the alert has one source
rather than two that can disagree. A next-service reading below the one just
taken is refused: it would make the car overdue the moment it was serviced.

--- needs attention

The dashboard had ten figures and no answer to "what do I do this morning". A
number says where the business stands; this says what is about to go wrong,
and only one of the two has a deadline.

Six kinds, overdue first: a paper about to expire or already expired, a
service due by date or distance, a car due back today or already late, money
still to collect where a date was agreed or the car is back, a deposit still
held on a finished booking, and a new enquiry. Each row says which record and
why in one sentence, with a button that opens it.

Urgency is a colour and a word, because colour alone is not a message to
everyone. Nothing to do hides the panel entirely: an empty "Needs attention"
heading every morning is how people stop reading the one that is not empty.

--- who this phone number belongs to

The number was already matched when a booking was saved -- silently, and too
late. Whoever was typing had re-keyed the address and the licence, and a
different spelling quietly kept whichever came first.

It is asked when the number is finished now. The details arrive, their
previous hires arrive with them, and only empty boxes are filled: a name
somebody has just corrected is left alone, or the lookup becomes a thing to
fight rather than a thing that helps.

--- still to do

The period filter on the dashboard, the fleet calendar, and splitting the
booking detail into tabs. Those three are real work and none of them has a
deadline attached, which is why they are after these.

Checked: twenty-eight browser assertions over the vehicle fields, the alert
strip, the lookup and the service modal; ten more over the alert rules
themselves, including a blank date being ignored rather than read as year
zero; and the earlier suites still green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
What needs doing this morning, and what keeps the fleet on the road
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants