MAIN - #17280
MAIN#17280niteeshkanna-sh wants to merge 111 commits into
Conversation
|
Hi @niteeshkanna-sh! Thank you for your pull request and welcome to our community. Action RequiredIn 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. ProcessIn 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 If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks! |
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
Move the admin panel to the repository root
The band has referenced /open-road.jpg since it was written and the file has never existed, so it has been falling back to flat navy. public_html already held a usable picture -- a gold car on a mountain road at sunset, which happens to sit almost exactly on the brand's navy and gold. Converted from a 3.5 MB PNG to a 166 KB WebP at 1920 wide. The band is full-bleed behind a scrim, so it never resolves finer than a wide desktop viewport and the rest of those bytes bought nothing anyone could see. Deliberately only here. It is a stylised car in snowy mountains, not a photograph of the fleet and not Kanyakumari, and this band is scene-setting rather than evidence. On a service card the same picture would read as "this is the vehicle you would get", which would be a claim rather than a mood. The comment in the component says so, so the next person does not reuse it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Fill the open-road band with a real image
The dashboard opened at 763px of content on a 900px-tall window, and every card was a fixed 5- or 4-column row, so 17 stat cards took 5 rows on desktop and 17 rows on a phone. - Panel heading and its description now sit on one line instead of stacked, at the top of each panel. - Stat grids use auto-fill minmax() instead of a pinned column count, so a wide screen packs more cards per row and a phone shows two rather than one. The 900px/560px breakpoints stop fighting that by pinning columns of their own and just lower the minimum card width. - Smaller cards: padding 18px -> 11px 13px, value 1.5rem -> 1.15rem, label 0.78rem -> 0.72rem, tighter gaps and margins. - Value pinned to the bottom of the card (margin-top: auto) so the numbers line up across a row when a label wraps to two lines. Measured on a static copy of the dashboard panel, viewport 900px tall: 763px -> 487px at 1440px wide, 763px -> 506px at 1280px wide. Also fixes a contrast failure this touched: .stat-label used --ink-faint, 3.35:1 on white, below the 4.5:1 WCAG AA floor for text that size. Since the label was getting smaller, it moves to --ink-dim at 7.54:1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Two things stop a car added in the panel from showing up on the site the way the owner expects. Cars not appearing at all ------------------------- The site asks /admin/api/public-vehicles.php for the fleet, and when that request fails for any reason it falls back to an empty list and shows "Ask us what's available". Six quite different faults all end there -- the panel not published at /admin, a missing config.php, wrong database credentials, PHP not executing, a car that is not Available, a car with no rate card -- and the page looks identical for all of them. The site already logs which one it hit, but it logs to the browser's developer console, which is not somewhere the person running the business will ever look. public/fleet-check.html asks the same question the site asks and says in plain English what came back and what to do about it, with the raw response when there is one worth seeing. It is a real file in the web root, so it is served even in the case where the whole /admin folder is missing -- which is exactly when a diagnostic living inside /admin would be useless. The Vehicles panel links to it. No picture on any car --------------------- The card markup had an image slot and nothing ever filled it: the public endpoint returns no image column, so every card rendered the brand's first letter in grey. Cards now resolve a photograph by filename -- car-maruti-suzuki-swift, then car-swift, then car-suv -- and fall back to the site's own drawing rather than a letter. Dropping a file into public/photos is all it takes; there is nothing to register. Four files named after the body types put a real photograph on every card at once. car-brezza.avif was already in the repository and is now in photos/, where it will attach itself to a Brezza in the fleet. Also fixes the build's content fetch, which still pointed at admin.niteshacars.in months after the panel moved. That host has no certificate, so the fetch failed on every build and shipped the defaults -- meaning nothing typed into "Website content" in the panel has been reaching the site either. It now uses the same origin the canonical URLs come from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Put a picture on every car, make a missing car diagnosable, compact the dashboard
…rawn logo The snake is drawn rather than a stock image: it sits on the navy header at about 48px, where a photograph of a snake is a brown smudge, and the gold is the site's own so it reads as part of the mark rather than a sticker on top of it. A coil is the one snake shape rotation flatters -- a straight snake spinning would read as a stick on a spindle. The body tapers, which a stroked arc cannot do because stroke-width is one number for the whole path, so it is built as a filled outline: down the outer edge, round the tail, back along the inner edge, with the head rotated onto the body's tangent where it ends rather than set at a fixed angle. The first version was drawn at a size nobody sees it at. At 36px the fine body and tight coil turned into something that looked like a loading spinner beside the logo, so the proportions are now set for the size it actually renders: a thicker body, a more open coil, and 48px rather than 36. It also sat alone in the gap before the navigation with nothing to belong to; it now sits next to the logo. It turns once every 18 seconds, slow enough to read as drift rather than as something loading, and it is hidden below 640px, where the row is already the logo, a call button and the menu. Continuous rotation is the kind of motion that makes some people nauseous and it never stops on its own, so prefers-reduced-motion turns it off. Verified both ways in a real browser: the transform advances about 20 degrees a second normally, and the animation computes to none with the setting on. The logo badge now follows the same rule the car photographs do: drop a file named logo into public/photos and it takes over the drawn NS badge on the next build, with nothing to edit here. A file named snake does the same for the snake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The deploy generated admin/config.php from DB_* secrets and uploaded it beside the panel. That was right when the panel's config could only live there. It is now a trap. The panel reads nitesha-config/config.php from above the document root, specifically so that a deploy cannot reach it. But config_path() checks beside the panel first, so an uploaded copy silently wins over the real one. A secret that had gone stale -- and the database was renamed since these were set -- would take the admin offline with "Database unavailable" on a deploy whose only intent was to change the website, while the working config sat there untouched and ignored. So the step is gone and config.php is excluded from the upload again. The deploy now has no way to affect the panel's database settings, which is the property the move above the document root was for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The guard that verifies the deploy target reported every curl failure as "could not list the FTP login directory, so the target is unverified". That reads like a network problem or a wrong path, so two runs went looking at the wrong thing -- the actual cause both times was curl exit 67, which means the server rejected the username and password. Now exit 67 gets its own message naming the cause and the three places to fix it. Everything else keeps the old wording, which is right for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
install.php wrote config.php to __DIR__ . '/config.php', beside the
panel. That is inside the document root, and the document root is rebuilt
from scratch on every deploy -- so the installer's own output was erased
by the next publish and the panel came back saying it had never been set
up. That is not hypothetical: it is what happened, and it took the admin
offline.
config() already looks for nitesha-config/config.php in each directory
above the panel, which is why the working config was moved there by hand.
The installer now writes to that same place, one level above the document
root, so an install survives a deploy without anyone having to know this.
Three cases, all exercised against a real directory layout:
nothing exists yet -> <account>/nitesha-config/config.php
a config already exists -> that one is reused, wherever it is, so a
working install never ends up with two
files and config() picking a coin flip
parent cannot be written -> falls back beside the panel, because a
panel that works until the next deploy
beats a panel that never starts
The environment check was reporting on the wrong directory -- it tested
the panel folder while the file was going somewhere else -- so it now
checks the directory that will actually be written, and says which one
that is and whether a deploy can reach it. The success screen names the
path, and warns when it landed inside the website folder.
storage_path moves above the document root for the same reason. Nothing
reads it today, which is exactly why it would have been found the hard
way later.
The "not set up yet" response is now a page rather than one line of plain
text on a 500. It is the first thing an owner sees after a deploy wipes
the config, and "Not set up yet" reads, at that moment, like the data is
gone. It now says which file is missing, shows the two paths that were
actually searched -- computed, so they cannot drift from the code -- says
plainly that the database is untouched, and links to the installer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
install() created the Super Admin account and only then wrote config.php. That breaks the one case this file is now most often opened for: a database that still holds every booking and account, and a config.php that a deploy erased. In that state create_user() fails on the duplicate email, install() returns early, and write_config() never runs -- so the panel is still unconfigured, still shows "not set up yet", and the installer sends you round the same loop with no way out. The data was never at risk, but there was no route back in. Now the config is written as soon as the database details are known to work, and an account count decides what happens next: rows already there means this is a reconnection rather than an installation, so no second Super Admin is made and the last screen says to sign in with the existing one. An empty database still gets the account it asked for, and if creating it fails the message says the settings were saved and a reload should let you in -- because by then it is true. Verified by extracting the call order from the parsed function with comments stripped: db_connect, migrate, write_config, the account count, create_user, with the only early return between the write and the account being the write failing itself. Checking it by eye first gave the wrong answer -- the regex matched the words create_user() and write_config() in the comment explaining the bug. The full path could not be run here: this sandbox has no MySQL server, so the installer's last four UI tests cannot pass and the reconnect branch is unexercised against a live database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Make the panel survive a deploy, and put a turning snake in the header
…lishes The site has been down with a 403 and the reason was never a wipe. Hostinger's Git deployment clones the repository and publishes the folder named in its "Root directory" setting. That setting is public_html, this repository has a folder called public_html, and it held five leftover images from the old site and no index.html. Apache had nothing to serve at the domain root and directory listings are off, so it answered 403. The deployment reported success every time, because it had done exactly what it was asked. Everything that did not add up follows from this. README.md 404s because it sits at the repository root, which is never published. The folder looked "empty" because five images and no index page is, to anyone looking for a website, empty. And Hostinger's deploy runs no build -- the deployment of the last merge took seven seconds -- so my-app/dist never existed on that server and nothing was ever going to appear there. So public_html is now the build output, committed. Carrying build output in a repository is not something to do lightly; it is the right answer when the host cannot build, because then the repository has to carry the built thing or the site does not exist. `npm run publish:site` rebuilds the folder from scratch -- from empty, so a deleted page actually leaves the server -- and refuses to write a tree missing index.html, .htaccess or admin/index.php, which are the three files whose absence takes the whole site or the whole panel down. The five old images move to assets-original/. They are the only copies, nothing references them, and anything left in public_html would now be served at the root of the domain and erased by the next publish. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Make public_html the built site, which is what Hostinger actually publishes
Hostinger publishes public_html/ verbatim and runs no build, so that folder is the website. A change to my-app/ or admin/ that does not also update it never reaches the server -- and nothing anywhere reports a problem. The deploy succeeds, the site keeps serving the previous build, and the only symptom is that the thing you changed is not there. That is the worst shape a failure can take, and it is one forgotten command away at all times. So this builds on every push and pull request and fails with the list of differing paths when the two do not match. It is deliberately read-only. Regenerating and committing the folder would mean a workflow that can push to main on its own, which is a far larger permission than catching a stale folder is worth; the fix stays a person running `npm run publish:site`. Checked both ways against the current tree: it passes as things stand, and appending one line to public_html/index.html makes it report "Files my-app/dist/index.html and public_html/index.html differ". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Vehicles added in the panel already reach the site the moment they are
saved -- the browser asks for them. The words did not. "Website content"
was read once, by the build, and baked into the HTML, so an edit sat
invisible until somebody happened to rebuild and publish. With the site
now published by committing a built folder, "somebody happens to
rebuild" is not a thing that occurs on its own.
That is the same silent failure as a car that never appears on the site:
the person did what the interface offered and nothing happened, with no
error to explain it.
Baking it was right for a reason that still holds. WhatsApp, Facebook and
most link unfurlers do not run JavaScript, and a site competing on local
search wants its words in the markup rather than behind a fetch. So the
baked copy stays and remains what is served; the browser then asks the
panel for anything newer and swaps it in. Crawlers and unfurlers get the
built copy, visitors get the current one. An edit therefore shows to
people immediately and to Google at the next publish, which is the right
way round.
The merge rules move to src/content/merge.mjs and are now imported by
both the build script and the browser. They decide which of the panel's
edits are safe to apply, and two copies would eventually disagree -- a
disagreement that shows up as the site displaying something the panel
never sanctioned.
Verified against a stub panel in a real browser:
a correctly shaped edit HTML as served keeps the built wording, and
the heading becomes the panel's after load
a malformed section ignored, shipped copy kept, and the console
names the nine missing fields
The first attempt at that test invented its own field names, so the merge
rejected it and the edit correctly did not appear -- which looked exactly
like the feature being broken. The shape has to come from defaults.json.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Show the panel's content edits live, and catch a stale public_html
Hostinger's Git deployment clones the whole repository into the document
root. "Root directory: public_html" is the destination on the server, not
a folder inside the repository. I read it the other way and spent several
rounds putting the site into a public_html/ folder here, which the deploy
then cloned to public_html/public_html/ -- one level too deep, no
index.html at the top, and a 403 every time. Both readings produce a 403,
which is why nothing distinguished them until a listing of the document
root showed .git, my-app, package.json and a nested public_html sitting
in it.
So the built site now sits at the top level of the repository, beside the
source, and public_html/ is gone. It is not a pretty tree. It is what
this host does, and the alternative was asking someone to change a
control panel setting every time the arrangement moved.
admin/ is both the panel's source and part of the build output -- the
build copies it verbatim, and the two are byte-identical -- so it stays
one directory rather than becoming two.
Because the source now shares the web root with the site, .htaccess has
to keep it unreachable. It already denied dotfiles, package.json and the
config files; it now also denies .github (which the .git rule misses,
needing a slash straight after "git" where this has "hub"), the my-app,
scripts and assets-original trees, every .md, and LICENSE. The .git rule
stops being a precaution here: the deploy puts .git in the document root
on every run by design, and it cannot be removed without giving up
automatic deploys, so those rules are the only thing between it and the
internet. The database password in that history still needs rotating.
publish-site.mjs replaces publish-public-html.mjs. It cannot empty the
target first -- the source lives there -- so it records what it wrote in
.site-manifest.json and removes those entries on the next run, which is
what stops a deleted page living at the top level for good.
Verified by serving the repository root the way Apache will:
/ 200, correct title
/cars/ 200
/fleet-check.html 200
/assets/index-*.js 200, 352540 bytes
/admin/install.php 200
/admin/ 500, and the body is "The panel cannot find its
configuration" -- correct, config.php is not in the
repository
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Put the site at the top level, where the deploy actually lands
That message means the CSRF check failed, and the CSRF check fails when
the token stored in the session on one request is not there on the next.
Which is almost never about tokens: it is the session not surviving
between the page loading and the form being submitted. Four quite
different faults produce it and they look identical from the outside,
which is exactly the shape of problem that has cost the most time here.
session-check.php does what signing in does -- stores a token, renders it
in a form, checks it when the form comes back -- and reports what
happened alongside the settings that decide it: whether the browser
returned a cookie, whether session storage is writable, whether the
cookie is marked Secure, and whether the visitor is actually on HTTPS.
It singles out the one combination that silently eats every session: a
cookie marked Secure served over plain HTTP, which the browser accepts
and then refuses to send back. That check outranks the round-trip result,
because browsers exempt localhost from the Secure rule -- a local copy
round-trips happily on settings that lose every session on a real domain.
Without that ordering the page reported success and named a fault in the
same breath, which is worse than reporting neither.
Two real mismatches found while reading the path, both in what install.php
writes:
https_only came from $_SERVER['HTTPS'] alone. This host terminates TLS
upstream, so PHP is handed a plain HTTP request for a visitor who
arrived over HTTPS -- the site's own .htaccess says exactly that about
%{HTTPS} and redirects on X-Forwarded-Proto instead. It now reads both,
and REQUEST_SCHEME as a third opinion. This is the value that decides
whether the session cookie carries Secure.
session_minutes was written; the panel reads session_idle_minutes. A
hardcoded fallback was doing the work, so changing the value in
config.php had no effect at all.
Neither is proven to be the cause of what is happening on the server --
that is what the new page is for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Say why the sign-in page reports "Session expired"
The Add Vehicle form had no way to attach a picture. Photographs only
worked by dropping a file into the repository under a filename matching
the car, which is no use to the person who actually knows which car is
which.
Pick a file, press Save, and it appears on the website's fleet card.
Where the file goes matters more than it looks. Uploads are written under
storage_path, above the document root, because this host deploys by
rebuilding the web root from the repository -- a photograph written
inside it would survive until the next push and no longer. config.php was
lost exactly that way, and a customer-facing photo vanishing during an
unrelated deploy would be the same bug in different clothes. Being
outside the web root also means Apache cannot serve it, so photo.php
reads and streams it: a PHP process per image, in exchange for nothing in
that directory ever being executed.
What decides whether an upload is an image is the file's own contents,
via getimagesize -- never its name or the type the browser claims, both
chosen by whoever is uploading. Verified over a real multipart POST:
a real PNG accepted as .png
a PHP script named car.jpg,
declared image/jpeg refused
The stored name is ours, never the uploader's, because a filename from a
browser can contain path separators. photo.php then only answers for names
matching the exact shape this generates, which is a better rule than
trying to enumerate the ways a path can escape a directory. Every attempt
returns 404:
../secret.txt, ../../nitesha-config/config.php, ..%2Fsecret.txt,
v7-../../../etc/passwd, a well-formed name for a file that does not
exist, and an empty name
The URL is built against the panel's root rather than used as returned.
The endpoint sends "photo.php?f=...", relative to the panel; in an <img>
that would resolve against the page instead, so the fleet page would ask
for /cars/photo.php and every photograph would 404 on some pages and work
on others. Confirmed in a browser: the card requests /admin/photo.php and
gets 200 image/png, and a vehicle with no photograph still falls back to
the drawing.
The upload is a second request after the save, because a photograph is
stored against a vehicle id and a vehicle being created has none until
the save returns one. The form hides that. Remove marks the photograph
for deletion but changes nothing until Save, so Remove then Cancel leaves
the vehicle as it was.
Not verified: anything touching the database. This sandbox has no MySQL,
so the migration, the UPDATE, and the audit entries are unexercised.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Add a photograph to a vehicle from the panel
The document root caches every .js and .css for a year as immutable. That is right for the site's build output, where a changed file is a changed filename because the name carries a content hash. It is wrong for the panel: admin.js, admin.css, api.js, car-data.js and booking-data.js have fixed names, so a browser that loaded the panel once would not ask for them again until 2027 -- and every change to the panel would reach nobody who had ever used it, with no error and nothing to notice. The vehicle photograph upload merged minutes ago would have been the first casualty. Two parts, because one alone does not finish the job. admin/.htaccess overrides the header for this folder, which fixes it for browsers that ask -- and a browser already holding the file will not ask. So the tags now carry the file's modification time: a changed file is a different URL, and the cached copy cannot match it. asset() lives in its own src/assets.php. It went into http.php first, which was wrong in a way worth recording: http.php is the JSON endpoints' plumbing, and no page that renders HTML loads it. dashboard.php, index.php and content.php all require csrf.php and nothing else, so asset() was defined precisely where it was never called from and every panel page would have died on an undefined function. Caught by loading the pages rather than by reading them -- php -l passes on a file that calls a function that does not exist. Verified: asset() returns admin.css?v=<mtime> and falls back to ?v=0 for a file that is not there; the require precedes the first call in all three pages; index.php reaches its database call, which is as far as anything gets here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Stop the panel's own scripts being cached for a year
Saving a vehicle with a photograph failed, and the reason was worse than the symptom: the column the photograph feature needs had never been created, on any installation. migrate() ran from exactly two places -- install.php, which refuses once an account exists, and a button on the content page that nobody has a reason to press. So a deploy could add a migration and nothing would ever apply it. The code shipped and the database did not, and the first sign was a feature failing with a SQL error. It now runs on dashboard load. migrate() already records what it has applied and skips those, so the cost is one small SELECT per load. A failure is reported in a banner rather than thrown: a migration that cannot run is worth knowing about, and is not a reason to refuse to show a panel that otherwise works. The public fleet endpoint had the same problem from the other side, and it mattered more. It selects a narrow list of columns by name, and one of those was the new one -- so between a deploy and an admin next signing in, niteshacars.in/cars would have been asking for a column that did not exist. A change made entirely inside the panel could empty the fleet on the live site, for visitors, with nobody signed in to notice. It now names the column only when it is there and selects NULL otherwise, so the site does not depend on anyone having opened the admin. Both forms of the query were checked for validity. The upload endpoint says what to do rather than returning a SQL error, in the window where the column is genuinely missing. Still unverified: the migration actually applying. There is no MySQL here. What was checked is that the file parses to exactly the one ALTER statement intended, and that every new function is defined by the require chain the pages use -- the last change defined a helper in a file no page loads, which php -l cannot catch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Run pending migrations when the panel loads
The photograph upload told the user to "open the Dashboard tab once and try again". They did, and it could not have worked: the panel switches tabs in JavaScript without loading the page, so the migration that runs on page load never ran. An instruction that only works if you know that is not an instruction, it is a trap. That message was mine and it was wrong. So the schema is brought up to date in api_guard instead. Every endpoint in the panel goes through it, which means a deploy that adds a column has it created by the first action taken after that deploy, whatever the action is and whichever page the person is on. Nothing to be told, and nothing to remember. migrate() is cheap but not free, and the panel makes several API calls per page, so migrate_if_needed() decides whether to bother. What it compares is the newest migration filename: a deploy that adds one changes that string, so the next request applies it and every request after skips. There is no version number anyone has to remember to bump. A failure is returned rather than swallowed, and deliberately not remembered -- recording it would mean the session gave up retrying for as long as it lasted. The dashboard shows it in a banner; the upload now says the database could not be updated and that the database user may not be allowed to change tables, which is the actual remaining cause once the migration has genuinely been attempted. Exercised with migrate() stubbed, since there is still no MySQL here: first call runs once, records 002_b.sql two further calls skipped, nothing re-run a new file appears runs again, records 003_new.sql the call after that skipped migrate() throws error returned, marker left unset the call after that retries, and succeeds The first version of that test reported ran=0 for every case and looked like the feature was dead. The harness was wrong -- __DIR__ inside the eval'd copy pointed at the temp directory, so the glob found no migrations at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Apply pending migrations on any panel action, not on a page load
No description provided.