diff --git a/apps/scoutgame/components/common/Header/Header.tsx b/apps/scoutgame/components/common/Header/Header.tsx index a9b9481b98..5cbd1524b8 100644 --- a/apps/scoutgame/components/common/Header/Header.tsx +++ b/apps/scoutgame/components/common/Header/Header.tsx @@ -44,6 +44,7 @@ export function Header() { [0] = { }, secondary: { main: secondaryText, - light: secondaryLightText + light: secondaryLightText, + dark: secondaryDarkText }, inputBackground: { main: inputBackgroundDarkMode diff --git a/apps/scoutgame/tsconfig.json b/apps/scoutgame/tsconfig.json index e9c9dfba19..1eae439fbe 100644 --- a/apps/scoutgame/tsconfig.json +++ b/apps/scoutgame/tsconfig.json @@ -42,4 +42,4 @@ }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["../../node_modules", ".next/", "public/sw.js", "sw.js"] -} +} \ No newline at end of file diff --git a/apps/scoutgamecron/src/scripts/deleteBuilderAndRedistributePoints.ts b/apps/scoutgamecron/src/scripts/deleteBuilderAndRedistributePoints.ts index aa01911e33..c459b58584 100644 --- a/apps/scoutgamecron/src/scripts/deleteBuilderAndRedistributePoints.ts +++ b/apps/scoutgamecron/src/scripts/deleteBuilderAndRedistributePoints.ts @@ -1,6 +1,6 @@ import { prisma } from '@charmverse/core/prisma-client'; import { currentSeason } from '@packages/scoutgame/dates'; -import { sendPoints } from '@packages/scoutgame/points/sendPoints'; +import { sendPointsForMiscEvent } from '@packages/scoutgame/points/builderEvents/sendPointsForMiscEvent'; async function deleteBuilderAndRedistributePoints({ builderPath }: { builderPath: string }) { const builder = await prisma.scout.findUnique({ @@ -58,13 +58,13 @@ async function deleteBuilderAndRedistributePoints({ builderPath }: { builderPath }); for (const [scoutId, tokensPurchased] of Object.entries(nftPurchaseEventsRecord)) { const points = tokensPurchased * 20; - await sendPoints({ + await sendPointsForMiscEvent({ tx, builderId: scoutId, points, description: `You received a ${points} point gift from Scout Game`, claimed: true, - earnedAs: 'scout' + earnedAs: 'scout', }); await prisma.userSeasonStats.update({ where: { diff --git a/apps/scoutgamecron/src/scripts/issuePoints.ts b/apps/scoutgamecron/src/scripts/issuePoints.ts index 5ab89c89c6..9a96e4e578 100644 --- a/apps/scoutgamecron/src/scripts/issuePoints.ts +++ b/apps/scoutgamecron/src/scripts/issuePoints.ts @@ -1,7 +1,7 @@ import { log } from '@charmverse/core/log'; import { prisma } from '@charmverse/core/prisma-client'; import { currentSeason, getLastWeek } from '@packages/scoutgame/dates'; -import { sendPoints } from '@packages/scoutgame/points/sendPoints'; +import { sendPointsForMiscEvent } from '@packages/scoutgame/points/builderEvents/sendPointsForMiscEvent'; import { refreshPointStatsFromHistory } from '@packages/scoutgame/points/refreshPointStatsFromHistory'; const fids: number[] = []; @@ -37,7 +37,7 @@ async function issuePoints({ points }: { points: number }) { await prisma.$transaction( async (tx) => { - await sendPoints({ + await sendPointsForMiscEvent({ builderId: scout.id, points, claimed: true, diff --git a/apps/scoutgametelegram/app/friends/page.tsx b/apps/scoutgametelegram/app/friends/page.tsx new file mode 100644 index 0000000000..9ba48f08e9 --- /dev/null +++ b/apps/scoutgametelegram/app/friends/page.tsx @@ -0,0 +1,3 @@ +export default function FriendsPage() { + return
FriendsPage
; +} diff --git a/apps/scoutgametelegram/app/quests/page.tsx b/apps/scoutgametelegram/app/quests/page.tsx new file mode 100644 index 0000000000..a042db32d8 --- /dev/null +++ b/apps/scoutgametelegram/app/quests/page.tsx @@ -0,0 +1,16 @@ +import { QuestsPage } from 'components/quests/QuestsPage'; +import { getDailyClaims } from 'lib/claims/getDailyClaims'; +import { getQuests } from 'lib/quests/getQuests'; +import { getUserFromSession } from 'lib/session/getUserFromSession'; + +export default async function Quests() { + const user = await getUserFromSession(); + + if (!user) { + return null; + } + + const dailyClaims = await getDailyClaims(user.id); + const quests = await getQuests(user.id); + return ; +} diff --git a/apps/scoutgametelegram/components/quests/QuestsPage.tsx b/apps/scoutgametelegram/components/quests/QuestsPage.tsx new file mode 100644 index 0000000000..a41277b0d7 --- /dev/null +++ b/apps/scoutgametelegram/components/quests/QuestsPage.tsx @@ -0,0 +1,22 @@ +import { Box } from '@mui/material'; + +import { InfoBackgroundImage } from 'components/layout/InfoBackgroundImage'; +import type { DailyClaim } from 'lib/claims/getDailyClaims'; +import type { QuestInfo } from 'lib/quests/getQuests'; + +import { DailyClaimGallery } from './components/DailyClaimGallery/DailyClaimGallery'; +import { QuestsList } from './components/QuestsList/QuestsList'; + +export function QuestsPage({ dailyClaims, quests }: { dailyClaims: DailyClaim[]; quests: QuestInfo[] }) { + return ( + <> + + + + + + + + + ); +} diff --git a/apps/scoutgametelegram/components/quests/components/DailyClaimGallery/DailyClaimCard.tsx b/apps/scoutgametelegram/components/quests/components/DailyClaimGallery/DailyClaimCard.tsx new file mode 100644 index 0000000000..c29c888ad8 --- /dev/null +++ b/apps/scoutgametelegram/components/quests/components/DailyClaimGallery/DailyClaimCard.tsx @@ -0,0 +1,85 @@ +'use client'; + +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import { Stack, Typography } from '@mui/material'; +import { DateTime } from 'luxon'; +import Image from 'next/image'; +import { useAction } from 'next-safe-action/hooks'; + +import { claimDailyRewardAction } from 'lib/claims/claimDailyRewardAction'; +import type { DailyClaim } from 'lib/claims/getDailyClaims'; + +import { DailyClaimGift } from './DailyClaimGift'; + +export function DailyClaimCard({ dailyClaim }: { dailyClaim: DailyClaim }) { + const { execute: claimDailyReward, isExecuting } = useAction(claimDailyRewardAction); + const currentWeekDay = DateTime.fromJSDate(new Date()).weekday; + const isPastDay = currentWeekDay > dailyClaim.day; + const isClaimToday = currentWeekDay === dailyClaim.day; + const isClaimed = dailyClaim.claimed; + const buttonLabel = isClaimToday && !isClaimed ? 'Claim' : dailyClaim.isBonus ? 'Bonus' : `Day ${dailyClaim.day}`; + const canClaim = isClaimToday && !isClaimed && !isExecuting; + const variant = isPastDay ? 'disabled' : isClaimToday ? 'secondary' : 'primary'; + + return ( + { + if (canClaim) { + claimDailyReward({ isBonus: dailyClaim.isBonus, dayOfWeek: currentWeekDay }); + } + }} + > + + {!isClaimed ? ( + + {dailyClaim.isBonus ? ( + + + + + + ) : ( + + )} + + ) : ( + + )} + + {dailyClaim.isBonus ? '+3' : '+1'} + Scout game icon + + + + {buttonLabel} + + + ); +} diff --git a/apps/scoutgametelegram/components/quests/components/DailyClaimGallery/DailyClaimGallery.tsx b/apps/scoutgametelegram/components/quests/components/DailyClaimGallery/DailyClaimGallery.tsx new file mode 100644 index 0000000000..5fd728a361 --- /dev/null +++ b/apps/scoutgametelegram/components/quests/components/DailyClaimGallery/DailyClaimGallery.tsx @@ -0,0 +1,24 @@ +import { Grid2 as Grid, Stack, Typography } from '@mui/material'; + +import type { DailyClaim } from 'lib/claims/getDailyClaims'; + +import { DailyClaimCard } from './DailyClaimCard'; +import { NextClaimCountdown } from './NextClaimCountdown'; + +export function DailyClaimGallery({ dailyClaims }: { dailyClaims: DailyClaim[] }) { + return ( + + + Daily Claim + + + + {dailyClaims.map((dailyClaim) => ( + + + + ))} + + + ); +} diff --git a/apps/scoutgametelegram/components/quests/components/DailyClaimGallery/DailyClaimGift.tsx b/apps/scoutgametelegram/components/quests/components/DailyClaimGallery/DailyClaimGift.tsx new file mode 100644 index 0000000000..039c8c48f1 --- /dev/null +++ b/apps/scoutgametelegram/components/quests/components/DailyClaimGallery/DailyClaimGift.tsx @@ -0,0 +1,177 @@ +import { SvgIcon } from '@mui/material'; + +import { brandColor, disabledTextColorDarkMode, secondaryDarkText } from 'theme/colors'; + +export function DailyClaimGift({ + variant = 'primary', + size = 64 +}: { + variant: 'disabled' | 'primary' | 'secondary'; + size: number; +}) { + const primaryColor = brandColor; + const secondaryColor = secondaryDarkText; + const disabledColor = disabledTextColorDarkMode; + + const fillColor = variant === 'primary' ? primaryColor : variant === 'secondary' ? secondaryColor : disabledColor; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/scoutgametelegram/components/quests/components/DailyClaimGallery/NextClaimCountdown.tsx b/apps/scoutgametelegram/components/quests/components/DailyClaimGallery/NextClaimCountdown.tsx new file mode 100644 index 0000000000..bd0998c8ac --- /dev/null +++ b/apps/scoutgametelegram/components/quests/components/DailyClaimGallery/NextClaimCountdown.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { Stack, Typography } from '@mui/material'; +import { timeUntilFuture } from '@packages/utils/dates'; +import { DateTime } from 'luxon'; +import { useEffect, useState } from 'react'; + +export function NextClaimCountdown() { + const currentDate = DateTime.now().toFormat('dd-MM-yyyy'); + const nextDay = DateTime.fromFormat(currentDate, 'dd-MM-yyyy').plus({ days: 1 }); + const [timeStr, setTimeStr] = useState(timeUntilFuture(nextDay.toMillis())); + + const nextDayMillis = nextDay.toMillis(); + + useEffect(() => { + const timeout = setInterval(() => { + setTimeStr(timeUntilFuture(nextDayMillis)); + }, 1000); + + return () => clearInterval(timeout); + }, [setTimeStr, nextDayMillis]); + + if (!timeStr) { + return null; + } + + return ( + + + NEXT REWARD: + + + + {timeStr.hours} + + + h + + + {timeStr.minutes} + + m + + + ); +} diff --git a/apps/scoutgametelegram/components/quests/components/QuestsList/QuestCard.tsx b/apps/scoutgametelegram/components/quests/components/QuestsList/QuestCard.tsx new file mode 100644 index 0000000000..d9c6fc9d88 --- /dev/null +++ b/apps/scoutgametelegram/components/quests/components/QuestsList/QuestCard.tsx @@ -0,0 +1,51 @@ +'use client'; + +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; +import { Stack, Typography } from '@mui/material'; +import Image from 'next/image'; +import { useAction } from 'next-safe-action/hooks'; + +import { completeQuestAction } from 'lib/quests/completeQuestAction'; +import { QuestsRecord, type QuestInfo } from 'lib/quests/getQuests'; + +export function QuestCard({ quest }: { quest: QuestInfo }) { + const { execute, isExecuting } = useAction(completeQuestAction); + + return ( + { + if (!quest.completed && !isExecuting) { + execute({ questType: quest.type }); + const link = QuestsRecord[quest.type].link; + if (link) { + window.open(link, link.startsWith('http') ? '_blank' : '_self'); + } + } + }} + > + + {QuestsRecord[quest.type].icon} + + {QuestsRecord[quest.type].label} + + +{QuestsRecord[quest.type].points} + Scoutgame icon + + + + {quest.completed ? : } + + ); +} diff --git a/apps/scoutgametelegram/components/quests/components/QuestsList/QuestsList.tsx b/apps/scoutgametelegram/components/quests/components/QuestsList/QuestsList.tsx new file mode 100644 index 0000000000..7383792440 --- /dev/null +++ b/apps/scoutgametelegram/components/quests/components/QuestsList/QuestsList.tsx @@ -0,0 +1,20 @@ +import { Typography, Stack } from '@mui/material'; + +import type { QuestInfo } from 'lib/quests/getQuests'; + +import { QuestCard } from './QuestCard'; + +export function QuestsList({ quests }: { quests: QuestInfo[] }) { + return ( + + + Quests + + + {quests.map((quest) => ( + + ))} + + + ); +} diff --git a/apps/scoutgametelegram/hooks/useInitTelegramData.ts b/apps/scoutgametelegram/hooks/useInitTelegramData.ts index 95edf53fbe..7c3fd11337 100644 --- a/apps/scoutgametelegram/hooks/useInitTelegramData.ts +++ b/apps/scoutgametelegram/hooks/useInitTelegramData.ts @@ -1,6 +1,6 @@ /* eslint-disable import/no-extraneous-dependencies */ +import { log } from '@charmverse/core/log'; import WebApp from '@twa-dev/sdk'; -import { redirect } from 'next/navigation'; import { useAction } from 'next-safe-action/hooks'; import { useEffect } from 'react'; @@ -23,12 +23,15 @@ export function useInitTelegramData() { // redirect('/welcome/onboarding'); // } } + }, + onError: (error) => { + log.error('Error loading user', { error: error.error.serverError }); } }); useEffect(() => { // Load the Telegram Web App SDK - if (isMounted) { + if (isMounted && typeof window !== 'undefined') { WebApp.ready(); } }, [isMounted]); diff --git a/apps/scoutgametelegram/lib/actions/actionClient.ts b/apps/scoutgametelegram/lib/actions/actionClient.ts new file mode 100644 index 0000000000..7775caf0ee --- /dev/null +++ b/apps/scoutgametelegram/lib/actions/actionClient.ts @@ -0,0 +1,22 @@ +import { UnauthorisedActionError } from '@charmverse/core/errors'; +import { prisma } from '@charmverse/core/prisma-client'; +import { actionClient } from '@connect-shared/lib/actions/actionClient'; + +export { actionClient }; + +export const authActionClient = actionClient.use(async ({ next, ctx }) => { + const scoutId = ctx.session.scoutId; + + if (!scoutId) { + throw new UnauthorisedActionError('You are not logged in. Please try to login'); + } + + await prisma.scout.findUniqueOrThrow({ + where: { id: scoutId }, + select: { id: true } + }); + + return next({ + ctx: { ...ctx, session: { ...ctx.session, scoutId } } + }); +}); diff --git a/apps/scoutgametelegram/lib/claims/__tests__/claimDailyReward.spec.ts b/apps/scoutgametelegram/lib/claims/__tests__/claimDailyReward.spec.ts new file mode 100644 index 0000000000..a68fbdd740 --- /dev/null +++ b/apps/scoutgametelegram/lib/claims/__tests__/claimDailyReward.spec.ts @@ -0,0 +1,117 @@ +import { prisma } from '@charmverse/core/prisma-client'; +import { getCurrentWeek } from '@packages/scoutgame/dates'; +import { mockBuilder } from '@packages/scoutgame/testing/database'; + +import { claimDailyReward } from '../claimDailyReward'; + +describe('claimDailyReward', () => { + it('should throw error if bonus reward is claimed on a non-last day of the week', async () => { + const builder = await mockBuilder(); + const userId = builder.id; + const isBonus = true; + await expect(claimDailyReward({ userId, isBonus, dayOfWeek: 1 })).rejects.toThrow(); + }); + + it('should throw error if daily reward is claimed twice in a day', async () => { + const builder = await mockBuilder(); + const userId = builder.id; + await claimDailyReward({ userId, isBonus: false, dayOfWeek: 1 }); + await expect(claimDailyReward({ userId, isBonus: false, dayOfWeek: 1 })).rejects.toThrow(); + }); + + it('should claim regular daily reward', async () => { + const builder = await mockBuilder(); + const userId = builder.id; + + await claimDailyReward({ userId, isBonus: false, dayOfWeek: 1 }); + + const dailyClaimEvent = await prisma.scoutDailyClaimEvent.findFirstOrThrow({ + where: { + userId, + dayOfWeek: 1 + } + }); + const pointsReceipt = await prisma.pointsReceipt.findFirstOrThrow({ + where: { + recipientId: userId, + event: { + dailyClaimEventId: dailyClaimEvent.id, + type: 'daily_claim' + } + } + }); + const scout = await prisma.scout.findUniqueOrThrow({ + where: { + id: userId + }, + select: { + currentBalance: true, + userSeasonStats: { + select: { + pointsEarnedAsBuilder: true + } + }, + userAllTimeStats: { + select: { + pointsEarnedAsBuilder: true + } + } + } + }); + + expect(dailyClaimEvent).toBeDefined(); + expect(pointsReceipt).toBeDefined(); + expect(pointsReceipt.value).toBe(1); + expect(scout.currentBalance).toBe(1); + expect(scout.userSeasonStats[0].pointsEarnedAsBuilder).toBe(1); + expect(scout.userAllTimeStats[0].pointsEarnedAsBuilder).toBe(1); + }); + + it('should claim daily reward streak', async () => { + const builder = await mockBuilder(); + const userId = builder.id; + + await claimDailyReward({ userId, isBonus: true, dayOfWeek: 7 }); + + const dailyClaimStreakEvent = await prisma.scoutDailyClaimStreakEvent.findFirstOrThrow({ + where: { + userId, + week: getCurrentWeek() + } + }); + const pointsReceipt = await prisma.pointsReceipt.findFirstOrThrow({ + where: { + recipientId: userId, + event: { + dailyClaimStreakEventId: dailyClaimStreakEvent.id, + type: 'daily_claim_streak' + } + } + }); + const scout = await prisma.scout.findUniqueOrThrow({ + where: { + id: userId + }, + select: { + currentBalance: true, + userSeasonStats: { + select: { + pointsEarnedAsBuilder: true + } + }, + userAllTimeStats: { + select: { + pointsEarnedAsBuilder: true + } + } + } + }); + + expect(dailyClaimStreakEvent).toBeDefined(); + expect(pointsReceipt).toBeDefined(); + expect(pointsReceipt.value).toBe(3); + expect(scout.currentBalance).toBe(3); + expect(scout.userSeasonStats[0].pointsEarnedAsBuilder).toBe(3); + expect(scout.userAllTimeStats[0].pointsEarnedAsBuilder).toBe(3); + }); +}); diff --git a/apps/scoutgametelegram/lib/claims/__tests__/getDailyClaims.spec.ts b/apps/scoutgametelegram/lib/claims/__tests__/getDailyClaims.spec.ts new file mode 100644 index 0000000000..813d3929a5 --- /dev/null +++ b/apps/scoutgametelegram/lib/claims/__tests__/getDailyClaims.spec.ts @@ -0,0 +1,59 @@ +import { getLastWeek } from '@packages/scoutgame/dates'; +import { mockBuilder, mockBuilderEvent } from '@packages/scoutgame/testing/database'; + +import { claimDailyReward } from '../claimDailyReward'; +import { getDailyClaims } from '../getDailyClaims'; + +describe('getDailyClaims', () => { + it('should return the daily claims', async () => { + const builder = await mockBuilder(); + const builder2 = await mockBuilder(); + const userId = builder.id; + + // Mock a daily commit event + mockBuilderEvent({ + builderId: builder.id, + eventType: 'daily_commit' + }); + + // Mock a daily claim event for last week + mockBuilderEvent({ + builderId: builder.id, + eventType: 'daily_claim', + week: getLastWeek() + }); + + // Mock a daily claim event for a different builder + mockBuilderEvent({ + builderId: builder2.id, + eventType: 'daily_claim' + }); + + await claimDailyReward({ + userId, + dayOfWeek: 1, + isBonus: false + }); + + await claimDailyReward({ + userId, + dayOfWeek: 7, + isBonus: false + }); + + await claimDailyReward({ + userId, + dayOfWeek: 7, + isBonus: true + }); + + const claims = await getDailyClaims(userId); + expect(claims).toHaveLength(8); + const claimedEvents = claims.filter((claim) => claim.claimed); + expect(claimedEvents.map((claim) => ({ day: claim.day, isBonus: claim.isBonus }))).toEqual([ + { day: 1, isBonus: false }, + { day: 7, isBonus: false }, + { day: 7, isBonus: true } + ]); + }); +}); diff --git a/apps/scoutgametelegram/lib/claims/claimDailyReward.ts b/apps/scoutgametelegram/lib/claims/claimDailyReward.ts new file mode 100644 index 0000000000..1ad81de1d2 --- /dev/null +++ b/apps/scoutgametelegram/lib/claims/claimDailyReward.ts @@ -0,0 +1,54 @@ +import { prisma } from '@charmverse/core/prisma-client'; +import { getCurrentWeek } from '@packages/scoutgame/dates'; +import { sendPointsForDailyClaim } from '@packages/scoutgame/points/builderEvents/sendPointsForDailyClaim'; +import { sendPointsForDailyClaimStreak } from '@packages/scoutgame/points/builderEvents/sendPointsForDailyClaimStreak'; + +export async function claimDailyReward({ + userId, + isBonus, + dayOfWeek +}: { + userId: string; + isBonus?: boolean; + dayOfWeek: number; +}) { + if (dayOfWeek !== 7 && isBonus) { + throw new Error('Bonus reward can only be claimed on the last day of the week'); + } + + if (isBonus) { + const existingEvent = await prisma.scoutDailyClaimStreakEvent.findFirst({ + where: { + userId, + week: getCurrentWeek() + } + }); + + if (existingEvent) { + throw new Error('Daily reward streak already claimed'); + } + + await sendPointsForDailyClaimStreak({ + builderId: userId, + points: 3 + }); + } else { + const existingEvent = await prisma.scoutDailyClaimEvent.findFirst({ + where: { + userId, + week: getCurrentWeek(), + dayOfWeek + } + }); + + if (existingEvent) { + throw new Error('Daily reward already claimed'); + } + + await sendPointsForDailyClaim({ + builderId: userId, + points: 1, + dayOfWeek + }); + } +} diff --git a/apps/scoutgametelegram/lib/claims/claimDailyRewardAction.ts b/apps/scoutgametelegram/lib/claims/claimDailyRewardAction.ts new file mode 100644 index 0000000000..707afa11bb --- /dev/null +++ b/apps/scoutgametelegram/lib/claims/claimDailyRewardAction.ts @@ -0,0 +1,19 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; + +import { authActionClient } from 'lib/actions/actionClient'; + +import { claimDailyReward } from './claimDailyReward'; +import { claimDailyRewardSchema } from './claimDailyRewardSchema'; + +export const claimDailyRewardAction = authActionClient + .schema(claimDailyRewardSchema) + .action(async ({ parsedInput, ctx }) => { + await claimDailyReward({ + userId: ctx.session.scoutId, + isBonus: parsedInput.isBonus, + dayOfWeek: parsedInput.dayOfWeek + }); + revalidatePath('/quests'); + }); diff --git a/apps/scoutgametelegram/lib/claims/claimDailyRewardSchema.ts b/apps/scoutgametelegram/lib/claims/claimDailyRewardSchema.ts new file mode 100644 index 0000000000..9cb009cd07 --- /dev/null +++ b/apps/scoutgametelegram/lib/claims/claimDailyRewardSchema.ts @@ -0,0 +1,6 @@ +import * as yup from 'yup'; + +export const claimDailyRewardSchema = yup.object({ + isBonus: yup.boolean(), + dayOfWeek: yup.number().required() +}); diff --git a/apps/scoutgametelegram/lib/claims/getDailyClaims.ts b/apps/scoutgametelegram/lib/claims/getDailyClaims.ts new file mode 100644 index 0000000000..dae61c794b --- /dev/null +++ b/apps/scoutgametelegram/lib/claims/getDailyClaims.ts @@ -0,0 +1,48 @@ +import { prisma } from '@charmverse/core/prisma-client'; +import { getCurrentWeek } from '@packages/scoutgame/dates'; + +export type DailyClaim = { + day: number; + claimed: boolean; + isBonus?: boolean; +}; + +export async function getDailyClaims(userId: string): Promise { + const currentWeek = getCurrentWeek(); + const dailyClaimEvents = await prisma.scoutDailyClaimEvent.findMany({ + where: { + userId, + week: currentWeek + }, + orderBy: { + dayOfWeek: 'asc' + } + }); + + const dailyClaimStreakEvent = await prisma.scoutDailyClaimStreakEvent.findFirst({ + where: { + userId, + week: currentWeek + } + }); + + return new Array(7) + .fill(null) + .map((_, index) => { + const dailyClaimEvent = dailyClaimEvents.find((_dailyClaimEvent) => _dailyClaimEvent.dayOfWeek === index + 1); + + const dailyClaimInfo = { + day: index + 1, + claimed: !!dailyClaimEvent, + isBonus: false + }; + + // For the last day of the week, return 2 claims: one for the daily claim and one for the bonus claim + if (index === 6) { + return [dailyClaimInfo, { ...dailyClaimInfo, claimed: !!dailyClaimStreakEvent, isBonus: true }]; + } + + return [dailyClaimInfo]; + }) + .flat(); +} diff --git a/apps/scoutgametelegram/lib/quests/__tests__/completeQuest.spec.ts b/apps/scoutgametelegram/lib/quests/__tests__/completeQuest.spec.ts new file mode 100644 index 0000000000..60e2001a6f --- /dev/null +++ b/apps/scoutgametelegram/lib/quests/__tests__/completeQuest.spec.ts @@ -0,0 +1,51 @@ +import { prisma } from '@charmverse/core/prisma-client'; +import { mockBuilder } from '@packages/scoutgame/testing/database'; + +import { completeQuest } from '../completeQuest'; +import { QuestsRecord } from '../getQuests'; + +describe('completeQuest', () => { + it('should throw an error if the quest is already completed', async () => { + const builder = await mockBuilder(); + await completeQuest(builder.id, 'follow-x-account'); + await expect(completeQuest(builder.id, 'follow-x-account')).rejects.toThrow('Quest already completed'); + }); + + it('should complete a quest', async () => { + const builder = await mockBuilder(); + await completeQuest(builder.id, 'follow-x-account'); + + const quest = await prisma.scoutSocialQuest.findFirstOrThrow({ + where: { + userId: builder.id, + type: 'follow-x-account' + } + }); + + expect(quest).not.toBeNull(); + + const points = await prisma.pointsReceipt.findMany({ + where: { + recipientId: builder.id, + event: { + scoutSocialQuestId: quest.id, + type: 'social_quest' + } + } + }); + + expect(points.length).toBe(1); + expect(points[0].value).toBe(QuestsRecord['follow-x-account'].points); + + const scout = await prisma.scout.findUniqueOrThrow({ + where: { + id: builder.id + }, + select: { + currentBalance: true + } + }); + + expect(scout.currentBalance).toBe(QuestsRecord['follow-x-account'].points); + }); +}); diff --git a/apps/scoutgametelegram/lib/quests/completeQuest.ts b/apps/scoutgametelegram/lib/quests/completeQuest.ts new file mode 100644 index 0000000000..6ad9f272fa --- /dev/null +++ b/apps/scoutgametelegram/lib/quests/completeQuest.ts @@ -0,0 +1,24 @@ +import { prisma } from '@charmverse/core/prisma-client'; +import { sendPointsForSocialQuest } from '@packages/scoutgame/points/builderEvents/sendPointsForSocialQuest'; + +import { QuestsRecord } from './getQuests'; + +export async function completeQuest(userId: string, questType: string) { + const points = QuestsRecord[questType].points; + const quest = await prisma.scoutSocialQuest.findFirst({ + where: { + type: questType, + userId + } + }); + + if (quest) { + throw new Error('Quest already completed'); + } + + await sendPointsForSocialQuest({ + builderId: userId, + points, + type: questType + }); +} diff --git a/apps/scoutgametelegram/lib/quests/completeQuestAction.ts b/apps/scoutgametelegram/lib/quests/completeQuestAction.ts new file mode 100644 index 0000000000..bfd7b32e3d --- /dev/null +++ b/apps/scoutgametelegram/lib/quests/completeQuestAction.ts @@ -0,0 +1,19 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import * as yup from 'yup'; + +import { authActionClient } from 'lib/actions/actionClient'; + +import { completeQuest } from './completeQuest'; + +export const completeQuestAction = authActionClient + .schema( + yup.object({ + questType: yup.string().required() + }) + ) + .action(async ({ parsedInput, ctx }) => { + await completeQuest(ctx.session.scoutId, parsedInput.questType); + revalidatePath('/quests'); + }); diff --git a/apps/scoutgametelegram/lib/quests/getQuests.tsx b/apps/scoutgametelegram/lib/quests/getQuests.tsx new file mode 100644 index 0000000000..27ee1899ff --- /dev/null +++ b/apps/scoutgametelegram/lib/quests/getQuests.tsx @@ -0,0 +1,58 @@ +import { prisma } from '@charmverse/core/prisma-client'; +import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'; +import XIcon from '@mui/icons-material/X'; +import Image from 'next/image'; +import React from 'react'; + +export type QuestInfo = { + type: string; + completed: boolean; +}; + +export const QuestsRecord: Record< + string, + { + points: number; + icon: React.ReactNode; + label: string; + link?: string; + } +> = { + 'follow-x-account': { + points: 50, + icon: , + label: 'Follow @scoutgamexyz', + link: 'https://x.com/@scoutgamexyz' + }, + 'share-x-channel': { + points: 50, + icon: , + label: 'Share this channel' + }, + 'watch-gameplay-video': { + points: 50, + icon: , + label: 'Watch game play video', + link: 'https://www.youtube.com/@scoutgamexyz' + }, + 'invite-friend': { + points: 5, + icon: Friends icon, + label: 'Invite a friend', + link: '/friends' + } +}; + +export async function getQuests(userId: string): Promise { + const socialQuests = await prisma.scoutSocialQuest.findMany({ + where: { + userId + } + }); + + return Object.keys(QuestsRecord).map((type) => ({ + type, + completed: socialQuests.some((quest) => quest.type === type), + ...QuestsRecord[type] + })); +} diff --git a/apps/scoutgametelegram/middleware.ts b/apps/scoutgametelegram/middleware.ts index e8e46a5155..b94dc9fe4b 100644 --- a/apps/scoutgametelegram/middleware.ts +++ b/apps/scoutgametelegram/middleware.ts @@ -1,4 +1,3 @@ -import { log } from '@charmverse/core/log'; import { getSession } from '@connect-shared/lib/session/getSession'; import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; @@ -12,6 +11,10 @@ export async function middleware(request: NextRequest) { const path = request.nextUrl.pathname; const response = NextResponse.next(); // Create a response object to set cookies + if (isLoggedIn && (path === '/' || path === '/welcome')) { + return NextResponse.redirect(new URL('/quests', request.url)); + } + // We don't have a '/' page anymore since we need to handle 2 different layouts if (path === '/') { return NextResponse.redirect(new URL('/welcome', request.url)); diff --git a/apps/scoutgametelegram/public/images/friends-icon.svg b/apps/scoutgametelegram/public/images/friends-icon.svg new file mode 100644 index 0000000000..13f70c5798 --- /dev/null +++ b/apps/scoutgametelegram/public/images/friends-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/scoutgametelegram/public/images/scout-game-profile-icon.png b/apps/scoutgametelegram/public/images/scout-game-profile-icon.png new file mode 100644 index 0000000000..5a54d9f4cc Binary files /dev/null and b/apps/scoutgametelegram/public/images/scout-game-profile-icon.png differ diff --git a/apps/scoutgametelegram/theme/colors.ts b/apps/scoutgametelegram/theme/colors.ts index 9eb4ae78c7..f6351719cb 100644 --- a/apps/scoutgametelegram/theme/colors.ts +++ b/apps/scoutgametelegram/theme/colors.ts @@ -1,6 +1,7 @@ export const brandColor = '#A06CD5'; // purple -export const purpleDisabled = 'rgba(160, 108, 213, 0.25)'; // purple dark +export const brandColorDark = '#332245'; // purple dark export const secondaryText = '#69DDFF'; // blue +export const secondaryDarkText = '#0580A4'; // dark blue export const blackText = '#191919'; // black export const secondaryLightText = '#D8E1FF'; // light blue export const primaryTextColorDarkMode = '#D8E1FF'; diff --git a/apps/scoutgametelegram/theme/theme.tsx b/apps/scoutgametelegram/theme/theme.tsx index 185a8fce3a..de2441feef 100644 --- a/apps/scoutgametelegram/theme/theme.tsx +++ b/apps/scoutgametelegram/theme/theme.tsx @@ -11,10 +11,11 @@ import { inputBackgroundDarkMode, inputBorderDarkMode, primaryTextColorDarkMode, - purpleDisabled, + brandColorDark, secondaryText, secondaryLightText, - blackText + blackText, + secondaryDarkText } from './colors'; const interFont = Inter({ @@ -56,11 +57,12 @@ const themeOptions: Parameters[0] = { }, primary: { main: brandColor, - dark: purpleDisabled + dark: brandColorDark }, secondary: { main: secondaryText, - light: secondaryLightText + light: secondaryLightText, + dark: secondaryDarkText }, inputBackground: { main: inputBackgroundDarkMode diff --git a/package-lock.json b/package-lock.json index 604b955432..6d5a5533a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "@bangle.dev/pm-commands": "^0.31.6", "@bangle.dev/utils": "^0.31.6", "@beam-australia/react-env": "^3.1.1", - "@charmverse/core": "^0.94.0", + "@charmverse/core": "^0.94.1-rc-feat-sg-quest-p.8", "@column-resizer/core": "^1.0.2", "@datadog/browser-logs": "^4.42.2", "@datadog/browser-rum": "^4.42.2", @@ -7058,9 +7058,9 @@ } }, "node_modules/@charmverse/core": { - "version": "0.94.0", - "resolved": "https://registry.npmjs.org/@charmverse/core/-/core-0.94.0.tgz", - "integrity": "sha512-wb7RFrgSPH7mNO9cndGhxHbMvQqNC6ZJg86uEf2ragJY/Ao0Xb+wenAmbkEQs2LDp7dQGI0/nrBYXH02mQJETw==", + "version": "0.94.1-rc-feat-sg-quest-p.8", + "resolved": "https://registry.npmjs.org/@charmverse/core/-/core-0.94.1-rc-feat-sg-quest-p.8.tgz", + "integrity": "sha512-aYWucDva4ul1+LmHamNU1J4jVYEmUt8+aPpxDQ9KqwX2hXyfaCWhvsjfxljhmpoDPLZ8VZu0zO+Hkz+qerUJkA==", "hasInstallScript": true, "dependencies": { "@datadog/browser-logs": "^4.42.2", @@ -76110,9 +76110,9 @@ } }, "@charmverse/core": { - "version": "0.94.0", - "resolved": "https://registry.npmjs.org/@charmverse/core/-/core-0.94.0.tgz", - "integrity": "sha512-wb7RFrgSPH7mNO9cndGhxHbMvQqNC6ZJg86uEf2ragJY/Ao0Xb+wenAmbkEQs2LDp7dQGI0/nrBYXH02mQJETw==", + "version": "0.94.1-rc-feat-sg-quest-p.8", + "resolved": "https://registry.npmjs.org/@charmverse/core/-/core-0.94.1-rc-feat-sg-quest-p.8.tgz", + "integrity": "sha512-aYWucDva4ul1+LmHamNU1J4jVYEmUt8+aPpxDQ9KqwX2hXyfaCWhvsjfxljhmpoDPLZ8VZu0zO+Hkz+qerUJkA==", "requires": { "@datadog/browser-logs": "^4.42.2", "@prisma/client": "^5.7.1", diff --git a/package.json b/package.json index 1259f83f75..5ca34e3fa7 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,7 @@ "@bangle.dev/pm-commands": "^0.31.6", "@bangle.dev/utils": "^0.31.6", "@beam-australia/react-env": "^3.1.1", - "@charmverse/core": "^0.94.0", + "@charmverse/core": "^0.94.1-rc-feat-sg-quest-p.8", "@column-resizer/core": "^1.0.2", "@datadog/browser-logs": "^4.42.2", "@datadog/browser-rum": "^4.42.2", diff --git a/packages/scoutgame/src/builderNfts/mintNFT.ts b/packages/scoutgame/src/builderNfts/mintNFT.ts index 01e75ebd9f..49caaab7a8 100644 --- a/packages/scoutgame/src/builderNfts/mintNFT.ts +++ b/packages/scoutgame/src/builderNfts/mintNFT.ts @@ -13,7 +13,7 @@ export type MintNFTParams = { }; export async function mintNFT(params: MintNFTParams) { - const { builderNftId, recipientAddress, amount, scoutId, pointsValue, paidWithPoints } = params; + const { builderNftId, recipientAddress, amount, scoutId } = params; const builderNft = await prisma.builderNft.findFirstOrThrow({ where: { id: builderNftId diff --git a/packages/scoutgame/src/points/__tests__/sendPoints.spec.ts b/packages/scoutgame/src/points/__tests__/sendPoints.spec.ts index 56b3ffad17..9a5fa72dde 100644 --- a/packages/scoutgame/src/points/__tests__/sendPoints.spec.ts +++ b/packages/scoutgame/src/points/__tests__/sendPoints.spec.ts @@ -2,13 +2,13 @@ import { prisma } from '@charmverse/core/prisma-client'; import { currentSeason } from '../../dates'; import { mockBuilder } from '../../testing/database'; -import { sendPoints } from '../sendPoints'; +import { sendPointsForMiscEvent } from '../builderEvents/sendPointsForMiscEvent'; describe('sendPoints', () => { it('should send points quietly', async () => { const builder = await mockBuilder(); const mockPoints = 100; - await sendPoints({ + await sendPointsForMiscEvent({ builderId: builder.id, points: mockPoints, hideFromNotifications: true, @@ -40,7 +40,7 @@ describe('sendPoints', () => { it('should send points earned as builder', async () => { const builder = await mockBuilder(); const mockPoints = 100; - await sendPoints({ + await sendPointsForMiscEvent({ builderId: builder.id, points: mockPoints, description: 'Test description', diff --git a/packages/scoutgame/src/points/builderEvents/sendPointsForDailyClaim.ts b/packages/scoutgame/src/points/builderEvents/sendPointsForDailyClaim.ts new file mode 100644 index 0000000000..4e33d17685 --- /dev/null +++ b/packages/scoutgame/src/points/builderEvents/sendPointsForDailyClaim.ts @@ -0,0 +1,82 @@ +import type { Prisma } from '@charmverse/core/prisma-client'; +import { prisma } from '@charmverse/core/prisma-client'; +import type { ISOWeek } from '@packages/scoutgame/dates'; +import { currentSeason, getCurrentWeek } from '@packages/scoutgame/dates'; + +import { incrementPointsEarnedStats } from '../updatePointsEarned'; + +export async function sendPointsForDailyClaim({ + builderId, + season = currentSeason, + week = getCurrentWeek(), + points, + dayOfWeek, + tx +}: { + dayOfWeek: number; + builderId: string; + points: number; + season?: ISOWeek; + week?: ISOWeek; + tx?: Prisma.TransactionClient; +}) { + async function txHandler(_tx: Prisma.TransactionClient) { + await _tx.scoutDailyClaimEvent.create({ + data: { + dayOfWeek, + week, + user: { + connect: { + id: builderId + } + }, + event: { + create: { + builderId, + type: 'daily_claim', + week, + season, + pointsReceipts: { + create: { + claimedAt: new Date(), + value: points, + recipientId: builderId, + activities: { + create: { + type: 'points', + userId: builderId, + recipientType: 'builder' + } + } + } + } + } + } + } + }); + + await _tx.scout.update({ + where: { + id: builderId + }, + data: { + currentBalance: { + increment: points + } + } + }); + + await incrementPointsEarnedStats({ + season, + userId: builderId, + builderPoints: points, + tx: _tx + }); + } + + if (tx) { + return txHandler(tx); + } else { + return prisma.$transaction(txHandler); + } +} diff --git a/packages/scoutgame/src/points/builderEvents/sendPointsForDailyClaimStreak.ts b/packages/scoutgame/src/points/builderEvents/sendPointsForDailyClaimStreak.ts new file mode 100644 index 0000000000..148dcd8241 --- /dev/null +++ b/packages/scoutgame/src/points/builderEvents/sendPointsForDailyClaimStreak.ts @@ -0,0 +1,79 @@ +import type { Prisma } from '@charmverse/core/prisma-client'; +import { prisma } from '@charmverse/core/prisma-client'; +import type { ISOWeek } from '@packages/scoutgame/dates'; +import { currentSeason, getCurrentWeek } from '@packages/scoutgame/dates'; + +import { incrementPointsEarnedStats } from '../updatePointsEarned'; + +export async function sendPointsForDailyClaimStreak({ + builderId, + season = currentSeason, + week = getCurrentWeek(), + points, + tx +}: { + builderId: string; + points: number; + season?: ISOWeek; + week?: ISOWeek; + tx?: Prisma.TransactionClient; +}) { + async function txHandler(_tx: Prisma.TransactionClient) { + await _tx.scoutDailyClaimStreakEvent.create({ + data: { + week, + user: { + connect: { + id: builderId + } + }, + event: { + create: { + builderId, + type: 'daily_claim_streak', + week, + season, + pointsReceipts: { + create: { + claimedAt: new Date(), + value: points, + recipientId: builderId, + activities: { + create: { + type: 'points', + userId: builderId, + recipientType: 'builder' + } + } + } + } + } + } + } + }); + + await _tx.scout.update({ + where: { + id: builderId + }, + data: { + currentBalance: { + increment: points + } + } + }); + + await incrementPointsEarnedStats({ + season, + userId: builderId, + builderPoints: points, + tx: _tx + }); + } + + if (tx) { + return txHandler(tx); + } else { + return prisma.$transaction(txHandler); + } +} diff --git a/packages/scoutgame/src/points/sendPoints.ts b/packages/scoutgame/src/points/builderEvents/sendPointsForMiscEvent.ts similarity index 94% rename from packages/scoutgame/src/points/sendPoints.ts rename to packages/scoutgame/src/points/builderEvents/sendPointsForMiscEvent.ts index 460cde4c73..aee05f3297 100644 --- a/packages/scoutgame/src/points/sendPoints.ts +++ b/packages/scoutgame/src/points/builderEvents/sendPointsForMiscEvent.ts @@ -3,9 +3,9 @@ import { prisma } from '@charmverse/core/prisma-client'; import type { ISOWeek } from '@packages/scoutgame/dates'; import { currentSeason, getCurrentWeek } from '@packages/scoutgame/dates'; -import { incrementPointsEarnedStats } from './updatePointsEarned'; +import { incrementPointsEarnedStats } from '../updatePointsEarned'; -export async function sendPoints({ +export async function sendPointsForMiscEvent({ builderId, season = currentSeason, week = getCurrentWeek(), diff --git a/packages/scoutgame/src/points/builderEvents/sendPointsForSocialQuest.ts b/packages/scoutgame/src/points/builderEvents/sendPointsForSocialQuest.ts new file mode 100644 index 0000000000..0c2f0dd1b5 --- /dev/null +++ b/packages/scoutgame/src/points/builderEvents/sendPointsForSocialQuest.ts @@ -0,0 +1,81 @@ +import type { Prisma } from '@charmverse/core/prisma-client'; +import { prisma } from '@charmverse/core/prisma-client'; +import type { ISOWeek } from '@packages/scoutgame/dates'; +import { currentSeason, getCurrentWeek } from '@packages/scoutgame/dates'; + +import { incrementPointsEarnedStats } from '../updatePointsEarned'; + +export async function sendPointsForSocialQuest({ + builderId, + season = currentSeason, + week = getCurrentWeek(), + points, + type, + tx +}: { + type: string; + builderId: string; + points: number; + season?: ISOWeek; + week?: ISOWeek; + tx?: Prisma.TransactionClient; +}) { + async function txHandler(_tx: Prisma.TransactionClient) { + await _tx.scoutSocialQuest.create({ + data: { + type, + user: { + connect: { + id: builderId + } + }, + event: { + create: { + builderId, + type: 'social_quest', + week, + season, + pointsReceipts: { + create: { + claimedAt: new Date(), + value: points, + recipientId: builderId, + activities: { + create: { + type: 'points', + userId: builderId, + recipientType: 'builder' + } + } + } + } + } + } + } + }); + + await _tx.scout.update({ + where: { + id: builderId + }, + data: { + currentBalance: { + increment: points + } + } + }); + + await incrementPointsEarnedStats({ + season, + userId: builderId, + builderPoints: points, + tx: _tx + }); + } + + if (tx) { + return txHandler(tx); + } else { + return prisma.$transaction(txHandler); + } +} diff --git a/packages/scoutgame/src/testing/database.ts b/packages/scoutgame/src/testing/database.ts index c981764a08..9e1e0a2073 100644 --- a/packages/scoutgame/src/testing/database.ts +++ b/packages/scoutgame/src/testing/database.ts @@ -139,13 +139,24 @@ export async function mockGemPayoutEvent({ }); } -export async function mockBuilderEvent({ builderId, eventType }: { builderId: string; eventType: BuilderEventType }) { +export async function mockBuilderEvent({ + builderId, + eventType, + week = getCurrentWeek(), + createdAt = new Date() +}: { + builderId: string; + eventType: BuilderEventType; + week?: string; + createdAt?: Date; +}) { return prisma.builderEvent.create({ data: { + createdAt, builderId, season: mockSeason, type: eventType, - week: getCurrentWeek() + week } }); } diff --git a/packages/scoutgame/src/users/findOrCreateUser.ts b/packages/scoutgame/src/users/findOrCreateUser.ts index f56dc4ad2a..0383ed8b47 100644 --- a/packages/scoutgame/src/users/findOrCreateUser.ts +++ b/packages/scoutgame/src/users/findOrCreateUser.ts @@ -1,6 +1,6 @@ import { InvalidInputError } from '@charmverse/core/errors'; import { log } from '@charmverse/core/log'; -import type { BuilderEventType, Scout, ScoutWallet } from '@charmverse/core/prisma-client'; +import type { BuilderEventType, Scout } from '@charmverse/core/prisma-client'; import { prisma } from '@charmverse/core/prisma-client'; import { getUserS3FilePath, diff --git a/packages/utils/package.json b/packages/utils/package.json index 6f155fcc59..fb203d6a12 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -20,4 +20,4 @@ "./constants": "./src/constants.ts", "./url": "./src/url.ts" } -} +} \ No newline at end of file diff --git a/packages/utils/src/dates.ts b/packages/utils/src/dates.ts index 2cd16fe7d6..a6204fa492 100644 --- a/packages/utils/src/dates.ts +++ b/packages/utils/src/dates.ts @@ -9,3 +9,24 @@ export function getRelativeTime(date: Date | string) { }) ?.replace(' ago', ''); } + +export function timeUntilFuture(date?: number) { + if (!date) { + return null; // No future dates available + } + + const now = new Date().getTime(); + const timeDifference = date - now; + + const days = Math.floor(timeDifference / (1000 * 60 * 60 * 24)); + const hours = Math.floor((timeDifference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((timeDifference % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((timeDifference % (1000 * 60)) / 1000); + + const timeString = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart( + 2, + '0' + )}`; + + return { days, timeString, hours, minutes, seconds }; +} diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js index 30b03e3d77..d8d25a4c24 100644 --- a/public/mockServiceWorker.js +++ b/public/mockServiceWorker.js @@ -278,4 +278,4 @@ async function respondWithMock(response) { }); return mockedResponse; -} +} \ No newline at end of file