A full-surface QA audit of skills.maahir.io — every linked page crawled, every page re-rendered at mobile (390px), tablet (768px) and desktop (1440px), and every form, API and interactive element exercised end to end. The marketing site is genuinely well built. The candidate-facing surface underneath it is not ready: the contact form silently discards every message, the signup flow demands a company name from job seekers, and the assessment engine has no assessment configured for any live job.
What was actually exercised, and what was deliberately left out.
/api/auth/login, /api/auth/register, /api/public/jobs, /api/public/apply./dashboard redirects to /login with no credentials supplied. Everything behind the login wall — candidate ranking, bulk invites, pipeline kanban, CSV export, override UI, the hiring-AI assistant — is untested.assessment_template_id: null, so there is no question bank to grade against. The assessment engine's correctness is completely unverified — this is the highest-value untested area remaining.Each of these breaks a real user journey. In priority order.
The /contact form's submit handler does not send anything. It hides the form, reveals a green "Thank you! Your message has been sent. We'll get back to you within 24 hours." panel, and stops there. The source comment says so outright:
// In production, this would POST to /api/contact document.getElementById('contactForm').classList.add('hidden'); document.getElementById('successMessage').classList.remove('hidden');
There is no fetch call, no action attribute and no method attribute on the form. The matching API route does not exist — POST /api/public/contact returns 404 "The route api/public/contact could not be found."
Verified: filled the form with a valid name, email and message, clicked Send Message, and watched the network panel — zero API requests were made while the success panel appeared instantly. With JavaScript disabled, the form falls back to a GET and dumps the user's message into the URL query string:
https://skills.maahir.io/contact?name=QA&email=qa%40example.com&subject=&message=msg
All 10 jobs returned by the public jobs API carry assessment_template_id: null and assessment_required: true simultaneously. The job cards promise "Skill test included" and "≈25 min test". The candidate applies, the API returns 201 with a valid assessment token, and the assessment page renders exactly one line:
Assessment not configured.
Verified: completed a real application for Junior Accountant at Systems Limited. The apply API returned 201, the browser was redirected to /assessment/jX3vIQ6BNow…, and that page displayed "Assessment not configured." — with no question, no progress indicator, no way to continue and no error state.
The one real job with a description — Intern (Finance and Account) at Server4Sale — also has a null template. Separately, the assessment page's own source contains an offline banner ("You appear to be offline. Your answers are saved locally and will sync when you reconnect.") sitting in the default DOM alongside the completion screen, which needs a state check before it can render.
/jobs, / and /pricing is actively contradicted by the product.Every page in the site repeats one promise, in the footer of all 28 pages: "Free for job seekers". On the homepage it becomes "For every job seeker — free forever" and "Free for job seekers, CNIC never shared, Works on slow internet". The pricing page states "Job seekers never pay" and gives job seekers their own "FREE forever" tier.
The registration form requires company_name as a non-optional field:
$ curl -X POST https://skills.maahir.io/api/auth/register \
-d '{"name":"…","email":"…","password":"…","password_confirmation":"…"}'
{"message":"The company name field is required.","errors":{"company_name":["The company name field is required."]}} 422
Supplying a company name creates a tenant and registers the account with the role tenant_owner — the employer/organisation role, not a candidate role. There is no candidate signup path anywhere on the site. The only job-seeking CTA on the homepage, "I want a job", points at /register — the employer form.
The job board at /jobs is the most public-facing product surface, and it currently lists test data. Verified against the live /api/public/jobs response — 10 published jobs, of which the first three are:
The city filter dropdown on /jobs offers East Jaquan, Hackettville, North Jamalberg alongside the real Pakistani cities. These are Faker-generated place names, not Pakistan. The tenants are named "Agency T" and "Probe Agency A".
This directly breaks the "Built for Pakistan" positioning and the "Made for Pakistan" badge, and it is the first thing any employer, candidate or investor will see. It also poisons the /apply funnel — the very first job card links straight into an apply form for a fictional role in a fictional city.
is_published: true is set on these records, this is live content — not a staging preview someone forgot to hide.Every page loads the same module bundle. It is empty on the wire:
$ curl -I https://skills.maahir.io/build/assets/app-BvRk9kiK.js
HTTP/2 200
content-type: application/javascript
content-length: 0
The CSS bundle beside it is healthy — app-Ckq0k-Cg.css returns 62,019 bytes and renders correctly. The build/manifest.json lists the full asset set including Inter and Noto Nastaliq Urdu webfonts, and those fonts do load correctly, so the build pipeline itself ran — the emitted JS file is the thing that came out empty.
Impact today is contained but fragile. The critical interactive paths — mobile hamburger, contact form, login, register, job board, apply form, assessment, agency workspace — all carry their own inline <script> blocks, so they still work. That is luck, not design: the page is carrying a 7.2 KB inline copy of a browser-logging script on every single page while the intended bundle is empty, and any new component that assumes the bundle will ship will fail silently in production.
The three header dropdowns — For Organizations, For Individuals FREE and Platform — are opened purely by CSS group-hover. They carry no click handler, no aria-expanded state change, and no keyboard focus target that opens them.
Verified on an emulated iPhone (390×844, touch enabled): tapping For Organizations did not open the panel — measured panel height 0px, computed visibility false, after a 5-second tap attempt that timed out because the element never became interactive. The same markup on desktop opens fine on hover.
This matters more than usual for this product specifically: the site's own homepage feature list advertises "Works on slow internet" and "Built for Pakistan: Urdu, PKR, CNIC-safe identity, WhatsApp, NAVTTC-aligned" and the platform page sells "Built for Pakistan — Urdu, mobile, 3G". A Pakistani candidate or recruiter on a phone cannot reach Hiring, Campus & Workforce, Skill Passport, Govt Job Prep, Certification Prep or Skill Paths from the header. The mobile hamburger does open the same destinations — but the hamburger is the only way in, and every one of those destinations is also a top-level section of the product.
Not launch-blocking on their own, but each one costs credibility, reach or conversion.
The apply form is declared <form id="applyForm" novalidate>. The submit handler calls form.checkValidity() and then form.reportValidity() — but because novalidate is set on the form element, reportValidity() is suppressed and the browser never shows its native bubble.
Verified: clicked Apply with first_name and email empty. Result: no API request fired, no error message anywhere in the DOM, the button stayed enabled and unchanged, and the user was left on the same page with no idea why nothing happened. The inputs were correctly flagged as invalid internally (:invalid, "Please fill out this field.") — the information existed, it just was never surfaced.
The job card renderer maps employment types through a fixed lookup:
const typeLabels = { full_time: 'Full-time', part_time: 'Part-time',
contract: 'Contract', internship: 'Internship' };
The API also returns "intern" — a value the map does not cover. The fallback is j.employment_type, so the raw key is rendered verbatim. The live Intern (Finance and Account) card at Server4Sale displays:
Intern (Finance and Account) Server4Sale · Karachi intern Skill test included
A lowercase bare enum key sitting next to a properly-capitalised badge. The same map mismatch means the Contract filter option exists in the dropdown but no job has that type, and Internship and intern are treated as separate types in the filter even though both are internships.
Checked all 28 pages: 0 of 28 have a rel="canonical". 0 of 28 have og:title, og:description, og:image or og:url. 0 of 28 have twitter:card. Six pages are also missing a meta description entirely — /agency, /login, /register, /contact, /about, /privacy and /terms.
Also missing site-wide: /sitemap.xml returns 404, and /robots.txt contains only User-agent: * / Disallow: with no Sitemap: line.
All 20 marketing pages emit a FAQPage JSON-LD block with "mainEntity":[] — a valid but completely empty schema. The homepage renders six real, well-written FAQ items via <details> elements:
{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[]}
Visible on the homepage: Is it really free for job seekers? · Is my CNIC safe? · Are the tests available in Urdu? · How do you stop cheating? · Can our university or NGO join? · What does it cost a company? — the exact content Google requires to award FAQ rich results.
<h1> at all/login, /register and /contact each have zero <h1> elements. Every one of them opens with a <h2> instead. All three are conversion-critical entry points.
A second structural issue runs across the site: 22 of 28 pages jump from <h2> straight to <h4>, skipping <h3> entirely. The footer is the usual culprit — its column headings are <h4> — but it occurs on every marketing page including /pricing and /help.
<h1> on the signup page weakens the page that matters most for conversion.The For Organizations dropdown and the footer list a link labelled "Agency Workspace — Live demo portal" pointing at /agency. That page's script immediately checks for a token and redirects:
const token = localStorage.getItem('token'); if (!token) window.location.href = '/login';
Verified: navigating to /agency as an anonymous visitor lands on https://skills.maahir.io/login with no explanation of why. A "live demo" that requires a login is not a demo — and this link sits in the primary navigation of all 20 marketing pages.
Additionally, /agency renders a different, much thinner layout (92 words vs 400–1,755 on the other pages) with no <h1>, no footer nav and no shared CSS bundle — it is a separate layout that has drifted from the rest of the site.
</span> is rendered as visible text on the homepageInside the homepage pricing teaser, a closing tag was HTML-escaped into the text content instead of being closed properly:
<span class="font-semibold text-accent-700">Job seekers: always free.</span> Universities, NGOs & NAVTTC programs: always free.
Verified in the browser — the rendered text of that paragraph is:
Job seekers: always free.</span> Universities, NGOs & NAVTTC programs: always free. Full pricing →
It appears in the pricing section of the homepage only; the standalone /pricing page renders the same sentence correctly.
Measured at 390px, every page reports between 8 and 19 interactive elements below the 44×44px minimum. The recurring offenders are the dropdown-panel links (Hiring at 41×19, Campus & Workforce at 139×19, Growth at 49×19), the inline CTA links ("See how hiring works" at 191×24, "Full pricing →" at 94×19) and the logo lockup (151×32).
There is no horizontal overflow at any breakpoint — the layout itself is sound — but with the desktop dropdowns unreachable on touch (#6), the mobile hamburger is the only route to navigation, and it is a single 44px button at the top-right.
The homepage hero carries four hard numbers: 52,000+ skill tests taken, 340+ companies hiring, 19 partner institutes and a 96% test completion rate. The same page shows "We screened 340 applicants in one afternoon" in a customer quote, and "20-minute tests" as a product promise while every live job advertises 25 or 30 minutes.
None of these are reachable through any public API. The only public data endpoint returns 10 jobs from 6 tenants — and three of those tenants are Agency T, Probe Agency A and other test records. The internal /about page adds "16 intelligence layers, 379+ job roles, 28 role families" — again with no public corroboration.
What the site promises, checked against what the product actually does.
assessment_template_id: null. Applying yields "Assessment not configured."company_name and creates a tenant_owner (employer) account. No candidate signup exists.POST /api/public/contact → 404./login for any anonymous visitor.dir="rtl" on all 23 Urdu nodes./, /solutions/hiring and /platform/skill-passport, with consistent evidence-based language./pricing tiers, the homepage pricing block and all 28 footers.The authenticated half of the product was never reached. These are the open questions, not guesses.
/jobs public filters (search, city, type) do work, but the employer-side filter set is entirely unverified./platform/hiring and /about, plus the claim that "every HR decision is auditable"./register only creates employer tenants, there may be an unpublished candidate onboarding path — or there may not be one at all. This needs a product decision before a code fix.Three ways to unblock: paste a working employer account's credentials, create a seeded tenant with test data, or send screenshots of each screen so the flow can be reviewed without an account. Each unlocks a different slice of the list above.
Regression-test these on every deploy — they are all currently right.
All 48 unique internal links across 28 pages resolve with a 200. Zero 404s, zero redirects, zero broken references. The footer, header, body CTAs and in-page anchors all point at live pages. This is the single best-maintained aspect of the site.
Across all 84 page-viewport runs, document.scrollWidth equalled the viewport width on every single page. No page forces a horizontal scrollbar at 390px, 768px or 1440px. The Tailwind responsive layout is genuinely well built — this is a real strength for the low-bandwidth market the product targets.
Across all 84 runs, no uncaught JavaScript exceptions, no console errors, no failed network requests and no 4xx/5xx triggered by page load. Console output is clean even on the pages with the 0-byte bundle, because the critical logic lives in inline scripts.
All 23 Urdu text nodes across 21 pages render with computed direction: rtl and correct font resolution. The Noto Nastaliq Urdu webfont is served and loads correctly from the build manifest. Bilingual content is present on the homepage, every solution page, /jobs, /learn, /skill-passport, /govt-prep and /cert-prep. For a Pakistan-first product this is exactly right.
Bad credentials → 401 {"errors":[{"message":"Invalid credentials."}]}. Empty body → 422 with per-field messages. Password mismatch → 422 "The password field confirmation does not match." Unauthenticated /dashboard → 302 to /login. CSRF tokens are present and rotate per form. This is production-grade behaviour.
At 390px the button toggles mobileMenu from hidden (height 0px) to open (height 1065px) and updates aria-expanded. The menu exposes all platform destinations. It is a proper 44×44px target. This is the correct pattern — the header dropdowns in #6 should be rebuilt to match it.
Searching "accountant" on /jobs narrowed 10 results to exactly 1 — the Junior Accountant role — and the count line updated to "1 job found". City and type selects are populated from live API data. The filter logic itself is sound; only the underlying data is wrong.
All six homepage FAQ items toggle correctly via native <details> with no JS dependency. The answers are specific and honest — "Yes — forever… We charge employers, never candidates" directly addresses the pricing question. This content deserves to be surfaced via structured data (#10).
All 6 homepage images have descriptive alt text, explicit width/height attributes (preventing layout shift) and loading="lazy" where appropriate. The 3 missing avatar files do 404, but each carries an onerror handler that swaps in an initials avatar — so the degradation is graceful and invisible. Verified: no broken-image icons render anywhere.
The stylesheet is 62,019 bytes of Tailwind v4.3.5 and renders correctly everywhere. The Vite manifest lists the complete asset set including 5 Inter weights and 4 Noto Nastaliq Urdu weights, all of which resolve and load. The build system works — only the JS entry point came out empty (#5).
With valid data, POST /api/public/apply returned 201, minted a real assessment token and redirected the browser correctly. File upload accepts PDF/DOCX with a 6MB cap. The backend apply path is sound — it is the assessment layer behind it that is empty (#2).
/privacy and /terms are both present, both dated "Last updated: November 2026", and both cover the right ground — data categories, 90-day retention, access/delete/export rights, AI vendor processing disclosure (naming MiniMax and OpenAI), and a no-emotion-detection / no-personality-inference guarantee in the terms. A missing privacy page is a common launch blocker; this one exists and reads well.
All 28 pages have unique, descriptive titles — "Fair PKR pricing", "Help Center — MaahirSkills", "Agency Workspace — MaahirSkills" — with zero duplicates. Length is appropriate throughout (19–71 characters). Only the canonical and OG tags are missing (#9).
20 marketing pages cover solutions, platform, product and company — and each one is distinct, not a duplicate with a changed headline. The information architecture is genuinely well thought out: /solutions/* by persona, /platform/* by product surface, /product by use case. Nothing here needs restructuring.
Complete coverage of all 28 pages. Meta columns marked — are missing from every page.
| Page | H1 | Words | Links | Meta | Status |
|---|---|---|---|---|---|
| / | 1 | 1093 | 78 | — | Stray </span> text |
| /solutions/hiring | 1 | 447 | 61 | — | ok |
| /solutions/campus | 1 | 447 | 61 | — | ok |
| /solutions/growth | 1 | 419 | 61 | — | ok |
| /solutions/agencies | 1 | 409 | 61 | — | ok |
| /agency | 1 | 86 | 8 | — | Redirects to /login |
| /skill-passport | 1 | 450 | 61 | — | ok |
| /govt-prep | 1 | 433 | 61 | — | ok |
| /cert-prep | 1 | 442 | 61 | — | ok |
| /learn | 1 | 425 | 61 | — | ok |
| /jobs | 1 | 338 | 59 | — | Lorem-ipsum jobs |
| /partners | 1 | 352 | 62 | — | ok |
| /platform/hiring | 1 | 1755 | 63 | — | ok |
| /platform/campus | 1 | 1530 | 63 | — | ok |
| /platform/skill-development | 1 | 858 | 63 | — | ok |
| /platform/corporate | 1 | 832 | 61 | — | ok |
| /platform/agency | 1 | 698 | 61 | — | ok |
| /platform/skill-passport | 1 | 782 | 61 | — | ok |
| /platform/technology | 1 | 697 | 60 | — | ok |
| /platform | 1 | 668 | 69 | — | ok |
| /pricing | 1 | 449 | 62 | — | ok |
| /help | 1 | 454 | 75 | — | ok |
| /login | 0 | 31 | 9 | — | No H1 |
| /register | 0 | 39 | 9 | — | No H1 · needs company |
| /contact | 0 | 66 | 8 | — | No H1 · discards message |
| /about | 1 | 384 | 10 | — | ok |
| /privacy | 1 | 209 | 8 | — | ok |
| /terms | 1 | 212 | 8 | — | ok |
| Asset / endpoint | Expected | Actual | Impact |
|---|---|---|---|
| /build/assets/app-BvRk9kiK.js | JS bundle | 200 · 0 bytes | Broken build, masked by inline scripts |
| /favicon.ico | icon | 200 · 0 bytes | Blank tab icon sitewide |
| /sitemap.xml | sitemap | 404 | No search-engine sitemap |
| /api/public/contact | POST | 404 route not found | Contact form has no backend |
| /images/avatar-*.png | 3 images | 404 ×3 | Graceful — initials fallback |
| /build/assets/app-Ckq0k-Cg.css | CSS bundle | 200 · 62,019 bytes | Healthy |
| /build/manifest.json | manifest | 200 | Healthy — fonts listed |
| /robots.txt | robots | 200 · no Sitemap line | Minor |
| /api/public/jobs | JSON | 200 · 10 jobs | Works — data is test content |
| /api/auth/login | POST | 401 / 422 as appropriate | Healthy |
| /api/auth/register | POST | 201 / 422 as appropriate | Healthy API — wrong product requirement |
| /api/public/apply | POST | 201 · mints token | Healthy — no template behind it |
| /dashboard | 302 → /login | 302 → /login | Healthy |
How the audit was run, so any result can be re-checked or re-run.
curl -sL, following redirects, recording status, byte size and final URL. All 48 internal links and 19 assets checked individually for status and content-type.networkidle wait, then a DOM probe measuring scrollWidth vs viewport, elements wider than the viewport, image load state, touch-target geometry, document.fonts.check() for Inter and Noto Nastaliq Urdu, computed direction on every Urdu node, and horizontal-scroll containers.console.error, console.warning, uncaught pageerror and requestfailed event recorded across all 84 runs.onerror handlers audited.Data hygiene: one test tenant and one test candidate were created to exercise the auth and apply paths. The candidate row may appear in employer reports — it should be purged before the next real run.
$ # full page inventory for p in / /solutions/hiring /solutions/campus /solutions/growth /solutions/agencies \ /agency /skill-passport /govt-prep /cert-prep /learn /jobs /partners \ /platform /platform/hiring /platform/campus /platform/skill-development \ /platform/corporate /platform/agency /platform/skill-passport \ /platform/technology /pricing /help /login /register /contact \ /about /privacy /terms; do curl -s -o /dev/null -w "%{http_code} $p\n" "https://skills.maahir.io$p" done # the empty bundle (#5) curl -sI https://skills.maahir.io/build/assets/app-BvRk9kiK.js | grep -i content-length content-length: 0 # contact form discards the message (#1) curl -s -X POST https://skills.maahir.io/api/public/contact \ -H 'Content-Type: application/json' -d '{}' {"message":"The route api/public/contact could not be found."} # job seekers forced to give a company name (#3) curl -s -X POST https://skills.maahir.io/api/auth/register \ -H 'Content-Type: application/json' \ -d '{"name":"QA","email":"qa@example.com","password":"Test12345!","password_confirmation":"Test12345!"}' {"message":"The company name field is required.", ...} 422 # no live job has an assessment (#2) curl -s https://skills.maahir.io/api/public/jobs | \ python3 -c "import json,sys; d=json.load(sys.stdin); \ print([(j['title'], j['assessment_template_id']) for j in d['data']])" # empty FAQ schema (#10) curl -s https://skills.maahir.io/ | grep -o '"mainEntity":\[[^]]*\]' "mainEntity":[]