feat: promo code
This commit is contained in:
parent
0ddb431536
commit
bcac2584ed
12 changed files with 1908 additions and 54 deletions
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
132
website/src/lib/components/self/PromoCodeDialog.svelte
Normal file
132
website/src/lib/components/self/PromoCodeDialog.svelte
Normal 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>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { pgTable, text, timestamp, boolean, decimal, serial, varchar, integer, primaryKey, pgEnum, index } from "drizzle-orm/pg-core";
|
||||
import { pgTable, text, timestamp, boolean, decimal, serial, varchar, integer, primaryKey, pgEnum, index, unique } from "drizzle-orm/pg-core";
|
||||
|
||||
export const transactionTypeEnum = pgEnum('transaction_type', ['BUY', 'SELL']);
|
||||
|
||||
|
|
@ -139,3 +139,25 @@ export const commentLike = pgTable("comment_like", {
|
|||
pk: primaryKey({ columns: [table.userId, table.commentId] }),
|
||||
};
|
||||
});
|
||||
|
||||
export const promoCode = pgTable('promo_code', {
|
||||
id: serial('id').primaryKey(),
|
||||
code: varchar('code', { length: 50 }).notNull().unique(),
|
||||
description: text('description'),
|
||||
rewardAmount: decimal('reward_amount', { precision: 20, scale: 8 }).notNull(),
|
||||
maxUses: integer('max_uses'), // null = unlimited
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
createdBy: integer('created_by').references(() => user.id),
|
||||
});
|
||||
|
||||
export const promoCodeRedemption = pgTable('promo_code_redemption', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id').notNull().references(() => user.id),
|
||||
promoCodeId: integer('promo_code_id').notNull().references(() => promoCode.id),
|
||||
rewardAmount: decimal('reward_amount', { precision: 20, scale: 8 }).notNull(),
|
||||
redeemedAt: timestamp('redeemed_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
userPromoUnique: unique().on(table.userId, table.promoCodeId),
|
||||
}));
|
||||
|
|
|
|||
20
website/src/lib/types/promo-code.ts
Normal file
20
website/src/lib/types/promo-code.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export interface PromoCode {
|
||||
id: number;
|
||||
code: string;
|
||||
description?: string;
|
||||
rewardAmount: string;
|
||||
maxUses?: number;
|
||||
isActive: boolean;
|
||||
expiresAt?: string;
|
||||
createdAt: string;
|
||||
createdBy?: number;
|
||||
usedCount?: number;
|
||||
}
|
||||
|
||||
export interface PromoCodeRedemption {
|
||||
id: number;
|
||||
userId: number;
|
||||
promoCodeId: number;
|
||||
rewardAmount: string;
|
||||
redeemedAt: string;
|
||||
}
|
||||
|
|
@ -143,4 +143,34 @@ export function formatTimeAgo(date: string) {
|
|||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
export 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`;
|
||||
}
|
||||
|
||||
export function getExpirationDate(option: string): string | null {
|
||||
if (!option) return null;
|
||||
|
||||
const now = new Date();
|
||||
switch (option) {
|
||||
case '1h':
|
||||
return new Date(now.getTime() + 60 * 60 * 1000).toISOString();
|
||||
case '1d':
|
||||
return new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString();
|
||||
case '3d':
|
||||
return new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString();
|
||||
case '7d':
|
||||
return new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
case '30d':
|
||||
return new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const formatMarketCap = formatValue;
|
||||
|
|
|
|||
Reference in a new issue