Subscription UI
Subscription tier UI improvements and patterns
Created: 2026-02-05 Status: In Development Priority: HIGH (Feature 1), MEDIUM (Features 2 & 3)
Overview
This document specifies three UI improvements to enhance subscription tier awareness and engagement in CritForge:
- Beta Tester Trial Countdown - Show remaining trial days with urgency indicators
- Founding Member Badge Toggle - Allow users to display/hide founding member status
- Tier Indicator in Header - Persistent visual reminder of current subscription tier
Feature 1: Beta Tester Trial Countdown
Context
Beta testers receive a 30-day trial with premium features and discount eligibility:
- Friends: 40% lifetime discount
- Public beta: 50% Year 1 discount
Currently, beta testers see only a static "Beta Tester" badge with no indication of time remaining. This creates risk of:
- Unexpected trial expiration
- Lost conversion opportunity (no urgency prompts)
- Poor user experience (no actionable information)
Requirements
Functional:
- Calculate days remaining from
trial_expires_attimestamp - Display countdown only for
subscription_tier === 'beta_tester' - Update display based on urgency thresholds
- Handle edge cases: expired trials, null timestamps, invalid dates
Visual:
- Clear, readable countdown text
- Color-coded urgency states
- Progress bar showing trial completion percentage
- Non-intrusive placement (enhance existing card, don't overshadow)
Accessibility:
- ARIA live region for countdown updates
- Sufficient color contrast for all urgency states
- Clear semantic structure for screen readers
Design Specification
Placement
Add countdown section BELOW the existing "Beta Tester" badge within the subscription card (lines 468-478 in settings/page.tsx):
Current Badge Section:
┌────────────────────────────────────────────────┐
│ Current Tier: Beta Tester │
│ ⚡ Beta Tester "Early access..." │
└────────────────────────────────────────────────┘
New Layout with Countdown:
┌────────────────────────────────────────────────┐
│ Current Tier: Beta Tester │
│ ⚡ Beta Tester "Early access..." │
│ │
│ Trial Countdown (NEW SECTION): │
│ [Progress Bar] 23 days remaining │
│ Your trial expires on Feb 28, 2026 │
└────────────────────────────────────────────────┘
Color Coding (Urgency States)
| Days Remaining | Color Scheme | Hex Colors | Usage |
|---|---|---|---|
| 30-15 days | Green (Success) | text-green-500, bg-green-500/20, border-green-500/30 | Normal state, no urgency |
| 14-8 days | Blue (Info) | text-blue-500, bg-blue-500/20, border-blue-500/30 | Awareness prompt |
| 7-1 days | Amber (Warning) | text-amber-500, bg-amber-500/20, border-amber-500/30 | High urgency (matches password warning) |
| 0 days (expired) | Red (Destructive) | text-destructive, bg-destructive/20, border-destructive/30 | Trial ended |
Component Structure
// Pseudo-code structure
{isBetaTester && profile.trial_expires_at && (
<div className="mt-4 space-y-2">
{/* Countdown Header */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Trial Status</span>
<span className={`text-sm font-bold ${urgencyColor}`}>
{daysRemaining} days remaining
</span>
</div>
{/* Progress Bar */}
<div className="w-full bg-muted rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${progressBarColor}`}
style={{ width: `${percentComplete}%` }}
role="progressbar"
aria-valuenow={percentComplete}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Trial ${percentComplete}% complete`}
/>
</div>
{/* Expiry Date & CTA */}
<div className={`p-3 rounded-md border ${urgencyBgColor} ${urgencyBorderColor}`}>
<p className="text-xs">
Trial expires on {expiryDate}
</p>
{/* Conditional upgrade prompt for under 8 days */}
{daysRemaining <= 7 && (
<Button size="sm" className="mt-2">
Upgrade Now
</Button>
)}
</div>
</div>
)}
Progress Bar Logic
// Calculate trial progress (0-100%)
const trialStart = new Date(profile.created_at);
const trialEnd = new Date(profile.trial_expires_at);
const now = new Date();
const totalTrialDuration = trialEnd.getTime() - trialStart.getTime();
const elapsed = now.getTime() - trialStart.getTime();
const percentComplete = Math.min(100, Math.max(0, (elapsed / totalTrialDuration) * 100));
// Invert for "remaining" progress bar (optional design choice)
const percentRemaining = 100 - percentComplete;
Urgency Messages
| State | Message | Icon | CTA |
|---|---|---|---|
| 30-15 days | "Plenty of time to explore premium features" | ✓ | None |
| 14-8 days | "Your trial is halfway complete" | ℹ️ | None |
| 7-1 days | "Your trial expires soon. Upgrade now to keep your content and features!" | ⚠️ | "Upgrade Now" button |
| 0 days | "Your trial has expired. Upgrade to restore access." | ❌ | "Upgrade Now" button |
Edge Cases
- Null
trial_expires_at: Don't render countdown (fallback to badge only) - Invalid date: Log error, show generic "Beta Tester" badge
- Negative days (expired): Show "Trial Expired" with red styling
- Future date > 30 days: Cap display at "30+ days remaining"
Implementation Notes
File: /Applications/Development/Projects/CritForge/src/app/[locale]/settings/page.tsx
Changes:
- Add countdown calculation logic after profile load (line ~286)
- Add countdown UI below beta tester badge (line ~478)
- Extract urgency color logic to utility function
- Add ARIA live region for dynamic updates
Utility Function (suggest adding to /src/lib/utils/date-helpers.ts):
export interface TrialCountdown {
daysRemaining: number;
percentComplete: number;
urgencyState: 'success' | 'info' | 'warning' | 'danger';
expiryDate: string;
isExpired: boolean;
}
export function calculateTrialCountdown(
trialExpiresAt: string | null,
createdAt: string
): TrialCountdown | null {
if (!trialExpiresAt) return null;
try {
const now = new Date();
const expiryDate = new Date(trialExpiresAt);
const startDate = new Date(createdAt);
// Calculate days remaining
const msRemaining = expiryDate.getTime() - now.getTime();
const daysRemaining = Math.ceil(msRemaining / (1000 * 60 * 60 * 24));
// Calculate percent complete
const totalDuration = expiryDate.getTime() - startDate.getTime();
const elapsed = now.getTime() - startDate.getTime();
const percentComplete = Math.min(100, Math.max(0, (elapsed / totalDuration) * 100));
// Determine urgency state
let urgencyState: 'success' | 'info' | 'warning' | 'danger';
if (daysRemaining < 0) urgencyState = 'danger';
else if (daysRemaining <= 7) urgencyState = 'warning';
else if (daysRemaining <= 14) urgencyState = 'info';
else urgencyState = 'success';
return {
daysRemaining: Math.max(0, daysRemaining),
percentComplete: Math.round(percentComplete),
urgencyState,
expiryDate: expiryDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
}),
isExpired: daysRemaining < 0
};
} catch (error) {
console.error('[COUNTDOWN] Invalid date format:', error);
return null;
}
}
Testing Requirements
-
Manual Testing:
- Test all 4 urgency states (modify
trial_expires_atin DB) - Verify progress bar accuracy
- Check mobile responsive layout
- Validate accessibility (screen reader, keyboard nav)
- Test all 4 urgency states (modify
-
E2E Test (add to
/e2e/settings.spec.ts):- Beta tester sees countdown
- Non-beta tester doesn't see countdown
- Expired trial shows correct messaging
- Upgrade button appears for under 8 days
Feature 2: Founding Member Badge Toggle
Context
First 100 paying customers receive "Founding Member" status with benefits:
- Permanent badge on profile
- Recognition in community
- Potential future perks
Users should control whether this badge is publicly visible on their profile page.
Requirements
Functional:
- Add
badge_preferencesJSONB column to profiles table - Create toggle UI in settings page
- Update preferences via API call
- Display badge on profile page (future feature, placeholder for now)
Database Schema:
- Use flexible JSONB structure to support future badges
- Default
show_founding_membertofalse(opt-in) - Store badge grant timestamp in
founding_member_granted_at
Business Logic:
- Only show toggle if
is_founding_member = true - Badge is awarded in Stripe webhook after first payment
- Badge is permanent (cannot be removed, only hidden)
Database Migration
File: /Applications/Development/Projects/CritForge/src/lib/db/migrations/20260205_add_badge_preferences.sql
-- Add badge preferences column (JSONB for flexibility)
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS badge_preferences JSONB DEFAULT '{"show_founding_member": false}'::jsonb;
-- Add index for badge queries
CREATE INDEX IF NOT EXISTS idx_profiles_badge_preferences
ON profiles USING GIN (badge_preferences);
-- RLS Policy: Users can only update their own badge preferences
CREATE POLICY "Users can update own badge preferences"
ON profiles
FOR UPDATE
USING (auth.uid() = id)
WITH CHECK (auth.uid() = id);
-- Validation: Ensure badge_preferences has correct structure
ALTER TABLE profiles
ADD CONSTRAINT IF NOT EXISTS check_badge_preferences_format
CHECK (
badge_preferences ? 'show_founding_member' AND
(badge_preferences->>'show_founding_member')::boolean IS NOT NULL
);
-- Backfill existing profiles with default structure
UPDATE profiles
SET badge_preferences = '{"show_founding_member": false}'::jsonb
WHERE badge_preferences IS NULL
OR NOT (badge_preferences ? 'show_founding_member');
Design Specification
Placement
Add new section AFTER the Display Preferences card (after line 620 in settings/page.tsx):
┌─────────────────────────────────────────────────┐
│ 🏅 Badges & Recognition │
│ │
│ Manage which badges appear on your profile │
│ │
│ Founding Member Badge │
│ [Toggle Switch: OFF] │
│ │
│ You are one of the first 100 paying │
│ subscribers! Show this badge on your profile │
│ to celebrate your early support. │
│ │
│ Preview: [🏅 Founding Member] ← Badge design │
└─────────────────────────────────────────────────┘
Conditional Rendering
Only show this card if:
const isFoundingMember = profile.is_founding_member;
if (!isFoundingMember) return null;
Component Structure
{/* Badges & Recognition (Founding Members Only) */}
{profile.is_founding_member && (
<DndCard>
<DndCardHeader>
<div className="flex items-center gap-2">
<Award className="h-5 w-5 text-dragon-500" /> {/* lucide-react icon */}
<DndCardTitle>Badges & Recognition</DndCardTitle>
</div>
<DndCardDescription>
Manage which badges appear on your public profile
</DndCardDescription>
</DndCardHeader>
<DndCardContent className="space-y-4">
{/* Founding Member Toggle */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="founding-member-toggle" className="font-medium">
Founding Member Badge
</Label>
{/* Badge Preview Inline */}
<Badge className="bg-gold-base/20 text-gold-base border-gold-base/30">
<Award className="h-3 w-3 mr-1" />
Founding Member
</Badge>
</div>
<p className="text-sm text-muted-foreground">
You are one of the first 100 paying subscribers!
Show this badge on your profile to celebrate your early support.
</p>
{/* Grant Date */}
{profile.founding_member_granted_at && (
<p className="text-xs text-muted-foreground">
Earned on {new Date(profile.founding_member_granted_at).toLocaleDateString()}
</p>
)}
</div>
{/* Toggle Switch */}
<Switch
id="founding-member-toggle"
checked={showFoundingMember}
onCheckedChange={handleToggleFoundingMember}
disabled={isUpdatingBadge}
aria-label="Show Founding Member badge on profile"
/>
</div>
{/* Info Banner */}
<div className="flex items-start gap-2 p-3 bg-blue-500/10 border border-blue-500/30 rounded-md">
<Info className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground">
Your badge will appear on your public profile page and in community features.
This badge is permanent and cannot be removed, but you can hide it anytime.
</p>
</div>
</DndCardContent>
</DndCard>
)}
API Endpoint
File: /Applications/Development/Projects/CritForge/src/app/api/user/badge-preferences/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { z } from 'zod';
const BadgePreferencesSchema = z.object({
show_founding_member: z.boolean(),
});
export async function PATCH(request: NextRequest) {
try {
const supabase = await createClient();
// Authenticate user
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Parse request body
const body = await request.json();
const { show_founding_member } = BadgePreferencesSchema.parse(body);
// Update badge preferences (merge with existing)
const { data: profile, error: updateError } = await supabase
.from('profiles')
.update({
badge_preferences: {
show_founding_member
}
})
.eq('id', user.id)
.select('badge_preferences')
.single();
if (updateError) {
console.error('[BADGE_PREFS] Update failed:', updateError);
return NextResponse.json(
{ error: 'Failed to update badge preferences' },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
badge_preferences: profile.badge_preferences
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid request body', details: error.errors },
{ status: 400 }
);
}
console.error('[BADGE_PREFS] Unexpected error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
Client-Side Logic
// In settings/page.tsx
const [showFoundingMember, setShowFoundingMember] = useState(false);
const [isUpdatingBadge, setIsUpdatingBadge] = useState(false);
// Load badge preference
useEffect(() => {
if (profile?.badge_preferences) {
setShowFoundingMember(
profile.badge_preferences.show_founding_member ?? false
);
}
}, [profile]);
// Handle toggle
const handleToggleFoundingMember = async (checked: boolean) => {
setIsUpdatingBadge(true);
try {
const response = await fetch('/api/user/badge-preferences', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ show_founding_member: checked })
});
if (!response.ok) {
throw new Error('Failed to update badge preferences');
}
setShowFoundingMember(checked);
toast.success(
checked
? 'Founding Member badge enabled!'
: 'Founding Member badge hidden'
);
} catch (error) {
console.error('[BADGE_TOGGLE] Error:', error);
toast.error('Failed to update badge preference');
setShowFoundingMember(!checked); // Revert on error
} finally {
setIsUpdatingBadge(false);
}
};
Testing Requirements
-
Unit Tests:
- API endpoint validates schema
- RLS policies prevent unauthorized updates
- Toggle state persists across page reloads
-
E2E Tests:
- Founding members see badge section
- Non-founding members don't see section
- Toggle updates database correctly
- Badge preview matches actual badge design
Feature 3: Tier Indicator in Header
Context
Users currently have no persistent visual reminder of their subscription tier. This causes:
- Confusion about feature access ("Why can't I do X?")
- Missed upgrade opportunities (users forget they're on trial)
- Poor tier awareness across the app
Requirements
Functional:
- Display current tier in header/user menu
- Visible on all pages (persistent)
- Color-coded by tier type
- Tooltip with tier benefits on hover
Visual:
- Subtle, non-intrusive indicator
- Consistent with existing header design
- Mobile-responsive (different layout for small screens)
Performance:
- No extra database queries (use existing user session data)
- Tier info cached in UserMenu server component
Design Specification
Placement Options
Option A: Badge in User Dropdown (RECOMMENDED)
Pros:
- No header real estate impact
- Already shows user email, logical place for tier
- Easy to implement
Cons:
- Not visible until dropdown opened
- Less persistent awareness
User Menu Dropdown:
┌──────────────────────────────┐
│ Account │
│ [email protected] │
│ ⚡ Beta Tester ← NEW BADGE │
├──────────────────────────────┤
│ ⚙️ Settings │
│ 🛡️ Admin Panel │
├──────────────────────────────┤
│ 🚪 Log Out │
└──────────────────────────────┘
Option B: Colored Dot on Avatar
Pros:
- Always visible
- Subtle, doesn't clutter UI
Cons:
- Hard to interpret (what does color mean?)
- Accessibility issues (color alone)
Option C: Mini Badge Next to Avatar
Pros:
- Clear tier indication
- Always visible
Cons:
- Takes header space
- May look cluttered on mobile
RECOMMENDATION: Option A (badge in dropdown) for MVP, consider Option C for v2 based on user feedback.
Implementation: Option A (Dropdown Badge)
File: /Applications/Development/Projects/CritForge/src/components/user-menu.tsx
Changes:
- Fetch subscription tier from profile
- Add tier badge after email in dropdown label
- Add tier-specific icon and color
// user-menu.tsx modifications
// Add tier icons
import { Crown, Zap, Clock, Sparkles, CreditCard } from "lucide-react";
export async function UserMenu() {
const supabase = await createClient();
const t = await getTranslations('common.userMenu');
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return (
<Link href="/login">
<Button variant="ghost" size="sm" className="min-h-[44px] min-w-[44px]">
{t('signIn')}
</Button>
</Link>
);
}
// NEW: Fetch subscription tier
const { data: profile } = await supabase
.from('profiles')
.select('subscription_tier, trial_expires_at')
.eq('id', user.id)
.single();
const subscriptionTier = profile?.subscription_tier || 'free';
// NEW: Tier badge configuration
const tierConfig = {
beta_tester: {
label: 'Beta Tester',
icon: Zap,
className: 'bg-dragon-500/20 text-dragon-500 border-dragon-500/30'
},
trial: {
label: 'Trial',
icon: Clock,
className: 'bg-blue-500/20 text-blue-500 border-blue-500/30'
},
premium: {
label: 'Premium',
icon: Crown,
className: 'bg-gold-base/20 text-gold-base border-gold-base/30'
},
party_plan: {
label: 'Party Plan',
icon: Crown,
className: 'bg-green-500/20 text-green-500 border-green-500/30'
},
pay_as_you_go: {
label: 'Pay-As-You-Go',
icon: CreditCard,
className: 'bg-gray-500/20 text-gray-500 border-gray-500/30'
},
free: {
label: 'Free',
icon: Sparkles,
className: 'bg-muted/50 text-muted-foreground border-muted'
},
expired_trial: {
label: 'Trial Expired',
icon: Clock,
className: 'bg-destructive/20 text-destructive border-destructive/30'
}
};
const tierInfo = tierConfig[subscriptionTier as keyof typeof tierConfig] || tierConfig.free;
const TierIcon = tierInfo.icon;
const isSuperAdmin = await checkIsSuperAdmin();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="relative min-h-[44px] min-w-[44px] rounded-full"
aria-label={t('userMenuAriaLabel')}
>
<User className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-2">
<p className="text-sm font-medium leading-none">{t('account')}</p>
<p className="text-xs leading-none text-muted-foreground">{user.email}</p>
{/* NEW: Tier Badge */}
<Badge className={`${tierInfo.className} w-fit`}>
<TierIcon className="h-3 w-3 mr-1" />
{tierInfo.label}
</Badge>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Rest of menu items... */}
</DropdownMenuContent>
</DropdownMenu>
);
}
Tier Color Palette
| Tier | Icon | Color | Hex | Rationale |
|---|---|---|---|---|
| Beta Tester | ⚡ Zap | Dragon Red/Purple | dragon-500 | Premium access, special status |
| Trial | 🕐 Clock | Blue | blue-500 | Temporary, time-limited |
| Premium | 👑 Crown | Gold | gold-base | Premium tier, paid |
| Party Plan | 👑 Crown | Green | green-500 | Group tier, collaborative |
| Pay-As-You-Go | 💳 CreditCard | Gray | gray-500 | Neutral, transactional |
| Free | ✨ Sparkles | Muted | muted-foreground | Base tier |
| Expired Trial | 🕐 Clock | Red | destructive | Requires action |
Tooltip Enhancement (Optional v2 Feature)
Add tooltip on hover showing tier benefits:
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge className={tierInfo.className}>
<TierIcon className="h-3 w-3 mr-1" />
{tierInfo.label}
</Badge>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-xs">
<div className="space-y-1">
<p className="font-medium">{tierInfo.label}</p>
<ul className="text-xs space-y-0.5">
{tierInfo.benefits.map((benefit, i) => (
<li key={i}>✓ {benefit}</li>
))}
</ul>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
Testing Requirements
-
Visual Regression:
- Test all 7 tier states (beta_tester, trial, premium, party_plan, pay_as_you_go, free, expired_trial)
- Verify colors match design system
- Check mobile responsiveness
-
E2E Tests:
- User sees correct tier in dropdown
- Tier updates after subscription change
- Colors and icons match tier
Implementation Priority
-
Phase 1 (This Sprint): Beta Tester Countdown
- High user impact (retention)
- Clear requirements
- Single component change
-
Phase 2 (Next Sprint): Founding Member Badge
- Database migration needed
- New API endpoint
- Feature flag for rollout
-
Phase 3 (Future): Tier Indicator
- QOL improvement
- Low complexity
- Can iterate based on feedback
Accessibility Checklist
- All color states meet WCAG AA contrast (4.5:1)
- ARIA live regions for dynamic countdown updates
- Keyboard navigation for all interactive elements
- Screen reader labels for icons and badges
- Focus indicators visible on all controls
- Touch targets ≥44×44px on mobile
- Color not sole indicator of state (use icons + text)
Mobile Responsiveness Notes
Beta Tester Countdown:
- Stack progress bar and text vertically on under 640px screens
- Reduce font size for expiry date on mobile
- Ensure CTA button is full-width on mobile
Badge Toggle:
- Stack toggle and description vertically on under 640px
- Ensure toggle switch is ≥44px touch target
Tier Indicator:
- Same badge style in dropdown on all screen sizes
- Truncate long tier names if needed
Future Enhancements (Out of Scope)
- Animated Countdown: Real-time countdown timer (updates every minute)
- Trial Extension: Allow GMs to request trial extension (manual approval)
- Badge Collection Page: Dedicated page showing all earned badges
- Tier Comparison Modal: Click tier badge → see tier comparison chart
- Custom Badges: Admin-granted badges for contributors, streamers, etc.
References
- Settings Page:
/Applications/Development/Projects/CritForge/src/app/[locale]/settings/page.tsx - User Menu:
/Applications/Development/Projects/CritForge/src/components/user-menu.tsx - Database Schema:
/Applications/Development/Projects/CritForge/src/types/database.ts - Pricing Strategy:
/Applications/Development/Projects/CritForge/docs/business/pricing-strategy.md - Design System:
/Applications/Development/Projects/CritForge/docs/architecture/design-system.md