mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Initial commit
This commit is contained in:
194
hooks/use-toast.ts
Normal file
194
hooks/use-toast.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
"use client"
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
67
hooks/useCoins.tsx
Normal file
67
hooks/useCoins.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { loadCoinsData, addCoins, removeCoins } from '@/app/actions/data'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { CoinTransaction, TransactionType } from '@/lib/types'
|
||||
|
||||
export function useCoins() {
|
||||
const [balance, setBalance] = useState(0)
|
||||
const [transactions, setTransactions] = useState<CoinTransaction[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchCoins()
|
||||
}, [])
|
||||
|
||||
const fetchCoins = async () => {
|
||||
const data = await loadCoinsData()
|
||||
setBalance(data.balance)
|
||||
setTransactions(data.transactions || [])
|
||||
}
|
||||
|
||||
const addAmount = async (
|
||||
amount: number,
|
||||
description: string,
|
||||
type: TransactionType = 'MANUAL_ADJUSTMENT',
|
||||
relatedItemId?: string
|
||||
) => {
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
toast({
|
||||
title: "Invalid amount",
|
||||
description: "Please enter a valid positive number"
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await addCoins(amount, description, type, relatedItemId)
|
||||
setBalance(data.balance)
|
||||
setTransactions(data.transactions)
|
||||
return data
|
||||
}
|
||||
|
||||
const removeAmount = async (
|
||||
amount: number,
|
||||
description: string,
|
||||
type: TransactionType = 'MANUAL_ADJUSTMENT',
|
||||
relatedItemId?: string
|
||||
) => {
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
toast({
|
||||
title: "Invalid amount",
|
||||
description: "Please enter a valid positive number"
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await removeCoins(amount, description, type, relatedItemId)
|
||||
setBalance(data.balance)
|
||||
setTransactions(data.transactions)
|
||||
return data
|
||||
}
|
||||
|
||||
return {
|
||||
balance,
|
||||
transactions,
|
||||
addAmount,
|
||||
removeAmount,
|
||||
fetchCoins
|
||||
}
|
||||
}
|
||||
104
hooks/useHabits.tsx
Normal file
104
hooks/useHabits.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { loadHabitsData, saveHabitsData, addCoins, removeCoins } from '@/app/actions/data'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { Undo2 } from 'lucide-react'
|
||||
import { Habit } from '@/lib/types'
|
||||
|
||||
export function useHabits() {
|
||||
const [habits, setHabits] = useState<Habit[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchHabits()
|
||||
}, [])
|
||||
|
||||
const fetchHabits = async () => {
|
||||
const data = await loadHabitsData()
|
||||
setHabits(data.habits)
|
||||
}
|
||||
|
||||
const addHabit = async (habit: Omit<Habit, 'id'>) => {
|
||||
const newHabit = { ...habit, id: Date.now().toString() }
|
||||
const newHabits = [...habits, newHabit]
|
||||
setHabits(newHabits)
|
||||
await saveHabitsData({ habits: newHabits })
|
||||
}
|
||||
|
||||
const editHabit = async (updatedHabit: Habit) => {
|
||||
const newHabits = habits.map(habit =>
|
||||
habit.id === updatedHabit.id ? updatedHabit : habit
|
||||
)
|
||||
setHabits(newHabits)
|
||||
await saveHabitsData({ habits: newHabits })
|
||||
}
|
||||
|
||||
const deleteHabit = async (id: string) => {
|
||||
const newHabits = habits.filter(habit => habit.id !== id)
|
||||
setHabits(newHabits)
|
||||
await saveHabitsData({ habits: newHabits })
|
||||
}
|
||||
|
||||
const completeHabit = async (habit: Habit) => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
if (!habit.completions.includes(today)) {
|
||||
const updatedHabit = {
|
||||
...habit,
|
||||
completions: [...habit.completions, today]
|
||||
}
|
||||
const updatedHabits = habits.map(h =>
|
||||
h.id === habit.id ? updatedHabit : h
|
||||
)
|
||||
await saveHabitsData({ habits: updatedHabits })
|
||||
const coinsData = await addCoins(habit.coinReward, `Completed habit: ${habit.name}`, 'HABIT_COMPLETION', habit.id)
|
||||
|
||||
setHabits(updatedHabits)
|
||||
|
||||
toast({
|
||||
title: "Habit completed!",
|
||||
description: `You earned ${habit.coinReward} coins.`,
|
||||
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(habit)}><Undo2 className="h-4 w-4" />Undo</ToastAction>
|
||||
})
|
||||
|
||||
return coinsData.balance
|
||||
} else {
|
||||
toast({
|
||||
title: "Habit already completed",
|
||||
description: "You've already completed this habit today.",
|
||||
variant: "destructive",
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const undoComplete = async (habit: Habit) => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const updatedHabit = {
|
||||
...habit,
|
||||
completions: habit.completions.filter(date => date !== today)
|
||||
}
|
||||
const updatedHabits = habits.map(h =>
|
||||
h.id === habit.id ? updatedHabit : h
|
||||
)
|
||||
await saveHabitsData({ habits: updatedHabits })
|
||||
const coinsData = await removeCoins(habit.coinReward, `Undid habit completion: ${habit.name}`, 'HABIT_UNDO', habit.id)
|
||||
|
||||
setHabits(updatedHabits)
|
||||
|
||||
toast({
|
||||
title: "Completion undone",
|
||||
description: `${habit.coinReward} coins have been deducted.`,
|
||||
action: <ToastAction altText="Redo" onClick={() => completeHabit(habit)}><Undo2 className="h-4 w-4" />Undo</ToastAction>
|
||||
})
|
||||
|
||||
return coinsData.balance
|
||||
}
|
||||
|
||||
return {
|
||||
habits,
|
||||
addHabit,
|
||||
editHabit,
|
||||
deleteHabit,
|
||||
completeHabit,
|
||||
undoComplete
|
||||
}
|
||||
}
|
||||
84
hooks/useWishlist.tsx
Normal file
84
hooks/useWishlist.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { loadWishlistItems, saveWishlistItems } from '@/app/actions/data'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { WishlistItemType } from '@/lib/types'
|
||||
import { useCoins } from '@/hooks/useCoins'
|
||||
import { celebrations } from '@/utils/celebrations'
|
||||
|
||||
export function useWishlist() {
|
||||
const [wishlistItems, setWishlistItems] = useState<WishlistItemType[]>([])
|
||||
const { balance, removeAmount } = useCoins()
|
||||
|
||||
useEffect(() => {
|
||||
fetchWishlistItems()
|
||||
}, [])
|
||||
|
||||
const fetchWishlistItems = async () => {
|
||||
const items = await loadWishlistItems()
|
||||
setWishlistItems(items)
|
||||
}
|
||||
|
||||
const addWishlistItem = async (item: Omit<WishlistItemType, 'id'>) => {
|
||||
const newItem = { ...item, id: Date.now().toString() }
|
||||
const newItems = [...wishlistItems, newItem]
|
||||
setWishlistItems(newItems)
|
||||
await saveWishlistItems(newItems)
|
||||
}
|
||||
|
||||
const editWishlistItem = async (updatedItem: WishlistItemType) => {
|
||||
const newItems = wishlistItems.map(item =>
|
||||
item.id === updatedItem.id ? updatedItem : item
|
||||
)
|
||||
setWishlistItems(newItems)
|
||||
await saveWishlistItems(newItems)
|
||||
}
|
||||
|
||||
const deleteWishlistItem = async (id: string) => {
|
||||
const newItems = wishlistItems.filter(item => item.id !== id)
|
||||
setWishlistItems(newItems)
|
||||
await saveWishlistItems(newItems)
|
||||
}
|
||||
|
||||
const redeemWishlistItem = async (item: WishlistItemType) => {
|
||||
if (balance >= item.coinCost) {
|
||||
await removeAmount(
|
||||
item.coinCost,
|
||||
`Redeemed reward: ${item.name}`,
|
||||
'WISH_REDEMPTION',
|
||||
item.id
|
||||
)
|
||||
|
||||
// Randomly choose a celebration effect
|
||||
const celebrationEffects = [
|
||||
celebrations.basic,
|
||||
celebrations.fireworks,
|
||||
celebrations.shower
|
||||
]
|
||||
const randomEffect = celebrationEffects[Math.floor(Math.random() * celebrationEffects.length)]
|
||||
randomEffect()
|
||||
|
||||
toast({
|
||||
title: "🎉 Reward Redeemed!",
|
||||
description: `You've redeemed "${item.name}" for ${item.coinCost} coins.`,
|
||||
})
|
||||
|
||||
return true
|
||||
} else {
|
||||
toast({
|
||||
title: "Not enough coins",
|
||||
description: `You need ${item.coinCost - balance} more coins to redeem this reward.`,
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
wishlistItems,
|
||||
addWishlistItem,
|
||||
editWishlistItem,
|
||||
deleteWishlistItem,
|
||||
redeemWishlistItem,
|
||||
canRedeem: (cost: number) => balance >= cost
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user