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
71 changes: 63 additions & 8 deletions src/components/common/AdUnit.astro
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export interface Props {
className?: string;
style?: string;
lazy?: boolean;
/**
* When set (px), this is an in-flow slot: reserve a fixed-height box and HOLD it
* (never collapse) so a filled creative fits exactly and unfilled slots do not
* shift layout. Keeps mobile CLS at zero on the two in-content calculator slots.
*/
reserveHeight?: number;
}

const {
Expand All @@ -26,23 +32,44 @@ const {
className = '',
style = 'display:block',
lazy = true,
reserveHeight,
} = Astro.props;

const isProd = import.meta.env.PROD;
const clientId = import.meta.env.PUBLIC_ADSENSE_ID || 'ca-pub-8014911033000505';
const uniqueId = `ad-${slot}-${Math.random().toString(36).slice(2, 8)}`;

// In-flow reserve-and-hold slot: deterministic fixed-height box, no collapse.
const isReserved = typeof reserveHeight === 'number' && reserveHeight > 0;
const containerClass = `ad-container${isReserved ? ' ad-reserved' : ''} ${className}`.trim();
const containerStyle = isReserved
? `min-height:${reserveHeight}px;height:${reserveHeight}px`
: undefined;
// Fixed-height, responsive-width unit (Google AdSense answer 9183363): omitting
// data-ad-format + data-full-width-responsive bounds the served creative to the box.
const insStyle = isReserved ? `display:block;width:100%;height:${reserveHeight}px` : style;
---

{
isProd && (
<div class={`ad-container ${className}`} id={uniqueId} data-ad-lazy={lazy ? 'true' : 'false'}>
<div
class={containerClass}
id={uniqueId}
data-ad-lazy={lazy ? 'true' : 'false'}
style={containerStyle}
>
{isReserved && (
<span class="ad-reserved-label" aria-hidden="true">
Advertisement
</span>
)}
<ins
class="adsbygoogle"
style={style}
style={insStyle}
data-ad-client={clientId}
data-ad-slot={slot}
data-ad-format={format}
data-full-width-responsive="true"
data-ad-format={isReserved ? undefined : format}
data-full-width-responsive={isReserved ? undefined : 'true'}
data-ad-layout={layout}
data-ad-layout-key={layoutKey}
/>
Expand All @@ -61,22 +88,50 @@ const uniqueId = `ad-${slot}-${Math.random().toString(36).slice(2, 8)}`;
}

<style>
/* Collapse ad containers when AdSense doesn't fill them */
.ad-container {
min-height: 100px; /* Reserve space to prevent CLS */
overflow: hidden;
transition: max-height 0.3s ease, opacity 0.3s ease;
}
.ad-container:has(ins[data-ad-status="unfilled"]) {
/*
* Collapse ONLY non-reserved unfilled slots (mobile anchor + desktop sidebar/top).
* No transition: an animated max-height collapse is itself a layout-shift event.
* Reserved in-flow slots (.ad-reserved) are excluded so they never collapse.
*/
.ad-container:not(.ad-reserved):has(ins[data-ad-status="unfilled"]) {
max-height: 0 !important;
opacity: 0;
margin: 0 !important;
padding: 0 !important;
}
.ad-container.ad-unfilled {
.ad-container:not(.ad-reserved).ad-unfilled {
max-height: 0 !important;
opacity: 0;
margin: 0 !important;
padding: 0 !important;
}
/*
* Reserved in-flow slot: a fixed deterministic box that holds its height.
* The faint placeholder sits behind the <ins>; a filled creative paints over it,
* an unfilled slot simply shows it — neither changes the box height (CLS = 0).
*/
.ad-container.ad-reserved {
position: relative;
}
.ad-container.ad-reserved ins.adsbygoogle {
position: relative;
z-index: 1;
}
.ad-reserved-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.625rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-muted);
pointer-events: none;
z-index: 0;
}
</style>
22 changes: 15 additions & 7 deletions src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -122,23 +122,31 @@ const ogImageURL = ogImage
} catch (e) {}
}

