diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index d7c91c5..2baa504 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -100,6 +100,62 @@ test("homepage shows the property search and a working listings link", async ({ expect(page.url()).toContain("location=Lekki"); }); +test("footer navigation links stack instead of running together", async ({ page }) => { + await page.goto("/"); + + // Regression: the links were given `inline-flex` for a 44px tap target while + // their container still relied on `space-y-*` (a margin between BLOCK boxes). + // Inline-level boxes flow onto one line, so the footer rendered + // "ListingsBuyer PortalAdmin Dashboard". Each link must start its own line. + // Compared per column: separate footer columns sit side by side on desktop, + // so links in different columns share a line legitimately. + const result = await page.evaluate(() => { + const groups = new Map>(); + for (const link of document.querySelectorAll("footer a")) { + const parent = link.parentElement; + if (!parent) continue; + const rect = link.getBoundingClientRect(); + if (rect.width === 0) continue; + const entry = { + text: (link.textContent || "").trim(), + top: Math.round(rect.top), + left: Math.round(rect.left), + height: Math.round(rect.height), + }; + groups.set(parent, [...(groups.get(parent) ?? []), entry]); + } + + const collisions: string[] = []; + let linkCount = 0; + let shortest = Infinity; + for (const entries of groups.values()) { + if (entries.length < 2) continue; + linkCount += entries.length; + for (const entry of entries) { + shortest = Math.min(shortest, entry.height); + const sibling = entries.find( + (other) => other !== entry && Math.abs(other.top - entry.top) < 4, + ); + if (sibling) collisions.push(`${entry.text} + ${sibling.text}`); + } + } + return { collisions: [...new Set(collisions)], linkCount, shortest }; + }); + + expect(result.linkCount, "expected grouped footer navigation links").toBeGreaterThan(2); + expect( + result.collisions, + "footer links in the same column share a line — inline-level boxes in a non-flex container again", + ).toEqual([]); + + // The 44px tap target applies on phones only: above `sm` the links + // deliberately return to desktop density (sm:min-h-0). + const viewportWidth = page.viewportSize()?.width ?? 0; + if (viewportWidth < 640) { + expect(result.shortest, "footer link lost its 44px touch target on mobile").toBeGreaterThanOrEqual(44); + } +}); + test("tenant site content renders copy rather than blank sections", async ({ page }) => { await page.goto("/"); diff --git a/src/components/marketing/marketing-footer.tsx b/src/components/marketing/marketing-footer.tsx index 956b331..370d2a3 100644 --- a/src/components/marketing/marketing-footer.tsx +++ b/src/components/marketing/marketing-footer.tsx @@ -98,7 +98,7 @@ export function MarketingFooter({

Explore

-
+
Listings Buyer Portal Admin Dashboard @@ -108,7 +108,7 @@ export function MarketingFooter({

Company

-
+
About Team {showCareers ? ( diff --git a/src/components/marketing/property-card.tsx b/src/components/marketing/property-card.tsx index 82b3637..177577a 100644 --- a/src/components/marketing/property-card.tsx +++ b/src/components/marketing/property-card.tsx @@ -31,15 +31,13 @@ export function PropertyCard({ property }: { property: PropertySummary }) { />
{property.status} - - {property.verification.status === "VERIFIED" ? "Verified" : "Stale"} - + {/* Only the positive state is public. "Stale" and "Hidden" are + operator vocabulary from the verification workflow; to a buyer + they read as a warning about the company, on a site whose pitch + is verified listings. Absence says nothing, which is correct. */} + {property.verification.status === "VERIFIED" ? ( + Verified + ) : null}
@@ -47,7 +45,11 @@ export function PropertyCard({ property }: { property: PropertySummary }) {
{property.locationSummary}

{property.title}

{property.shortDescription}

-

{property.verification.label}

+ {/* verification.label carries internal states ("Listing hidden", + "Verification required"). Only the verified case is shown. */} + {property.verification.status === "VERIFIED" ? ( +

{property.verification.label}

+ ) : null}
{isLand ? ( diff --git a/src/modules/properties/queries.ts b/src/modules/properties/queries.ts index f6360a8..96fcd3b 100644 --- a/src/modules/properties/queries.ts +++ b/src/modules/properties/queries.ts @@ -19,6 +19,7 @@ import { properties as demoProperties, getPropertyBySlug } from "@/modules/prope import { buildPropertyVerificationPresentation, buildPublicPropertyVerificationWhere, + resolvePublicVisibilityCutoff, } from "@/modules/properties/verification"; import type { PropertySummary } from "@/types/domain"; @@ -208,13 +209,20 @@ export function buildPublicPropertyWhere( context: TenantContext, where?: Record, _now = new Date(), + /** + * now - hideDays. Supplied by the public entry points so a listing that has + * aged past the hide threshold leaves the site even though its stored + * visibility flag was never rewritten. Omitted, the filter falls back to the + * stored flags alone. + */ + hideBefore?: Date | null, ) { void _now; return scopeTenantWhere(context, { AND: [ where ?? {}, - buildPublicPropertyVerificationWhere(), + buildPublicPropertyVerificationWhere({ hideBefore }), ], status: { in: [...PUBLIC_PROPERTY_STATUSES], @@ -241,6 +249,7 @@ export function buildPublicPropertyFilterWhere( context: TenantContext, filters: PublicPropertyFilters, now = new Date(), + hideBefore?: Date | null, ) { const andFilters: Array> = []; @@ -336,6 +345,7 @@ export function buildPublicPropertyFilterWhere( context, andFilters.length > 0 ? { AND: andFilters } : undefined, now, + hideBefore, ); } @@ -427,7 +437,8 @@ export async function getPublicProperties( }; } - const where = buildPublicPropertyFilterWhere(context, resolvedFilters); + const hideBefore = await resolvePublicVisibilityCutoff(context.companyId); + const where = buildPublicPropertyFilterWhere(context, resolvedFilters, new Date(), hideBefore); const skip = (resolvedFilters.page - 1) * PAGE_SIZE; const radiusSearch = hasRadiusPropertySearch(resolvedFilters); @@ -597,7 +608,12 @@ export async function getPublicPropertyDetailBySlug( prisma.property as ScopedFindFirstDelegate, context, { - where: buildPublicPropertyWhere(context, { slug }), + where: buildPublicPropertyWhere( + context, + { slug }, + new Date(), + await resolvePublicVisibilityCutoff(context.companyId), + ), select: { id: true, slug: true, @@ -800,9 +816,12 @@ export async function getPublicBrochureByPropertySlug( prisma.property as ScopedFindFirstDelegate, context, { - where: buildPublicPropertyWhere(context, { - slug, - }), + where: buildPublicPropertyWhere( + context, + { slug }, + new Date(), + await resolvePublicVisibilityCutoff(context.companyId), + ), select: { brochureDocumentId: true, }, diff --git a/src/modules/properties/verification.test.ts b/src/modules/properties/verification.test.ts index 3178b28..ba84fee 100644 --- a/src/modules/properties/verification.test.ts +++ b/src/modules/properties/verification.test.ts @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { buildPropertyVerificationPresentation, buildPropertyVerificationUpdateInput, + buildPublicPropertyVerificationWhere, computeVerificationStatus, normalizeVerificationThresholds, updateVerificationState, @@ -129,3 +130,42 @@ test("verification status uses tenant-specific fresh and hide windows", () => { assert.equal(status, "HIDDEN"); }); + +test("public visibility filter falls back to the stored flags without a cutoff", () => { + const where = buildPublicPropertyVerificationWhere(); + + assert.deepEqual(where, { + isPubliclyVisible: true, + verificationStatus: { in: ["VERIFIED", "STALE"] }, + }); +}); + +test("public visibility filter drops listings that aged past the hide window", () => { + // The stored flags are only written when a property is mutated, so a listing + // that crossed the hide threshold kept `isPubliclyVisible: true` and stayed + // public while rendering "Listing hidden". The cutoff applies the same rule + // the presentation uses, in the query. + const thresholds = normalizeVerificationThresholds({ freshDays: 5, staleDays: 10, hideDays: 12 }); + const hideBefore = new Date(now.getTime() - thresholds.hideDays * 24 * 60 * 60 * 1000); + const where = buildPublicPropertyVerificationWhere({ hideBefore }) as { + lastVerifiedAt?: { gte: Date }; + }; + + assert.deepEqual(where.lastVerifiedAt, { gte: hideBefore }); + + // A listing last verified before the cutoff is exactly the one the + // presentation would label "Listing hidden". + const agedOut = new Date("2026-03-20T00:00:00.000Z"); + assert.equal(agedOut < hideBefore, true); + assert.equal( + computeVerificationStatus({ lastVerifiedAt: agedOut }, thresholds, now), + "HIDDEN", + ); + + const stillPublic = new Date("2026-03-25T00:00:00.000Z"); + assert.equal(stillPublic >= hideBefore, true); + assert.notEqual( + computeVerificationStatus({ lastVerifiedAt: stillPublic }, thresholds, now), + "HIDDEN", + ); +}); diff --git a/src/modules/properties/verification.ts b/src/modules/properties/verification.ts index 0e79514..455b853 100644 --- a/src/modules/properties/verification.ts +++ b/src/modules/properties/verification.ts @@ -215,13 +215,46 @@ export function buildPropertyVerificationPresentation( }; } -export function buildPublicPropertyVerificationWhere() { - return { +/** + * Public visibility filter. + * + * `isPubliclyVisible` / `verificationStatus` are STORED values, written only + * when a property is mutated — there is no sweep that ages them. The presented + * status, by contrast, is recomputed from `lastVerifiedAt` on every render. A + * listing that crossed the hide threshold therefore kept its stored "visible" + * flag and stayed on the public site while rendering the label "Listing + * hidden" — the policy was enforced in the UI copy but not in the query. + * + * Passing `hideBefore` (now - hideDays) applies the same rule the presentation + * uses, in SQL, so an unverified-for-too-long listing leaves the public site + * whether or not anything has written to the row since. + */ +export function buildPublicPropertyVerificationWhere(options?: { hideBefore?: Date | null }) { + const stored = { isPubliclyVisible: true, verificationStatus: { in: ["VERIFIED", "STALE"], }, }; + + if (!options?.hideBefore) { + return stored; + } + + return { + ...stored, + // `gte` also excludes rows that were never verified (NULL). + lastVerifiedAt: { gte: options.hideBefore }, + }; +} + +/** Cutoff before which a listing counts as hidden, for the public query. */ +export async function resolvePublicVisibilityCutoff( + companyId?: string | null, + now = new Date(), +) { + const thresholds = await getVerificationThresholdsForCompany(companyId); + return subDays(now, thresholds.hideDays); } export function buildPropertyVerificationUpdateInput(