Skip to content
Draft
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
14 changes: 12 additions & 2 deletions app/[locale]/sponsor/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { redirect, notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getCachedItems } from "@/lib/content";
import { SponsorForm } from "@/components/sponsor-ads";
import { Megaphone, Globe, TrendingUp, BadgeCheck, Sparkles, Shield } from "lucide-react";
import Link from "next/link";
import { getSponsorAdPricingConfig, getSponsorAdsEnabled } from "@/lib/utils/settings";

// Styling constants
const PAGE_WRAPPER = "min-h-screen bg-linear-to-b from-gray-50 via-white to-gray-50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950";
Expand All @@ -26,6 +27,15 @@ export default async function SponsorPage({
const session = await auth();
const t = await getTranslations("sponsor");

// Check if sponsor ads feature is enabled
const sponsorAdsEnabled = getSponsorAdsEnabled();
if (!sponsorAdsEnabled) {
notFound();
}

// Get pricing configuration
const pricingConfig = getSponsorAdPricingConfig();

// Check if user is authenticated
if (!session?.user?.id) {
redirect(`/${locale}/auth/signin?callbackUrl=/${locale}/sponsor`);
Expand Down Expand Up @@ -141,7 +151,7 @@ export default async function SponsorPage({
{/* Form or Empty State */}
{userItems.length > 0 ? (
<div className="mx-auto max-w-2xl">
<SponsorForm items={userItems} locale={locale} />
<SponsorForm items={userItems} locale={locale} pricingConfig={pricingConfig} />
</div>
) : (
<div className={EMPTY_STATE_WRAPPER}>
Expand Down
148 changes: 148 additions & 0 deletions components/admin/settings/SettingCurrencyInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
'use client';

import { Label } from '@/components/ui/label';
import { useState, useEffect, useCallback } from 'react';
import { cn } from '@/lib/utils';

interface SettingCurrencyInputProps {
label: string;
description?: string;
value: number;
onChange: (value: number) => void;
currency?: string;
placeholder?: string;
disabled?: boolean;
}

/**
* Formats a number with thousand separators based on locale
*/
function formatWithSeparators(value: string | number): string {
// Remove any non-numeric characters except decimal point
const numStr = String(value).replace(/[^\d.]/g, '');
const parts = numStr.split('.');

// Format integer part with thousand separators
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');

// Limit decimal places to 2
if (parts[1]) {
parts[1] = parts[1].slice(0, 2);
}

return parts.join('.');
}

/**
* Parses a formatted string back to a number
*/
function parseFormattedValue(value: string): number {
const cleaned = value.replace(/,/g, '');
const parsed = parseFloat(cleaned);
return isNaN(parsed) ? 0 : parsed;
}

/**
* Gets the currency symbol for a given currency code
*/
function getCurrencySymbol(currency: string): string {
const symbols: Record<string, string> = {
USD: '$',
EUR: '€',
GBP: '£',
CAD: 'C$',
AUD: 'A$',
};
return symbols[currency] || currency;
}

export function SettingCurrencyInput({
label,
description,
value,
onChange,
currency = 'USD',
placeholder = '0.00',
disabled = false,
}: SettingCurrencyInputProps) {
// Store the display value (formatted string)
const [displayValue, setDisplayValue] = useState(() => formatWithSeparators(value.toFixed(2)));

// Update display value when prop changes (e.g., from API)
useEffect(() => {
setDisplayValue(formatWithSeparators(value.toFixed(2)));
}, [value]);

const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const rawValue = e.target.value;

// Allow only numbers, commas, and decimal point
const sanitized = rawValue.replace(/[^\d.,]/g, '');

// If empty, show empty and set value to 0
if (!sanitized) {
setDisplayValue('');
return;
}

// Format with separators
const formatted = formatWithSeparators(sanitized);
setDisplayValue(formatted);
}, []);

const handleBlur = useCallback(() => {
// On blur, parse the value and call onChange
const numericValue = parseFormattedValue(displayValue);
onChange(numericValue);

// Also ensure display shows proper formatting with 2 decimal places
setDisplayValue(formatWithSeparators(numericValue.toFixed(2)));
}, [displayValue, onChange]);

const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
// Submit on Enter
if (e.key === 'Enter') {
e.currentTarget.blur();
}
}, []);

const currencySymbol = getCurrencySymbol(currency);

return (
<div className="py-3">
<Label className="text-sm font-medium text-gray-900 dark:text-gray-100">
{label}
</Label>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-2">
{description}
</p>
)}
<div className="relative max-w-md">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<span className="text-gray-500 dark:text-gray-400 text-sm font-medium">
{currencySymbol}
</span>
</div>
<input
type="text"
inputMode="decimal"
value={displayValue}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
className={cn(
"w-full flex items-center px-3 py-2 pl-8 rounded-lg transition-colors",
"border border-gray-300 dark:border-gray-600 bg-transparent",
"text-gray-900 dark:text-white text-sm",
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
"disabled:opacity-50 disabled:cursor-not-allowed",
"h-10"
)}
/>
</div>
</div>
);
}
5 changes: 4 additions & 1 deletion components/admin/settings/SettingSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface SettingSelectProps {
onChange: (value: string) => void;
options: SelectOption[];
disabled?: boolean;
usePortal?: boolean;
}

export function SettingSelect({
Expand All @@ -23,7 +24,8 @@ export function SettingSelect({
value,
onChange,
options,
disabled = false
disabled = false,
usePortal = false
}: SettingSelectProps) {
const handleSelectionChange = (keys: string[]) => {
if (keys.length > 0) {
Expand All @@ -46,6 +48,7 @@ export function SettingSelect({
onSelectionChange={handleSelectionChange}
disabled={disabled}
className="max-w-md"
usePortal={usePortal}
>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
Expand Down
113 changes: 113 additions & 0 deletions components/admin/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
import { Sliders } from 'lucide-react';
import { SettingSwitch } from './SettingSwitch';
import { SettingSelect } from './SettingSelect';
import { SettingInput } from './SettingInput';
import { SettingCurrencyInput } from './SettingCurrencyInput';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';

Expand Down Expand Up @@ -137,6 +139,17 @@ interface FooterConfigSettings {
theme_selector_enabled?: boolean;
}

interface SponsorAdsSettings {
enabled?: boolean;
weekly_price?: number;
monthly_price?: number;
currency?: string;
}

interface MonetizationConfigSettings {
sponsor_ads?: SponsorAdsSettings;
}

interface Settings {
categories_enabled?: boolean;
companies_enabled?: boolean;
Expand All @@ -145,6 +158,7 @@ interface Settings {
header?: HeaderConfigSettings;
homepage?: HomepageSettings;
footer?: FooterConfigSettings;
monetization?: MonetizationConfigSettings;
[key: string]: unknown;
}

Expand Down Expand Up @@ -513,6 +527,105 @@ export function SettingsPage() {
)}
</AccordionContent>
</AccordionItem>

{/* Monetization Settings Section */}
<AccordionItem
value="monetization"
className={ACCORDION_ITEM_CLASSES}
>
<AccordionTrigger>
<div className="text-left w-full">
<h3 className={ACCORDION_TITLE_CLASSES}>
{t('MONETIZATION_TITLE')}
</h3>
<p className={ACCORDION_DESC_CLASSES}>
{t('MONETIZATION_DESC')}
</p>
</div>
</AccordionTrigger>
<AccordionContent className={ACCORDION_CONTENT_CLASSES}>
{loading ? (
<p className={PLACEHOLDER_TEXT_CLASSES}>
Loading settings...
</p>
) : (
<>
{/* Sponsor Ads Subsection */}
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4">
<h4 className="text-base font-medium text-gray-800 dark:text-gray-200 mb-1">
{t('MONETIZATION_SPONSOR_ADS_TITLE')}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{t('MONETIZATION_SPONSOR_ADS_DESC')}
</p>
</div>
<SettingSwitch
label={t('SPONSOR_ADS_ENABLED_LABEL')}
description={t('SPONSOR_ADS_ENABLED_DESC')}
value={settings.monetization?.sponsor_ads?.enabled ?? true}
onChange={(value) => updateSetting('monetization.sponsor_ads.enabled', value)}
disabled={saving}
/>
<SettingCurrencyInput
label={t('SPONSOR_ADS_WEEKLY_PRICE_LABEL')}
description={t('SPONSOR_ADS_WEEKLY_PRICE_DESC')}
value={settings.monetization?.sponsor_ads?.weekly_price ?? 100}
onChange={(value) => updateSetting('monetization.sponsor_ads.weekly_price', value)}
currency={settings.monetization?.sponsor_ads?.currency ?? 'USD'}
placeholder="100.00"
disabled={saving}
/>
<SettingCurrencyInput
label={t('SPONSOR_ADS_MONTHLY_PRICE_LABEL')}
description={t('SPONSOR_ADS_MONTHLY_PRICE_DESC')}
value={settings.monetization?.sponsor_ads?.monthly_price ?? 300}
onChange={(value) => updateSetting('monetization.sponsor_ads.monthly_price', value)}
currency={settings.monetization?.sponsor_ads?.currency ?? 'USD'}
placeholder="300.00"
disabled={saving}
/>
<SettingSelect
label={t('SPONSOR_ADS_CURRENCY_LABEL')}
description={t('SPONSOR_ADS_CURRENCY_DESC')}
value={settings.monetization?.sponsor_ads?.currency ?? 'USD'}
onChange={(value) => updateSetting('monetization.sponsor_ads.currency', value)}
options={[
// Major currencies
{ value: 'USD', label: 'USD - US Dollar' },
{ value: 'EUR', label: 'EUR - Euro' },
{ value: 'GBP', label: 'GBP - British Pound' },
// Americas
{ value: 'CAD', label: 'CAD - Canadian Dollar' },
{ value: 'BRL', label: 'BRL - Brazilian Real' },
{ value: 'MXN', label: 'MXN - Mexican Peso' },
// Asia Pacific
{ value: 'AUD', label: 'AUD - Australian Dollar' },
{ value: 'JPY', label: 'JPY - Japanese Yen' },
{ value: 'CNY', label: 'CNY - Chinese Yuan' },
{ value: 'KRW', label: 'KRW - South Korean Won' },
{ value: 'INR', label: 'INR - Indian Rupee' },
{ value: 'IDR', label: 'IDR - Indonesian Rupiah' },
{ value: 'THB', label: 'THB - Thai Baht' },
{ value: 'VND', label: 'VND - Vietnamese Dong' },
// Europe
{ value: 'PLN', label: 'PLN - Polish Zloty' },
{ value: 'BGN', label: 'BGN - Bulgarian Lev' },
{ value: 'RUB', label: 'RUB - Russian Ruble' },
{ value: 'UAH', label: 'UAH - Ukrainian Hryvnia' },
{ value: 'TRY', label: 'TRY - Turkish Lira' },
{ value: 'CHF', label: 'CHF - Swiss Franc' },
// Middle East
{ value: 'ILS', label: 'ILS - Israeli Shekel' },
{ value: 'SAR', label: 'SAR - Saudi Riyal' },
{ value: 'AED', label: 'AED - UAE Dirham' },
]}
disabled={saving}
usePortal={true}
/>
</>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
Expand Down
Loading
Loading