Day 8 - A marketing banner component for VannaCharm
A small but powerful marketing banner component for VannaCharm that uses local storage and authentication state to show one-time messages to users
Written by Chris on December 8th, 2025
This post is part of the 'The 12 Days of Full Stack Dev' series, an adventure through full stack fintech app development. Join me through the first 12 days of December as we develop a variety of new features, fixes, and components within the Full Stack Craft fintech family. For the full list of days and topics in this series, see the original post: The 12 Days of Full Stack Dev.
Any good web marketer knows you need as many "CTAs" as possible. Well, maybe not every marketer, but I do know that having a well placed marketing banner can help drive conversions, especially during special sales or events. We can combine a bit of usage of local storage, as well with user authentication state to create a simple but effective marketing banner component for VannaCharm.
All together, the component looks like this:
import * as React from 'react';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useUserTier } from '@/hooks/useUserTier';
// KEEP OLD IDS FOR LEGACY OF NOT SHOWING THEM! (examples here, we don't have any old ones yet)
const SOME_OLD_ID = 'SOME_OLD_ID';
// Current banner
const HOLIDAY_SALE_2025_BANNER_DISMISSED = 'HOLIDAY_SALE_2025_BANNER_DISMISSED';
// A sticky banner that will appear across any page on the site until dismissed (then it is dismissed forever)
export function MarketingBanner() {
const [dismissed, setDismissed] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const { tier, isLoading: isTierLoading } = useUserTier();
useEffect(() => {
// Ensure old ones are always dismissed
localStorage.setItem(SOME_OLD_ID, 'true');
// Current one - check if dismissed
const isBannerDismissed = localStorage.getItem(HOLIDAY_SALE_2025_BANNER_DISMISSED);
if (isBannerDismissed === 'true') {
setDismissed(true);
}
}, []);
const handleClose = () => {
setIsClosing(true);
localStorage.setItem(HOLIDAY_SALE_2025_BANNER_DISMISSED, 'true');
// Wait for animation to complete before fully dismissing
setTimeout(() => {
setDismissed(true);
}, 300);
};
// Don't render anything while loading tier info
if (isTierLoading) {
return null;
}
// Don't show to users who already have lifetime tiers (nothing to convert!)
const hasLifetime = tier === 'starter-lifetime' || tier === 'premium-lifetime';
if (hasLifetime) {
return null;
}
// Don't show if already dismissed
if (dismissed) {
return null;
}
return (
<div
className={`sticky top-0 bg-lime-400 text-black text-center py-3 px-4 z-[60] transition-all duration-300 ease-in-out ${
isClosing ? '-mt-12 opacity-0' : 'mt-0 opacity-100'
}`}
role="alert"
>
<div className="max-w-7xl mx-auto flex justify-center items-center">
<div className="text-sm sm:text-base font-medium pr-8">
🎉 Holiday Sale!{' '}
<Link
href="/pricing"
className="underline font-bold hover:text-gray-800 transition-colors"
>
Get 50% off lifetime access - our best deal of the year!
</Link>
</div>
<button
type="button"
aria-label="Dismiss banner"
onClick={handleClose}
className="absolute right-2 sm:right-4 top-1/2 -translate-y-1/2 p-2 hover:bg-lime-500 rounded transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
</div>
);
}
We render it over the nav, so it looks like this:
Note this is setup to be shown only once, if the user decides to dismiss it, it will never be shown again. This is done by storing a flag in local storage when the user clicks the dismiss button. Finally, we also check the user's subscription tier (using our existing useUserTier hook) to avoid showing the banner to users who already have lifetime access, since they don't need to be converted!
That's It!
-Chris