diff --git a/src/App.jsx b/src/App.jsx index 635b0be..f2a3951 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,7 +2,9 @@ //
tests whether centered layout works. //
tests whether reusable panel and vertical spacing classes work. //

tests title style -import Header from "./components/header.jsx"; +import { BrowserRouter, Navigate, Route, Routes, Outlet } from "react-router-dom"; +import { isAuthenticated } from "./api/userApi"; + import OpeningPage from "./pages/OpeningPage.jsx"; import RegisterPage from "./pages/RegisterPage.jsx"; import LoginPage from "./pages/LoginPage.jsx"; @@ -13,41 +15,92 @@ import EggDashboardPage from "./pages/EggDashboardPage.jsx"; import InventoryPage from "./pages/InventoryPage.jsx"; import ShopPage from "./pages/ShopPage.jsx"; import MemoryArchivePage from "./pages/MemoryArchivePage.jsx"; -import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom"; -// 1. Create a wrapper component inside the Router context -function AppContent() { - const location = useLocation(); +import Header from "./components/header.jsx"; +import MusicPlayer from "./components/MusicPlayer.jsx"; + +function ProtectedRoute({ children }) { + if (!isAuthenticated()) { + return ; + } + + return children; +} + +function ProtectedPage({ children }) { + return ( + +
+ {children} + + ); +} + +function PublicOnlyRoute({ children }) { + if (isAuthenticated()) { + return ; + } + + return children; +} - // Define paths where you don't want the header - const hideHeaderPaths = ["/shop"]; +function ProtectedLayout() { + if (!isAuthenticated()) { + return ; + } return ( <> - {/* Only render Header if the current path isn't in the hide list */} - {!hideHeaderPaths.includes(location.pathname) &&
} - -
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
+
+ + ); } -// 2. Keep your main App component clean function App() { return ( - + + + + + } + /> + + + + + } + /> + + + + + } + /> + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + ); } diff --git a/src/api/eggApi.js b/src/api/eggApi.js index 5dcbf86..9ae7020 100644 --- a/src/api/eggApi.js +++ b/src/api/eggApi.js @@ -1,3 +1,6 @@ +import { getRawShopItems } from "./shopApi"; +import { getInventoryItems } from "./inventoryApi"; + const EGG_STORAGE_KEY = "memory_egg_egg"; const defaultEgg = { @@ -7,9 +10,15 @@ const defaultEgg = { glow: 0, warmth: 0, weight: 0, + active_background_id: null, active_music_id: null, active_decoration_id: null, + + equipped_background: "default", + selected_music: null, + equipped_cosmetic: null, + updated_at: new Date().toISOString(), }; @@ -39,35 +48,6 @@ function saveEggToStorage(egg) { localStorage.setItem(EGG_STORAGE_KEY, JSON.stringify(egg)); } -function getEmptyStats() { - return { - glow: 0, - warmth: 0, - weight: 0, - }; -} - -function getDecorationStats(decorationItem) { - const stats = getEmptyStats(); - - if (!decorationItem || decorationItem.item_type !== "decoration") { - return stats; - } - - if (!decorationItem.effect_type || !decorationItem.effect_value) { - return stats; - } - - if (!Object.hasOwn(stats, decorationItem.effect_type)) { - return stats; - } - - return { - ...stats, - [decorationItem.effect_type]: Number(decorationItem.effect_value), - }; -} - function findEquippedItemByType(inventoryItems, itemType) { return inventoryItems.find( (item) => item.item_type === itemType && item.is_equipped @@ -75,11 +55,19 @@ function findEquippedItemByType(inventoryItems, itemType) { } export async function getEgg() { - return loadEggFromStorage(); + try { + const rawShopItems = await getRawShopItems(); + const inventoryItems = await getInventoryItems(rawShopItems); + + return recalculateEggFromInventory(inventoryItems); + } catch (error) { + console.warn("Failed to sync egg from inventory:", error); + return loadEggFromStorage(); + } } export async function recalculateEggFromInventory(inventoryItems) { - const egg = loadEggFromStorage(); + const currentEgg = loadEggFromStorage(); const equippedBackground = findEquippedItemByType( inventoryItems, @@ -91,18 +79,53 @@ export async function recalculateEggFromInventory(inventoryItems) { "decoration" ); - const decorationStats = getDecorationStats(equippedDecoration); + const bonusStats = inventoryItems.reduce( + (totals, item) => { + if (!item.is_equipped || !item.effect_type || item.effect_value == null) { + return totals; + } + + const effectValue = Number(item.effect_value); + + if (Number.isNaN(effectValue)) { + return totals; + } + + if (!["glow", "warmth", "weight"].includes(item.effect_type)) { + return totals; + } + + return { + ...totals, + [item.effect_type]: totals[item.effect_type] + effectValue, + }; + }, + { + glow: 0, + warmth: 0, + weight: 0, + } + ); + + const baseGlow = currentEgg.base_glow ?? currentEgg.glow ?? 0; + const baseWarmth = currentEgg.base_warmth ?? currentEgg.warmth ?? 0; + const baseWeight = currentEgg.base_weight ?? currentEgg.weight ?? 0; + const updatedEgg = { - ...egg, - stage: 1, - glow: decorationStats.glow, - warmth: decorationStats.warmth, - weight: decorationStats.weight, - active_background_id: equippedBackground?.item_id ?? null, - active_music_id: equippedMusic?.item_id ?? null, - active_decoration_id: equippedDecoration?.item_id ?? null, - updated_at: new Date().toISOString(), + ...currentEgg, + + base_glow: baseGlow, + base_warmth: baseWarmth, + base_weight: baseWeight, + + glow: Math.min(100, baseGlow + bonusStats.glow), + warmth: Math.min(100, baseWarmth + bonusStats.warmth), + weight: Math.min(100, baseWeight + bonusStats.weight), + + equipped_background: equippedBackground?.asset_key || "default", + selected_music: equippedMusic?.asset_key || null, + equipped_cosmetic: equippedDecoration?.asset_key || null, }; saveEggToStorage(updatedEgg); diff --git a/src/api/inventoryApi.js b/src/api/inventoryApi.js index db47ee2..1fd5111 100644 --- a/src/api/inventoryApi.js +++ b/src/api/inventoryApi.js @@ -1,6 +1,85 @@ const USER_ITEMS_STORAGE_KEY = "memory_egg_user_items"; const USER_ID = 1; +const TOKEN_STORAGE_KEY = "memory_egg_token"; +const API_BASE_URL = + import.meta.env.VITE_API_BASE_URL || "http://localhost:5000/api"; + +const BACKEND_ASSET_KEY_MAP = { + "Crisp Autumn Background": "fall_bg", + "Green Field Background": "grass_bg", + "Night Street Background": "nightstreet_bg", + + "Eternity In Moments Music": "eternity_in_moments", + "Gold Phenomenon Music": "gold_phenomenon", + "Mi Querido Music": "mi_querido", + + Angelic: "angelic", + Beard: "beard", + "Dirty Boots": "dirty_boots", + "Flower Crown": "flower_crown", + Glasses: "glasses", + "Life Buoy": "life_buoy", + "On Fire": "on_fire", + "Spinning Hat": "spinning_hat", + "Top Hat": "top_hat", + "Work Overall": "work_overall", +}; + +function getAssetKeyFromBackendItem(item) { + if (item.asset_key) { + return item.asset_key; + } + + if (BACKEND_ASSET_KEY_MAP[item.name]) { + return BACKEND_ASSET_KEY_MAP[item.name]; + } + + if (item.asset_url?.includes("fall-bg")) { + return "fall_bg"; + } + + if (item.asset_url?.includes("grass-bg")) { + return "grass_bg"; + } + + if (item.asset_url?.includes("nightstreet-bg")) { + return "nightstreet_bg"; + } + + if (item.asset_url?.includes("eternity-in-moments")) { + return "eternity_in_moments"; + } + + if (item.asset_url?.includes("gold-phenomenon")) { + return "gold_phenomenon"; + } + + if (item.asset_url?.includes("mi-querido")) { + return "mi_querido"; + } + + return item.name; +} + +function getAuthToken() { + return localStorage.getItem(TOKEN_STORAGE_KEY); +} + +function shouldUseBackend() { + return Boolean(getAuthToken()); +} + +function getAuthHeaders() { + const token = getAuthToken(); + + return { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; +} + + function loadUserItemsFromStorage() { const savedUserItems = localStorage.getItem(USER_ITEMS_STORAGE_KEY); @@ -23,6 +102,117 @@ function saveUserItemsToStorage(userItems) { localStorage.setItem(USER_ITEMS_STORAGE_KEY, JSON.stringify(userItems)); } +// BACKEND INTEGRATION + +async function loadInventoryFromBackend() { + const response = await fetch(`${API_BASE_URL}/auth/me/inventory`, { + method: "GET", + headers: getAuthHeaders(), + }); + + const data = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error(data?.error || data?.message || "Failed to load inventory."); + } + + const userItems = Array.isArray(data?.userItems) ? data.userItems : []; + const items = Array.isArray(data?.items) ? data.items : []; + + return { + userItems: userItems.map(normalizeUserItem), + items: items.map(normalizeShopItem), + }; +} + + +// Helper function +function normalizeItemType(itemType) { + if (itemType === "cosmetic") { + return "decoration"; + } + + return itemType; +} + +function toDisplayName(assetKey) { + if (!assetKey) { + return "Unknown Item"; + } + + return assetKey + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +function normalizeShopItem(item) { + const assetKey = getAssetKeyFromBackendItem(item); + + return { + ...item, + item_id: item.item_id || item.id, + id: item.id || item.item_id, + name: item.display_name || item.title || item.name || toDisplayName(assetKey), + item_type: normalizeItemType(item.item_type), + asset_key: assetKey, + price: item.price ?? item.price_will ?? 0, + is_active: item.is_active === true || item.is_active === 1, + }; +} + +function normalizeUserItem(userItem) { + return { + ...userItem, + user_item_id: userItem.user_item_id || userItem.id, + id: userItem.id || userItem.user_item_id, + item_id: userItem.item_id, + quantity: userItem.quantity ?? 1, + is_equipped: + userItem.is_equipped === true || + userItem.is_equipped === 1 || + userItem.equipped === true, + }; +} + +async function equipItemOnBackend(itemId) { + const response = await fetch(`${API_BASE_URL}/egg/equip`, { + method: "PATCH", + headers: getAuthHeaders(), + body: JSON.stringify({ + item_id: itemId, + }), + }); + + const data = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error(data?.error || data?.message || "Failed to equip item."); + } + + return data; +} + +async function unequipItemOnBackend(itemId) { + const response = await fetch(`${API_BASE_URL}/egg/unequip`, { + method: "PATCH", + headers: getAuthHeaders(), + body: JSON.stringify({ + item_id: itemId, + }), + }); + + const data = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error(data?.error || data?.message || "Failed to unequip item."); + } + + return data; +} + +// + function buildInventoryView(userItem, shopItem) { return { user_item_id: userItem.user_item_id, @@ -38,18 +228,42 @@ function buildInventoryView(userItem, shopItem) { price: shopItem.price, effect_type: shopItem.effect_type, effect_value: shopItem.effect_value, + asset_key: shopItem.asset_key, asset_url: shopItem.asset_url, is_active: shopItem.is_active, }; } -/* exported api logics*/ +// exported api logics export async function getUserItems() { + if (shouldUseBackend()) { + const inventory = await loadInventoryFromBackend(); + return inventory.userItems; + } + return loadUserItemsFromStorage(); } export async function getInventoryItems(shopItems) { + if (shouldUseBackend()) { + const inventory = await loadInventoryFromBackend(); + + return inventory.userItems + .map((userItem) => { + const matchingShopItem = inventory.items.find( + (item) => Number(item.item_id) === Number(userItem.item_id) + ); + + if (!matchingShopItem) { + return null; + } + + return buildInventoryView(userItem, matchingShopItem); + }) + .filter(Boolean); + } + const userItems = loadUserItemsFromStorage(); return userItems @@ -108,6 +322,25 @@ export async function addUserItem(itemId) { } export async function equipUserItem(userItemId, shopItems) { + if (shouldUseBackend()) { + const inventory = await loadInventoryFromBackend(); + + const selectedUserItem = inventory.userItems.find( + (userItem) => Number(userItem.user_item_id) === Number(userItemId) + ); + + if (!selectedUserItem) { + throw new Error("Inventory item not found."); + } + + await equipItemOnBackend(selectedUserItem.item_id); + + const updatedInventory = await loadInventoryFromBackend(); + + return updatedInventory.userItems; + } + + const userItems = loadUserItemsFromStorage(); const selectedUserItem = userItems.find( @@ -150,6 +383,24 @@ export async function equipUserItem(userItemId, shopItems) { } export async function unequipUserItem(userItemId) { + if (shouldUseBackend()) { + const inventory = await loadInventoryFromBackend(); + + const selectedUserItem = inventory.userItems.find( + (userItem) => Number(userItem.user_item_id) === Number(userItemId) + ); + + if (!selectedUserItem) { + throw new Error("Inventory item not found."); + } + + await unequipItemOnBackend(selectedUserItem.item_id); + + const updatedInventory = await loadInventoryFromBackend(); + + return updatedInventory.userItems; + } + const userItems = loadUserItemsFromStorage(); const updatedUserItems = userItems.map((userItem) => { diff --git a/src/api/postsApi.js b/src/api/postsApi.js index 0132ca7..1db97a7 100644 --- a/src/api/postsApi.js +++ b/src/api/postsApi.js @@ -1,4 +1,8 @@ const STORAGE_KEY = "memory_egg_posts"; +const TOKEN_STORAGE_KEY = "memory_egg_token"; + +const API_BASE_URL = + import.meta.env.VITE_API_BASE_URL || "http://localhost:5000/api"; const defaultPosts = [ { @@ -31,8 +35,24 @@ const defaultPosts = [ }, ]; +function getAuthToken() { + return localStorage.getItem(TOKEN_STORAGE_KEY); +} + +function shouldUseBackend() { + return Boolean(getAuthToken()); +} + +function getAuthHeaders() { + const token = getAuthToken(); + + return { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; +} -/* localStorage for testing WritePostPage and MemoryARchivePage interaction */ +/* localStorage for mock mode */ function loadPostsFromStorage() { const savedPosts = localStorage.getItem(STORAGE_KEY); @@ -56,9 +76,6 @@ function savePostsToStorage(posts) { localStorage.setItem(STORAGE_KEY, JSON.stringify(posts)); } - - - function countWords(text) { const trimmed = text.trim(); @@ -73,54 +90,142 @@ function calculateWillReward(wordCount) { return Math.max(1, Math.floor(wordCount / 10)); } +function normalizePost(post) { + if (!post) { + return null; + } + + return { + ...post, + post_id: post.post_id || post.id, + id: post.id || post.post_id, + }; +} + export async function getAllPosts() { - return loadPostsFromStorage(); + if (!shouldUseBackend()) { + return loadPostsFromStorage(); + } + + // BACKEND INTEGRATION + + const response = await fetch(`${API_BASE_URL}/posts/all`, { + method: "GET", + headers: getAuthHeaders(), + }); + + const data = await response.json().catch(() => null); + + if (response.status === 404) { + return []; + } + + if (!response.ok) { + throw new Error(data?.error || data?.message || "Failed to load posts."); + } + + const posts = Array.isArray(data) ? data : data?.posts; + + return Array.isArray(posts) ? posts.map(normalizePost) : []; } export async function getPostById(postId) { - const posts = loadPostsFromStorage(); + if (!shouldUseBackend()) { + const posts = loadPostsFromStorage(); + + return posts.find((post) => Number(post.post_id) === Number(postId)); + } + + // BACKEND INTEGRATION - return posts.find((post) => post.post_id === Number(postId)); + const response = await fetch(`${API_BASE_URL}/posts/${postId}`, { + method: "GET", + headers: getAuthHeaders(), + }); + + const data = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error(data?.error || data?.message || "Failed to load post."); + } + + return normalizePost(data.post || data); } export async function createPost(postData) { - const posts = loadPostsFromStorage(); - const wordCount = countWords(postData.content); + if (!shouldUseBackend()) { + const posts = loadPostsFromStorage(); + const wordCount = countWords(postData.content); + + const newPost = { + post_id: Date.now(), + id: Date.now(), + user_id: 1, + title: postData.title, + content: postData.content, + image_url: postData.image_url || null, + tag: postData.tag, + visibility: postData.visibility, + word_count: wordCount, + will_reward: calculateWillReward(wordCount), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + const updatedPosts = [newPost, ...posts]; + + savePostsToStorage(updatedPosts); + + return newPost; + } - const newPost = { - post_id: Date.now(), - user_id: 1, - title: postData.title, - content: postData.content, - image_url: postData.image_url || null, - tag: postData.tag, - visibility: postData.visibility, - word_count: wordCount, - will_reward: calculateWillReward(wordCount), - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }; + // BACKEND INTEGRATION + + const response = await fetch(`${API_BASE_URL}/posts`, { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ + title: postData.title, + content: postData.content, + image_url: postData.image_url || null, + tag: postData.tag?.toLowerCase(), + visibility: postData.visibility, + }), + }); - const updatedPosts = [newPost, ...posts]; + const data = await response.json().catch(() => null); - savePostsToStorage(updatedPosts); + if (!response.ok) { + throw new Error(data?.error || data?.message || "Failed to create post."); + } - return newPost; + return normalizePost(data.post || data); } export async function deletePost(postId) { - const posts = loadPostsFromStorage(); - /* - console.log("Before delete:", posts); - console.log("Trying to delete postId:", postId); - */ - const updatedPosts = posts.filter( - (post) => Number(post.post_id) !== Number(postId) - ); - /* - console.log("After delete:", updatedPosts); - */ - savePostsToStorage(updatedPosts); + if (!shouldUseBackend()) { + const posts = loadPostsFromStorage(); + + const updatedPosts = posts.filter( + (post) => Number(post.post_id) !== Number(postId) + ); + + savePostsToStorage(updatedPosts); + + return true; + } + + // BACKEND INTEGRATION + + const response = await fetch(`${API_BASE_URL}/posts/${postId}`, { + method: "DELETE", + headers: getAuthHeaders(), + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + throw new Error(data?.error || data?.message || "Failed to delete post."); + } return true; } \ No newline at end of file diff --git a/src/api/questsApi.js b/src/api/questsApi.js index d361375..b83716d 100644 --- a/src/api/questsApi.js +++ b/src/api/questsApi.js @@ -2,51 +2,70 @@ import { addWill } from "./userApi"; const QUEST_STORAGE_KEY = "memory_egg_quests"; +const API_BASE_URL = + import.meta.env.VITE_API_BASE_URL || "http://localhost:5000/api"; + +function getAuthHeaders() { + const token = localStorage.getItem("memory_egg_token"); + + return { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; +} + +function shouldUseBackend() { + return Boolean(localStorage.getItem("memory_egg_token")); +} + const defaultQuests = [ { + user_quest_id: 1, quest_id: 1, + user_id: 1, title: "Write a Study Memory", description: "Write one post with the study tag.", - quest_type: "tag", + quest_type: "post_tag", required_tag: "study", - required_word_count: 1, - requires_image: false, + required_word_count: 0, + required_image: 0, reward_will: 10, status: "assigned", assigned_date: new Date().toISOString().slice(0, 10), completed_post_id: null, completed_at: null, - claimed_at: null, }, { + user_quest_id: 2, quest_id: 2, + user_id: 1, title: "A Small Reflection", description: "Write a reflection post with at least 20 words.", - quest_type: "word_count", + quest_type: "post_tag_word_count", required_tag: "reflection", required_word_count: 20, - requires_image: false, + required_image: 0, reward_will: 15, status: "assigned", assigned_date: new Date().toISOString().slice(0, 10), completed_post_id: null, completed_at: null, - claimed_at: null, }, { + user_quest_id: 3, quest_id: 3, + user_id: 1, title: "Capture a Memory", description: "Write any post with an image attached.", quest_type: "image", required_tag: null, - required_word_count: 1, - requires_image: true, + required_word_count: 0, + required_image: 1, reward_will: 20, status: "assigned", assigned_date: new Date().toISOString().slice(0, 10), completed_post_id: null, completed_at: null, - claimed_at: null, }, ]; @@ -73,19 +92,56 @@ function saveQuestsToStorage(quests) { } function doesPostCompleteQuest(post, quest) { - const tagMatches = !quest.required_tag || post.tag === quest.required_tag; + if (!post || !quest) { + return false; + } + + const questType = quest.quest_type; + const postTag = post.tag; + const requiredTag = quest.required_tag; + const postWordCount = Number(post.word_count || 0); + const requiredWordCount = Number(quest.required_word_count || 0); + const hasImage = Boolean(post.image_url); + + if (questType === "post_tag") { + return postTag === requiredTag; + } + + if (questType === "word_count") { + return postWordCount >= requiredWordCount; + } + + if (questType === "image") { + return hasImage; + } - const wordCountMatches = - !quest.required_word_count || - post.word_count >= quest.required_word_count; + if (questType === "post_tag_image") { + return postTag === requiredTag && hasImage; + } - const imageMatches = !quest.requires_image || Boolean(post.image_url); + if (questType === "post_tag_word_count") { + return postTag === requiredTag && postWordCount >= requiredWordCount; + } - return tagMatches && wordCountMatches && imageMatches; + return false; } export async function getTodayQuests() { - return loadQuestsFromStorage(); + if (!shouldUseBackend()) { + return loadQuestsFromStorage(); + } + + const response = await fetch(`${API_BASE_URL}/quests/today`, { + method: "GET", + headers: getAuthHeaders(), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.error || "Failed to load today's quests."); + } + + return response.json(); } @@ -107,7 +163,7 @@ export async function checkPostAgainstQuests(post) { return { ...quest, status: "completed", - completed_post_id: post.post_id, + completed_post_id: post.post_id || post.id, completed_at: new Date().toISOString(), }; }); @@ -117,38 +173,68 @@ export async function checkPostAgainstQuests(post) { return updatedQuests; } -export async function claimQuestReward(questId) { - const quests = loadQuestsFromStorage(); - - const selectedQuest = quests.find( - (quest) => Number(quest.quest_id) === Number(questId) - ); +export async function claimQuestReward({ userQuestId, postId }) { + if (!shouldUseBackend()) { + const quests = loadQuestsFromStorage(); - if (!selectedQuest) { - throw new Error("Quest not found."); - } + const selectedQuest = quests.find( + (quest) => Number(quest.user_quest_id) === Number(userQuestId) + ); - if (selectedQuest.status !== "completed") { - throw new Error("Quest is not completed yet."); - } + if (!selectedQuest) { + throw new Error("Quest not found."); + } - const updatedQuests = quests.map((quest) => { - if (Number(quest.quest_id) !== Number(questId)) { - return quest; + if (selectedQuest.status === "claimed") { + throw new Error("Quest has already been claimed."); } - return { - ...quest, - status: "claimed", - claimed_at: new Date().toISOString(), - }; - }); + const updatedQuests = quests.map((quest) => { + if (Number(quest.user_quest_id) !== Number(userQuestId)) { + return quest; + } + + return { + ...quest, + status: "claimed", + completed_post_id: postId, + completed_at: new Date().toISOString().slice(0, 10), + }; + }); + saveQuestsToStorage(updatedQuests); const updatedUser = await addWill(selectedQuest.reward_will); return { - reward_will: selectedQuest.reward_will, - user: updatedUser, + userQuest: { + ...selectedQuest, + status: "claimed", + completed_post_id: postId, + completed_at: new Date().toISOString().slice(0, 10), + }, + user: updatedUser, + reward_will: selectedQuest.reward_will, }; + } + + const response = await fetch(`${API_BASE_URL}/quests/${userQuestId}/claim`, { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ + post_id: postId, + }), + }); + + const data = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error(data?.error || "Failed to claim quest."); + } + + if (data?.user) { + localStorage.setItem("memory_egg_user", JSON.stringify(data.user)); + } + + return data; } \ No newline at end of file diff --git a/src/api/shopApi.js b/src/api/shopApi.js index 8b0f2aa..4e7c2a8 100644 --- a/src/api/shopApi.js +++ b/src/api/shopApi.js @@ -6,104 +6,275 @@ import { import { spendWill } from "./userApi"; const SHOP_ITEMS_STORAGE_KEY = "memory_egg_shop_items"; +const TOKEN_STORAGE_KEY = "memory_egg_token"; +const API_BASE_URL = + import.meta.env.VITE_API_BASE_URL || "http://localhost:5000/api"; + +function getAuthToken() { + return localStorage.getItem(TOKEN_STORAGE_KEY); +} + +function shouldUseBackend() { + return Boolean(getAuthToken()); +} + +function getAuthHeaders() { + const token = getAuthToken(); + + return { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; +} + +const BACKEND_ASSET_KEY_MAP = { + "Crisp Autumn Background": "fall_bg", + "Green Field Background": "grass_bg", + "Night Street Background": "nightstreet_bg", + + "Eternity In Moments Music": "eternity_in_moments", + "Gold Phenomenon Music": "gold_phenomenon", + "Mi Querido Music": "mi_querido", + + Angelic: "angelic", + Beard: "beard", + "Dirty Boots": "dirty_boots", + "Flower Crown": "flower_crown", + Glasses: "glasses", + "Life Buoy": "life_buoy", + "On Fire": "on_fire", + "Spinning Hat": "spinning_hat", + "Top Hat": "top_hat", + "Work Overall": "work_overall", +}; + +function getAssetKeyFromBackendItem(item) { + if (item.asset_key) { + return item.asset_key; + } + + if (BACKEND_ASSET_KEY_MAP[item.name]) { + return BACKEND_ASSET_KEY_MAP[item.name]; + } + + if (item.asset_url?.includes("fall-bg")) { + return "fall_bg"; + } + + if (item.asset_url?.includes("grass-bg")) { + return "grass_bg"; + } + + if (item.asset_url?.includes("nightstreet-bg")) { + return "nightstreet_bg"; + } + + if (item.asset_url?.includes("eternity-in-moments")) { + return "eternity_in_moments"; + } + + if (item.asset_url?.includes("gold-phenomenon")) { + return "gold_phenomenon"; + } + + if (item.asset_url?.includes("mi-querido")) { + return "mi_querido"; + } + + return item.name; +} + const defaultShopItems = [ { item_id: 1, - name: "Starry Night", + name: "Fall Window", item_type: "background", - description: "A quiet night sky for your egg's resting place.", + description: "A calm autumn view outside the egg's window.", price: 150, effect_type: null, effect_value: null, + asset_key: "fall_bg", asset_url: null, is_active: true, }, { item_id: 2, - name: "Good Morning", + name: "Grass Field", item_type: "background", - description: "A warm morning background filled with soft light.", + description: "A peaceful green field for quiet reflection.", price: 180, effect_type: null, effect_value: null, + asset_key: "grass_bg", asset_url: null, is_active: true, }, { item_id: 3, - name: "Dreamy Cloud", + name: "Night Street", item_type: "background", - description: "A gentle cloudy scene for slow reflection.", + description: "A quiet street scene glowing under the night lights.", price: 130, effect_type: null, effect_value: null, + asset_key: "nightstreet_bg", asset_url: null, is_active: true, }, { item_id: 4, - name: "Warm Blanket", + name: "Angelic", item_type: "decoration", - description: "A soft blanket that gives the egg warmth.", + description: "A bright angelic halo for your egg.", price: 120, - effect_type: "warmth", + effect_type: "glow", effect_value: "15", + asset_key: "angelic", asset_url: null, is_active: true, }, { item_id: 5, - name: "Tiny Lamp", + name: "Beard", + item_type: "decoration", + description: "A dignified beard for a wise-looking egg.", + price: 100, + effect_type: "weight", + effect_value: "10", + asset_key: "beard", + asset_url: null, + is_active: true, + }, + { + item_id: 6, + name: "Dirty Boots", + item_type: "decoration", + description: "A pair of muddy boots from a long memory walk.", + price: 110, + effect_type: "weight", + effect_value: "12", + asset_key: "dirty_boots", + asset_url: null, + is_active: true, + }, + { + item_id: 7, + name: "Flower Crown", + item_type: "decoration", + description: "A gentle flower crown that gives the egg warmth.", + price: 140, + effect_type: "warmth", + effect_value: "15", + asset_key: "flower_crown", + asset_url: null, + is_active: true, + }, + { + item_id: 8, + name: "Glasses", + item_type: "decoration", + description: "Tiny glasses for a thoughtful egg.", + price: 100, + effect_type: "glow", + effect_value: "10", + asset_key: "glasses", + asset_url: null, + is_active: true, + }, + { + item_id: 9, + name: "Life Buoy", + item_type: "decoration", + description: "A floating ring for memories that need saving.", + price: 130, + effect_type: "warmth", + effect_value: "12", + asset_key: "life_buoy", + asset_url: null, + is_active: true, + }, + { + item_id: 10, + name: "On Fire", item_type: "decoration", - description: "A small lamp that helps the egg glow softly.", + description: "A blazing effect for an egg full of energy.", price: 180, effect_type: "glow", - effect_value: "15", + effect_value: "20", + asset_key: "on_fire", asset_url: null, is_active: true, }, { - item_id: 6, - name: "Shell Ribbon", + item_id: 11, + name: "Spinning Hat", item_type: "decoration", - description: "A decorative ribbon that gives the egg weight.", - price: 220, + description: "A playful spinning hat for a cheerful egg.", + price: 130, + effect_type: "warmth", + effect_value: "10", + asset_key: "spinning_hat", + asset_url: null, + is_active: true, + }, + { + item_id: 12, + name: "Top Hat", + item_type: "decoration", + description: "A formal top hat for a classy egg.", + price: 160, effect_type: "weight", effect_value: "15", + asset_key: "top_hat", asset_url: null, is_active: true, }, { - item_id: 7, - name: "Rainy Lullaby", + item_id: 13, + name: "Work Overall", + item_type: "decoration", + description: "A hardworking overall for an egg with discipline.", + price: 170, + effect_type: "weight", + effect_value: "18", + asset_key: "work_overall", + asset_url: null, + is_active: true, + }, + { + item_id: 14, + name: "Eternity in Moments", item_type: "music", - description: "A quiet rainy melody for writing memories.", + description: "A quiet track for long memory writing.", price: 160, effect_type: null, effect_value: null, + asset_key: "eternity_in_moments", asset_url: null, is_active: true, }, { - item_id: 8, - name: "Morning Piano", + item_id: 15, + name: "Gold Phenomenon", item_type: "music", - description: "A calm piano piece for beginning the day.", + description: "A warm background track with a gentle glow.", price: 190, effect_type: null, effect_value: null, + asset_key: "gold_phenomenon", asset_url: null, is_active: true, }, { - item_id: 9, - name: "Soft Static", + item_id: 16, + name: "Mi Querido", item_type: "music", - description: "A soft ambient track for quiet focus.", + description: "A soft emotional melody for reflection.", price: 100, effect_type: null, effect_value: null, + asset_key: "mi_querido", asset_url: null, is_active: true, }, @@ -135,6 +306,62 @@ function loadShopItemsFromStorage() { return parsedShopItems; } +// BACKEND INTEGRATION + +async function loadShopItemsFromBackend() { + const response = await fetch(`${API_BASE_URL}/shop/items`, { + method: "GET", + headers: getAuthHeaders(), + }); + + const data = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error(data?.error || data?.message || "Failed to load shop items."); + } + + const items = Array.isArray(data) ? data : data?.items; + + return Array.isArray(items) ? items.map(normalizeShopItem) : []; +} + +//Helper function +function normalizeItemType(itemType) { + if (itemType === "cosmetic") { + return "decoration"; + } + + return itemType; +} + +function toDisplayName(assetKey) { + if (!assetKey) { + return "Unknown Item"; + } + + return assetKey + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +function normalizeShopItem(item) { + const assetKey = getAssetKeyFromBackendItem(item); + + return { + ...item, + item_id: item.item_id || item.id, + id: item.id || item.item_id, + name: item.display_name || item.title || item.name || toDisplayName(assetKey), + item_type: normalizeItemType(item.item_type), + asset_key: assetKey, + price: item.price ?? item.price_will ?? 0, + is_active: item.is_active === true || item.is_active === 1, + }; +} + +//status check + function decorateShopItems(shopItems, userItems) { return shopItems.map((shopItem) => { const ownedUserItem = userItems.find( @@ -153,17 +380,47 @@ function decorateShopItems(shopItems, userItems) { /* exported api logic */ export async function getRawShopItems() { + if (shouldUseBackend()) { + return loadShopItemsFromBackend(); + } + return loadShopItemsFromStorage(); } export async function getShopItems() { - const shopItems = loadShopItemsFromStorage(); + const shopItems = await getRawShopItems(); const userItems = await getUserItems(); return decorateShopItems(shopItems, userItems); } export async function purchaseShopItem(itemId) { + if (shouldUseBackend()) { + const response = await fetch(`${API_BASE_URL}/shop/purchase`, { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ + item_id: itemId, + }), + }); + + const data = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error(data?.error || data?.message || "Failed to purchase item."); + } + + const updatedShopItems = await getShopItems(); + const updatedInventoryItems = await getInventoryItems(await getRawShopItems()); + + return { + purchasedItem: data?.item || data?.purchasedItem || null, + user: data?.user || null, + shopItems: updatedShopItems, + inventoryItems: updatedInventoryItems, + }; + } + const shopItems = loadShopItemsFromStorage(); const selectedItem = shopItems.find( diff --git a/src/api/userApi.js b/src/api/userApi.js index f0e1efd..4ca3a13 100644 --- a/src/api/userApi.js +++ b/src/api/userApi.js @@ -1,26 +1,22 @@ const USER_STORAGE_KEY = "memory_egg_user"; +const TOKEN_STORAGE_KEY = "memory_egg_token"; +const EGG_STORAGE_KEY = "memory_egg_egg"; -const defaultUser = { - user_id: 1, - email: "demo@nacimiento.app", - nickname: "Wanderer", - will_balance: 999, - created_at: new Date().toISOString(), -}; +const API_BASE_URL = + import.meta.env.VITE_API_BASE_URL || "http://localhost:5000/api"; function loadUserFromStorage() { const savedUser = localStorage.getItem(USER_STORAGE_KEY); if (!savedUser) { - localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(defaultUser)); - return defaultUser; + return null; } const parsedUser = JSON.parse(savedUser); if (!parsedUser || typeof parsedUser !== "object") { - localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(defaultUser)); - return defaultUser; + localStorage.removeItem(USER_STORAGE_KEY); + return null; } return parsedUser; @@ -30,19 +26,179 @@ function saveUserToStorage(user) { localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user)); } +function saveEggToStorage(egg) { + if (egg) { + localStorage.setItem(EGG_STORAGE_KEY, JSON.stringify(egg)); + } +} + +function saveAuthSession(data) { + localStorage.setItem(TOKEN_STORAGE_KEY, data.token); + saveUserToStorage(data.user); + saveEggToStorage(data.egg); + + return data.user; +} + +export function getAuthToken() { + return localStorage.getItem(TOKEN_STORAGE_KEY); +} + +export function isAuthenticated() { + return Boolean(getAuthToken()); +} + +function getAuthHeaders() { + const token = getAuthToken(); + + return { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; +} -/* Exported API logic to be replaced by backend */ +export function logoutUser() { + localStorage.removeItem(TOKEN_STORAGE_KEY); + localStorage.removeItem(USER_STORAGE_KEY); + localStorage.removeItem(EGG_STORAGE_KEY); +} + +export async function registerUser({ nickname, email, password }) { + const response = await fetch(`${API_BASE_URL}/auth/register`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + nickname, + email, + password, + }), + }); + + const data = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error(data?.error || data?.message || "Failed to register."); + } + + return saveAuthSession(data); +} + +export async function loginUser({ email, password }) { + const response = await fetch(`${API_BASE_URL}/auth/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email, + password, + }), + }); + + const data = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error(data?.error || data?.message || "Failed to log in."); + } + + return saveAuthSession(data); +} + +function normalizeCurrentUserResponse(data) { + // Backend currently returns: + // { user: { user: {...}, egg: {...}, active_background: ..., ... } } + // But this also supports simpler future shapes. + const sessionData = data?.user?.user ? data.user : data; + + return { + user: sessionData?.user || data?.user || null, + egg: sessionData?.egg || data?.egg || null, + active_background: + sessionData?.active_background || data?.active_background || null, + active_music: sessionData?.active_music || data?.active_music || null, + active_cosmetic: + sessionData?.active_cosmetic || data?.active_cosmetic || null, + }; +} + +function saveCurrentSessionToStorage(session) { + if (session.user) { + saveUserToStorage(session.user); + } + + if (session.egg) { + saveEggToStorage(session.egg); + } + + if (session.active_background) { + localStorage.setItem( + "memory_egg_active_background", + JSON.stringify(session.active_background) + ); + } + + if (session.active_music) { + localStorage.setItem( + "memory_egg_active_music", + JSON.stringify(session.active_music) + ); + } + + if (session.active_cosmetic) { + localStorage.setItem( + "memory_egg_active_cosmetic", + JSON.stringify(session.active_cosmetic) + ); + } +} export async function getCurrentUser() { - return loadUserFromStorage(); + const token = getAuthToken(); + + if (!token) { + return loadUserFromStorage(); + } + + try { + const response = await fetch(`${API_BASE_URL}/auth/me`, { + method: "GET", + headers: getAuthHeaders(), + }); + + const data = await response.json().catch(() => null); + + if (!response.ok) { + if (response.status === 401 || response.status === 403 || response.status === 404) { + logoutUser(); + return null; + } + + throw new Error(data?.error || data?.message || "Failed to load user."); + } + + const session = normalizeCurrentUserResponse(data); + + saveCurrentSessionToStorage(session); + + return session.user; + } catch (error) { + console.warn("Failed to refresh current user:", error); + return loadUserFromStorage(); + } } export async function addWill(amount) { const user = loadUserFromStorage(); + if (!user) { + throw new Error("User is not logged in."); + } + const updatedUser = { ...user, - will_balance: user.will_balance + Number(amount), + will_balance: Number(user.will_balance || 0) + Number(amount), }; saveUserToStorage(updatedUser); @@ -52,15 +208,20 @@ export async function addWill(amount) { export async function spendWill(amount) { const user = loadUserFromStorage(); + + if (!user) { + throw new Error("User is not logged in."); + } + const numericAmount = Number(amount); - if (user.will_balance < numericAmount) { + if (Number(user.will_balance || 0) < numericAmount) { throw new Error("Not enough Will."); } const updatedUser = { ...user, - will_balance: user.will_balance - numericAmount, + will_balance: Number(user.will_balance || 0) - numericAmount, }; saveUserToStorage(updatedUser); diff --git a/src/assets/assetRegistry.js b/src/assets/assetRegistry.js new file mode 100644 index 0000000..bf09c09 --- /dev/null +++ b/src/assets/assetRegistry.js @@ -0,0 +1,89 @@ +import defaultBackground from "./background.png"; +import defaultEgg from "./egg.PNG"; +import defaultNest from "./nest.PNG"; +import notebook from "./notebook.PNG"; +import windowFrame from "./windowframe.PNG"; + +import defaultBg from "./background/default-bg.jpg"; +import fallBackground from "./background/fall-bg.PNG"; +import grassBackground from "./background/grass-bg.PNG"; +import nightStreetBackground from "./background/nightstreet-bg.png"; + +import angelic from "./cosmetics/Angelic.png"; +import beard from "./cosmetics/Beard.png"; +import dirtyBoots from "./cosmetics/DirtyBoots.png"; +import flowerCrown from "./cosmetics/FlowerCrown.png"; +import glasses from "./cosmetics/Glasses.png"; +import lifeBuoy from "./cosmetics/LifeBuoy.png"; +import onFire from "./cosmetics/OnFire.png"; +import spinningHat from "./cosmetics/SpinningHat.png"; +import topHat from "./cosmetics/TopHat.png"; +import workOverall from "./cosmetics/WorkOverall.png"; + +import eternityInMoments from "./music/eternity-in-moments.m4a"; +import goldPhenomenon from "./music/gold-phenomenon.m4a"; +import miQuerido from "./music/mi-querido.m4a"; + +import eternityInMomentsCover from "./music-covers/eternity-in-moments.jpeg"; +import miQueridoCover from "./music-covers/mi-querido.jpeg"; +import goldPhenomenonCover from "./music-covers/gold-phenomenon.png"; + +export const baseAssets = { + background: defaultBackground, + egg: defaultEgg, + nest: defaultNest, + notebook, + windowFrame, +}; + +export const backgroundAssets = { + default: defaultBg, + fall_bg: fallBackground, + grass_bg: grassBackground, + nightstreet_bg: nightStreetBackground, +}; + +export const cosmeticAssets = { + angelic, + beard, + dirty_boots: dirtyBoots, + flower_crown: flowerCrown, + glasses, + life_buoy: lifeBuoy, + on_fire: onFire, + spinning_hat: spinningHat, + top_hat: topHat, + work_overall: workOverall, +}; + +export const musicAssets = { + eternity_in_moments: eternityInMoments, + gold_phenomenon: goldPhenomenon, + mi_querido: miQuerido, +}; + +export const musicCoverAssets = { + eternity_in_moments: eternityInMomentsCover, + mi_querido: miQueridoCover, + gold_phenomenon: goldPhenomenonCover, +}; + +export function getBackgroundAsset(backgroundKey) { + return backgroundAssets[backgroundKey] || backgroundAssets.default; +} + +export function getCosmeticAsset(cosmeticKey) { + if (!cosmeticKey) { + return null; + } + + return cosmeticAssets[cosmeticKey] || null; +} + +export function getMusicAsset(musicKey) { + return musicAssets[musicKey] || null; +} + +export function getMusicCoverAsset(assetKey) { + return musicCoverAssets[assetKey] || null; +} \ No newline at end of file diff --git a/src/assets/background/default-bg.jpg b/src/assets/background/default-bg.jpg new file mode 100644 index 0000000..7dc9ba9 Binary files /dev/null and b/src/assets/background/default-bg.jpg differ diff --git a/src/assets/cosmetics/Angelic.png b/src/assets/cosmetics/Angelic.png new file mode 100644 index 0000000..f43e2a8 Binary files /dev/null and b/src/assets/cosmetics/Angelic.png differ diff --git a/src/assets/cosmetics/Beard.png b/src/assets/cosmetics/Beard.png new file mode 100644 index 0000000..7538065 Binary files /dev/null and b/src/assets/cosmetics/Beard.png differ diff --git a/src/assets/cosmetics/DirtyBoots.png b/src/assets/cosmetics/DirtyBoots.png new file mode 100644 index 0000000..8876dbe Binary files /dev/null and b/src/assets/cosmetics/DirtyBoots.png differ diff --git a/src/assets/cosmetics/FlowerCrown.png b/src/assets/cosmetics/FlowerCrown.png new file mode 100644 index 0000000..79ee34e Binary files /dev/null and b/src/assets/cosmetics/FlowerCrown.png differ diff --git a/src/assets/cosmetics/Glasses.png b/src/assets/cosmetics/Glasses.png new file mode 100644 index 0000000..08fea5f Binary files /dev/null and b/src/assets/cosmetics/Glasses.png differ diff --git a/src/assets/cosmetics/LifeBuoy.png b/src/assets/cosmetics/LifeBuoy.png new file mode 100644 index 0000000..b5ded77 Binary files /dev/null and b/src/assets/cosmetics/LifeBuoy.png differ diff --git a/src/assets/cosmetics/OnFire.png b/src/assets/cosmetics/OnFire.png new file mode 100644 index 0000000..4da382f Binary files /dev/null and b/src/assets/cosmetics/OnFire.png differ diff --git a/src/assets/cosmetics/SpinningHat.png b/src/assets/cosmetics/SpinningHat.png new file mode 100644 index 0000000..f5c0147 Binary files /dev/null and b/src/assets/cosmetics/SpinningHat.png differ diff --git a/src/assets/cosmetics/TopHat.png b/src/assets/cosmetics/TopHat.png new file mode 100644 index 0000000..8df6ef4 Binary files /dev/null and b/src/assets/cosmetics/TopHat.png differ diff --git a/src/assets/cosmetics/WorkOverall.png b/src/assets/cosmetics/WorkOverall.png new file mode 100644 index 0000000..50e2d41 Binary files /dev/null and b/src/assets/cosmetics/WorkOverall.png differ diff --git a/src/assets/music-covers/eternity-in-moments.jpeg b/src/assets/music-covers/eternity-in-moments.jpeg new file mode 100644 index 0000000..0f1de89 Binary files /dev/null and b/src/assets/music-covers/eternity-in-moments.jpeg differ diff --git a/src/assets/music-covers/gold-phenomenon.png b/src/assets/music-covers/gold-phenomenon.png new file mode 100644 index 0000000..0400538 Binary files /dev/null and b/src/assets/music-covers/gold-phenomenon.png differ diff --git a/src/assets/music-covers/mi-querido.jpeg b/src/assets/music-covers/mi-querido.jpeg new file mode 100644 index 0000000..b7d2d11 Binary files /dev/null and b/src/assets/music-covers/mi-querido.jpeg differ diff --git a/src/components/MusicPlayer.css b/src/components/MusicPlayer.css new file mode 100644 index 0000000..99b588d --- /dev/null +++ b/src/components/MusicPlayer.css @@ -0,0 +1,210 @@ +.music-player { + position: fixed; + left: 0; + top: 0; + z-index: 1000; + width: 280px; + height: 280px; + border: 1px solid #d1c1b4; + border-radius: 22px; + background: rgba(255, 253, 248, 0.97); + color: #2f241f; + box-shadow: 0 16px 36px rgba(70, 51, 38, 0.18); + backdrop-filter: blur(8px); + touch-action: none; + user-select: none; + cursor: grab; +} + +.music-player-main { + height: 100%; + display: grid; + grid-template-rows: auto 62px auto auto 1fr; + gap: 0.75rem; + padding: 1rem; +} + +.music-player-drag-hint { + color: #9a8579; + font-size: 0.58rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; + text-align: right; +} + +.music-player-disc { + width: 62px; + height: 62px; + display: grid; + place-items: center; + justify-self: center; + border: 1px solid #cdbeb4; + border-radius: 50%; + background: #f3ede5; + color: #6a5148; + font-size: 1.65rem; + box-shadow: inset 0 0 0 10px #fffdf8; +} + +.music-player-text { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.15rem; + text-align: center; +} + +.music-player-text span { + color: #7a675c; + font-size: 0.58rem; + font-weight: 900; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.music-player-text strong { + overflow: hidden; + color: #3c2a24; + font-family: var(--font-serif); + font-size: 1rem; + white-space: nowrap; + text-overflow: ellipsis; +} + +.music-player-text small { + color: #8a766b; + font-size: 0.68rem; + font-style: italic; +} + +.music-volume-control { + display: grid; + gap: 0.35rem; +} + +.music-volume-control label { + color: #6c5147; + font-size: 0.62rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.music-volume-control input { + width: 100%; + accent-color: #72584e; + cursor: pointer; +} + +.music-player-actions { + align-self: end; + display: flex; + align-items: center; + justify-content: center; + gap: 0.45rem; +} + +.music-player-actions button, +.music-mini-button { + min-height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid #cdbeb4; + border-radius: 999px; + background: #fff8ef; + color: #5d463c; + font-size: 0.72rem; + font-weight: 900; + cursor: pointer; +} + +.music-player-actions button { + min-width: 82px; + padding: 0 0.75rem; +} + +.music-player-actions button:hover, +.music-mini-button:hover { + background: #f3ede5; +} + +.music-player-actions button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.music-player-collapsed { + width: 52px; + height: 52px; + border-radius: 999px; +} + +.music-player.music-player-collapsed { + width: 52px; + height: 52px; + min-width: 52px; + min-height: 52px; + max-width: 52px; + max-height: 52px; + display: block; + overflow: hidden; + border-radius: 999px; + padding: 0; +} + +.music-player.music-player-collapsed .music-mini-button { + width: 100%; + height: 100%; +} + +.music-mini-button { + width: 52px; + height: 52px; + padding: 0; + font-size: 1.25rem; + cursor: pointer; + user-select: none; +} + +.music-player:active { + cursor: grabbing; +} + +.music-player-actions button, +.music-volume-control input, +.music-mini-button { + cursor: pointer; +} + +@media (max-width: 680px) { + .music-player { + width: 240px; + height: 260px; + } + + .music-player-main { + padding: 0.85rem; + gap: 0.6rem; + } + + .music-player-actions button { + min-width: 72px; + } + + .music-player.music-player-collapsed { + width: 46px; + height: 46px; + min-width: 46px; + min-height: 46px; + max-width: 46px; + max-height: 46px; + } + + .music-player.music-player-collapsed .music-mini-button { + width: 46px; + height: 46px; + font-size: 1.05rem; + } +} \ No newline at end of file diff --git a/src/components/MusicPlayer.jsx b/src/components/MusicPlayer.jsx new file mode 100644 index 0000000..2a4694d --- /dev/null +++ b/src/components/MusicPlayer.jsx @@ -0,0 +1,361 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useEgg } from "../hooks/useEgg"; +import { getMusicAsset } from "../assets/assetRegistry"; +import "./MusicPlayer.css"; + +const MUSIC_DISPLAY_NAMES = { + eternity_in_moments: "Eternity in Moments", + gold_phenomenon: "Gold Phenomenon", + mi_querido: "Mi Querido", +}; + +const PLAYER_SIZE = { + width: 280, + height: 280, +}; + +const PLAYER_MARGIN = 16; + +function getMusicName(musicKey) { + return MUSIC_DISPLAY_NAMES[musicKey] || "No music selected"; +} + +function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} + +function getDefaultPosition() { + return { + x: Math.max(PLAYER_MARGIN, window.innerWidth - PLAYER_SIZE.width - PLAYER_MARGIN), + y: Math.max(PLAYER_MARGIN, window.innerHeight - PLAYER_SIZE.height - PLAYER_MARGIN), + }; +} + +function getSavedPosition() { + const savedPosition = localStorage.getItem("memory_egg_music_player_position"); + + if (!savedPosition) { + return getDefaultPosition(); + } + + try { + const parsed = JSON.parse(savedPosition); + + return { + x: clamp( + Number(parsed.x) || PLAYER_MARGIN, + PLAYER_MARGIN, + window.innerWidth - PLAYER_SIZE.width - PLAYER_MARGIN + ), + y: clamp( + Number(parsed.y) || PLAYER_MARGIN, + PLAYER_MARGIN, + window.innerHeight - PLAYER_SIZE.height - PLAYER_MARGIN + ), + }; + } catch { + return getDefaultPosition(); + } +} + +export default function MusicPlayer() { + const { egg, loading, reloadEgg } = useEgg(); + + const audioRef = useRef(null); + const dragStateRef = useRef(null); + const movedDuringDragRef = useRef(false); + const previousMusicKeyRef = useRef(null); + + const [collapsed, setCollapsed] = useState( + localStorage.getItem("memory_egg_music_player_collapsed") === "true" + ); + const [playing, setPlaying] = useState(false); + const [volume, setVolume] = useState(() => { + const savedVolume = Number(localStorage.getItem("memory_egg_music_volume")); + return Number.isFinite(savedVolume) ? savedVolume : 0.6; + }); + const [position, setPosition] = useState(getSavedPosition); + const [errorMessage, setErrorMessage] = useState(""); + + const selectedMusicKey = egg?.selected_music || egg?.selectedMusic || null; + + const selectedMusicSrc = useMemo(() => { + if (!selectedMusicKey) { + return null; + } + + return getMusicAsset(selectedMusicKey); + }, [selectedMusicKey]); + + useEffect(() => { + const audio = audioRef.current; + + if (audio) { + audio.volume = volume; + } + + localStorage.setItem("memory_egg_music_volume", String(volume)); + }, [volume]); + + useEffect(() => { + localStorage.setItem( + "memory_egg_music_player_collapsed", + String(collapsed) + ); + }, [collapsed]); + + useEffect(() => { + localStorage.setItem( + "memory_egg_music_player_position", + JSON.stringify(position) + ); + }, [position]); + + useEffect(() => { + if (previousMusicKeyRef.current === selectedMusicKey) { + return; + } + + previousMusicKeyRef.current = selectedMusicKey; + + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.currentTime = 0; + audioRef.current.load(); + } + }, [selectedMusicKey]); + + useEffect(() => { + function handleInventoryOrEggUpdate() { + reloadEgg().catch((error) => { + console.warn("Failed to refresh music player egg state:", error); + }); + } + + window.addEventListener("memory-egg:inventory-updated", handleInventoryOrEggUpdate); + window.addEventListener("memory-egg:egg-updated", handleInventoryOrEggUpdate); + + return () => { + window.removeEventListener( + "memory-egg:inventory-updated", + handleInventoryOrEggUpdate + ); + window.removeEventListener("memory-egg:egg-updated", handleInventoryOrEggUpdate); + }; + }, [reloadEgg]); + + useEffect(() => { + function handleResize() { + setPosition((currentPosition) => ({ + x: clamp( + currentPosition.x, + PLAYER_MARGIN, + window.innerWidth - PLAYER_SIZE.width - PLAYER_MARGIN + ), + y: clamp( + currentPosition.y, + PLAYER_MARGIN, + window.innerHeight - PLAYER_SIZE.height - PLAYER_MARGIN + ), + })); + } + + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + + function handlePointerDown(event) { + const interactiveElement = event.target.closest( + "button, input, a, audio, label" + ); + + if (interactiveElement && !collapsed) { + return; + } + + movedDuringDragRef.current = false; + event.currentTarget.setPointerCapture(event.pointerId); + + dragStateRef.current = { + pointerId: event.pointerId, + startPointerX: event.clientX, + startPointerY: event.clientY, + startX: position.x, + startY: position.y, + }; + } + + function handlePointerMove(event) { + const dragState = dragStateRef.current; + + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } + + const nextX = dragState.startX + event.clientX - dragState.startPointerX; + const nextY = dragState.startY + event.clientY - dragState.startPointerY; + + const movedDistance = + Math.abs(event.clientX - dragState.startPointerX) + + Math.abs(event.clientY - dragState.startPointerY); + + if (movedDistance > 4) { + movedDuringDragRef.current = true; + } + + const collapsedSize = window.innerWidth <= 680 ? 46 : 52; + const currentWidth = collapsed ? collapsedSize : PLAYER_SIZE.width; + const currentHeight = collapsed ? collapsedSize : PLAYER_SIZE.height; + + setPosition({ + x: clamp( + nextX, + PLAYER_MARGIN, + window.innerWidth - currentWidth - PLAYER_MARGIN + ), + y: clamp( + nextY, + PLAYER_MARGIN, + window.innerHeight - currentHeight - PLAYER_MARGIN + ), + }); + } + + function handlePointerUp(event) { + const dragState = dragStateRef.current; + + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } + + const wasMoved = movedDuringDragRef.current; + + dragStateRef.current = null; + movedDuringDragRef.current = false; + + if (collapsed && !wasMoved) { + setCollapsed(false); + } + } + + async function handleTogglePlay() { + if (!selectedMusicSrc || !audioRef.current) { + return; + } + + try { + setErrorMessage(""); + + if (playing) { + audioRef.current.pause(); + setPlaying(false); + return; + } + + audioRef.current.volume = volume; + await audioRef.current.play(); + setPlaying(true); + } catch (error) { + console.warn("Failed to play music:", error); + setPlaying(false); + setErrorMessage("Click play again or check browser audio permission."); + } + } + + function handleEnded() { + setPlaying(false); + } + + if (loading && !egg) { + return null; + } + + return ( + + ); +} \ No newline at end of file diff --git a/src/components/header.css b/src/components/header.css new file mode 100644 index 0000000..8f1735c --- /dev/null +++ b/src/components/header.css @@ -0,0 +1,167 @@ +.app-header { + min-height: 58px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 1rem; + padding: 0 2rem; + background: var(--color-bg-soft); + border-bottom: 1px solid var(--color-border); + color: var(--color-text); +} + +.app-header-brand { + font-family: var(--font-serif); + font-size: 1.1rem; + font-weight: 800; + color: #4d342c; + text-decoration: none; + white-space: nowrap; +} + +.app-header-nav { + display: flex; + align-items: center; + gap: 0.45rem; + min-width: 0; + overflow-x: auto; +} + +.app-header-nav a { + min-height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 0.75rem; + border-radius: 999px; + background: #ded8cc; + color: #5b4b41; + font-size: 0.74rem; + font-weight: 800; + text-decoration: none; + white-space: nowrap; +} + +.app-header-nav a:hover { + background: #d2c8ba; +} + +.app-header-user-area { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.7rem; + white-space: nowrap; +} + +.app-header-will { + min-height: 28px; + display: inline-flex; + align-items: center; + padding: 0 0.9rem; + border: 1px solid #cdbeb4; + border-radius: 999px; + background: #fffdf8; + color: #5d463c; + font-size: 0.78rem; + font-weight: 900; +} + +.app-header-profile { + display: flex; + align-items: center; + gap: 0.55rem; + color: inherit; + text-decoration: none; +} + +.app-header-profile span, +.app-header-profile strong { + display: block; + line-height: 1.05; + text-align: right; +} + +.app-header-profile span { + font-size: 0.58rem; + font-weight: 900; + letter-spacing: 0.12em; + color: #5f473d; + text-transform: uppercase; +} + +.app-header-profile strong { + font-family: var(--font-serif); + font-size: 0.88rem; + color: #3c2a24; +} + +.app-header-avatar { + width: 32px; + height: 32px; + border: 1.3px solid #7b6658; + border-radius: 50%; + position: relative; + flex: 0 0 auto; +} + +.app-header-avatar::before { + content: ""; + position: absolute; + top: 5px; + left: 50%; + width: 8px; + height: 8px; + border: 1.3px solid #7b6658; + border-radius: 50%; + transform: translateX(-50%); +} + +.app-header-avatar::after { + content: ""; + position: absolute; + left: 50%; + bottom: 5px; + width: 18px; + height: 10px; + border: 1.3px solid #7b6658; + border-bottom: none; + border-radius: 999px 999px 0 0; + transform: translateX(-50%); +} + +.app-header-logout { + min-height: 28px; + padding: 0 0.75rem; + border: 1px solid #cdbeb4; + border-radius: 999px; + background: #fffdf8; + color: #5d463c; + font-size: 0.72rem; + font-weight: 900; + cursor: pointer; +} + +.app-header-logout:hover { + background: #f3ede5; +} + +@media (max-width: 780px) { + .app-header { + grid-template-columns: 1fr auto; + padding: 0.75rem 1rem; + } + + .app-header-nav { + order: 3; + grid-column: 1 / -1; + } + + .app-header-will { + display: none; + } + + .app-header-logout { + display: none; + } +} \ No newline at end of file diff --git a/src/components/header.jsx b/src/components/header.jsx index b237899..e24e252 100644 --- a/src/components/header.jsx +++ b/src/components/header.jsx @@ -1,17 +1,66 @@ -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; +import { logoutUser } from "../api/userApi"; +import { useCurrentUser } from "../hooks/useCurrentUser"; +import "./header.css"; + +function getDaysSince(dateString) { + if (!dateString) { + return 0; + } + + const createdDate = new Date(dateString); + const today = new Date(); + + const differenceMs = today - createdDate; + const differenceDays = Math.floor(differenceMs / (1000 * 60 * 60 * 24)); + + return Math.max(0, differenceDays); +} export default function Header() { + const navigate = useNavigate(); + const { user } = useCurrentUser(); + + const nickname = user?.nickname || "User"; + const daysSinceJoin = getDaysSince(user?.created_at); + const willBalance = Number(user?.will_balance || 0); + + function handleLogout() { + logoutUser(); + navigate("/", { replace: true }); + } + return ( -
-

