diff --git a/packages/good-design/src/apps/usertasks/ManagerTaskCards.stories.tsx b/packages/good-design/src/apps/usertasks/ManagerTaskCards.stories.tsx new file mode 100644 index 00000000..5f0087ac --- /dev/null +++ b/packages/good-design/src/apps/usertasks/ManagerTaskCards.stories.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { ClaimerTasksCompact } from "./managerTaskCard"; +import { NativeBaseProvider, Box } from "native-base"; +import { action } from "@storybook/addon-actions"; +import { SAMPLE_TASKS } from "./mockData"; + +export default { + title: "UserTasks/ClaimerTasksCompact", + component: ClaimerTasksCompact +}; + +export const AsModal = () => ( + + + + + +); diff --git a/packages/good-design/src/apps/usertasks/managerTaskCard.tsx b/packages/good-design/src/apps/usertasks/managerTaskCard.tsx new file mode 100644 index 00000000..bc99318a --- /dev/null +++ b/packages/good-design/src/apps/usertasks/managerTaskCard.tsx @@ -0,0 +1,650 @@ +import React, { useState, useEffect, useMemo, useCallback, FC } from "react"; +import { VStack, HStack, Pressable, Box, Spinner, useToast } from "native-base"; +import { Linking } from "react-native"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import moment from "moment"; +import { TransText, TransButton } from "../../core/layout"; +import { SAMPLE_TASKS } from "./mockData"; +import BasicStyledModal from "../../core/web3/modals/BasicStyledModal"; +import { noop } from "lodash"; + + +export interface TaskReward { + type: "points" | "tokens" | "badge"; + amount?: number; + description: string; +} + +export const useClaimerTasks = () => { + const [dismissedTasks, setDismissedTasks] = useState<{ [key: string]: number }>({}); + const [completedTasks, setCompletedTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [dismissing, setDismissing] = useState(false); + const toast = useToast(); + + useEffect(() => { + const loadData = async () => { + try { + const [dismissed, completed] = await Promise.all([ + AsyncStorage.getItem("dismissed_claimer_tasks"), + AsyncStorage.getItem("completed_claimer_tasks") + ]); + if (dismissed) setDismissedTasks(JSON.parse(dismissed)); + if (completed) setCompletedTasks(JSON.parse(completed)); + } catch (error) { + console.warn("Failed to load task data:", error); + toast.show({ + title: "Error", + description: "Failed to load tasks. Please try again." + }); + } finally { + setLoading(false); + } + }; + void loadData(); + }, []); + + const dismissTask = async (taskId: string, skipToast = false) => { + const now = Date.now(); + const updated = { ...dismissedTasks, [taskId]: now }; + setDismissedTasks(updated); + try { + await AsyncStorage.setItem("dismissed_claimer_tasks", JSON.stringify(updated)); + if (!skipToast) { + toast.show({ + title: "Task Dismissed", + description: "Task has been dismissed." + }); + } + } catch (e) { + console.warn("Failed to store dismissed task:", e); + toast.show({ + title: "Error", + description: "Failed to dismiss task. Please try again." + }); + } + }; + + const dismissAllTasks = async (taskIds: string[], onTaskDismiss?: (taskId: string) => void) => { + setDismissing(true); + const now = Date.now(); + const updated = taskIds.reduce((acc, taskId) => ({ ...acc, [taskId]: now }), dismissedTasks); + setDismissedTasks(updated); + try { + await AsyncStorage.setItem("dismissed_claimer_tasks", JSON.stringify(updated)); + taskIds.forEach(taskId => { + if (onTaskDismiss) onTaskDismiss(taskId); + }); + toast.show({ + title: "Tasks Dismissed", + description: "All tasks have been dismissed." + }); + } catch (e) { + console.warn("Failed to store dismissed tasks:", e); + toast.show({ + title: "Error", + description: "Failed to dismiss tasks. Please try again." + }); + } finally { + setDismissing(false); + } + }; + + const completeTask = useCallback(async (taskId: string) => { + if (completedTasks.includes(taskId)) { + toast.show({ + title: "Task Already Completed", + description: "This task has already been completed." + }); + return; + } + const updated = [...completedTasks, taskId]; + setCompletedTasks(updated); + try { + await AsyncStorage.setItem("completed_claimer_tasks", JSON.stringify(updated)); + toast.show({ + title: "Success", + description: "Task completed successfully!" + }); + } catch (e) { + console.warn("Failed to store completed task:", e); + toast.show({ + title: "Error", + description: "Failed to mark task as completed. Please try again." + }); + } + }, [completedTasks, toast]); + + const availableTasks = useMemo(() => { + const now = moment(); + const fourDaysDuration = moment.duration(4, 'days'); + + return SAMPLE_TASKS.filter(task => { + const startTime = moment(task.duration.startDate); + const endTime = moment(task.duration.endDate); + + if (now.isBefore(startTime) || now.isAfter(endTime)) return false; + + if (completedTasks.includes(task.id)) return false; + + const dismissed = dismissedTasks[task.id]; + if (dismissed) { + const dismissedTime = moment(dismissed); + if (now.diff(dismissedTime) < fourDaysDuration.asMilliseconds()) return false; + } + + return true; + }); + }, [dismissedTasks, completedTasks]); + + const mainTask = availableTasks.find(t => t.priority === "main"); + const secondaryTasks = availableTasks.filter(t => t.priority === "secondary"); + + return { + mainTask, + secondaryTasks, + loading, + dismissing, + hasActiveTasks: availableTasks.length > 0, + dismissTask, + dismissAllTasks, + completeTask + }; +}; + + +export interface ClaimerTask { + type: "noTasks" | "activeTasks" | "secondaryTasks" | "modalContent" | "learn"; + taskId?: string; + content?: string; + id: string; + title: string; + description: string; + category: "social" | "donation" | "referral" | "engagement"; + reward?: TaskReward; + duration: { startDate: string; endDate: string }; + actionUrl?: string; + icon?: string; + rewardAmount?: string; + rewardColor?: string; + priority?: "main" | "secondary"; +} + +interface ManagerTaskProps { + type: ClaimerTask["type"]; + task?: ClaimerTask; + isPending: boolean; + customTitle?: string; + onClose?: () => void; + onPress?: () => void; + fontStyles?: any; +} + +const TaskModalContent: React.FC<{ task: ClaimerTask; fontStyles?: any }> = ({ task, fontStyles }) => { + const { subHeading, subContent } = fontStyles ?? {}; + + return ( + + {/* Icon */} + + + + + + + {/* Task Title */} + + + {/* Task Description */} + + + {/* Reward */} + {task.rewardAmount && ( + + + + + )} + + ); +}; + +export const ManagerTask: FC = ({ + onClose = noop, + task, + isPending, + customTitle, + onPress, + fontStyles, + ...props +}) => { + if (!task) return null; + + return ( + } + withOverlay="dark" + withCloseButton={true} + footer={ + onPress && ( + + ) + } + /> + ); +}; + +interface ClaimerTasksCardProps { + tasks?: ClaimerTask[]; + onTaskComplete?: (taskId: string) => void; + onTaskDismiss?: (taskId: string) => void; + fontStyles?: any; + ContentComponent?: any; + showAsModal?: boolean; +} + +const ManagerModalContent = ({ content }: { content: string }) => ( + +); + +const ManagerContentDismiss = () => ( + + + + + +); + +const ManagerSecondaryTasks = () => ( + + + +); + +const ManagerDoNextTasks = () => ( + + + + +); + +const DefaultContentComponent = { + noTasks: ManagerContentDismiss, + activeTasks: ManagerDoNextTasks, + secondaryTasks: ManagerSecondaryTasks, + modalContent: ManagerModalContent +}; + + +const TasksCardContent: React.FC<{ + mainTask?: ClaimerTask; + secondaryTasks: ClaimerTask[]; + dismissing: boolean; + fontStyles?: any; + ContentComponent: any; + onTaskSelect: (task: ClaimerTask) => void; + onDismissAll: () => void; + ManagerModalContent: any; +}> = ({ + mainTask, + secondaryTasks, + dismissing, + fontStyles, + ContentComponent, + onTaskSelect, + onDismissAll, + ManagerModalContent +}) => { + const { secondaryTasks: ManagerSecondaryTasks } = ContentComponent; + + return ( + + {/* Main Task */} + {mainTask && ( + + {/* Icon */} + + + + + + + {/* Task Title */} + + + + + + + + {/* CTA Button */} + + onTaskSelect(mainTask)} + testID="main-task-button" + variant="ghost" + /> + + + )} + + {/* Secondary Tasks */} + {secondaryTasks.length > 0 && ( + + + + + + {secondaryTasks.map(task => ( + onTaskSelect(task)} + _pressed={{ bg: "gray.50" }} + px={4} + py={4} + bg="white" + borderRadius="xl" + borderWidth={1} + borderColor="gray.200" + testID={`task-${task.id}`} + w="100%" + > + + + + + + + + + + + + + + + + ))} + + + )} + + {/* Footer Button */} + + + + + ); +}; + +export const ClaimerTasksCard: React.FC = ({ + onTaskComplete, + onTaskDismiss, + fontStyles, + ContentComponent = DefaultContentComponent, + showAsModal = true +}) => { + const { mainTask, secondaryTasks, loading, dismissing, hasActiveTasks, dismissAllTasks, completeTask } = useClaimerTasks(); + const [selectedTask, setSelectedTask] = useState(null); + const [showMainModal, setShowMainModal] = useState(false); + + const ManagerModalContent = ContentComponent.modalContent; + const ManagerContentDismiss = ContentComponent.noTasks; + const ManagerDoNextTasks = ContentComponent.activeTasks; + + const handleDismissAll = async () => { + const taskIds = [...(mainTask ? [mainTask.id] : []), ...secondaryTasks.map(task => task.id)]; + if (taskIds.length > 0) { + await dismissAllTasks(taskIds, onTaskDismiss); + setShowMainModal(false); + } + }; + + const openTask = useCallback(async (task: ClaimerTask) => { + if (task.actionUrl) { + try { + await Linking.openURL(task.actionUrl); + await completeTask(task.id); + if (onTaskComplete) onTaskComplete(task.id); + setSelectedTask(null); + } catch (error) { + console.warn("Failed to open task URL:", error); + } + } + }, [completeTask, onTaskComplete]); + + const handleTaskSelect = (task: ClaimerTask) => { + if (task.id === mainTask?.id) { + void openTask(task); + } else { + setSelectedTask(task); + } + }; + + useEffect(() => { + if (!loading && hasActiveTasks && showAsModal) { + setShowMainModal(true); + } + }, [loading, hasActiveTasks, showAsModal]); + + if (loading) { + return ( + + + + + + ); + } + + if (!hasActiveTasks) { + return ( + {/* handle close - maybe navigate away? */}} + title="" + body={} + withOverlay="dark" + withCloseButton={true} + /> + ); + } + + if (!showAsModal) { + return ( + + + + + + + + + {/* Individual Task Modal */} + {selectedTask && ( + setSelectedTask(null)} + onPress={() => void openTask(selectedTask)} + fontStyles={fontStyles} + /> + )} + + ); + } + + return ( + <> + {/* Main Tasks Modal */} + setShowMainModal(false)} + title="" + body={ + + + + + } + withOverlay="dark" + withCloseButton={true} + /> + + {/* Individual Task Modal */} + {selectedTask && ( + setSelectedTask(null)} + onPress={() => void openTask(selectedTask)} + fontStyles={fontStyles} + /> + )} + + ); +}; + +export { ClaimerTasksCard as ClaimerTasksCompact }; \ No newline at end of file diff --git a/packages/good-design/src/apps/usertasks/mockData.ts b/packages/good-design/src/apps/usertasks/mockData.ts new file mode 100644 index 00000000..2e9be43c --- /dev/null +++ b/packages/good-design/src/apps/usertasks/mockData.ts @@ -0,0 +1,44 @@ +import { ClaimerTask } from "./managerTaskCard"; + +export const SAMPLE_TASKS: ClaimerTask[] = [ + { + id: "treasury-vote", + title: "Have your say in the Treasury Proposal", + description: "Your vote decides where 10 MG$ goes.", + category: "engagement", + priority: "main", + reward: {type: "tokens", amount: 10, description: "Cast My Vote"}, + duration: {startDate: "2026-02-01", endDate: "2026-02-28"}, + actionUrl: "https://www.gooddollar.org/", + icon: "📦", + type: "learn" + }, + { + id: "goodcollective-donate", + title: "Donate to GoodCollective", + description: "Support a verified cause", + category: "donation", + priority: "secondary", + reward: {type: "tokens", amount: -50, description: "Donate 50G$"}, + duration: {startDate: "2026-02-01", endDate: "2026-02-28"}, + actionUrl: "https://www.gooddollar.org/", + icon: "❤️", + rewardAmount: "-50 G$", + rewardColor: "red.500", + type: "learn" + }, + { + id: "invite-friend", + title: "Invite a friend & earn", + description: "You both get rewarded!", + category: "referral", + priority: "secondary", + reward: {type: "tokens", amount: 20, description: "Invite Friend"}, + duration: {startDate: "2026-02-01", endDate: "2026-02-28"}, + actionUrl: "https://www.gooddollar.org/", + icon: "👥", + rewardAmount: "+20 G$", + rewardColor: "green.500", + type: "learn" + } +]; \ No newline at end of file