// Collapse unfilled ad containers after AdSense processes them
// Resolve ad fill state after AdSense processes the slots.
// Reserved in-flow slots (.ad-reserved) are NEVER collapsed: they hold a
// fixed-height box so the page does not shift (CLS). We only collapse the
// out-of-flow mobile anchor and the desktop sidebar/top slots when unfilled.
setTimeout(function () {
var containers = document.querySelectorAll('.ad-container');
for (var i = 0; i < containers.length; i++) {
if (containers[i].classList.contains('ad-reserved')) continue;
var ins = containers[i].querySelector('ins.adsbygoogle');
if (!ins) continue;
var status = ins.getAttribute('data-ad-status');
var iframe = ins.querySelector('iframe');
var hasContent = iframe && iframe.clientHeight > 0;
if (
status === 'unfilled' ||
(!hasContent && ins.getAttribute('data-adsbygoogle-status'))
) {
var unfilled =
status === 'unfilled' || (!hasContent && ins.getAttribute('data-adsbygoogle-status'));
var filled = status === 'filled' || hasContent;
var anchor = containers[i].closest('.mobile-anchor-ad');
if (unfilled) {
containers[i].classList.add('ad-unfilled');
// Also collapse parent mobile anchor wrapper if present
var anchor = containers[i].closest('.mobile-anchor-ad');
if (anchor) anchor.style.display = 'none';
} else if (anchor && filled) {
// Anchor POSITIVELY filled: reserve bottom space so the fixed bar doesn't
// cover content. Gated on an observed fill (not !unfilled) so a pending,
// blocked, or never-loaded anchor never leaves a permanent bottom gap.
document.body.classList.add('has-anchor-ad');
Comment on lines +145 to +149

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recheck anchor fill after AdSense actually processes

On mobile sessions with no early interaction, the AdSense loader's own 3s timer only appends the async script, while this one-shot status check runs at the same 3s mark and can see neither data-ad-status nor an iframe yet. If the anchor fills after the script finishes loading, this branch is never revisited, so has-anchor-ad is not added and the fixed bottom ad can cover the page content.

Useful? React with 👍 / 👎.

}
}
}, 3000);
Expand Down
16 changes: 14 additions & 2 deletions src/layouts/CalculatorLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,14 @@ const colorClasses: Record<string, string> = {
<slot />

<!-- Mid-content Ad Unit (shows after calculator, before related) -->
<!-- In-flow on mobile: reserveHeight holds a fixed box so it never shifts layout (CLS) -->
<div class="my-8">
<AdUnit slot="8151016743" format="horizontal" className="rounded-xl overflow-hidden" />
<AdUnit
slot="8151016743"
format="horizontal"
reserveHeight={100}
className="rounded-xl overflow-hidden"
/>
</div>

<!-- Related Calculators Section -->
Expand Down Expand Up @@ -185,8 +191,14 @@ const colorClasses: Record<string, string> = {
}

<!-- Bottom Ad Unit -->
<!-- In-flow on mobile: reserveHeight holds a fixed box so it never shifts layout (CLS) -->
<div class="my-8">
<AdUnit slot="4738956161" className="rounded-xl overflow-hidden" />
<AdUnit
slot="4738956161"
format="horizontal"
reserveHeight={100}
className="rounded-xl overflow-hidden"
/>
</div>

<!-- Call to Action -->
Expand Down
17 changes: 17 additions & 0 deletions src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,23 @@ body {
overflow-x: hidden;
}

/* Safe-area utility (referenced by the mobile anchor ad wrapper) */
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}

/*
* When the mobile anchor ad is FILLED, BaseLayout's ad init adds .has-anchor-ad
* to <body> so page content clears the fixed bar. Added conditionally, so an
* unfilled (hidden) anchor leaves no permanent bottom gap. padding-bottom does
* not reposition existing content, so applying it post-load is CLS-safe.
*/
@media (max-width: 1023px) {
body.has-anchor-ad {
padding-bottom: calc(100px + env(safe-area-inset-bottom));
}
}

/* Remove 300ms tap delay on mobile for all interactive elements */
button,
a,
Expand Down
Loading