diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 05071ac..b1ca533 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -135,7 +135,9 @@
"mcp__claude_ai_Context7__resolve-library-id",
"mcp__claude_ai_Context7__query-docs",
"Bash(npm show:*)",
- "Bash(git:*)"
+ "Bash(git:*)",
+ "Bash(cp:*)",
+ "Bash(/home/mozyonee/Projects/widgetable/apps/server/src/storage/storage.config.ts:*)"
],
"deny": [],
"ask": []
diff --git a/apps/client/public/sw.js b/apps/client/public/sw.js
index 8e8ba21..7f51156 100644
--- a/apps/client/public/sw.js
+++ b/apps/client/public/sw.js
@@ -1 +1 @@
-if(!self.define){let e,s={};const a=(a,i)=>(a=new URL(a+".js",i).href,s[a]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=a,e.onload=s,document.head.appendChild(e)}else e=a,importScripts(a),s()}).then(()=>{let e=s[a];if(!e)throw new Error(`Module ${a} didn’t register its module`);return e}));self.define=(i,t)=>{const n=e||("document"in self?document.currentScript.src:"")||location.href;if(s[n])return;let c={};const r=e=>a(e,n),d={module:{uri:n},exports:c,require:r};s[n]=Promise.all(i.map(e=>d[e]||r(e))).then(e=>(t(...e),c))}}define(["./workbox-a41e51f5"],function(e){"use strict";importScripts("/push-sw.js"),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/chunks/369-6b0b57d2160535cd.js",revision:"6b0b57d2160535cd"},{url:"/_next/static/chunks/523-367f7faddea50012.js",revision:"367f7faddea50012"},{url:"/_next/static/chunks/636-e4a70979177dee32.js",revision:"e4a70979177dee32"},{url:"/_next/static/chunks/642-b86e7ae6e7d6a349.js",revision:"b86e7ae6e7d6a349"},{url:"/_next/static/chunks/652-95cbeb5beae6e21b.js",revision:"95cbeb5beae6e21b"},{url:"/_next/static/chunks/746-779741b4e2698125.js",revision:"779741b4e2698125"},{url:"/_next/static/chunks/782-66c8b25ee45ada2c.js",revision:"66c8b25ee45ada2c"},{url:"/_next/static/chunks/87c73c54-4584b83e8281e545.js",revision:"4584b83e8281e545"},{url:"/_next/static/chunks/968-8ed2aea14a0ac4b2.js",revision:"8ed2aea14a0ac4b2"},{url:"/_next/static/chunks/app/(pages)/account/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/account/page-2fbae0083b7dc14d.js",revision:"2fbae0083b7dc14d"},{url:"/_next/static/chunks/app/(pages)/auth/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/auth/page-a3893bff78da88a5.js",revision:"a3893bff78da88a5"},{url:"/_next/static/chunks/app/(pages)/friends/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/friends/page-3d2caa8400b2ede2.js",revision:"3d2caa8400b2ede2"},{url:"/_next/static/chunks/app/(pages)/layout-f852056f116289ee.js",revision:"f852056f116289ee"},{url:"/_next/static/chunks/app/(pages)/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/page-f3356bafa90086e3.js",revision:"f3356bafa90086e3"},{url:"/_next/static/chunks/app/(pages)/pet/%5Bid%5D/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/pet/%5Bid%5D/page-4f0d11d7c9308510.js",revision:"4f0d11d7c9308510"},{url:"/_next/static/chunks/app/_global-error/page-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/_not-found/page-600014be023479d4.js",revision:"600014be023479d4"},{url:"/_next/static/chunks/app/layout-301d0681551a315d.js",revision:"301d0681551a315d"},{url:"/_next/static/chunks/app/pwa/page-c4e32ec364eb42be.js",revision:"c4e32ec364eb42be"},{url:"/_next/static/chunks/framework-95999e7820816f6d.js",revision:"95999e7820816f6d"},{url:"/_next/static/chunks/main-059076a541984aa4.js",revision:"059076a541984aa4"},{url:"/_next/static/chunks/main-app-62d61c46dc5e7d2a.js",revision:"62d61c46dc5e7d2a"},{url:"/_next/static/chunks/next/dist/client/components/builtin/app-error-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/next/dist/client/components/builtin/forbidden-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/next/dist/client/components/builtin/global-error-828819635e256b84.js",revision:"828819635e256b84"},{url:"/_next/static/chunks/next/dist/client/components/builtin/not-found-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/next/dist/client/components/builtin/unauthorized-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-720e2e794d6522b8.js",revision:"720e2e794d6522b8"},{url:"/_next/static/css/a2a2710216ac4a71.css",revision:"a2a2710216ac4a71"},{url:"/_next/static/media/4cf2300e9c8272f7-s.p.woff2",revision:"18bae71b1e1b2bb25321090a3b563103"},{url:"/_next/static/media/747892c23ea88013-s.woff2",revision:"a0761690ccf4441ace5cec893b82d4ab"},{url:"/_next/static/media/8d697b304b401681-s.woff2",revision:"cc728f6c0adb04da0dfcb0fc436a8ae5"},{url:"/_next/static/media/93f479601ee12b01-s.p.woff2",revision:"da83d5f06d825c5ae65b7cca706cb312"},{url:"/_next/static/media/9610d9e46709d722-s.woff2",revision:"7b7c0ef93df188a852344fc272fc096b"},{url:"/_next/static/media/ba015fad6dcf6784-s.woff2",revision:"8ea4f719af3312a055caf09f34c89a77"},{url:"/auth",revision:"WO2sxNmWG3ki95APUVvrI"},{url:"/icon-192x192.png",revision:"651c40c4307dd375ef0b1d433bba2608"},{url:"/manifest.json",revision:"1934748cd753b835e3f8931cfd62bcab"},{url:"/push-sw.js",revision:"77a72c825e12dcf8f82553e26aa648e5"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:a,state:i})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/\.(png|jpg|jpeg|webp|gif|svg)$/i,new e.CacheFirst({cacheName:"images",plugins:[new e.ExpirationPlugin({maxEntries:200,maxAgeSeconds:2592e3})]}),"GET"),e.registerRoute(/\/users\/.*\/picture/,new e.StaleWhileRevalidate({cacheName:"profile-pictures",plugins:[new e.ExpirationPlugin({maxEntries:50,maxAgeSeconds:604800})]}),"GET")});
+if(!self.define){let e,s={};const a=(a,i)=>(a=new URL(a+".js",i).href,s[a]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=a,e.onload=s,document.head.appendChild(e)}else e=a,importScripts(a),s()}).then(()=>{let e=s[a];if(!e)throw new Error(`Module ${a} didn’t register its module`);return e}));self.define=(i,t)=>{const n=e||("document"in self?document.currentScript.src:"")||location.href;if(s[n])return;let c={};const r=e=>a(e,n),b={module:{uri:n},exports:c,require:r};s[n]=Promise.all(i.map(e=>b[e]||r(e))).then(e=>(t(...e),c))}}define(["./workbox-a41e51f5"],function(e){"use strict";importScripts("/push-sw.js"),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/chunks/369-6b0b57d2160535cd.js",revision:"6b0b57d2160535cd"},{url:"/_next/static/chunks/523-367f7faddea50012.js",revision:"367f7faddea50012"},{url:"/_next/static/chunks/636-e4a70979177dee32.js",revision:"e4a70979177dee32"},{url:"/_next/static/chunks/642-1099a1704070643b.js",revision:"1099a1704070643b"},{url:"/_next/static/chunks/652-95cbeb5beae6e21b.js",revision:"95cbeb5beae6e21b"},{url:"/_next/static/chunks/67-906b5ac132a903b9.js",revision:"906b5ac132a903b9"},{url:"/_next/static/chunks/746-779741b4e2698125.js",revision:"779741b4e2698125"},{url:"/_next/static/chunks/87c73c54-4584b83e8281e545.js",revision:"4584b83e8281e545"},{url:"/_next/static/chunks/968-8ed2aea14a0ac4b2.js",revision:"8ed2aea14a0ac4b2"},{url:"/_next/static/chunks/app/(pages)/account/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/account/page-59ddcbeeac4f8441.js",revision:"59ddcbeeac4f8441"},{url:"/_next/static/chunks/app/(pages)/auth/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/auth/page-a3893bff78da88a5.js",revision:"a3893bff78da88a5"},{url:"/_next/static/chunks/app/(pages)/friends/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/friends/page-3d2caa8400b2ede2.js",revision:"3d2caa8400b2ede2"},{url:"/_next/static/chunks/app/(pages)/layout-f852056f116289ee.js",revision:"f852056f116289ee"},{url:"/_next/static/chunks/app/(pages)/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/page-f3356bafa90086e3.js",revision:"f3356bafa90086e3"},{url:"/_next/static/chunks/app/(pages)/pet/%5Bid%5D/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/pet/%5Bid%5D/page-547ab5460e71e58b.js",revision:"547ab5460e71e58b"},{url:"/_next/static/chunks/app/_global-error/page-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/_not-found/page-600014be023479d4.js",revision:"600014be023479d4"},{url:"/_next/static/chunks/app/layout-1761d832b4871ab0.js",revision:"1761d832b4871ab0"},{url:"/_next/static/chunks/app/pwa/page-c4e32ec364eb42be.js",revision:"c4e32ec364eb42be"},{url:"/_next/static/chunks/framework-95999e7820816f6d.js",revision:"95999e7820816f6d"},{url:"/_next/static/chunks/main-059076a541984aa4.js",revision:"059076a541984aa4"},{url:"/_next/static/chunks/main-app-62d61c46dc5e7d2a.js",revision:"62d61c46dc5e7d2a"},{url:"/_next/static/chunks/next/dist/client/components/builtin/app-error-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/next/dist/client/components/builtin/forbidden-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/next/dist/client/components/builtin/global-error-828819635e256b84.js",revision:"828819635e256b84"},{url:"/_next/static/chunks/next/dist/client/components/builtin/not-found-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/next/dist/client/components/builtin/unauthorized-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-720e2e794d6522b8.js",revision:"720e2e794d6522b8"},{url:"/_next/static/css/a2a2710216ac4a71.css",revision:"a2a2710216ac4a71"},{url:"/_next/static/media/4cf2300e9c8272f7-s.p.woff2",revision:"18bae71b1e1b2bb25321090a3b563103"},{url:"/_next/static/media/747892c23ea88013-s.woff2",revision:"a0761690ccf4441ace5cec893b82d4ab"},{url:"/_next/static/media/8d697b304b401681-s.woff2",revision:"cc728f6c0adb04da0dfcb0fc436a8ae5"},{url:"/_next/static/media/93f479601ee12b01-s.p.woff2",revision:"da83d5f06d825c5ae65b7cca706cb312"},{url:"/_next/static/media/9610d9e46709d722-s.woff2",revision:"7b7c0ef93df188a852344fc272fc096b"},{url:"/_next/static/media/ba015fad6dcf6784-s.woff2",revision:"8ea4f719af3312a055caf09f34c89a77"},{url:"/auth",revision:"SmC_IgqAkgyZVdYLF82iW"},{url:"/icon-192x192.png",revision:"651c40c4307dd375ef0b1d433bba2608"},{url:"/manifest.json",revision:"1934748cd753b835e3f8931cfd62bcab"},{url:"/push-sw.js",revision:"77a72c825e12dcf8f82553e26aa648e5"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:a,state:i})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/\.(png|jpg|jpeg|webp|gif|svg)$/i,new e.CacheFirst({cacheName:"images",plugins:[new e.ExpirationPlugin({maxEntries:200,maxAgeSeconds:2592e3})]}),"GET"),e.registerRoute(/\/users\/.*\/picture/,new e.StaleWhileRevalidate({cacheName:"profile-pictures",plugins:[new e.ExpirationPlugin({maxEntries:50,maxAgeSeconds:604800})]}),"GET")});
diff --git a/apps/client/src/app/(pages)/account/page.tsx b/apps/client/src/app/(pages)/account/page.tsx
index 615bcaa..5640dc0 100644
--- a/apps/client/src/app/(pages)/account/page.tsx
+++ b/apps/client/src/app/(pages)/account/page.tsx
@@ -4,9 +4,9 @@ import { Button, InputTextHidden } from '@/components/ui/Button';
import { Skeleton } from '@/components/ui/Skeleton';
import { useAuth } from '@/features/auth/hooks/useAuth';
import { setUserData } from '@/features/auth/slices/userSlice';
-import { ClaimButton } from '@/features/claims/components/ClaimButton';
-import { RewardsModal } from '@/features/claims/components/RewardsModal';
-import { useClaims } from '@/features/claims/hooks/useClaims';
+import { ItemButton } from '@/features/items/components/ItemButton';
+import { ItemsModal } from '@/features/items/components/ItemsModal';
+import { useItems } from '@/features/items/hooks/useItems';
import { usePushNotifications } from '@/features/notifications/hooks/usePushNotifications';
import { useTranslation } from '@/i18n/hooks/useTranslation';
import api from '@/lib/api';
@@ -14,7 +14,7 @@ import { useImagesLoaded } from '@/lib/hooks/useImagesLoaded';
import { callError, callSuccess } from '@/lib/toast';
import { useAppDispatch, useAppSelector } from '@/store';
import { Camera, Power, User as UserIcon } from '@nsmr/pixelart-react';
-import { LANGUAGES, User } from '@widgetable/types';
+import { ClaimType, LANGUAGES, User } from '@widgetable/types';
import Image from 'next/image';
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -49,7 +49,7 @@ const Account = () => {
const dispatch = useAppDispatch();
const user = useAppSelector((state) => state.user.userData);
const { t, language, setLanguage } = useTranslation();
- const { claimStatus, claimingType, lastRewards, claimDaily, claimQuick, closeRewardsModal } = useClaims();
+ const { itemStatus, claimingType, lastItems, claimDaily, claimQuick, closeItemsModal } = useItems();
const {
isSupported,
isSubscribed,
@@ -203,20 +203,20 @@ const Account = () => {
{t('account.carePackages')}
{t('account.carePackagesDesc')}
- {claimStatus && (
+ {itemStatus && (
- void claimDaily()}
/>
- void claimQuick()}
/>
@@ -283,7 +283,7 @@ const Account = () => {
>
)}
- {lastRewards && }
+ {lastItems && }
);
};
diff --git a/apps/client/src/app/(pages)/loading.tsx b/apps/client/src/app/(pages)/loading.tsx
index 06110b5..3457e37 100644
--- a/apps/client/src/app/(pages)/loading.tsx
+++ b/apps/client/src/app/(pages)/loading.tsx
@@ -4,7 +4,7 @@ export default function Loading() {
return (
diff --git a/apps/client/src/app/globals.css b/apps/client/src/app/globals.css
index 203d3ea..89531e6 100644
--- a/apps/client/src/app/globals.css
+++ b/apps/client/src/app/globals.css
@@ -64,6 +64,9 @@
--color-accent: var(--accent);
--color-danger: var(--danger);
--color-success: var(--success);
+ --color-tier-common: #22c55e;
+ --color-tier-premium: #3b82f6;
+ --color-tier-legendary: #a855f7;
--font-sans: 'Basis33', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'Basis33', ui-monospace, monospace;
@@ -124,6 +127,13 @@ a {
cursor: pointer;
}
+img {
+ -webkit-user-select: none;
+ user-select: none;
+ -webkit-touch-callout: none;
+ pointer-events: none;
+}
+
.bounce-squash {
animation: bounceSquash 1s ease-in-out infinite;
}
\ No newline at end of file
diff --git a/apps/client/src/data/actionSprites.ts b/apps/client/src/data/actionSprites.ts
deleted file mode 100644
index 9876daf..0000000
--- a/apps/client/src/data/actionSprites.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-export const ACTION_SPRITES: Record
= {
- // Feed Actions
- Sushi: 'food/food/sushi_rolls.png',
- Hamburger: 'food/food/hamburger.png',
- Lasagna: 'food/food/lasagna.png',
- 'Bento Box': 'food/food/bento_box.png',
- Pizza: 'food/food/pizza_slice.png',
- Ramen: 'food/food/ramen_bowl.png',
- Taco: 'food/food/taco.png',
- Burrito: 'food/food/burrito.png',
- Sandwich: 'food/food/sandwich.png',
- 'Hot Dog': 'food/food/hot_dog.png',
- Soup: 'food/food/tomato_soup_bowl.png',
- 'Rice Bowl': 'food/food/rice_bowl.png',
- 'Mac & Cheese': 'food/food/mac_and_cheese_bowl.png',
- Watermelon: 'food/fruits/watermelon_slice.png',
- Mango: 'food/fruits/mango.png',
- Strawberry: 'food/plants/strawberry.png',
- Cookie: 'food/desserts/chocolate_chip_cookies.png',
- Brownie: 'food/desserts/brownie.png',
- Donut: 'food/desserts/glazed_donut.png',
- 'French Fries': 'food/food/french_fries.png',
-
- // Drink Actions
- 'Orange Juice': 'food/desserts/orange_juice.png',
- 'Apple Cider': 'food/desserts/apple_cider_mug.png',
- 'Fruit Parfait': 'food/food/fruit_parfait_cup.png',
- Latte: 'food/desserts/latte.png',
- Milk: 'food/food/milk_carton.png',
- 'Hot Cocoa': 'food/desserts/cocoa_mug.png',
- 'Fruit Tea': 'food/desserts/tea_cup.png',
- Cappuccino: 'food/desserts/cappuccino_mug.png',
-
- // Wash Actions
- 'Bath Salts': 'bathroom/bath_salts_jar.png',
- Bathtub: 'bathroom/bathtub.png',
- Shampoo: 'bathroom/shampoo_bottle.png',
- Shower: 'bathroom/shower_stall.png',
- 'Mouth Wash': 'bathroom/mouth_wash_bottle.png',
- 'Hand Soap': 'bathroom/hand_soap_pump.png',
- Brush: 'bathroom/toothbrush.png',
- 'Wipe Down': 'bathroom/bath_sponge.png',
- Sponge: 'bathroom/natural_sponge.png',
-
- // Valentine Items - Chocolates
- 'Chocolate Bar Red': '/valentine/chocolate_bar_red.png',
- 'Chocolate Bar Brown': '/valentine/chocolate_bar_brown.png',
- 'Chocolate Bar Blue': '/valentine/chocolate_bar_blue.png',
- 'Chocolate Bar White': '/valentine/chocolate_bar_white.png',
- 'Chocolate Bar Duo': '/valentine/chocolate_bar_red_blue.png',
- Chocolate: '/valentine/chocolate.png',
-
- // Valentine Items - Letters & Envelopes
- 'Letter Sealed Red Heart': '/valentine/letter_sealed_red_heart.png',
- 'Letter Sealed Red Border': '/valentine/letter_sealed_red_border.png',
- 'Letter Sealed Pink': '/valentine/letter_sealed_pink.png',
- 'Letter Sealed Small Heart': '/valentine/letter_sealed_small_heart.png',
- 'Letter Sealed Orange': '/valentine/letter_sealed_orange.png',
- 'Letter Sealed Pink Heart': '/valentine/letter_sealed_pink_heart.png',
- 'Letter Sealed Brown': '/valentine/letter_sealed_brown.png',
- 'Letter Sealed Amber': '/valentine/letter_sealed_amber.png',
- 'Envelope White': '/valentine/envelope_white.png',
- 'Envelope Pink': '/valentine/envelope_pink.png',
-
- // Care Actions
- Toilet: 'bathroom/toilet.png',
- 'Quick Potty': 'bathroom/toilet_paper_roll.png',
- 'Long Sleep': 'bedroom/blue_bed_horizontal.png',
- Nap: 'bedroom/white_pillow.png',
- Rest: 'bedroom/folded_blanket.png',
- 'Grooming Session': 'bathroom/hair_brush.png',
- 'Nail Trim': 'bathroom/nail_file.png',
- 'Ear Cleaning': 'bathroom/cotton_swab_jar.png',
-};
-
-export const getActionSprite = (actionName: string): string | undefined => {
- const sprite = ACTION_SPRITES[actionName];
- if (!sprite) return undefined;
- return sprite.startsWith('/') ? sprite : `/assets_new/${sprite}`;
-};
diff --git a/apps/client/src/data/petScales.ts b/apps/client/src/data/petScales.ts
deleted file mode 100644
index 65126d0..0000000
--- a/apps/client/src/data/petScales.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { PET_SCALES, PetType } from '@widgetable/types';
-
-export const getPetScale = (petType: PetType | 'egg'): number => {
- return PET_SCALES[petType] ?? 1.0;
-};
diff --git a/apps/client/src/features/auth/hooks/useAuth.ts b/apps/client/src/features/auth/hooks/useAuth.ts
index a48927e..dc5f826 100644
--- a/apps/client/src/features/auth/hooks/useAuth.ts
+++ b/apps/client/src/features/auth/hooks/useAuth.ts
@@ -1,7 +1,7 @@
'use client';
import { logout, setAuthenticated, setUserData } from '@/features/auth/slices/userSlice';
-import { clearClaims } from '@/features/claims/slices/claimsSlice';
+import { clearItems } from '@/features/items/slices/itemsSlice';
import { clearPets } from '@/features/pets/slices/petsSlice';
import api from '@/lib/api';
import { persistor, useAppDispatch, useAppSelector } from '@/store';
@@ -37,7 +37,7 @@ export const useAuth = () => {
const handleLogout = async () => {
dispatch(clearPets());
- dispatch(clearClaims());
+ dispatch(clearItems());
dispatch(logout());
await persistor.purge();
router.push('/auth');
diff --git a/apps/client/src/features/claims/hooks/useClaims.ts b/apps/client/src/features/claims/hooks/useClaims.ts
deleted file mode 100644
index a4218b7..0000000
--- a/apps/client/src/features/claims/hooks/useClaims.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { useRefreshUser } from '@/features/auth/hooks/useRefreshUser';
-import { setClaimStatus as setClaimStatusAction } from '@/features/claims/slices/claimsSlice';
-import { useTranslation } from '@/i18n/hooks/useTranslation';
-import api, { isAbortError } from '@/lib/api';
-import { usePolling } from '@/lib/hooks/usePolling';
-import { callError } from '@/lib/toast';
-import { useAppDispatch, useAppSelector } from '@/store';
-import { ClaimResult, ClaimStatus } from '@widgetable/types';
-import { useCallback, useEffect, useState } from 'react';
-
-export const useClaims = () => {
- const { t } = useTranslation();
- const dispatch = useAppDispatch();
- const claimStatus = useAppSelector((state) => state.claims.claimStatus);
- const loaded = useAppSelector((state) => state.claims.loaded);
- const refreshUser = useRefreshUser();
-
- const [claimingType, setClaimingType] = useState<'daily' | 'quick' | null>(null);
- const [lastRewards, setLastRewards] = useState(null);
-
- const loadClaimStatus = useCallback(async () => {
- try {
- const response = await api.get('/claims/status');
- dispatch(setClaimStatusAction(response.data));
- } catch (error: unknown) {
- if (!isAbortError(error)) callError(t('claims.failedLoad'));
- }
- }, [dispatch]);
-
- const executeClaim = async (endpoint: string, type: 'daily' | 'quick') => {
- if (claimingType) return;
-
- setClaimingType(type);
-
- try {
- const response = await api.post(endpoint);
- const result = response.data;
-
- setLastRewards(result);
- await loadClaimStatus();
-
- await refreshUser();
- } catch {
- callError(t('claims.failedClaim'));
- } finally {
- setClaimingType(null);
- }
- };
-
- const claimDaily = useCallback(async () => {
- await executeClaim('/claims/daily', 'daily');
- }, [claimingType, loadClaimStatus, dispatch, refreshUser]);
-
- const claimQuick = useCallback(async () => {
- await executeClaim('/claims/quick', 'quick');
- }, [claimingType, loadClaimStatus, dispatch, refreshUser]);
-
- useEffect(() => {
- void loadClaimStatus();
- }, [loadClaimStatus]);
-
- usePolling(() => void loadClaimStatus(), 60000);
-
- return {
- claimStatus,
- claimingType,
- lastRewards,
- loading: !loaded,
- claimDaily,
- claimQuick,
- refreshStatus: loadClaimStatus,
- closeRewardsModal: () => setLastRewards(null),
- };
-};
diff --git a/apps/client/src/features/claims/slices/claimsSlice.ts b/apps/client/src/features/claims/slices/claimsSlice.ts
deleted file mode 100644
index 6e2fe33..0000000
--- a/apps/client/src/features/claims/slices/claimsSlice.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { createSlice, PayloadAction } from '@reduxjs/toolkit';
-import { ClaimStatus } from '@widgetable/types';
-
-interface ClaimsState {
- claimStatus: ClaimStatus | null;
- loaded: boolean;
-}
-
-const initialState: ClaimsState = {
- claimStatus: null,
- loaded: false,
-};
-
-export const claimsSlice = createSlice({
- name: 'claims',
- initialState,
- reducers: {
- setClaimStatus: (state, action: PayloadAction) => {
- state.claimStatus = action.payload;
- state.loaded = true;
- },
- clearClaims: () => initialState,
- },
-});
-
-export const { setClaimStatus, clearClaims } = claimsSlice.actions;
-export default claimsSlice.reducer;
diff --git a/apps/client/src/features/friends/components/GiftModal.tsx b/apps/client/src/features/friends/components/GiftModal.tsx
index a184f63..9f840cc 100644
--- a/apps/client/src/features/friends/components/GiftModal.tsx
+++ b/apps/client/src/features/friends/components/GiftModal.tsx
@@ -2,7 +2,7 @@
import { Modal } from '@/components/ui/Modal';
import { Skeleton } from '@/components/ui/Skeleton';
-import { getActionSprite } from '@/data/actionSprites';
+import { getItemSprite } from '@widgetable/types';
import { useRefreshUser } from '@/features/auth/hooks/useRefreshUser';
import { useTranslation } from '@/i18n/hooks/useTranslation';
import api from '@/lib/api';
@@ -29,7 +29,7 @@ export const GiftModal = ({ isOpen, onClose, friend }: GiftModalProps) => {
const giftableItems = VALENTINE_GIFT_ITEM_NAMES.filter((name) => (inventory[name] ?? 0) > 0).map((name) => ({
name,
count: inventory[name],
- sprite: getActionSprite(name),
+ sprite: getItemSprite(name),
}));
const spriteUrls = giftableItems.map((item) => item.sprite).filter(Boolean) as string[];
diff --git a/apps/client/src/features/claims/components/ClaimButton.tsx b/apps/client/src/features/items/components/ItemButton.tsx
similarity index 61%
rename from apps/client/src/features/claims/components/ClaimButton.tsx
rename to apps/client/src/features/items/components/ItemButton.tsx
index 038d6fa..f685086 100644
--- a/apps/client/src/features/claims/components/ClaimButton.tsx
+++ b/apps/client/src/features/items/components/ItemButton.tsx
@@ -4,32 +4,31 @@ import { ICON_SIZES } from '@/config/constants';
import { Button } from '@/components/ui/Button';
import { useTranslation } from '@/i18n/hooks/useTranslation';
import { Check, Clock } from '@nsmr/pixelart-react';
-import { ClaimTimer } from './ClaimTimer';
+import { ClaimType } from '@widgetable/types';
+import { ItemTimer } from './ItemTimer';
-interface ClaimButtonProps {
- type: 'daily' | 'quick' | 'debug';
+interface ItemButtonProps {
+ type: ClaimType;
available: boolean;
- claimingType: 'daily' | 'quick' | 'debug' | null;
- nextClaimTime?: Date;
+ claimingType: ClaimType | null;
+ nextItemTime?: Date;
onClaim: () => void;
}
-export const ClaimButton = ({ type, available, claimingType, nextClaimTime, onClaim }: ClaimButtonProps) => {
+export const ItemButton = ({ type, available, claimingType, nextItemTime, onClaim }: ItemButtonProps) => {
const { t } = useTranslation();
const isClaiming = claimingType === type;
const getButtonText = () => {
- if (isClaiming) return t('claims.collecting');
+ if (isClaiming) return t('items.collecting');
switch (type) {
- case 'daily':
- return available ? t('claims.dailyCarePackage') : t('claims.carePackage');
- case 'quick':
- return available ? t('claims.quickCarePackage') : t('claims.carePackage');
- case 'debug':
- return t('claims.debugClaim');
+ case ClaimType.DAILY:
+ return available ? t('items.dailyCarePackage') : t('items.carePackage');
+ case ClaimType.QUICK:
+ return available ? t('items.quickCarePackage') : t('items.carePackage');
default:
- return t('claims.claimRewards');
+ return t('items.claimItems');
}
};
@@ -54,8 +53,8 @@ export const ClaimButton = ({ type, available, claimingType, nextClaimTime, onCl
{!available && !isClaiming && (
- {t('claims.nextIn')}
-
+ {t('items.nextIn')}
+
)}
diff --git a/apps/client/src/features/claims/components/ClaimTimer.tsx b/apps/client/src/features/items/components/ItemTimer.tsx
similarity index 53%
rename from apps/client/src/features/claims/components/ClaimTimer.tsx
rename to apps/client/src/features/items/components/ItemTimer.tsx
index 82ba102..b64b847 100644
--- a/apps/client/src/features/claims/components/ClaimTimer.tsx
+++ b/apps/client/src/features/items/components/ItemTimer.tsx
@@ -2,14 +2,14 @@
import { useCountdown } from '@/lib/hooks/useCountdown';
-interface ClaimTimerProps {
- nextClaimTime?: Date;
+interface ItemTimerProps {
+ nextItemTime?: Date;
}
-export const ClaimTimer = ({ nextClaimTime }: ClaimTimerProps) => {
- const { timeLeft, isReady } = useCountdown(nextClaimTime);
+export const ItemTimer = ({ nextItemTime }: ItemTimerProps) => {
+ const { timeLeft, isReady } = useCountdown(nextItemTime);
- if (!nextClaimTime) return null;
+ if (!nextItemTime) return null;
const displayText = isReady ? 'Ready!' : timeLeft;
diff --git a/apps/client/src/features/claims/components/RewardsModal.tsx b/apps/client/src/features/items/components/ItemsModal.tsx
similarity index 56%
rename from apps/client/src/features/claims/components/RewardsModal.tsx
rename to apps/client/src/features/items/components/ItemsModal.tsx
index 480af00..4fe5613 100644
--- a/apps/client/src/features/claims/components/RewardsModal.tsx
+++ b/apps/client/src/features/items/components/ItemsModal.tsx
@@ -2,39 +2,47 @@
import { Modal } from '@/components/ui/Modal';
import { ICON_SIZES } from '@/config/constants';
-import { getActionSprite } from '@/data/actionSprites';
+import { getItemSprite } from '@widgetable/types';
import { useTranslation } from '@/i18n/hooks/useTranslation';
import { Bed, Check, Coffee, Edit, Zap } from '@nsmr/pixelart-react';
-import { ClaimResult, EGG_ITEM_NAME, ItemReward, ItemTier } from '@widgetable/types';
+import {
+ ACTION_AMOUNT_BY_NAME,
+ EGG_ITEM_NAME,
+ getItemTier,
+ ItemResult,
+ ItemReward,
+ ItemTier,
+ VALENTINE_GIFT_ITEMS,
+} from '@widgetable/types';
import Image from 'next/image';
-interface RewardsModalProps {
- rewards: ClaimResult;
+interface ItemsModalProps {
+ result: ItemResult;
onClose: () => void;
}
-const getTierColor = (tier: ItemTier): string => {
- switch (tier) {
- case ItemTier.BASIC:
- return 'border-gray-400';
- case ItemTier.COMMON:
- return 'border-green-500';
- case ItemTier.PREMIUM:
- return 'border-blue-500';
- case ItemTier.LEGENDARY:
- return 'border-purple-500';
- default:
- return 'border-gray-400';
- }
+const VALENTINE_TIER_BY_NAME = new Map(VALENTINE_GIFT_ITEMS.map((item) => [item.name, item.tier]));
+
+const getItemTierByName = (name: string): ItemTier => {
+ const valentineTier = VALENTINE_TIER_BY_NAME.get(name);
+ if (valentineTier !== undefined) return valentineTier;
+ return getItemTier(ACTION_AMOUNT_BY_NAME[name] ?? 0);
+};
+
+const TIER_COLORS: Record = {
+ [ItemTier.BASIC]: 'border-inactive',
+ [ItemTier.COMMON]: 'border-tier-common',
+ [ItemTier.PREMIUM]: 'border-tier-premium',
+ [ItemTier.LEGENDARY]: 'border-tier-legendary',
};
const ItemDisplay = ({ item, index }: { item: ItemReward; index: number }) => {
const { t } = useTranslation();
- const spritePath = getActionSprite(item.name);
+ const spritePath = getItemSprite(item.name);
return (
{spritePath && (
@@ -48,21 +56,25 @@ const ItemDisplay = ({ item, index }: { item: ItemReward; index: number }) => {
);
};
-export const RewardsModal = ({ rewards, onClose }: RewardsModalProps) => {
+export const ItemsModal = ({ result, onClose }: ItemsModalProps) => {
const { t } = useTranslation();
- const sortedFood = [...rewards.rewards.food].sort((a, b) => b.tier - a.tier);
- const sortedDrinks = [...rewards.rewards.drinks].sort((a, b) => b.tier - a.tier);
- const sortedHygiene = [...rewards.rewards.hygiene].sort((a, b) => b.tier - a.tier);
- const sortedCare = [...(rewards.rewards.care || [])].sort((a, b) => b.tier - a.tier);
+ const sortedFood = [...result.items.food].sort((a, b) => getItemTierByName(b.name) - getItemTierByName(a.name));
+ const sortedDrinks = [...result.items.drinks].sort((a, b) => getItemTierByName(b.name) - getItemTierByName(a.name));
+ const sortedHygiene = [...result.items.hygiene].sort(
+ (a, b) => getItemTierByName(b.name) - getItemTierByName(a.name),
+ );
+ const sortedCare = [...(result.items.care || [])].sort(
+ (a, b) => getItemTierByName(b.name) - getItemTierByName(a.name),
+ );
return (
}
- maxWidth="2xl"
+ maxWidth="sm"
lockScroll
preventTouchMove
contentClassName="p-6 space-y-6"
@@ -71,21 +83,21 @@ export const RewardsModal = ({ rewards, onClose }: RewardsModalProps) => {
onClick={onClose}
className="w-full bg-primary hover:bg-primary/90 text-white font-bold py-3 px-6 rounded-lg transition-colors"
>
- {t('rewards.awesome')}
+ {t('items.awesome')}
}
>
- {rewards.rewards.food.length > 0 && (
+ {result.items.food.length > 0 && (
- {t('rewards.food')} (
- {t('rewards.items', {
- count: rewards.rewards.food.reduce((sum, item) => sum + item.quantity, 0),
+ {t('items.food')} (
+ {t('items.count', {
+ count: result.items.food.reduce((sum, item) => sum + item.quantity, 0),
})}
)
-
+
{sortedFood.map((item, idx) => (
))}
@@ -93,17 +105,17 @@ export const RewardsModal = ({ rewards, onClose }: RewardsModalProps) => {
)}
- {rewards.rewards.drinks.length > 0 && (
+ {result.items.drinks.length > 0 && (
- {t('rewards.drinks')} (
- {t('rewards.items', {
- count: rewards.rewards.drinks.reduce((sum, item) => sum + item.quantity, 0),
+ {t('items.drinks')} (
+ {t('items.count', {
+ count: result.items.drinks.reduce((sum, item) => sum + item.quantity, 0),
})}
)
-
+
{sortedDrinks.map((item, idx) => (
))}
@@ -111,17 +123,17 @@ export const RewardsModal = ({ rewards, onClose }: RewardsModalProps) => {
)}
- {rewards.rewards.hygiene.length > 0 && (
+ {result.items.hygiene.length > 0 && (
- {t('rewards.hygiene')} (
- {t('rewards.items', {
- count: rewards.rewards.hygiene.reduce((sum, item) => sum + item.quantity, 0),
+ {t('items.hygiene')} (
+ {t('items.count', {
+ count: result.items.hygiene.reduce((sum, item) => sum + item.quantity, 0),
})}
)
-
+
{sortedHygiene.map((item, idx) => (
{
- {t('rewards.care')} (
- {t('rewards.items', { count: sortedCare.reduce((sum, item) => sum + item.quantity, 0) })})
+ {t('items.care')} (
+ {t('items.count', { count: sortedCare.reduce((sum, item) => sum + item.quantity, 0) })})
-
+
{sortedCare.map((item, idx) => (
{
)}
- {(rewards.rewards.valentines?.length ?? 0) > 0 && (
+ {(result.items.valentines?.length ?? 0) > 0 && (
{
className="w-5 h-5"
style={{ imageRendering: 'pixelated' }}
/>
- {t('rewards.valentines')} (
- {t('rewards.items', {
- count: rewards.rewards.valentines!.reduce((sum, item) => sum + item.quantity, 0),
+ {t('items.valentines')} (
+ {t('items.count', {
+ count: result.items.valentines!.reduce((sum, item) => sum + item.quantity, 0),
})}
)
-
- {[...rewards.rewards.valentines!]
- .sort((a, b) => b.tier - a.tier)
+
+ {[...result.items.valentines!]
+ .sort((a, b) => getItemTierByName(b.name) - getItemTierByName(a.name))
.map((item, idx) => (
))}
@@ -177,17 +189,17 @@ export const RewardsModal = ({ rewards, onClose }: RewardsModalProps) => {
)}
- {rewards.rewards.eggs > 0 && (
+ {result.items.eggs > 0 && (
-
{t('rewards.bonus')}
+
{t('items.bonus')}
{EGG_ITEM_NAME}
-
x{rewards.rewards.eggs}
-
{t('rewards.luckyFind')}
+
x{result.items.eggs}
+
{t('items.luckyFind')}
diff --git a/apps/client/src/features/items/hooks/useItems.ts b/apps/client/src/features/items/hooks/useItems.ts
new file mode 100644
index 0000000..dde9d43
--- /dev/null
+++ b/apps/client/src/features/items/hooks/useItems.ts
@@ -0,0 +1,75 @@
+'use client';
+import { useRefreshUser } from '@/features/auth/hooks/useRefreshUser';
+import { setItemStatus as setItemStatusAction } from '@/features/items/slices/itemsSlice';
+import { useTranslation } from '@/i18n/hooks/useTranslation';
+import api, { isAbortError } from '@/lib/api';
+import { usePolling } from '@/lib/hooks/usePolling';
+import { callError } from '@/lib/toast';
+import { useAppDispatch, useAppSelector } from '@/store';
+import { ClaimType, ItemResult, ItemStatus } from '@widgetable/types';
+import { useCallback, useEffect, useState } from 'react';
+
+export const useItems = () => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const itemStatus = useAppSelector((state) => state.items.itemStatus);
+ const loaded = useAppSelector((state) => state.items.loaded);
+ const refreshUser = useRefreshUser();
+
+ const [claimingType, setClaimingType] = useState
(null);
+ const [lastItems, setLastItems] = useState(null);
+
+ const loadItemStatus = useCallback(async () => {
+ try {
+ const response = await api.get('/items/status');
+ dispatch(setItemStatusAction(response.data));
+ } catch (error: unknown) {
+ if (!isAbortError(error)) callError(t('items.failedLoad'));
+ }
+ }, [dispatch]);
+
+ const executeItemClaim = async (endpoint: string, type: ClaimType) => {
+ if (claimingType) return;
+
+ setClaimingType(type);
+
+ try {
+ const response = await api.post(endpoint);
+ const result = response.data;
+
+ setLastItems(result);
+ await loadItemStatus();
+
+ await refreshUser();
+ } catch {
+ callError(t('items.failedClaim'));
+ } finally {
+ setClaimingType(null);
+ }
+ };
+
+ const claimDaily = useCallback(async () => {
+ await executeItemClaim('/items/daily', ClaimType.DAILY);
+ }, [claimingType, loadItemStatus, dispatch, refreshUser]);
+
+ const claimQuick = useCallback(async () => {
+ await executeItemClaim('/items/quick', ClaimType.QUICK);
+ }, [claimingType, loadItemStatus, dispatch, refreshUser]);
+
+ useEffect(() => {
+ void loadItemStatus();
+ }, [loadItemStatus]);
+
+ usePolling(() => void loadItemStatus(), 60000);
+
+ return {
+ itemStatus,
+ claimingType,
+ lastItems,
+ loading: !loaded,
+ claimDaily,
+ claimQuick,
+ refreshStatus: loadItemStatus,
+ closeItemsModal: () => setLastItems(null),
+ };
+};
diff --git a/apps/client/src/features/items/slices/itemsSlice.ts b/apps/client/src/features/items/slices/itemsSlice.ts
new file mode 100644
index 0000000..2a849e2
--- /dev/null
+++ b/apps/client/src/features/items/slices/itemsSlice.ts
@@ -0,0 +1,27 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { ItemStatus } from '@widgetable/types';
+
+interface ItemsState {
+ itemStatus: ItemStatus | null;
+ loaded: boolean;
+}
+
+const initialState: ItemsState = {
+ itemStatus: null,
+ loaded: false,
+};
+
+export const itemsSlice = createSlice({
+ name: 'items',
+ initialState,
+ reducers: {
+ setItemStatus: (state, action: PayloadAction) => {
+ state.itemStatus = action.payload;
+ state.loaded = true;
+ },
+ clearItems: () => initialState,
+ },
+});
+
+export const { setItemStatus, clearItems } = itemsSlice.actions;
+export default itemsSlice.reducer;
diff --git a/apps/client/src/features/pets/components/Pet.tsx b/apps/client/src/features/pets/components/Pet.tsx
index 1f0b089..d09d534 100644
--- a/apps/client/src/features/pets/components/Pet.tsx
+++ b/apps/client/src/features/pets/components/Pet.tsx
@@ -3,9 +3,9 @@
import { Button, InputTextHidden } from '@/components/ui/Button';
import { Skeleton } from '@/components/ui/Skeleton';
import { HTTP_STATUS } from '@/config/constants';
-import { getActionSprite } from '@/data/actionSprites';
+import { getItemSprite } from '@widgetable/types';
import { setUserData } from '@/features/auth/slices/userSlice';
-import { RewardsModal } from '@/features/claims/components/RewardsModal';
+import { ItemsModal } from '@/features/items/components/ItemsModal';
import { BackgroundSelector } from '@/features/pets/components/BackgroundSelector';
import { InviteModal } from '@/features/pets/components/InviteModal';
import PetSprite from '@/features/pets/components/PetSprite';
@@ -30,7 +30,7 @@ import {
Users,
Zap,
} from '@nsmr/pixelart-react';
-import { ClaimResult, formatTime, Pet, PET_THRESHOLDS, User } from '@widgetable/types';
+import { getItemTier, ItemResult, ItemTier, formatTime, Pet, PET_THRESHOLDS, User } from '@widgetable/types';
import {
getExpForCurrentLevel,
@@ -44,19 +44,11 @@ import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { usePet } from '../hooks/usePet';
-const getTierColor = (tier?: number): string => {
- switch (tier) {
- case 1:
- return 'border-gray-400/50';
- case 2:
- return 'border-green-500/50';
- case 3:
- return 'border-blue-500/50';
- case 4:
- return 'border-purple-500/50';
- default:
- return 'border-gray-400/50';
- }
+const TIER_COLORS: Record = {
+ [ItemTier.BASIC]: 'border-inactive/50',
+ [ItemTier.COMMON]: 'border-tier-common/50',
+ [ItemTier.PREMIUM]: 'border-tier-premium/50',
+ [ItemTier.LEGENDARY]: 'border-tier-legendary/50',
};
const actionBarBtnClass =
@@ -71,7 +63,7 @@ const PetPage = () => {
const [eggTimeLeft, setEggTimeLeft] = useState('');
const [expeditionTimeLeft, setExpeditionTimeLeft] = useState('');
const [canClaimExpedition, setCanClaimExpedition] = useState(false);
- const [lastRewards, setLastRewards] = useState(null);
+ const [lastItems, setLastItems] = useState(null);
const [bgSelectorOpen, setBgSelectorOpen] = useState(false);
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
@@ -187,8 +179,8 @@ const PetPage = () => {
const handleClaimExpedition = async () => {
if (!pet?._id) return;
try {
- const rewards = await api.post(`/pets/${pet._id}/expedition/claim`);
- setLastRewards(rewards.data);
+ const rewards = await api.post(`/pets/${pet._id}/expedition/claim`);
+ setLastItems(rewards.data);
await Promise.all([loadPet(), api.get('/auth/me').then((r) => dispatch(setUserData(r.data)))]);
} catch {
@@ -224,7 +216,8 @@ const PetPage = () => {
const isDisabled = inventoryCount === 0 || isNeedTooHigh;
return {
name: action.name,
- sprite: getActionSprite(action.name),
+ sprite: getItemSprite(action.name),
+ amount: action.amount,
inventoryCost: action.inventoryCost,
inventoryCount,
isDisabled,
@@ -472,7 +465,7 @@ const PetPage = () => {
disabled={
isEgg || pet.isOnExpedition || !!currentAnimation || action.isDisabled
}
- className={`flex flex-col items-center justify-center gap-1 p-2 rounded-lg border-2 bg-surface transition-colors ${getTierColor(action.inventoryCost)} ${isEgg || pet.isOnExpedition || currentAnimation || action.isDisabled ? 'opacity-30 cursor-not-allowed' : 'hover:bg-secondary/10 cursor-pointer'}`}
+ className={`flex flex-col items-center justify-center gap-1 p-2 rounded-lg border-2 bg-surface transition-colors ${TIER_COLORS[getItemTier(action.amount)]} ${isEgg || pet.isOnExpedition || currentAnimation || action.isDisabled ? 'opacity-30 cursor-not-allowed' : 'hover:bg-secondary/10 cursor-pointer'}`}
>
{action.sprite && (
@@ -513,7 +506,7 @@ const PetPage = () => {
onInvite={(friendId) => void sendCoparentingRequest(friendId)}
/>
- {lastRewards &&
setLastRewards(null)} />}
+ {lastItems && setLastItems(null)} />}
>
);
};
diff --git a/apps/client/src/features/pets/components/PetSprite.tsx b/apps/client/src/features/pets/components/PetSprite.tsx
index 11587fb..9ebe8cf 100644
--- a/apps/client/src/features/pets/components/PetSprite.tsx
+++ b/apps/client/src/features/pets/components/PetSprite.tsx
@@ -1,5 +1,5 @@
import { PET_SPRITE_SIZES } from '@/config/constants';
-import { getPetScale } from '@/data/petScales';
+import { getPetScale } from '@widgetable/types';
import {
ANIMATION_DURATIONS,
Pet,
diff --git a/apps/client/src/store/index.ts b/apps/client/src/store/index.ts
index a6357ea..7010228 100644
--- a/apps/client/src/store/index.ts
+++ b/apps/client/src/store/index.ts
@@ -5,19 +5,19 @@ import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import { persistReducer, persistStore } from 'redux-persist';
import userReducer from '@/features/auth/slices/userSlice';
-import claimsReducer from '@/features/claims/slices/claimsSlice';
import petsReducer from '@/features/pets/slices/petsSlice';
+import itemsReducer from '@/features/items/slices/itemsSlice';
const persistConfig = {
key: 'root',
storage,
- whitelist: ['user', 'pets', 'claims', 'adminUsers', 'adminStats'],
+ whitelist: ['user', 'pets', 'items', 'adminUsers', 'adminStats'],
};
const rootReducer = combineReducers({
user: userReducer,
pets: petsReducer,
- claims: claimsReducer,
+ items: itemsReducer,
});
const persistedReducer = persistReducer(persistConfig, rootReducer);
diff --git a/apps/server/README.md b/apps/server/README.md
index ad63ae6..b45e981 100644
--- a/apps/server/README.md
+++ b/apps/server/README.md
@@ -43,7 +43,7 @@ Each domain is a NestJS module following the controller → service → entity p
| `auth` | Register/login/logout, JWT signing, Argon2 password hashing |
| `users` | User CRUD, avatar upload/download via MinIO |
| `pets` | Pet CRUD + retroactive stats degradation |
-| `claims` | Pet action claiming system |
+| `rewards` | Pet action claiming system |
| `friends` | Friend management |
| `gifts` | Gift exchange system |
| `notifications` | Push notifications (web-push) |
diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts
index 7b527b1..89e0aa2 100644
--- a/apps/server/src/app.module.ts
+++ b/apps/server/src/app.module.ts
@@ -6,13 +6,12 @@ import { diskStorage } from 'multer';
import { tmpdir } from 'os';
import { extname } from 'path';
import { AuthModule } from 'src/auth/auth.module';
-import { ClaimsModule } from 'src/claims/claims.module';
import { CoparentingModule } from 'src/coparenting/coparenting.module';
import { FriendsModule } from 'src/friends/friends.module';
import { GiftsModule } from 'src/gifts/gifts.module';
import { NotificationsModule } from 'src/notifications/notifications.module';
import { PetsModule } from 'src/pets/pets.module';
-import { SharedModule } from 'src/shared/shared.module';
+import { ItemsModule } from 'src/items/items.module';
import { StorageModule } from 'src/storage/storage.module';
import { UsersModule } from 'src/users/users.module';
import { v4 as uuidv4 } from 'uuid';
@@ -46,7 +45,6 @@ import { v4 as uuidv4 } from 'uuid';
},
inject: [ConfigService],
}),
- SharedModule,
StorageModule,
UsersModule,
FriendsModule,
@@ -54,7 +52,7 @@ import { v4 as uuidv4 } from 'uuid';
CoparentingModule,
PetsModule,
AuthModule,
- ClaimsModule,
+ ItemsModule,
NotificationsModule,
],
controllers: [],
diff --git a/apps/server/src/claims/claims.service.ts b/apps/server/src/claims/claims.service.ts
deleted file mode 100644
index d835f87..0000000
--- a/apps/server/src/claims/claims.service.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
-import { InjectModel } from '@nestjs/mongoose';
-import { CLAIMS_CONFIG, ClaimResult, ClaimStatus, PetActionCategory, TIME_CONVERSION } from '@widgetable/types';
-import { Model, Types } from 'mongoose';
-import { Pet, PetDocument } from 'src/pets/entities/pet.entity';
-import { RewardsService } from 'src/shared/rewards.service';
-import { User, UserDocument } from 'src/users/entities/user.entity';
-import { UsersService } from 'src/users/users.service';
-
-@Injectable()
-export class ClaimsService {
- constructor(
- @InjectModel(User.name) private userModel: Model,
- @InjectModel(Pet.name) private petModel: Model,
- private readonly usersService: UsersService,
- private readonly rewardsService: RewardsService,
- ) {}
-
- async getClaimStatus(userId: Types.ObjectId): Promise {
- const user = await this.userModel.findById(userId);
- if (!user) throw new NotFoundException();
-
- const pets = await this.petModel.find({ parents: { $in: [userId] } });
- const petCount = pets.filter((pet) => !pet.isEgg).length;
-
- const now = new Date();
- const dailyAvailable = this.checkCooldown(user.lastDailyClaimTime, CLAIMS_CONFIG.DAILY_COOLDOWN_HOURS, now);
- const quickAvailable = this.checkCooldown(user.lastQuickClaimTime, CLAIMS_CONFIG.QUICK_COOLDOWN_HOURS, now);
-
- return {
- dailyAvailable,
- quickAvailable,
- nextDailyTime: dailyAvailable
- ? undefined
- : this.getNextClaimTime(user.lastDailyClaimTime!, CLAIMS_CONFIG.DAILY_COOLDOWN_HOURS),
- nextQuickTime: quickAvailable
- ? undefined
- : this.getNextClaimTime(user.lastQuickClaimTime!, CLAIMS_CONFIG.QUICK_COOLDOWN_HOURS),
- petCount,
- };
- }
-
- async claimDaily(userId: Types.ObjectId): Promise {
- return this.executeClaim(userId, 'daily');
- }
-
- async claimQuick(userId: Types.ObjectId): Promise {
- return this.executeClaim(userId, 'quick');
- }
-
- async claimDebug(userId: Types.ObjectId): Promise {
- if (process.env.NODE_ENV !== 'development' && process.env.ALLOW_DEBUG_CLAIMS !== 'true') {
- throw new BadRequestException();
- }
- return this.executeClaim(userId, 'daily', true);
- }
-
- private async executeClaim(
- userId: Types.ObjectId,
- claimType: 'daily' | 'quick',
- skipCooldown = false,
- ): Promise {
- const user = await this.userModel.findById(userId);
- if (!user) throw new NotFoundException();
-
- const pets = await this.petModel.find({ parents: { $in: [userId] } });
- const activePets = pets.filter((pet) => !pet.isEgg);
- const petCount = activePets.length;
-
- const now = new Date();
- const cooldownHours =
- claimType === 'daily' ? CLAIMS_CONFIG.DAILY_COOLDOWN_HOURS : CLAIMS_CONFIG.QUICK_COOLDOWN_HOURS;
- const lastClaimTime = claimType === 'daily' ? user.lastDailyClaimTime : user.lastQuickClaimTime;
-
- if (!skipCooldown && !this.checkCooldown(lastClaimTime, cooldownHours, now)) {
- throw new BadRequestException();
- }
-
- // Calculate rewards
- const rewards = this.calculateRewards(petCount, claimType === 'quick');
-
- await this.usersService.applyRewards(userId, rewards.rewards);
-
- // Update claim timestamp
- const updateField = claimType === 'daily' ? 'lastDailyClaimTime' : 'lastQuickClaimTime';
- await this.userModel.findByIdAndUpdate(userId, { [updateField]: now });
-
- return rewards;
- }
-
- private calculateRewards(petCount: number, isQuick: boolean): ClaimResult {
- const multiplier = isQuick ? CLAIMS_CONFIG.QUICK_REWARD_MULTIPLIER : 1.0;
- // Ensure minimum rewards even with 0 pets
- const petMultiplier = Math.max(1, Math.sqrt(petCount));
-
- // Calculate base amounts
- const foodCount = Math.floor(CLAIMS_CONFIG.BASE_FOOD_ITEMS * petMultiplier * multiplier);
- const drinkCount = Math.floor(CLAIMS_CONFIG.BASE_DRINK_ITEMS * petMultiplier * multiplier);
- const hygieneCount = Math.floor(CLAIMS_CONFIG.BASE_HYGIENE_ITEMS * petMultiplier * multiplier);
- const careCount = Math.floor(CLAIMS_CONFIG.BASE_CARE_ITEMS * petMultiplier * multiplier);
-
- // Select random items from each category
- const foodItems = this.rewardsService.selectRandomItems(PetActionCategory.FEED, foodCount);
- const drinkItems = this.rewardsService.selectRandomItems(PetActionCategory.DRINK, drinkCount);
- const hygieneItems = this.rewardsService.selectRandomItems(PetActionCategory.WASH, hygieneCount);
- const careItems = this.rewardsService.selectRandomItems(PetActionCategory.CARE, careCount);
-
- // Calculate egg chance (decreases with more pets: 35% → 5%)
- const eggChance = Math.max(CLAIMS_CONFIG.BASE_EGG_CHANCE / (1 + petCount * 0.6), 0.05);
- const earnedEggs = Math.random() < eggChance ? 1 : 0;
-
- // Valentine bonus (February only)
- const valentineItems = this.rewardsService.isValentineSeason()
- ? this.rewardsService.selectRandomValentineItems(Math.floor(2 * petMultiplier * multiplier))
- : [];
-
- const valentineCount = valentineItems.reduce((sum, item) => sum + item.quantity, 0);
- const totalItems = foodCount + drinkCount + hygieneCount + careCount + earnedEggs + valentineCount;
- const cooldownHours = isQuick ? CLAIMS_CONFIG.QUICK_COOLDOWN_HOURS : CLAIMS_CONFIG.DAILY_COOLDOWN_HOURS;
- const nextClaimTime = new Date(Date.now() + cooldownHours * 60 * 60 * 1000);
-
- return {
- success: true,
- rewards: {
- food: foodItems,
- drinks: drinkItems,
- hygiene: hygieneItems,
- care: careItems,
- eggs: earnedEggs,
- valentines: valentineItems.length > 0 ? valentineItems : undefined,
- },
- totalItems,
- nextClaimTime,
- };
- }
-
- private checkCooldown(lastClaimTime: Date | undefined, cooldownHours: number, now: Date): boolean {
- if (!lastClaimTime) return true;
- const timeSinceLastClaim = now.getTime() - lastClaimTime.getTime();
- const cooldownMs = TIME_CONVERSION.HOURS_TO_MS(cooldownHours);
- return timeSinceLastClaim >= cooldownMs;
- }
-
- private getNextClaimTime(lastClaimTime: Date, cooldownHours: number): Date {
- return new Date(lastClaimTime.getTime() + TIME_CONVERSION.HOURS_TO_MS(cooldownHours));
- }
-}
diff --git a/apps/server/src/friends/friends.service.ts b/apps/server/src/friends/friends.service.ts
index 00fe195..441daf8 100644
--- a/apps/server/src/friends/friends.service.ts
+++ b/apps/server/src/friends/friends.service.ts
@@ -1,5 +1,5 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
-import { USER_POPULATE_FIELDS } from 'src/shared/constants';
+import { USER_POPULATE_FIELDS } from 'src/users/users.constants';
import { InjectModel, InjectConnection } from '@nestjs/mongoose';
import { RequestType, RequestStatus, DEFAULT_LANGUAGE } from '@widgetable/types';
import { Model, Connection, Types } from 'mongoose';
diff --git a/apps/server/src/gifts/gifts.service.ts b/apps/server/src/gifts/gifts.service.ts
index 465ed26..456b344 100644
--- a/apps/server/src/gifts/gifts.service.ts
+++ b/apps/server/src/gifts/gifts.service.ts
@@ -1,22 +1,18 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
-import { InjectModel, InjectConnection } from '@nestjs/mongoose';
+import { InjectModel } from '@nestjs/mongoose';
import { DEFAULT_LANGUAGE, isValentineGiftItem } from '@widgetable/types';
-import { Model, Connection, Types } from 'mongoose';
+import { Model, Types } from 'mongoose';
import { NotificationsService, nt } from 'src/notifications/notifications.service';
import { User, UserDocument } from 'src/users/entities/user.entity';
import { UsersService } from 'src/users/users.service';
-import { BaseService } from 'src/common/base.service';
@Injectable()
-export class GiftsService extends BaseService {
+export class GiftsService {
constructor(
@InjectModel(User.name) private userModel: Model,
- @InjectConnection() connection: Connection,
private readonly usersService: UsersService,
private readonly notificationsService: NotificationsService,
- ) {
- super(connection);
- }
+ ) {}
async sendGift(senderId: Types.ObjectId, recipientId: string, itemName: string, quantity: number) {
if (senderId.toString() === recipientId) throw new BadRequestException();
@@ -31,9 +27,6 @@ export class GiftsService extends BaseService {
const recipientObjectId = new Types.ObjectId(recipientId);
- const hasItem = await this.usersService.hasInventory(senderId, itemName, quantity);
- if (!hasItem) throw new BadRequestException();
-
await this.usersService.consumeInventory(senderId, itemName, quantity);
const recipient = await this.userModel.findById(recipientId);
diff --git a/apps/server/src/claims/claims.controller.ts b/apps/server/src/items/items.controller.ts
similarity index 53%
rename from apps/server/src/claims/claims.controller.ts
rename to apps/server/src/items/items.controller.ts
index d86e32b..db29def 100644
--- a/apps/server/src/claims/claims.controller.ts
+++ b/apps/server/src/items/items.controller.ts
@@ -1,31 +1,27 @@
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
+import { ClaimType } from '@widgetable/types';
import { GetUser } from 'src/common/decorators/get-user.decorator';
import { JwtAuthGuard } from 'src/common/guards/jwt-auth.guard';
import { UserDocument } from 'src/users/entities/user.entity';
-import { ClaimsService } from './claims.service';
+import { ItemsService } from './items.service';
-@Controller('claims')
+@Controller('items')
@UseGuards(JwtAuthGuard)
-export class ClaimsController {
- constructor(private readonly claimsService: ClaimsService) {}
+export class ItemsController {
+ constructor(private readonly itemsService: ItemsService) {}
@Get('status')
async getStatus(@GetUser() user: UserDocument) {
- return this.claimsService.getClaimStatus(user._id);
+ return this.itemsService.getItemStatus(user._id);
}
@Post('daily')
async claimDaily(@GetUser() user: UserDocument) {
- return this.claimsService.claimDaily(user._id);
+ return this.itemsService.executeClaim(user._id, ClaimType.DAILY);
}
@Post('quick')
async claimQuick(@GetUser() user: UserDocument) {
- return this.claimsService.claimQuick(user._id);
- }
-
- @Post('debug')
- async claimDebug(@GetUser() user: UserDocument) {
- return this.claimsService.claimDebug(user._id);
+ return this.itemsService.executeClaim(user._id, ClaimType.QUICK);
}
}
diff --git a/apps/server/src/claims/claims.module.ts b/apps/server/src/items/items.module.ts
similarity index 66%
rename from apps/server/src/claims/claims.module.ts
rename to apps/server/src/items/items.module.ts
index e15dd9f..10145dd 100644
--- a/apps/server/src/claims/claims.module.ts
+++ b/apps/server/src/items/items.module.ts
@@ -3,8 +3,8 @@ import { MongooseModule } from '@nestjs/mongoose';
import { Pet, PetSchema } from 'src/pets/entities/pet.entity';
import { User, UserSchema } from 'src/users/entities/user.entity';
import { UsersModule } from 'src/users/users.module';
-import { ClaimsController } from './claims.controller';
-import { ClaimsService } from './claims.service';
+import { ItemsController } from './items.controller';
+import { ItemsService } from './items.service';
@Module({
imports: [
@@ -14,8 +14,8 @@ import { ClaimsService } from './claims.service';
]),
UsersModule,
],
- controllers: [ClaimsController],
- providers: [ClaimsService],
- exports: [ClaimsService],
+ controllers: [ItemsController],
+ providers: [ItemsService],
+ exports: [ItemsService],
})
-export class ClaimsModule {}
+export class ItemsModule {}
diff --git a/apps/server/src/items/items.service.ts b/apps/server/src/items/items.service.ts
new file mode 100644
index 0000000..39b8dad
--- /dev/null
+++ b/apps/server/src/items/items.service.ts
@@ -0,0 +1,206 @@
+import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
+import { sample } from 'lodash';
+import { ConfigService } from '@nestjs/config';
+import { InjectModel } from '@nestjs/mongoose';
+import {
+ CLAIM_TYPE_CONFIG,
+ ClaimType,
+ EXPEDITION_CONFIG,
+ getNextClaimTime,
+ isClaimAvailable,
+ ItemResult,
+ ItemReward,
+ ITEMS_CLAIM_CONFIG,
+ ItemStatus,
+ PET_ACTIONS_BY_CATEGORY,
+ PetActionCategory,
+ VALENTINE_GIFT_ITEMS,
+} from '@widgetable/types';
+import { Model, Types } from 'mongoose';
+import { Pet, PetDocument } from 'src/pets/entities/pet.entity';
+import { User, UserDocument } from 'src/users/entities/user.entity';
+import { UsersService } from 'src/users/users.service';
+
+@Injectable()
+export class ItemsService {
+ constructor(
+ @InjectModel(User.name) private userModel: Model,
+ @InjectModel(Pet.name) private petModel: Model,
+ private readonly usersService: UsersService,
+ private readonly configService: ConfigService,
+ ) {}
+
+ async getItemStatus(userId: Types.ObjectId): Promise {
+ const user = await this.userModel.findById(userId);
+ if (!user) throw new NotFoundException();
+
+ const pets = await this.petModel.find({ parents: { $in: [userId] } });
+ const petCount = pets.filter((pet) => !pet.isEgg).length;
+
+ const now = new Date();
+ const available = {} as Record;
+ const nextClaimTime: Partial> = {};
+
+ for (const type of Object.values(ClaimType)) {
+ const { cooldownHours } = CLAIM_TYPE_CONFIG[type];
+ const lastTime = user.lastClaimTimes?.get(type);
+ const isAvailable = isClaimAvailable(lastTime, cooldownHours, now);
+ available[type] = isAvailable;
+ if (!isAvailable) nextClaimTime[type] = getNextClaimTime(lastTime!, cooldownHours);
+ }
+
+ return { available, nextClaimTime, petCount };
+ }
+
+ async executeClaim(userId: Types.ObjectId, claimType: ClaimType): Promise {
+ const user = await this.userModel.findById(userId);
+ if (!user) throw new NotFoundException();
+
+ const pets = await this.petModel.find({ parents: { $in: [userId] } });
+ const petCount = pets.filter((pet) => !pet.isEgg).length;
+
+ const now = new Date();
+ const { cooldownHours, multiplier } = CLAIM_TYPE_CONFIG[claimType];
+ const lastClaimTime = user.lastClaimTimes?.get(claimType);
+
+ if (!isClaimAvailable(lastClaimTime, cooldownHours, now)) {
+ throw new BadRequestException();
+ }
+
+ const result = this.calculateClaimItems(petCount, multiplier);
+
+ await this.usersService.addInventoryBundle(userId, result.items);
+ await this.userModel.findByIdAndUpdate(userId, { [`lastClaimTimes.${claimType}`]: now });
+
+ return result;
+ }
+
+ calculateClaimItems(petCount: number, multiplier: number): ItemResult {
+ const petMultiplier = Math.max(1, Math.sqrt(petCount));
+
+ const foodCount = Math.floor(ITEMS_CLAIM_CONFIG.BASE_FOOD_ITEMS * petMultiplier * multiplier);
+ const drinkCount = Math.floor(ITEMS_CLAIM_CONFIG.BASE_DRINK_ITEMS * petMultiplier * multiplier);
+ const hygieneCount = Math.floor(ITEMS_CLAIM_CONFIG.BASE_HYGIENE_ITEMS * petMultiplier * multiplier);
+ const careCount = Math.floor(ITEMS_CLAIM_CONFIG.BASE_CARE_ITEMS * petMultiplier * multiplier);
+
+ const foodItems = this.selectRandomItems(PetActionCategory.FEED, foodCount);
+ const drinkItems = this.selectRandomItems(PetActionCategory.DRINK, drinkCount);
+ const hygieneItems = this.selectRandomItems(PetActionCategory.WASH, hygieneCount);
+ const careItems = this.selectRandomItems(PetActionCategory.CARE, careCount);
+
+ // Egg chance decreases as pet count grows
+ const eggChance = Math.max(
+ ITEMS_CLAIM_CONFIG.BASE_EGG_CHANCE / (1 + petCount * ITEMS_CLAIM_CONFIG.EGG_CHANCE_PET_DECAY),
+ ITEMS_CLAIM_CONFIG.MIN_EGG_CHANCE,
+ );
+ const earnedEggs = Math.random() < eggChance ? 1 : 0;
+
+ const isValentineSeason = this.configService.get('VALENTINE_SEASON', '').toLowerCase() === 'true';
+ const valentineItems = isValentineSeason
+ ? this.selectRandomValentineItems(Math.floor(2 * petMultiplier * multiplier))
+ : [];
+
+ const valentineCount = valentineItems.reduce((sum, item) => sum + item.quantity, 0);
+ const totalItems = foodCount + drinkCount + hygieneCount + careCount + earnedEggs + valentineCount;
+
+ return {
+ success: true,
+ items: {
+ food: foodItems,
+ drinks: drinkItems,
+ hygiene: hygieneItems,
+ care: careItems,
+ eggs: earnedEggs,
+ valentines: valentineItems.length > 0 ? valentineItems : undefined,
+ },
+ totalItems,
+ };
+ }
+
+ calculateExpeditionItems(pet: PetDocument): ItemResult {
+ const {
+ BASE_FOOD_ITEMS,
+ BASE_DRINK_ITEMS,
+ BASE_HYGIENE_ITEMS,
+ BASE_CARE_ITEMS,
+ MIN_EGG_CHANCE,
+ MAX_EGG_CHANCE,
+ LEVEL_ITEM_RATE,
+ LEVEL_ITEM_CAP,
+ EGG_CHANCE_LEVEL_GROWTH,
+ } = EXPEDITION_CONFIG;
+ // Caps bonus at +100% to prevent extreme scaling at high levels
+ const levelMultiplier = 1 + Math.min(pet.level * LEVEL_ITEM_RATE, LEVEL_ITEM_CAP);
+
+ const foodCount = Math.floor(BASE_FOOD_ITEMS * levelMultiplier);
+ const drinkCount = Math.floor(BASE_DRINK_ITEMS * levelMultiplier);
+ const hygieneCount = Math.floor(BASE_HYGIENE_ITEMS * levelMultiplier);
+ const careCount = Math.floor(BASE_CARE_ITEMS * levelMultiplier);
+
+ const foodItems = this.selectRandomItems(PetActionCategory.FEED, foodCount);
+ const drinkItems = this.selectRandomItems(PetActionCategory.DRINK, drinkCount);
+ const hygieneItems = this.selectRandomItems(PetActionCategory.WASH, hygieneCount);
+ const careItems = this.selectRandomItems(PetActionCategory.CARE, careCount);
+
+ // Logarithmic growth from MIN to MAX based on level
+ const eggChance = Math.min(
+ MIN_EGG_CHANCE + (MAX_EGG_CHANCE - MIN_EGG_CHANCE) * (1 - 1 / (1 + pet.level * EGG_CHANCE_LEVEL_GROWTH)),
+ MAX_EGG_CHANCE,
+ );
+ const earnedEggs = Math.random() < eggChance ? 1 : 0;
+
+ // February-only valentine items
+ const isValentineSeason = this.configService.get('VALENTINE_SEASON', '').toLowerCase() === 'true';
+ const valentineItems = isValentineSeason
+ ? this.selectRandomValentineItems(Math.floor(1 * levelMultiplier))
+ : [];
+
+ const valentineCount = valentineItems.reduce((sum, item) => sum + item.quantity, 0);
+
+ return {
+ success: true,
+ items: {
+ food: foodItems,
+ drinks: drinkItems,
+ hygiene: hygieneItems,
+ care: careItems,
+ eggs: earnedEggs,
+ valentines: valentineItems.length > 0 ? valentineItems : undefined,
+ },
+ totalItems: foodCount + drinkCount + hygieneCount + careCount + earnedEggs + valentineCount,
+ };
+ }
+
+ selectRandomItems(category: PetActionCategory, count: number): ItemReward[] {
+ const actions = PET_ACTIONS_BY_CATEGORY[category].filter((a) => a.inventoryCost !== undefined);
+ const itemCounts = new Map();
+
+ for (let i = 0; i < count; i++) {
+ const selected = sample(actions)!;
+ itemCounts.set(selected.name, (itemCounts.get(selected.name) || 0) + 1);
+ }
+
+ const result: ItemReward[] = [];
+ itemCounts.forEach((quantity, name) => {
+ result.push({ name, quantity });
+ });
+
+ return result;
+ }
+
+ selectRandomValentineItems(count: number): ItemReward[] {
+ const itemCounts = new Map();
+
+ for (let i = 0; i < count; i++) {
+ const selected = sample(VALENTINE_GIFT_ITEMS)!;
+ itemCounts.set(selected.name, (itemCounts.get(selected.name) || 0) + 1);
+ }
+
+ const result: ItemReward[] = [];
+ itemCounts.forEach((quantity, name) => {
+ result.push({ name, quantity });
+ });
+
+ return result;
+ }
+}
diff --git a/apps/server/src/notifications/notifications.constants.ts b/apps/server/src/notifications/notifications.constants.ts
new file mode 100644
index 0000000..11d980b
--- /dev/null
+++ b/apps/server/src/notifications/notifications.constants.ts
@@ -0,0 +1,3 @@
+export const WEBPUSH_CONFIG = {
+ INVALID_SUBSCRIPTION_CODES: [410, 404],
+} as const;
diff --git a/apps/server/src/notifications/notifications.controller.ts b/apps/server/src/notifications/notifications.controller.ts
index c32806c..68a1a22 100644
--- a/apps/server/src/notifications/notifications.controller.ts
+++ b/apps/server/src/notifications/notifications.controller.ts
@@ -30,9 +30,4 @@ export class NotificationsController {
unsubscribe(@Body() body: { endpoint: string }) {
return this.notificationsService.unsubscribe(body.endpoint);
}
-
- @Post('test')
- sendTestNotification(@GetUser() user: UserDocument) {
- return this.notificationsService.sendTestNotification(user._id);
- }
}
diff --git a/apps/server/src/notifications/notifications.service.ts b/apps/server/src/notifications/notifications.service.ts
index 56bbb2f..c955f73 100644
--- a/apps/server/src/notifications/notifications.service.ts
+++ b/apps/server/src/notifications/notifications.service.ts
@@ -4,17 +4,17 @@ import { InjectModel } from '@nestjs/mongoose';
import { Cron, CronExpression } from '@nestjs/schedule';
import { translate } from '@widgetable/i18n';
import {
- CLAIMS_CONFIG,
+ calculateDecayedNeeds,
+ CLAIM_TYPE_CONFIG,
+ ClaimType,
DEFAULT_LANGUAGE,
+ isClaimAvailable,
NOTIFICATION_CONFIG,
- PET_NEED_KEYS,
- PET_NEEDS_CONFIG,
- PET_UPDATE_INTERVAL,
+ PET_THRESHOLDS,
} from '@widgetable/types';
-import { clamp } from 'lodash';
import { Model, Types } from 'mongoose';
import { Pet, PetDocument } from 'src/pets/entities/pet.entity';
-import { WEBPUSH_CONFIG } from 'src/shared/constants';
+import { WEBPUSH_CONFIG } from './notifications.constants';
import { User, UserDocument } from 'src/users/entities/user.entity';
import * as webpush from 'web-push';
import { PushSubscription, PushSubscriptionDocument } from './entities/push-subscription.entity';
@@ -26,6 +26,8 @@ export const nt = (lang: string, key: string, params?: Record();
constructor(
@InjectModel(PushSubscription.name) private subscriptionModel: Model,
@@ -57,11 +59,6 @@ export class NotificationsService {
return this.subscriptionModel.deleteOne({ endpoint });
}
- private async getUserLang(userId: string | Types.ObjectId): Promise {
- const user = await this.userModel.findById(userId).select('language').lean();
- return user?.language || DEFAULT_LANGUAGE;
- }
-
private async batchGetUserLangs(userIds: string[]): Promise