Nacimiento - My Egg

- -
-
- USER NAME - N Days -
-
-
-
+
+ + Nacimiento + + + + +
+ ✧ {willBalance} Will + + + {nickname} + {daysSinceJoin} Days + +
); } \ No newline at end of file diff --git a/src/hooks/useCurrentUser.js b/src/hooks/useCurrentUser.js index 65c60eb..57f76a8 100644 --- a/src/hooks/useCurrentUser.js +++ b/src/hooks/useCurrentUser.js @@ -20,20 +20,34 @@ export function useCurrentUser() { let ignore = false; async function loadInitialUser() { - const data = await getCurrentUser(); - - if (!ignore) { - setUser(data); - setLoading(false); + setLoading(true); + + try { + const data = await getCurrentUser(); + + if (!ignore) { + setUser(data); + } + } finally { + if (!ignore) { + setLoading(false); + } } } + function handleUserUpdated() { + reloadUser(); + } + loadInitialUser(); + window.addEventListener("memory-egg:user-updated", handleUserUpdated); + return () => { ignore = true; + window.removeEventListener("memory-egg:user-updated", handleUserUpdated); }; - }, []); + }, [reloadUser]); return { user, diff --git a/src/hooks/useInventory.js b/src/hooks/useInventory.js index 865f01f..6650a03 100644 --- a/src/hooks/useInventory.js +++ b/src/hooks/useInventory.js @@ -43,6 +43,8 @@ export function useInventory() { Array.isArray(updatedInventoryItems) ? updatedInventoryItems : [] ); + window.dispatchEvent(new Event("memory-egg:inventory-updated")); + return updatedInventoryItems; } catch (error) { setErrorMessage(error.message); @@ -64,6 +66,8 @@ export function useInventory() { Array.isArray(updatedInventoryItems) ? updatedInventoryItems : [] ); + window.dispatchEvent(new Event("memory-egg:inventory-updated")); + return updatedInventoryItems; } catch (error) { setErrorMessage(error.message); diff --git a/src/hooks/usePosts.js b/src/hooks/usePosts.js index 38e65d7..3ec2c90 100644 --- a/src/hooks/usePosts.js +++ b/src/hooks/usePosts.js @@ -1,9 +1,10 @@ import { useCallback, useEffect, useState } from "react"; -import { createPost, deletePost, getAllPosts } from "../api/postsApi"; +import { createPost, deletePost, getAllPosts, getPostById } from "../api/postsApi"; export function usePosts() { const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(""); const reloadPosts = useCallback(async () => { setLoading(true); @@ -39,6 +40,17 @@ export function usePosts() { return true; }, []); + const getPost = useCallback(async (postId) => { + setErrorMessage(""); + + try { + return await getPostById(postId); + } catch (error) { + setErrorMessage(error.message || "Failed to load post."); + throw error; + } + }, []); + useEffect(() => { let ignore = false; @@ -63,8 +75,10 @@ export function usePosts() { return { posts, loading, + errorMessage, addPost, removePost, + getPost, reloadPosts, }; } \ No newline at end of file diff --git a/src/hooks/useQuests.js b/src/hooks/useQuests.js index 6807c2a..75cb009 100644 --- a/src/hooks/useQuests.js +++ b/src/hooks/useQuests.js @@ -8,45 +8,79 @@ import { export function useQuests() { const [quests, setQuests] = useState([]); const [loading, setLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(""); const reloadQuests = useCallback(async () => { setLoading(true); + setErrorMessage(""); - const data = await getTodayQuests(); + try { + const data = await getTodayQuests(); - setQuests(Array.isArray(data) ? data : []); - setLoading(false); + setQuests(Array.isArray(data) ? data : []); - return data; + return data; + } catch (error) { + setErrorMessage(error.message); + throw error; + } finally { + setLoading(false); + } }, []); const checkPostForQuestCompletion = useCallback(async (post) => { - const updatedQuests = await checkPostAgainstQuests(post); + setErrorMessage(""); - setQuests(Array.isArray(updatedQuests) ? updatedQuests : []); + try { + const updatedQuests = await checkPostAgainstQuests(post); - return updatedQuests; + setQuests(Array.isArray(updatedQuests) ? updatedQuests : []); + + return updatedQuests; + } catch (error) { + setErrorMessage(error.message); + throw error; + } }, []); - const claimReward = useCallback(async (questId) => { - await claimQuestReward(questId); + const claimQuestForPost = useCallback(async ({ userQuestId, postId }) => { + setErrorMessage(""); - const updatedQuests = await getTodayQuests(); + try { + const result = await claimQuestReward({ userQuestId, postId }); - setQuests(Array.isArray(updatedQuests) ? updatedQuests : []); + const updatedQuests = await getTodayQuests(); - return updatedQuests; + setQuests(Array.isArray(updatedQuests) ? updatedQuests : []); + + return result; + } catch (error) { + setErrorMessage(error.message); + throw error; + } }, []); useEffect(() => { let ignore = false; async function loadInitialQuests() { - const data = await getTodayQuests(); - - if (!ignore) { - setQuests(Array.isArray(data) ? data : []); - setLoading(false); + setLoading(true); + setErrorMessage(""); + + try { + const data = await getTodayQuests(); + + if (!ignore) { + setQuests(Array.isArray(data) ? data : []); + } + } catch (error) { + if (!ignore) { + setErrorMessage(error.message); + } + } finally { + if (!ignore) { + setLoading(false); + } } } @@ -60,8 +94,9 @@ export function useQuests() { return { quests, loading, + errorMessage, reloadQuests, checkPostForQuestCompletion, - claimReward, + claimQuestForPost, }; } \ No newline at end of file diff --git a/src/hooks/useShop.js b/src/hooks/useShop.js index f23c3da..a925375 100644 --- a/src/hooks/useShop.js +++ b/src/hooks/useShop.js @@ -26,21 +26,29 @@ export function useShop() { }; }, []); - const purchaseItem = useCallback(async (itemId) => { - setErrorMessage(""); + const purchaseItem = useCallback( + async (itemId) => { + setErrorMessage(""); - try { - const result = await purchaseShopItem(itemId); + try { + const result = await purchaseShopItem(itemId); - setShopItems(Array.isArray(result.shopItems) ? result.shopItems : []); - setUser(result.user); + const refreshed = await reloadShop(); - return result; - } catch (error) { - setErrorMessage(error.message); - throw error; - } - }, []); + window.dispatchEvent(new Event("memory-egg:user-updated")); + + return { + ...result, + shopItems: refreshed.shopItems, + user: refreshed.user, + }; + } catch (error) { + setErrorMessage(error.message); + throw error; + } + }, + [reloadShop] + ); useEffect(() => { let ignore = false; diff --git a/src/pages/EggDashboardPage.css b/src/pages/EggDashboardPage.css index 1e7934e..ce55c18 100644 --- a/src/pages/EggDashboardPage.css +++ b/src/pages/EggDashboardPage.css @@ -250,11 +250,6 @@ transform-origin: bottom right; } -.scene-egg img { - transform: scale(2); - transform-origin: bottom right; -} - .scene-egg { right: 480px; bottom: 50px; @@ -263,6 +258,35 @@ z-index: 6; transform: rotate(12deg); transform-origin: bottom center; + position: absolute; +} + +.scene-egg img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + image-rendering: pixelated; +} + +.scene-egg > img:first-child { + position: relative; + z-index: 1; + transform: scale(2); + transform-origin: bottom right; +} + +.scene-cosmetic { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + image-rendering: pixelated; + pointer-events: none; + z-index: 2; + transform: scale(2); + transform-origin: bottom right; } /* Egg stats */ diff --git a/src/pages/EggDashboardPage.jsx b/src/pages/EggDashboardPage.jsx index 2d67a65..2dff99c 100644 --- a/src/pages/EggDashboardPage.jsx +++ b/src/pages/EggDashboardPage.jsx @@ -1,26 +1,36 @@ import "./EggDashboardPage.css"; -import eggImage from "../assets/egg.PNG"; -import nestImage from "../assets/nest.PNG"; -import notebookImage from "../assets/notebook.PNG"; -import windowFrameImage from "../assets/windowframe.PNG"; -import windowBackgroundImage from "../assets/background.png"; - import { useEgg } from "../hooks/useEgg"; +import { useQuests } from "../hooks/useQuests"; +import { + baseAssets, + getBackgroundAsset, + getCosmeticAsset, +} from "../assets/assetRegistry"; +import { Link } from "react-router-dom"; function EggDashboardPage() { const { egg, loading } = useEgg(); + const { quests, loading: questsLoading } = useQuests(); + + const equippedCosmeticKey = egg?.equipped_cosmetic || egg?.equippedCosmetic || null; + const equippedCosmeticImage = equippedCosmeticKey + ? getCosmeticAsset(equippedCosmeticKey) + : null; + const equippedBackgroundKey = egg?.equipped_background || egg?.equippedBackground || "default"; + const equippedBackgroundImage = getBackgroundAsset(equippedBackgroundKey); + return (
@@ -28,11 +38,20 @@ function EggDashboardPage() {
- Nest + Nest
- Egg + Egg + + {equippedCosmeticImage && ( + + )}
@@ -86,34 +105,38 @@ function EggDashboardPage() {

My Notebook

Today’s Quests

+ {questsLoading ? ( +

Loading quests...

+ ) : quests.length === 0 ? ( +

No quests assigned.

+ ) : (
    -
  • - - Write 500+ words in total today -
  • -
  • - - Write about what you studied -
  • -
  • - - Upload one photo memory -
  • + {quests.map((quest) => ( +
  • + + {quest.title} +
  • + ))}
+ )}
diff --git a/src/pages/InventoryPage.css b/src/pages/InventoryPage.css index cff1278..564fce5 100644 --- a/src/pages/InventoryPage.css +++ b/src/pages/InventoryPage.css @@ -284,9 +284,32 @@ } .inventory-item-image img { + display: block; + image-rendering: pixelated; +} + +.inventory-item-image-background img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.inventory-item-image-decoration img { + width: 62%; + height: 62%; + object-fit: contain; +} + +.inventory-item-image-music img { width: 100%; height: 100%; object-fit: cover; + image-rendering: auto; +} + +.inventory-item-image-music span { + font-size: 2rem; + color: #7a5b50; } .inventory-image-placeholder { diff --git a/src/pages/InventoryPage.jsx b/src/pages/InventoryPage.jsx index 3ef82b4..3fa39c8 100644 --- a/src/pages/InventoryPage.jsx +++ b/src/pages/InventoryPage.jsx @@ -1,6 +1,13 @@ import { useMemo, useState } from "react"; import { useInventory } from "../hooks/useInventory"; import { useEgg } from "../hooks/useEgg"; +import { + getBackgroundAsset, + getCosmeticAsset, + getMusicCoverAsset, +} from "../assets/assetRegistry"; +import { Link } from "react-router-dom"; + import "./InventoryPage.css"; const inventoryCategories = [ @@ -21,6 +28,26 @@ const inventoryCategories = [ }, ]; +function getInventoryItemImage(item) { + if (!item?.asset_key) { + return item?.asset_url || null; + } + + if (item.item_type === "background") { + return getBackgroundAsset(item.asset_key); + } + + if (item.item_type === "decoration") { + return getCosmeticAsset(item.asset_key); + } + + if (item.item_type === "music") { + return getMusicCoverAsset(item.asset_key); + } + + return item.asset_url || null; +} + function InventoryPage() { const [activeCategory, setActiveCategory] = useState("background"); const [selectedUserItemId, setSelectedUserItemId] = useState(null); @@ -128,11 +155,11 @@ function InventoryPage() { type="button" onClick={() => setSelectedUserItemId(item.user_item_id)} > -
- {item.asset_url ? ( - {item.name} +
+ {getInventoryItemImage(item) ? ( + {item.name} ) : ( - + {item.item_type === "music" ? "♪" : "▧"} )}
@@ -158,17 +185,20 @@ function InventoryPage() {
- + Close - + {selectedItem ? ( ) : (

-
+

@@ -32,8 +83,12 @@ function LoginPage() { waiting.”

-
diff --git a/src/pages/MemoryArchivePage.jsx b/src/pages/MemoryArchivePage.jsx index 98403eb..7aad40d 100644 --- a/src/pages/MemoryArchivePage.jsx +++ b/src/pages/MemoryArchivePage.jsx @@ -2,6 +2,8 @@ import { useState } from "react"; import { usePosts } from "../hooks/usePosts"; import "./MemoryArchivePage.css"; +import { Link } from "react-router-dom"; + function formatPostDate(dateString) { if (!dateString) { @@ -129,7 +131,7 @@ function MemoryArchivePage() {

No memories yet

Write your first notebook post to fill the archive.

- Write a post + Write a post
); @@ -149,13 +151,13 @@ function MemoryArchivePage() {
@@ -218,7 +220,7 @@ function MemoryArchivePage() {
- ⌕ View + ⌕ View
diff --git a/src/pages/RegisterPage.css b/src/pages/RegisterPage.css index 49311b7..4b86467 100644 --- a/src/pages/RegisterPage.css +++ b/src/pages/RegisterPage.css @@ -126,6 +126,19 @@ font-weight: 700; } +.register-error-message { + margin: 0.6rem 0 0; + color: #b34135; + font-size: 0.82rem; + font-weight: 800; + line-height: 1.35; +} + +.register-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + /* responsive */ @media (max-width: 860px) { .register-page { diff --git a/src/pages/RegisterPage.jsx b/src/pages/RegisterPage.jsx index 3b4e945..4e86591 100644 --- a/src/pages/RegisterPage.jsx +++ b/src/pages/RegisterPage.jsx @@ -1,6 +1,48 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { registerUser } from "../api/userApi"; import "./RegisterPage.css"; function RegisterPage() { + const navigate = useNavigate(); + + const [nickname, setNickname] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(event) { + event.preventDefault(); + + if (submitting) { + return; + } + + setErrorMessage(""); + + if (!nickname.trim() || !email.trim() || !password.trim()) { + setErrorMessage("Please fill in all fields."); + return; + } + + setSubmitting(true); + + try { + await registerUser({ + nickname, + email, + password, + }); + + navigate("/nest"); + } catch (error) { + setErrorMessage(error.message || "Failed to create account."); + setSubmitting(false); + } + } + return (
@@ -20,29 +62,61 @@ function RegisterPage() { “Write your memories. Care for your egg. Your forgotten self is waiting.”

-
+ -
diff --git a/src/pages/ShopPage.css b/src/pages/ShopPage.css index 63bb3ba..3ec2b80 100644 --- a/src/pages/ShopPage.css +++ b/src/pages/ShopPage.css @@ -242,8 +242,12 @@ .shop-item-card { position: relative; - min-height: 270px; + height: 310px; padding: 1rem; + display: grid; + grid-template-rows: 170px minmax(0, 1fr); + gap: 1rem; + overflow: hidden; border: 1px solid #d7c5bd; border-radius: 18px; background: #fffdf8; @@ -252,7 +256,6 @@ cursor: pointer; box-shadow: 0 8px 16px rgba(70, 51, 38, 0.04); } - .shop-item-card:hover { border-color: #a8877a; box-shadow: 0 12px 24px rgba(70, 51, 38, 0.1); @@ -292,11 +295,33 @@ } .shop-item-image img { + display: block; + image-rendering: pixelated; +} + +.shop-item-image-background img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.shop-item-image-decoration img { + width: 78%; + height: 78%; + object-fit: contain; +} + +.shop-item-image-music img { width: 100%; height: 100%; object-fit: cover; + image-rendering: auto; } +.shop-item-image-music span { + font-size: 2rem; + color: #7a5b50; +} .shop-image-placeholder { width: 52px; height: 52px; @@ -305,26 +330,40 @@ } .shop-item-info { - min-height: 58px; - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 1rem; - margin-top: 1rem; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 0.8rem; } +.shop-item-info > div { + min-width: 0; + overflow: hidden; +} + + .shop-item-info strong { display: block; + overflow: hidden; color: #2f241f; font-size: 0.9rem; + white-space: nowrap; + text-overflow: ellipsis; } + .shop-item-description { - max-width: 160px; + max-width: 100%; margin: 0.25rem 0 0; color: #7c7167; font-size: 0.68rem; line-height: 1.35; + + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } .owned-label { @@ -347,6 +386,13 @@ white-space: nowrap; } +.owned-label, +.price-label { + align-self: end; + justify-self: end; + white-space: nowrap; +} + /* Footer */ .shop-footer { @@ -454,7 +500,8 @@ } .shop-item-card { - min-height: 230px; + height: 270px; + grid-template-rows: 135px minmax(0, 1fr); } .shop-item-image { @@ -523,9 +570,10 @@ } .shop-item-card { - min-height: 260px; + height: 280px; padding: 1rem; border-radius: 14px; + grid-template-rows: 125px minmax(0, 1fr); } .shop-item-image { @@ -538,7 +586,6 @@ grid-template-columns: 1fr auto; align-items: end; gap: 0.8rem; - margin-top: 1rem; } .shop-item-info strong { @@ -551,6 +598,11 @@ margin: 0.3rem 0 0; font-size: 0.72rem; line-height: 1.35; + + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } .owned-label, diff --git a/src/pages/ShopPage.jsx b/src/pages/ShopPage.jsx index 46244d3..5b33c59 100644 --- a/src/pages/ShopPage.jsx +++ b/src/pages/ShopPage.jsx @@ -1,6 +1,13 @@ import { useMemo, useState } from "react"; import { useShop } from "../hooks/useShop"; import "./ShopPage.css"; +import { + getBackgroundAsset, + getCosmeticAsset, + getMusicCoverAsset, +} from "../assets/assetRegistry"; + +import { Link } from "react-router-dom"; const shopCategories = [ { @@ -20,6 +27,26 @@ const shopCategories = [ }, ]; +function getShopItemImage(item) { + if (!item?.asset_key) { + return item?.asset_url || null; + } + + if (item.item_type === "background") { + return getBackgroundAsset(item.asset_key); + } + + if (item.item_type === "decoration") { + return getCosmeticAsset(item.asset_key); + } + + if (item.item_type === "music") { + return getMusicCoverAsset(item.asset_key); + } + + return item.asset_url || null; +} + function ShopPage() { const [activeCategory, setActiveCategory] = useState("background"); const [selectedItemId, setSelectedItemId] = useState(null); @@ -50,6 +77,20 @@ function ShopPage() { return visibleItems[0] ?? null; }, [activeCategory, selectedItemId, shopItems, visibleItems]); + const userWillBalance = Number(user?.will_balance || 0); + + const cannotAffordSelectedItem = + selectedItem && userWillBalance < Number(selectedItem.price); + + const shouldShowNotEnoughWill = + selectedItem && !selectedItem.owned && cannotAffordSelectedItem; + + const isBuyButtonDisabled = + !user || + !selectedItem || + selectedItem.owned || + cannotAffordSelectedItem; + function handleCategoryChange(categoryId) { const firstItem = shopItems.find( (item) => item.item_type === categoryId && item.is_active @@ -60,7 +101,7 @@ function ShopPage() { } async function handlePurchaseSelectedItem() { - if (!selectedItem) { + if (!selectedItem || selectedItem.owned || cannotAffordSelectedItem) { return; } @@ -74,24 +115,13 @@ function ShopPage() { return (
-
-
✧ {user ? user.will_balance : 0} Will
- -
-
- USER NAME - N Days -
-
-
-

▦ Egg Shop

- + Return To Egg - +
@@ -127,11 +157,11 @@ function ShopPage() { type="button" onClick={() => setSelectedItemId(item.item_id)} > -
- {item.asset_url ? ( - {item.name} +
+ {getShopItemImage(item) ? ( + {item.name} ) : ( - + {item.item_type === "music" ? "♪" : "▧"} )}
@@ -157,34 +187,27 @@ function ShopPage() {
- + Close - +
- {(errorMessage || - (selectedItem && user && user.will_balance < selectedItem.price)) && ( -

- {errorMessage || "Not enough Will."} -

+ {errorMessage && ( +

{errorMessage}

)} - {selectedItem && !selectedItem.owned && ( - + {!errorMessage && shouldShowNotEnoughWill && ( +

Not enough Will.

)} - {selectedItem?.owned && ( - - )} +
diff --git a/src/pages/ViewPostPage.jsx b/src/pages/ViewPostPage.jsx index ead1232..0ecf0ed 100644 --- a/src/pages/ViewPostPage.jsx +++ b/src/pages/ViewPostPage.jsx @@ -1,24 +1,48 @@ import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { deletePost, getPostById } from "../api/postsApi"; +import { usePosts } from "../hooks/usePosts"; import "./ViewPostPage.css"; +import { Link } from "react-router-dom"; + function ViewPostPage() { const { id } = useParams(); const navigate = useNavigate(); const [post, setPost] = useState(null); const [loading, setLoading] = useState(true); + const { getPost, removePost } = usePosts(); useEffect(() => { + let ignore = false; + async function loadPost() { - const foundPost = await getPostById(id); + setLoading(true); + + try { + const foundPost = await getPost(id); - setPost(foundPost || null); - setLoading(false); + if (!ignore) { + setPost(foundPost || null); + } + } catch (error) { + console.warn("Failed to load post:", error); + + if (!ignore) { + setPost(null); + } + } finally { + if (!ignore) { + setLoading(false); + } + } } loadPost(); - }, [id]); + + return () => { + ignore = true; + }; + }, [id, getPost]); if (loading) { return ( @@ -32,38 +56,37 @@ function ViewPostPage() { return (

Post not found.

- ← Back to Archive + ← Back to Archive
); } - async function handleDelete() { - /* - console.log("Delete button clicked"); - console.log("Current post:", post); - */ - const confirmed = window.confirm("Delete this memory?"); - /* - console.log("Confirmed:", confirmed); - */ + async function handleDeletePost() { + if (!post) { + return; + } + + const confirmed = window.confirm("Delete this post?"); + if (!confirmed) { return; } - await deletePost(post.post_id); - /* - console.log("Deleted post id:", post.post_id); - */ - navigate("/archive"); + try { + await removePost(post.post_id || post.id); + navigate("/archive", { replace: true }); + } catch (error) { + console.warn("Failed to delete post:", error); + } } return (
- + ← Back to Archive - +
@@ -89,7 +112,7 @@ function ViewPostPage() { diff --git a/src/pages/WritePostPage.css b/src/pages/WritePostPage.css index d9ff117..d63c49d 100644 --- a/src/pages/WritePostPage.css +++ b/src/pages/WritePostPage.css @@ -578,6 +578,45 @@ clip-path: inset(50%); } +/* message */ + +.write-submit-area { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-end; + min-width: 300px; +} + +.write-submit-message-slot { + position: absolute; + right: 0; + bottom: calc(100% + 0.45rem); + width: 300px; + min-height: 1.2rem; + text-align: right; +} + +.write-post-message { + margin: 0; + font-size: 0.78rem; + font-weight: 800; + line-height: 1.35; +} + +.write-post-message-error { + color: #b34135; +} + +.write-post-message-success { + color: #3b7d3f; +} + +.post-submit-button:disabled { + opacity: 0.65; + cursor: not-allowed; +} + /* Responsive */ @media (max-width: 760px) { diff --git a/src/pages/WritePostPage.jsx b/src/pages/WritePostPage.jsx index 366eb88..7af3cd8 100644 --- a/src/pages/WritePostPage.jsx +++ b/src/pages/WritePostPage.jsx @@ -1,16 +1,15 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; -/* import { createPost } from "../api/postsApi"; */ import { usePosts } from "../hooks/usePosts"; -/* import { checkPostAgainstQuests } from "../api/questsApi"; */ import { useCurrentUser } from "../hooks/useCurrentUser"; import { useQuests } from "../hooks/useQuests"; +import { doesPostLikelySatisfyQuest } from "../utils/questMatching"; import "./WritePostPage.css"; function WritePostPage() { const { addPost } = usePosts(); const { user, reloadUser } = useCurrentUser(); - const { quests, checkPostForQuestCompletion, claimReward } = useQuests(); + const { quests, claimQuestForPost } = useQuests(); const [title, setTitle] = useState(""); const [content, setContent] = useState(""); @@ -18,6 +17,10 @@ function WritePostPage() { /*const [imageUrl, setImageUrl] = useState("");*/ const [visibility, setVisibility] = useState("private"); + const [submitting, setSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + const [successMessage, setSuccessMessage] = useState(""); + const navigate = useNavigate(); {/*NOTE: LOGICS SHOULD BE IMPLEMENTED IN BACKEND. THESE ARE TEMPORARY PLACEHOLDERS */} @@ -25,30 +28,72 @@ function WritePostPage() { const estimatedWill = Math.max(1, Math.floor(wordCount / 10)); async function handleSubmit(event) { - event.preventDefault(); /*Stop page from refreshing in form submit*/ + event.preventDefault(); + + if (submitting) { + return; + } + + setErrorMessage(""); + setSuccessMessage(""); if (!title.trim() || !content.trim()) { - alert("Please write both title and content."); + setErrorMessage("Please write both title and content."); return; } - const { newPost, updatedPosts } = await addPost({ - title, - content, - tag, - image_url: null, - visibility, - }); + setSubmitting(true); - /*await checkPostForQuestCompletion(newPost);*/ + try { + const { newPost } = await addPost({ + title, + content, + tag, + image_url: null, + visibility, + }); - alert("Post created! Redirecting you to Archive Page"); - navigate("/archive"); - } + const createdPost = newPost.post || newPost; + const postId = createdPost.id || createdPost.post_id; + + const matchingQuests = quests.filter((quest) => + doesPostLikelySatisfyQuest(createdPost, quest) + ); + + let claimedQuestCount = 0; + + for (const quest of matchingQuests) { + try { + await claimQuestForPost({ + userQuestId: quest.user_quest_id, + postId, + }); + + claimedQuestCount += 1; + } catch (claimError) { + console.warn("Quest claim failed:", claimError); + } + } - async function handleClaimQuest(questId) { - await claimReward(questId); - await reloadUser(); + await reloadUser(); + + window.dispatchEvent(new Event("memory-egg:user-updated")); + + if (claimedQuestCount > 0) { + setSuccessMessage( + `Post created. ${claimedQuestCount} quest reward claimed! Redirecting...` + ); + } else { + setSuccessMessage("Post created. Redirecting..."); + } + + setTimeout(() => { + navigate("/archive"); + }, 700); + } catch (error) { + setErrorMessage(error.message || "Failed to create post."); + setSubmitting(false); + } } return ( @@ -147,9 +192,27 @@ function WritePostPage() { - +
+ {errorMessage && ( +

+ {errorMessage} +

+ )} + + {successMessage && ( +

+ {successMessage} +

+ )} + + +
@@ -190,7 +253,7 @@ function WritePostPage() { {quest.status} - + {/* REMOVED: quest completion is automated. {quest.status === "completed" && (