Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Element, Array<{ text: string; top: number; left: number; height: number }>>();
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("/");

Expand Down
4 changes: 2 additions & 2 deletions src/components/marketing/marketing-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export function MarketingFooter({
<h4 className="text-sm font-semibold uppercase tracking-[0.2em] text-[var(--ink-500)]">
Explore
</h4>
<div className="space-y-2 text-sm text-[var(--ink-700)]">
<div className="flex flex-col items-start gap-1 text-sm text-[var(--ink-700)]">
<Link href="/properties" className="footer-link admin-focus inline-flex min-h-11 w-fit items-center rounded sm:min-h-0">Listings</Link>
<Link href={buyerPortalHref} className="footer-link admin-focus inline-flex min-h-11 w-fit items-center rounded sm:min-h-0">Buyer Portal</Link>
<Link href={adminPortalHref} className="footer-link admin-focus inline-flex min-h-11 w-fit items-center rounded sm:min-h-0">Admin Dashboard</Link>
Expand All @@ -108,7 +108,7 @@ export function MarketingFooter({
<h4 className="text-sm font-semibold uppercase tracking-[0.2em] text-[var(--ink-500)]">
Company
</h4>
<div className="space-y-2 text-sm text-[var(--ink-700)]">
<div className="flex flex-col items-start gap-1 text-sm text-[var(--ink-700)]">
<Link href="/about" className="footer-link admin-focus inline-flex min-h-11 w-fit items-center rounded sm:min-h-0">About</Link>
<Link href="/team" className="footer-link admin-focus inline-flex min-h-11 w-fit items-center rounded sm:min-h-0">Team</Link>
{showCareers ? (
Expand Down
22 changes: 12 additions & 10 deletions src/components/marketing/property-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,23 +31,25 @@ export function PropertyCard({ property }: { property: PropertySummary }) {
/>
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
<Badge className="bg-white/90">{property.status}</Badge>
<Badge
className={
property.verification.tone === "success"
? "bg-emerald-100 text-emerald-800"
: "bg-amber-100 text-amber-800"
}
>
{property.verification.status === "VERIFIED" ? "Verified" : "Stale"}
</Badge>
{/* 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" ? (
<Badge className="bg-emerald-100 text-emerald-800">Verified</Badge>
) : null}
</div>
</div>
<div className="space-y-4 p-6">
<div className="space-y-2">
<div className="text-sm text-[var(--ink-500)]">{property.locationSummary}</div>
<h3 className="font-serif text-2xl text-[var(--ink-950)]">{property.title}</h3>
<p className="text-sm leading-6 text-[var(--ink-600)]">{property.shortDescription}</p>
<p className="text-xs font-medium text-[var(--ink-500)]">{property.verification.label}</p>
{/* verification.label carries internal states ("Listing hidden",
"Verification required"). Only the verified case is shown. */}
{property.verification.status === "VERIFIED" ? (
<p className="text-xs font-medium text-[var(--ink-500)]">{property.verification.label}</p>
) : null}
</div>
<div className="flex items-center justify-between text-sm text-[var(--ink-700)]">
{isLand ? (
Expand Down
31 changes: 25 additions & 6 deletions src/modules/properties/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -208,13 +209,20 @@ export function buildPublicPropertyWhere(
context: TenantContext,
where?: Record<string, unknown>,
_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],
Expand All @@ -241,6 +249,7 @@ export function buildPublicPropertyFilterWhere(
context: TenantContext,
filters: PublicPropertyFilters,
now = new Date(),
hideBefore?: Date | null,
) {
const andFilters: Array<Record<string, unknown>> = [];

Expand Down Expand Up @@ -336,6 +345,7 @@ export function buildPublicPropertyFilterWhere(
context,
andFilters.length > 0 ? { AND: andFilters } : undefined,
now,
hideBefore,
);
}

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
},
Expand Down
40 changes: 40 additions & 0 deletions src/modules/properties/verification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import assert from "node:assert/strict";
import {
buildPropertyVerificationPresentation,
buildPropertyVerificationUpdateInput,
buildPublicPropertyVerificationWhere,
computeVerificationStatus,
normalizeVerificationThresholds,
updateVerificationState,
Expand Down Expand Up @@ -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",
);
});
37 changes: 35 additions & 2 deletions src/modules/properties/verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading