feat: promo code
This commit is contained in:
parent
0ddb431536
commit
bcac2584ed
12 changed files with 1908 additions and 54 deletions
39
website/drizzle/0007_funny_hemingway.sql
Normal file
39
website/drizzle/0007_funny_hemingway.sql
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS "promo_code" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"code" varchar(50) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"reward_amount" numeric(20, 8) NOT NULL,
|
||||||
|
"max_uses" integer,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_by" integer,
|
||||||
|
CONSTRAINT "promo_code_code_unique" UNIQUE("code")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "promo_code_redemption" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"promo_code_id" integer NOT NULL,
|
||||||
|
"reward_amount" numeric(20, 8) NOT NULL,
|
||||||
|
"redeemed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "promo_code_redemption_user_id_promo_code_id_unique" UNIQUE("user_id","promo_code_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "promo_code" ADD CONSTRAINT "promo_code_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "promo_code_redemption" ADD CONSTRAINT "promo_code_redemption_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "promo_code_redemption" ADD CONSTRAINT "promo_code_redemption_promo_code_id_promo_code_id_fk" FOREIGN KEY ("promo_code_id") REFERENCES "public"."promo_code"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
1078
website/drizzle/meta/0007_snapshot.json
Normal file
1078
website/drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -50,6 +50,13 @@
|
||||||
"when": 1748189449547,
|
"when": 1748189449547,
|
||||||
"tag": "0006_happy_katie_power",
|
"tag": "0006_happy_katie_power",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1748262487765,
|
||||||
|
"tag": "0007_funny_hemingway",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -13,16 +13,17 @@
|
||||||
BriefcaseBusiness,
|
BriefcaseBusiness,
|
||||||
Coins,
|
Coins,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
SparklesIcon,
|
|
||||||
BadgeCheckIcon,
|
|
||||||
CreditCardIcon,
|
|
||||||
BellIcon,
|
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
Wallet,
|
Wallet,
|
||||||
Trophy,
|
Trophy,
|
||||||
Activity,
|
Activity,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
TrendingDown
|
TrendingDown,
|
||||||
|
User,
|
||||||
|
Settings,
|
||||||
|
Gift,
|
||||||
|
Shield,
|
||||||
|
Ticket
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { mode, setMode } from 'mode-watcher';
|
import { mode, setMode } from 'mode-watcher';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
@ -31,6 +32,7 @@
|
||||||
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
|
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
|
||||||
import SignInConfirmDialog from './SignInConfirmDialog.svelte';
|
import SignInConfirmDialog from './SignInConfirmDialog.svelte';
|
||||||
import DailyRewards from './DailyRewards.svelte';
|
import DailyRewards from './DailyRewards.svelte';
|
||||||
|
import PromoCodeDialog from './PromoCodeDialog.svelte';
|
||||||
import { signOut } from '$lib/auth-client';
|
import { signOut } from '$lib/auth-client';
|
||||||
import { formatValue, getPublicUrl } from '$lib/utils';
|
import { formatValue, getPublicUrl } from '$lib/utils';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
@ -43,13 +45,13 @@
|
||||||
{ title: 'Portfolio', url: '/portfolio', icon: BriefcaseBusiness },
|
{ title: 'Portfolio', url: '/portfolio', icon: BriefcaseBusiness },
|
||||||
{ title: 'Leaderboard', url: '/leaderboard', icon: Trophy },
|
{ title: 'Leaderboard', url: '/leaderboard', icon: Trophy },
|
||||||
{ title: 'Create coin', url: '/coin/create', icon: Coins }
|
{ title: 'Create coin', url: '/coin/create', icon: Coins }
|
||||||
],
|
]
|
||||||
navAdmin: [{ title: 'Admin', url: '/admin', icon: ShieldAlert }]
|
|
||||||
};
|
};
|
||||||
type MenuButtonProps = HTMLAttributes<HTMLAnchorElement | HTMLButtonElement>;
|
type MenuButtonProps = HTMLAttributes<HTMLAnchorElement | HTMLButtonElement>;
|
||||||
|
|
||||||
const { setOpenMobile, isMobile } = useSidebar();
|
const { setOpenMobile, isMobile } = useSidebar();
|
||||||
let shouldSignIn = $state(false);
|
let shouldSignIn = $state(false);
|
||||||
|
let showPromoCode = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($USER_DATA) {
|
if ($USER_DATA) {
|
||||||
|
|
@ -84,9 +86,32 @@
|
||||||
goto(`/coin/${coinSymbol.toLowerCase()}`);
|
goto(`/coin/${coinSymbol.toLowerCase()}`);
|
||||||
setOpenMobile(false);
|
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>
|
</script>
|
||||||
|
|
||||||
<SignInConfirmDialog bind:open={shouldSignIn} />
|
<SignInConfirmDialog bind:open={shouldSignIn} />
|
||||||
|
<PromoCodeDialog bind:open={showPromoCode} />
|
||||||
<Sidebar.Root collapsible="offcanvas">
|
<Sidebar.Root collapsible="offcanvas">
|
||||||
<Sidebar.Header>
|
<Sidebar.Header>
|
||||||
<div class="flex items-center gap-1 px-2 py-2">
|
<div class="flex items-center gap-1 px-2 py-2">
|
||||||
|
|
@ -121,25 +146,6 @@
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
{/each}
|
{/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.MenuItem>
|
||||||
<Sidebar.MenuButton>
|
<Sidebar.MenuButton>
|
||||||
{#snippet child({ props }: { props: MenuButtonProps })}
|
{#snippet child({ props }: { props: MenuButtonProps })}
|
||||||
|
|
@ -348,26 +354,38 @@
|
||||||
</DropdownMenu.Label>
|
</DropdownMenu.Label>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Group>
|
<DropdownMenu.Group>
|
||||||
<DropdownMenu.Item disabled={true}>
|
<DropdownMenu.Item onclick={handleAccountClick}>
|
||||||
<SparklesIcon />
|
<User />
|
||||||
Upgrade to Pro
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Group>
|
|
||||||
<DropdownMenu.Separator />
|
|
||||||
<DropdownMenu.Group>
|
|
||||||
<DropdownMenu.Item onclick={() => goto('/settings')}>
|
|
||||||
<BadgeCheckIcon />
|
|
||||||
Account
|
Account
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item disabled={true}>
|
<DropdownMenu.Item onclick={handleSettingsClick}>
|
||||||
<CreditCardIcon />
|
<Settings />
|
||||||
Billing
|
Settings
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item disabled={true}>
|
<DropdownMenu.Item onclick={() => (showPromoCode = true)}>
|
||||||
<BellIcon />
|
<Gift />
|
||||||
Notifications
|
Promo code
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Group>
|
</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.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
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 { USER_DATA } from '$lib/stores/user-data';
|
||||||
import { fetchPortfolioData } from '$lib/stores/portfolio-data';
|
import { fetchPortfolioData } from '$lib/stores/portfolio-data';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { formatTimeRemaining } from '$lib/utils';
|
||||||
|
|
||||||
interface RewardStatus {
|
interface RewardStatus {
|
||||||
canClaim: boolean;
|
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 {
|
function formatCurrency(value: number): string {
|
||||||
return value.toLocaleString('en-US', {
|
return value.toLocaleString('en-US', {
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
|
|
@ -149,7 +140,7 @@
|
||||||
variant={claimState === 'success' ? 'secondary' : rewardStatus?.canClaim ? 'default' : 'outline'}
|
variant={claimState === 'success' ? 'secondary' : rewardStatus?.canClaim ? 'default' : 'outline'}
|
||||||
>
|
>
|
||||||
{#if !rewardStatus || claimState === 'loading'}
|
{#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>
|
<span>{!rewardStatus ? 'Loading...' : 'Claiming...'}</span>
|
||||||
{:else if claimState === 'success'}
|
{:else if claimState === 'success'}
|
||||||
<CheckCircle class="h-4 w-4" />
|
<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']);
|
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] }),
|
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`;
|
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;
|
export const formatMarketCap = formatValue;
|
||||||
|
|
|
||||||
318
website/src/routes/admin/promo/+page.svelte
Normal file
318
website/src/routes/admin/promo/+page.svelte
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from '$lib/components/ui/card';
|
||||||
|
import { Alert, AlertDescription } from '$lib/components/ui/alert';
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
|
import { Plus, Ticket, Users, Calendar, CheckCircle, XCircle, Loader2 } from 'lucide-svelte';
|
||||||
|
import { USER_DATA } from '$lib/stores/user-data';
|
||||||
|
import { formatDate, getExpirationDate } from '$lib/utils';
|
||||||
|
import type { PromoCode } from '$lib/types/promo-code';
|
||||||
|
|
||||||
|
let code = $state('');
|
||||||
|
let rewardAmount = $state('');
|
||||||
|
let maxUses = $state('');
|
||||||
|
let expirationOption = $state('');
|
||||||
|
let isCreating = $state(false);
|
||||||
|
let createSuccess = $state(false);
|
||||||
|
let createMessage = $state('');
|
||||||
|
let hasCreateResult = $state(false);
|
||||||
|
|
||||||
|
const expirationOptions = [
|
||||||
|
{ value: '1h', label: '1 Hour' },
|
||||||
|
{ value: '1d', label: '1 Day' },
|
||||||
|
{ value: '3d', label: '3 Days' },
|
||||||
|
{ value: '7d', label: '7 Days' },
|
||||||
|
{ value: '30d', label: '30 Days' }
|
||||||
|
];
|
||||||
|
|
||||||
|
let currentExpirationLabel = $derived(
|
||||||
|
expirationOptions.find((option) => option.value === expirationOption)?.label ||
|
||||||
|
'Select expiration'
|
||||||
|
);
|
||||||
|
|
||||||
|
let promoCodes = $state<PromoCode[]>([]);
|
||||||
|
let isLoading = $state(true);
|
||||||
|
async function loadPromoCodes() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/promo');
|
||||||
|
if (response.ok) {
|
||||||
|
const json = await response.json();
|
||||||
|
|
||||||
|
promoCodes = json.codes;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to load promo codes:', response.status, response.statusText);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load promo codes:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let isFormValid = $derived(code.trim() && rewardAmount.trim());
|
||||||
|
|
||||||
|
async function createPromoCode() {
|
||||||
|
if (!code.trim() || !rewardAmount.trim()) return;
|
||||||
|
|
||||||
|
isCreating = true;
|
||||||
|
hasCreateResult = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/promo', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: code.trim().toUpperCase(),
|
||||||
|
rewardAmount: parseFloat(rewardAmount),
|
||||||
|
maxUses: maxUses.trim() ? parseInt(maxUses) : null,
|
||||||
|
expiresAt: expirationOption ? getExpirationDate(expirationOption) : null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
createSuccess = response.ok;
|
||||||
|
createMessage = response.ok
|
||||||
|
? 'Promo code created successfully!'
|
||||||
|
: result.error || 'Failed to create promo code';
|
||||||
|
hasCreateResult = true;
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
code = '';
|
||||||
|
rewardAmount = '';
|
||||||
|
maxUses = '';
|
||||||
|
expirationOption = '';
|
||||||
|
await loadPromoCodes();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
createSuccess = false;
|
||||||
|
createMessage = 'Failed to create promo code. Please try again.';
|
||||||
|
hasCreateResult = true;
|
||||||
|
} finally {
|
||||||
|
isCreating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
createPromoCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($USER_DATA?.isAdmin) {
|
||||||
|
loadPromoCodes();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Promo Codes - Admin | Rugplay</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if !$USER_DATA || !$USER_DATA.isAdmin}
|
||||||
|
<div class="flex h-screen items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-2xl font-bold">Access Denied</h1>
|
||||||
|
<p class="text-muted-foreground">You don't have permission to access this page.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="container mx-auto space-y-4 p-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Ticket class="h-5 w-5" />
|
||||||
|
<h1 class="text-2xl font-bold">Promo Codes</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<!-- Create Promo Code Form -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="pb-3">
|
||||||
|
<CardTitle class="flex items-center gap-2 text-lg">
|
||||||
|
<Plus class="h-4 w-4" />
|
||||||
|
Create
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription class="text-sm">
|
||||||
|
Draft a new promo code for users to redeem.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onsubmit={handleSubmit} class="space-y-3">
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="code" class="text-sm">Code *</Label>
|
||||||
|
<Input
|
||||||
|
id="code"
|
||||||
|
bind:value={code}
|
||||||
|
placeholder="WELCOME100"
|
||||||
|
disabled={isCreating}
|
||||||
|
class="h-8 uppercase"
|
||||||
|
style="text-transform: uppercase;"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="reward" class="text-sm">Reward Amount *</Label>
|
||||||
|
<Input
|
||||||
|
id="reward"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
bind:value={rewardAmount}
|
||||||
|
placeholder="100.00"
|
||||||
|
disabled={isCreating}
|
||||||
|
class="h-8"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="maxUses" class="text-sm">Max Uses</Label>
|
||||||
|
<Input
|
||||||
|
id="maxUses"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
bind:value={maxUses}
|
||||||
|
placeholder="Unlimited"
|
||||||
|
disabled={isCreating}
|
||||||
|
class="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="expires" class="text-sm">Expires In</Label>
|
||||||
|
<Select.Root type="single" bind:value={expirationOption} disabled={isCreating}>
|
||||||
|
<Select.Trigger class="h-8 w-full">
|
||||||
|
{currentExpirationLabel}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
<Select.Group>
|
||||||
|
{#each expirationOptions as option}
|
||||||
|
<Select.Item value={option.value} label={option.label}>
|
||||||
|
{option.label}
|
||||||
|
</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Group>
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if hasCreateResult}
|
||||||
|
<Alert
|
||||||
|
variant={createSuccess ? 'default' : 'destructive'}
|
||||||
|
class={createSuccess ? 'text-success' : ''}
|
||||||
|
>
|
||||||
|
{#if createSuccess}
|
||||||
|
<CheckCircle class="h-4 w-4 text-green-600" />
|
||||||
|
{:else}
|
||||||
|
<XCircle class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
<AlertDescription class={createSuccess ? 'text-green-800 dark:text-green-200' : ''}>
|
||||||
|
{createMessage}
|
||||||
|
{#if createSuccess && rewardAmount}
|
||||||
|
<span class="font-semibold"> (+${rewardAmount} reward)</span>
|
||||||
|
{/if}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isFormValid || isCreating}
|
||||||
|
class="h-8 w-full"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{#if isCreating}
|
||||||
|
<Loader2 class="h-3 w-3 animate-spin" />
|
||||||
|
Creating...
|
||||||
|
{:else}
|
||||||
|
<Plus class="h-3 w-3" />
|
||||||
|
Create Code
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Existing Promo Codes -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="pb-3">
|
||||||
|
<CardTitle class="text-lg">Active</CardTitle>
|
||||||
|
<CardDescription class="text-sm">Manage existing promo codes.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#if isLoading}
|
||||||
|
{#each Array(3) as _}
|
||||||
|
<div class="space-y-2 rounded-lg border p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Skeleton class="h-4 w-20" />
|
||||||
|
<Skeleton class="h-5 w-14" />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<Skeleton class="h-3 w-16" />
|
||||||
|
<Skeleton class="h-3 w-12" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else if promoCodes.length === 0}
|
||||||
|
<div class="text-muted-foreground py-6 text-center">
|
||||||
|
<Ticket class="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||||
|
<p class="text-sm">No codes created yet.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each promoCodes as promo (promo.id)}
|
||||||
|
<div class="space-y-2 rounded-lg border p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<code class="bg-muted rounded px-2 py-1 font-mono text-sm font-semibold">
|
||||||
|
{promo.code}
|
||||||
|
</code>
|
||||||
|
<Badge variant={promo.isActive ? 'default' : 'secondary'} class="text-xs">
|
||||||
|
{promo.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 text-xs">
|
||||||
|
<span>${promo.rewardAmount}</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Users class="h-3 w-3" />
|
||||||
|
<span>{promo.usedCount || 0}{promo.maxUses ? `/${promo.maxUses}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Calendar class="h-3 w-3" />
|
||||||
|
<span>{formatDate(promo.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
{#if promo.expiresAt}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Calendar class="h-3 w-3" />
|
||||||
|
<span>Exp: {formatDate(promo.expiresAt)}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Calendar class="h-3 w-3" />
|
||||||
|
<span>No expiry</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
87
website/src/routes/api/admin/promo/+server.ts
Normal file
87
website/src/routes/api/admin/promo/+server.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { auth } from '$lib/auth';
|
||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { promoCode, promoCodeRedemption } from '$lib/server/db/schema';
|
||||||
|
import { eq, count } from 'drizzle-orm';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const session = await auth.api.getSession({ headers: request.headers });
|
||||||
|
if (!session?.user || !session.user.isAdmin) {
|
||||||
|
throw error(403, 'Admin access required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code, description, rewardAmount, maxUses, expiresAt } = await request.json();
|
||||||
|
|
||||||
|
if (!code || !rewardAmount) {
|
||||||
|
return json({ error: 'Code and reward amount are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCode = code.trim().toUpperCase();
|
||||||
|
const userId = Number(session.user.id);
|
||||||
|
|
||||||
|
const [existingCode] = await db
|
||||||
|
.select({ id: promoCode.id })
|
||||||
|
.from(promoCode)
|
||||||
|
.where(eq(promoCode.code, normalizedCode))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingCode) {
|
||||||
|
return json({ error: 'Promo code already exists' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newPromoCode] = await db
|
||||||
|
.insert(promoCode)
|
||||||
|
.values({
|
||||||
|
code: normalizedCode,
|
||||||
|
description: description || null,
|
||||||
|
rewardAmount: Number(rewardAmount).toFixed(8),
|
||||||
|
maxUses: maxUses || null,
|
||||||
|
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||||
|
createdBy: userId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
promoCode: {
|
||||||
|
id: newPromoCode.id,
|
||||||
|
code: newPromoCode.code,
|
||||||
|
description: newPromoCode.description,
|
||||||
|
rewardAmount: Number(newPromoCode.rewardAmount),
|
||||||
|
maxUses: newPromoCode.maxUses,
|
||||||
|
expiresAt: newPromoCode.expiresAt
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ request }) => {
|
||||||
|
const session = await auth.api.getSession({ headers: request.headers });
|
||||||
|
if (!session?.user || !session.user.isAdmin) {
|
||||||
|
throw error(403, 'Admin access required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: promoCode.id,
|
||||||
|
code: promoCode.code,
|
||||||
|
description: promoCode.description,
|
||||||
|
rewardAmount: promoCode.rewardAmount,
|
||||||
|
maxUses: promoCode.maxUses,
|
||||||
|
isActive: promoCode.isActive,
|
||||||
|
createdAt: promoCode.createdAt,
|
||||||
|
expiresAt: promoCode.expiresAt,
|
||||||
|
usedCount: count(promoCodeRedemption.id).as('usedCount')
|
||||||
|
})
|
||||||
|
.from(promoCode)
|
||||||
|
.leftJoin(promoCodeRedemption, eq(promoCode.id, promoCodeRedemption.promoCodeId))
|
||||||
|
.groupBy(promoCode.id);
|
||||||
|
|
||||||
|
return json({
|
||||||
|
codes: rows.map(pc => ({
|
||||||
|
...pc,
|
||||||
|
rewardAmount: Number(pc.rewardAmount)
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
};
|
||||||
112
website/src/routes/api/promo/verify/+server.ts
Normal file
112
website/src/routes/api/promo/verify/+server.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { auth } from '$lib/auth';
|
||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user, promoCode, promoCodeRedemption } from '$lib/server/db/schema';
|
||||||
|
import { eq, and, count } from 'drizzle-orm';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const session = await auth.api.getSession({ headers: request.headers });
|
||||||
|
if (!session?.user) {
|
||||||
|
throw error(401, 'Not authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code } = await request.json();
|
||||||
|
|
||||||
|
if (!code || typeof code !== 'string' || code.trim().length === 0) {
|
||||||
|
return json({ error: 'Promo code is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCode = code.trim().toUpperCase();
|
||||||
|
const userId = Number(session.user.id);
|
||||||
|
|
||||||
|
return await db.transaction(async (tx) => {
|
||||||
|
const [promoData] = await tx
|
||||||
|
.select({
|
||||||
|
id: promoCode.id,
|
||||||
|
code: promoCode.code,
|
||||||
|
rewardAmount: promoCode.rewardAmount,
|
||||||
|
maxUses: promoCode.maxUses,
|
||||||
|
expiresAt: promoCode.expiresAt,
|
||||||
|
isActive: promoCode.isActive,
|
||||||
|
description: promoCode.description
|
||||||
|
})
|
||||||
|
.from(promoCode)
|
||||||
|
.where(eq(promoCode.code, normalizedCode))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!promoData) {
|
||||||
|
return json({ error: 'Invalid promo code' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!promoData.isActive) {
|
||||||
|
return json({ error: 'This promo code is no longer active' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promoData.expiresAt && new Date() > promoData.expiresAt) {
|
||||||
|
return json({ error: 'This promo code has expired' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingRedemption] = await tx
|
||||||
|
.select({ id: promoCodeRedemption.id })
|
||||||
|
.from(promoCodeRedemption)
|
||||||
|
.where(and(
|
||||||
|
eq(promoCodeRedemption.userId, userId),
|
||||||
|
eq(promoCodeRedemption.promoCodeId, promoData.id)
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingRedemption) {
|
||||||
|
return json({ error: 'You have already used this promo code' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promoData.maxUses !== null) {
|
||||||
|
const [{ totalUses }] = await tx
|
||||||
|
.select({ totalUses: count() })
|
||||||
|
.from(promoCodeRedemption)
|
||||||
|
.where(eq(promoCodeRedemption.promoCodeId, promoData.id));
|
||||||
|
|
||||||
|
if (totalUses >= promoData.maxUses) {
|
||||||
|
return json({ error: 'This promo code has reached its usage limit' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [userData] = await tx
|
||||||
|
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!userData) {
|
||||||
|
throw error(404, 'User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentBalance = Number(userData.baseCurrencyBalance || 0);
|
||||||
|
const rewardAmount = Number(promoData.rewardAmount);
|
||||||
|
const newBalance = currentBalance + rewardAmount;
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(user)
|
||||||
|
.set({
|
||||||
|
baseCurrencyBalance: newBalance.toFixed(8),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(user.id, userId));
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.insert(promoCodeRedemption)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
promoCodeId: promoData.id,
|
||||||
|
rewardAmount: rewardAmount.toFixed(8)
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
message: promoData.description || `Promo code redeemed! You received $${rewardAmount.toFixed(2)}`,
|
||||||
|
rewardAmount,
|
||||||
|
newBalance,
|
||||||
|
code: promoData.code
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in a new issue