feat: implement daily rewards system & streak

This commit is contained in:
Face 2025-05-26 13:05:47 +03:00
parent d692e86fe0
commit 37d76b243b
15 changed files with 2229 additions and 21 deletions

View file

@ -25,8 +25,8 @@
import { USER_DATA } from '$lib/stores/user-data';
import { PORTFOLIO_DATA, fetchPortfolioData } from '$lib/stores/portfolio-data';
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import SignInConfirmDialog from './SignInConfirmDialog.svelte';
import DailyRewards from './DailyRewards.svelte';
import { signOut } from '$lib/auth-client';
import { getPublicUrl } from '$lib/utils';
import { goto } from '$app/navigation';
@ -142,8 +142,18 @@
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
</Sidebar.GroupContent> </Sidebar.Group>
<!-- Daily Rewards -->
{#if $USER_DATA}
<Sidebar.Group>
<Sidebar.GroupContent>
<div class="px-2 py-1">
<DailyRewards />
</div>
</Sidebar.GroupContent>
</Sidebar.Group>
{/if}
<!-- Portfolio Summary -->
{#if $USER_DATA && $PORTFOLIO_DATA}

View file

@ -0,0 +1,166 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Gift, Clock, CheckCircle, Flame } 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';
interface RewardStatus {
canClaim: boolean;
rewardAmount: number;
baseReward: number;
timeRemaining: number;
nextClaimTime: string | null;
totalRewardsClaimed: number;
lastRewardClaim: string | null;
loginStreak: number;
}
type ClaimState = 'idle' | 'loading' | 'success';
let rewardStatus = $state<RewardStatus | null>(null);
let claimState = $state<ClaimState>('idle');
let error = $state<string | null>(null);
$effect(() => {
if ($USER_DATA) {
fetchRewardStatus();
const interval = setInterval(() => {
if (rewardStatus && !rewardStatus.canClaim) {
fetchRewardStatus();
}
}, 60000);
return () => clearInterval(interval);
}
});
async function fetchRewardStatus() {
try {
const response = await fetch('/api/rewards/claim');
if (response.ok) {
rewardStatus = await response.json();
error = null;
} else {
error = 'Failed to fetch reward status';
}
} catch (err) {
error = 'Network error';
console.error('Error fetching reward status:', err);
}
}
async function claimReward() {
if (!rewardStatus?.canClaim || claimState === 'loading') return;
claimState = 'loading';
error = null;
try {
const response = await fetch('/api/rewards/claim', {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
claimState = 'success';
toast.success(`Daily reward claimed! +$${formatCurrency(result.rewardAmount)}`, {
description:
rewardStatus.loginStreak > 0
? `Login streak: ${rewardStatus.loginStreak} days 🔥`
: undefined,
action: {
label: 'View Portfolio',
onClick: () => {
goto('/portfolio');
}
}
});
if ($USER_DATA) {
await fetchPortfolioData();
}
await fetchRewardStatus();
setTimeout(() => {
claimState = 'idle';
}, 1000);
} else {
const errorData = await response.json();
if (response.status === 429 && errorData.timeRemaining) {
await fetchRewardStatus();
const hours = Math.floor(errorData.timeRemaining / (60 * 60 * 1000));
const minutes = Math.floor((errorData.timeRemaining % (60 * 60 * 1000)) / (60 * 1000));
const timeText = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
const streakDescription =
rewardStatus?.loginStreak > 0
? `Login streak: ${rewardStatus.loginStreak} days 🔥. Next reward available in ${timeText}. Come back later!`
: `Next reward available in ${timeText}. Come back later!`;
toast.info('Daily reward on cooldown', {
description: streakDescription
});
} else {
error = errorData.error || errorData.message || 'Failed to claim reward';
toast.error('Failed to claim reward');
}
}
} catch (err) {
error = 'Network error';
toast.error('Network error', {
description: 'Please check your connection and try again.'
});
console.error('Error claiming reward:', err);
} finally {
if (claimState !== 'success') {
claimState = 'idle';
}
}
}
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,
maximumFractionDigits: 0
});
}
</script>
{#if $USER_DATA && rewardStatus}
<Button
onclick={claimReward}
disabled={claimState === 'loading' || !rewardStatus.canClaim}
class="w-full transition-all duration-300"
size="sm"
variant={claimState === 'success' ? 'secondary' : rewardStatus.canClaim ? 'default' : 'outline'}
>
{#if claimState === 'loading'}
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-current"></div>
<span>Claiming...</span>
{:else if claimState === 'success'}
<CheckCircle class="h-4 w-4" />
<span>Claimed!</span>
{:else if rewardStatus.canClaim}
<Gift class="h-4 w-4" />
<span>Claim ${formatCurrency(rewardStatus.rewardAmount)}</span>
{:else}
<Clock class="h-4 w-4" />
<span>Next in {formatTimeRemaining(rewardStatus.timeRemaining)}</span>
{/if}
</Button>
{/if}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import type { UserProfile } from '$lib/types/user-profile';
import SilentBadge from './SilentBadge.svelte';
import { Hash, Hammer } from 'lucide-svelte';
import { Hash, Hammer, Flame } from 'lucide-svelte';
let {
user,
@ -16,10 +16,13 @@
let badgeClass = $derived(size === 'sm' ? 'text-xs' : '');
</script>
<div class="flex items-center">
<div class="flex items-center gap-1">
{#if showId}
<SilentBadge icon={Hash} class="text-muted-foreground {badgeClass}" text="#{user.id} to join" />
{/if}
{#if user.loginStreak && user.loginStreak > 1}
<SilentBadge icon={Flame} text="{user.loginStreak} day streak" class="text-orange-500 {badgeClass}" />
{/if}
{#if user.isAdmin}
<SilentBadge icon={Hammer} text="Admin" class="text-primary {badgeClass}" />
{/if}

View file

@ -19,6 +19,13 @@ export const user = pgTable("user", {
}).notNull().default("10000.00000000"), // 10,000 *BUSS
bio: varchar("bio", { length: 160 }).default("Hello am 48 year old man from somalia. Sorry for my bed england. I selled my wife for internet connection for play “conter stirk”"),
username: varchar("username", { length: 30 }).notNull().unique(),
lastRewardClaim: timestamp("last_reward_claim", { withTimezone: true }),
totalRewardsClaimed: decimal("total_rewards_claimed", {
precision: 20,
scale: 8,
}).notNull().default("0.00000000"),
loginStreak: integer("login_streak").notNull().default(0)
});
export const session = pgTable("session", {

View file

@ -8,6 +8,7 @@ export interface UserProfile {
baseCurrencyBalance: number;
isAdmin: boolean;
totalPortfolioValue: number;
loginStreak: number;
}
export interface UserStats {

View file

@ -2,6 +2,8 @@
import { page } from '$app/state';
import { Button } from '$lib/components/ui/button';
let clickCount = $state(0);
const status = page.status;
const message = getDefaultMessage(status);
@ -19,6 +21,18 @@
return 'Something went wrong. We have no idea what happened, but you can blame us for it on X!';
}
}
function handleImageClick() {
clickCount++;
}
function handleMouseLeave() {
clickCount = 0;
}
let tooltipMessage = $derived(
clickCount >= 15 ? 'ok you win' : clickCount >= 3 ? 'stop clicking' : 'ts pmo too icl'
);
</script>
<svelte:head>
@ -40,9 +54,32 @@
</div>
</div>
<img
src="/404.gif"
alt="404 Error Illustration"
class="hidden h-64 w-64 object-contain lg:block"
/>
<div
class="group relative hidden lg:block"
role="button"
tabindex="0"
onclick={handleImageClick}
onmouseleave={handleMouseLeave}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleImageClick();
}
}}
aria-label="Click to interact with error illustration"
>
<img
src="/404.gif"
alt="404 Error Illustration"
class="h-64 w-64 cursor-pointer object-contain transition-transform duration-300 hover:rotate-12 hover:scale-110"
/>
<div
class="absolute -top-12 left-1/2 z-10 -translate-x-1/2 transform whitespace-nowrap rounded-lg bg-black px-3 py-1 text-sm text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100"
>
{tooltipMessage}
<div
class="absolute left-1/2 top-full h-0 w-0 -translate-x-1/2 transform border-l-4 border-r-4 border-t-4 border-transparent border-t-black"
></div>
</div>
</div>
</div>

View file

@ -132,7 +132,8 @@ export async function GET({ params, url }) {
creatorId: coin.creatorId,
creatorName: user.name,
creatorUsername: user.username,
creatorBio: user.bio
creatorBio: user.bio,
creatorImage: user.image
})
.from(coin)
.leftJoin(user, eq(coin.creatorId, user.id))

View file

@ -0,0 +1,155 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import type { RequestHandler } from './$types';
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
const THIRTY_SIX_HOURS_MS = 36 * 60 * 60 * 1000;
const REWARD_TIERS = [
1200, // Day 1
1500, // Day 2
1800, // Day 3
2100, // Day 4
2500, // Day 5
3000, // Day 6
3500, // Day 7
4000, // Day 8+
];
function calculateStreak(lastClaim: Date | null, currentStreak: number): number {
if (!lastClaim) return 1;
const timeSinceLastClaim = Date.now() - lastClaim.getTime();
// reset streak if more than 36 hours
if (timeSinceLastClaim > THIRTY_SIX_HOURS_MS) return 1;
// only increment if within valid window (12-36 hours)
if (timeSinceLastClaim >= TWELVE_HOURS_MS) return currentStreak + 1;
return currentStreak;
}
function calculateReward(streak: number): { total: number; base: number } {
const tierIndex = Math.min(streak - 1, REWARD_TIERS.length - 1);
const base = REWARD_TIERS[tierIndex];
return { total: base, base };
}
export const POST: RequestHandler = async ({ request }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) throw error(401, 'Not authenticated');
const userId = Number(session.user.id);
const now = new Date();
return await db.transaction(async (tx) => {
const userData = await tx.select({
id: user.id,
baseCurrencyBalance: user.baseCurrencyBalance,
lastRewardClaim: user.lastRewardClaim,
totalRewardsClaimed: user.totalRewardsClaimed,
loginStreak: user.loginStreak
})
.from(user)
.where(eq(user.id, userId))
.limit(1);
if (!userData[0]) {
throw error(404, 'User not found');
}
const currentUser = userData[0];
if (currentUser.lastRewardClaim) {
const timeSinceLastClaim = now.getTime() - currentUser.lastRewardClaim.getTime();
if (timeSinceLastClaim < TWELVE_HOURS_MS) {
return json({
error: 'Daily reward not yet available',
canClaim: false,
timeRemaining: TWELVE_HOURS_MS - timeSinceLastClaim
}, { status: 429 });
}
}
const newStreak = calculateStreak(currentUser.lastRewardClaim, currentUser.loginStreak || 0);
const reward = calculateReward(newStreak);
const currentBalance = parseFloat(currentUser.baseCurrencyBalance || '0');
const currentTotalRewards = parseFloat(currentUser.totalRewardsClaimed || '0');
const newBalance = currentBalance + reward.total;
const newTotalRewards = currentTotalRewards + reward.total;
await tx.update(user)
.set({
baseCurrencyBalance: newBalance.toFixed(8),
lastRewardClaim: now,
totalRewardsClaimed: newTotalRewards.toFixed(8),
loginStreak: newStreak
})
.where(eq(user.id, currentUser.id));
return json({
success: true,
rewardAmount: reward.total,
baseReward: reward.base,
newBalance,
totalRewardsClaimed: newTotalRewards,
loginStreak: newStreak,
nextClaimTime: new Date(now.getTime() + TWELVE_HOURS_MS)
});
});
};
export const GET: RequestHandler = async ({ request }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) throw error(401, 'Not authenticated');
const [currentUser] = await db.select({
id: user.id,
baseCurrencyBalance: user.baseCurrencyBalance,
lastRewardClaim: user.lastRewardClaim,
totalRewardsClaimed: user.totalRewardsClaimed,
loginStreak: user.loginStreak
})
.from(user)
.where(eq(user.id, Number(session.user.id)))
.limit(1);
if (!currentUser) {
throw error(404, 'User not found');
}
const now = new Date();
let canClaim = true;
let timeRemaining = 0;
let nextClaimTime = null;
if (currentUser.lastRewardClaim) {
const timeSinceLastClaim = now.getTime() - currentUser.lastRewardClaim.getTime();
if (timeSinceLastClaim < TWELVE_HOURS_MS) {
canClaim = false;
timeRemaining = TWELVE_HOURS_MS - timeSinceLastClaim;
nextClaimTime = new Date(currentUser.lastRewardClaim.getTime() + TWELVE_HOURS_MS);
}
}
const potentialStreak = calculateStreak(currentUser.lastRewardClaim, currentUser.loginStreak || 0);
const reward = calculateReward(potentialStreak);
return json({
canClaim,
rewardAmount: reward.total,
baseReward: reward.base,
timeRemaining,
nextClaimTime,
totalRewardsClaimed: Number(currentUser.totalRewardsClaimed || 0),
lastRewardClaim: currentUser.lastRewardClaim,
loginStreak: currentUser.loginStreak || 0
});
};

View file

@ -24,6 +24,7 @@ export async function GET({ params }) {
createdAt: true,
baseCurrencyBalance: true,
isAdmin: true,
loginStreak: true,
}
});

View file

@ -7,18 +7,10 @@
import TradeModal from '$lib/components/self/TradeModal.svelte';
import CommentSection from '$lib/components/self/CommentSection.svelte';
import UserProfilePreview from '$lib/components/self/UserProfilePreview.svelte';
import {
TrendingUp,
TrendingDown,
DollarSign,
Coins,
ChartColumn,
CalendarDays
} from 'lucide-svelte';
import { TrendingUp, TrendingDown, DollarSign, Coins, ChartColumn } from 'lucide-svelte';
import {
createChart,
ColorType,
type Time,
type IChartApi,
CandlestickSeries,
HistogramSeries
@ -61,7 +53,6 @@
coin = result.coin;
chartData = result.candlestickData || [];
volumeData = result.volumeData || [];
} catch (e) {
console.error('Failed to fetch coin data:', e);
toast.error('Failed to load coin data');