feat: promo code

This commit is contained in:
Face 2025-05-26 17:20:53 +03:00
parent 0ddb431536
commit bcac2584ed
12 changed files with 1908 additions and 54 deletions

View file

@ -13,16 +13,17 @@
BriefcaseBusiness,
Coins,
ChevronsUpDownIcon,
SparklesIcon,
BadgeCheckIcon,
CreditCardIcon,
BellIcon,
LogOutIcon,
Wallet,
Trophy,
Activity,
TrendingUp,
TrendingDown
TrendingDown,
User,
Settings,
Gift,
Shield,
Ticket
} from 'lucide-svelte';
import { mode, setMode } from 'mode-watcher';
import type { HTMLAttributes } from 'svelte/elements';
@ -31,6 +32,7 @@
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import SignInConfirmDialog from './SignInConfirmDialog.svelte';
import DailyRewards from './DailyRewards.svelte';
import PromoCodeDialog from './PromoCodeDialog.svelte';
import { signOut } from '$lib/auth-client';
import { formatValue, getPublicUrl } from '$lib/utils';
import { goto } from '$app/navigation';
@ -43,13 +45,13 @@
{ title: 'Portfolio', url: '/portfolio', icon: BriefcaseBusiness },
{ title: 'Leaderboard', url: '/leaderboard', icon: Trophy },
{ title: 'Create coin', url: '/coin/create', icon: Coins }
],
navAdmin: [{ title: 'Admin', url: '/admin', icon: ShieldAlert }]
]
};
type MenuButtonProps = HTMLAttributes<HTMLAnchorElement | HTMLButtonElement>;
const { setOpenMobile, isMobile } = useSidebar();
let shouldSignIn = $state(false);
let showPromoCode = $state(false);
$effect(() => {
if ($USER_DATA) {
@ -84,9 +86,32 @@
goto(`/coin/${coinSymbol.toLowerCase()}`);
setOpenMobile(false);
}
function handleAccountClick() {
if ($USER_DATA) {
goto(`/user/${$USER_DATA.id}`);
setOpenMobile(false);
}
}
function handleSettingsClick() {
goto('/settings');
setOpenMobile(false);
}
function handleAdminClick() {
goto('/admin');
setOpenMobile(false);
}
function handlePromoCodesClick() {
goto('/admin/promo');
setOpenMobile(false);
}
</script>
<SignInConfirmDialog bind:open={shouldSignIn} />
<PromoCodeDialog bind:open={showPromoCode} />
<Sidebar.Root collapsible="offcanvas">
<Sidebar.Header>
<div class="flex items-center gap-1 px-2 py-2">
@ -121,25 +146,6 @@
</Sidebar.MenuItem>
{/each}
{#if $USER_DATA?.isAdmin}
{#each data.navAdmin as item}
<Sidebar.MenuItem>
<Sidebar.MenuButton>
{#snippet child({ props }: { props: MenuButtonProps })}
<a
href={item.url}
onclick={() => handleNavClick(item.title)}
class={`${props.class}`}
>
<item.icon />
<span>{item.title}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
{/if}
<Sidebar.MenuItem>
<Sidebar.MenuButton>
{#snippet child({ props }: { props: MenuButtonProps })}
@ -348,26 +354,38 @@
</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item disabled={true}>
<SparklesIcon />
Upgrade to Pro
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item onclick={() => goto('/settings')}>
<BadgeCheckIcon />
<DropdownMenu.Item onclick={handleAccountClick}>
<User />
Account
</DropdownMenu.Item>
<DropdownMenu.Item disabled={true}>
<CreditCardIcon />
Billing
<DropdownMenu.Item onclick={handleSettingsClick}>
<Settings />
Settings
</DropdownMenu.Item>
<DropdownMenu.Item disabled={true}>
<BellIcon />
Notifications
<DropdownMenu.Item onclick={() => (showPromoCode = true)}>
<Gift />
Promo code
</DropdownMenu.Item>
</DropdownMenu.Group>
{#if $USER_DATA?.isAdmin}
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item
onclick={handleAdminClick}
class="text-primary hover:text-primary!"
>
<Shield class="text-primary" />
Admin Panel
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={handlePromoCodesClick}
class="text-primary hover:text-primary!"
>
<Ticket class="text-primary" />
Manage codes
</DropdownMenu.Item>
</DropdownMenu.Group>
{/if}
<DropdownMenu.Separator />
<DropdownMenu.Item
onclick={() => {

View file

@ -1,10 +1,11 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Gift, Clock, CheckCircle, Flame } from 'lucide-svelte';
import { Gift, Clock, CheckCircle, Flame, Loader2 } from 'lucide-svelte';
import { USER_DATA } from '$lib/stores/user-data';
import { fetchPortfolioData } from '$lib/stores/portfolio-data';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { formatTimeRemaining } from '$lib/utils';
interface RewardStatus {
canClaim: boolean;
@ -123,16 +124,6 @@
}
}
function formatTimeRemaining(timeMs: number): string {
const hours = Math.floor(timeMs / (60 * 60 * 1000));
const minutes = Math.floor((timeMs % (60 * 60 * 1000)) / (60 * 1000));
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
function formatCurrency(value: number): string {
return value.toLocaleString('en-US', {
minimumFractionDigits: 0,
@ -149,7 +140,7 @@
variant={claimState === 'success' ? 'secondary' : rewardStatus?.canClaim ? 'default' : 'outline'}
>
{#if !rewardStatus || claimState === 'loading'}
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-current"></div>
<Loader2 class="h-4 w-4 animate-spin" />
<span>{!rewardStatus ? 'Loading...' : 'Claiming...'}</span>
{:else if claimState === 'success'}
<CheckCircle class="h-4 w-4" />

View file

@ -0,0 +1,132 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Alert, AlertDescription } from '$lib/components/ui/alert';
import { Gift, XCircle, Loader2, CheckIcon } from 'lucide-svelte';
let { open = $bindable() } = $props();
let promoCode = $state('');
let isVerifying = $state(false);
let isSuccess = $state(false);
let message = $state('');
let hasResult = $state(false);
async function verifyPromoCode() {
if (!promoCode.trim()) return;
isVerifying = true;
hasResult = false;
try {
const response = await fetch('/api/promo/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ code: promoCode.trim() })
});
const result = await response.json();
isSuccess = response.ok;
message = response.ok ? result.message : result.error;
hasResult = true;
} catch (error) {
isSuccess = false;
message = 'Failed to verify promo code. Please try again.';
hasResult = true;
} finally {
isVerifying = false;
}
}
function handleSubmit(event: Event) {
event.preventDefault();
verifyPromoCode();
}
function resetDialog() {
promoCode = '';
isSuccess = false;
message = '';
hasResult = false;
isVerifying = false;
}
$effect(() => {
if (!open) {
resetDialog();
}
});
</script>
<Dialog.Root
bind:open
onOpenChange={(isOpen) => {
if (!isOpen) resetDialog();
}}
>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<Gift class="h-5 w-5" />
Promo Code
</Dialog.Title>
<Dialog.Description>
Enter your promo code below to redeem rewards and bonuses.
</Dialog.Description>
</Dialog.Header>
<form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<Label for="promo-code">Promo Code</Label>
<Input
id="promo-code"
bind:value={promoCode}
placeholder="CODE..."
disabled={isVerifying}
class="uppercase"
style="text-transform: uppercase;"
/>
</div>
{#if hasResult}
<Alert
variant={isSuccess ? 'default' : 'destructive'}
class={isSuccess ? 'text-success' : ''}
>
{#if isSuccess}
<CheckIcon class="h-4 w-4" />
{:else}
<XCircle class="h-4 w-4" />
{/if}
<AlertDescription>
{message}
</AlertDescription>
</Alert>
{/if}
<div class="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onclick={() => (open = false)}
disabled={isVerifying}
>
Cancel
</Button>
<Button type="submit" disabled={!promoCode.trim() || isVerifying}>
{#if isVerifying}
<Loader2 class="h-4 w-4 animate-spin" />
Verifying...
{:else}
Redeem Code
{/if}
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Root>