From b8b7f2347e6b96659d81ad36f9ff8bbdd45417b9 Mon Sep 17 00:00:00 2001 From: Ash Keel Date: Thu, 20 Jan 2022 12:22:16 +0100 Subject: [PATCH] Add goals! --- frontend/src/locale/en/translation.json | 34 +- frontend/src/ui/pages/LoyaltyRewards.tsx | 422 +++++++++++++++++++++-- 2 files changed, 419 insertions(+), 37 deletions(-) diff --git a/frontend/src/locale/en/translation.json b/frontend/src/locale/en/translation.json index 80847a9..088b7cb 100644 --- a/frontend/src/locale/en/translation.json +++ b/frontend/src/locale/en/translation.json @@ -122,7 +122,8 @@ "moderators": "Moderators", "streamer": "Streamer only" }, - "remove-command-title": "Remove command {{name}}?" + "remove-command-title": "Remove command {{name}}?", + "no-commands": "Chatbot has no commands configured" }, "bottimers": { "title": "Bot timers", @@ -138,7 +139,8 @@ "timer-interval": "Minimul interval", "timer-activity": "Minimul chat activity (0 to disable)", "timer-activity-desc": "messages in the last 5 minutes", - "timer-messages": "Messages" + "timer-messages": "Messages", + "no-timers": "There are no timers configured" }, "auth": { "title": "Authentication required", @@ -199,7 +201,14 @@ "debug": { "dismiss-warning": "I am not afraid! ...well ok maybe a little", "big-ass-warning": "Using this page can severely wreck your database. Please make sure you know what you're doing!", - "disclaimer-header": "Big scary disclaimer" + "disclaimer-header": "Big scary disclaimer", + "title": "Debug ops", + "read-key": "Read DB key", + "write-key": "Write DB key", + "fix-json": "Fix JSON", + "console-ops": "Console operations", + "dump-keys": "Dump all DB keys", + "dump-all": "Dump all KV pairs as JSON" }, "loyalty-rewards": { "title": "Rewards and goals", @@ -209,7 +218,7 @@ "reward-filter": "Search by reward name", "create-reward": "Create reward", "reward-id": "Reward ID", - "id-already-in-use": "ID already in use by another reward", + "id-already-in-use": "ID already in use", "reward-name": "Name", "reward-icon": "Icon (as remote URL)", "reward-desc": "Description", @@ -220,7 +229,19 @@ "reward-details-placeholder": "What extra details to ask the viewer for", "reward-details": "Require viewer details", "edit-reward": "Edit reward", - "remove-reward-title": "Remore reward \"{{name}}\"?" + "remove-reward-title": "Remore reward \"{{name}}\"?", + "no-rewards": "There are no loyalty rewards, why not start with an Hydrate or Stretch?", + "no-goals": "There are no goals configured", + "create-goal": "Create goal", + "edit-goal": "Edit goal", + "goal-id": "Goal ID", + "goal-id-hint": "This is what viewers will have to write to contribute to, ie. \"!contribute goal-id-here\".", + "goal-name": "Name", + "goal-icon": "Goal icon (as remote URL)", + "goal-name-hint": "This is what viewers will see they contributed to ie. \"USER has contributed to GOALNAME\"", + "goal-cost": "Total points required", + "goal-desc": "Description", + "goal-filter": "Search by goal name" }, "strimertul": { "need-help": "Need help?", @@ -242,7 +263,8 @@ "ok": "OK", "add": "Add", "warning-delete": "This cannot be undone", - "create": "Create" + "create": "Create", + "submit": "Submit" }, "debug": { "dev-build": "Development build" diff --git a/frontend/src/ui/pages/LoyaltyRewards.tsx b/frontend/src/ui/pages/LoyaltyRewards.tsx index 2cf2609..0ce1f80 100644 --- a/frontend/src/ui/pages/LoyaltyRewards.tsx +++ b/frontend/src/ui/pages/LoyaltyRewards.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { useModule } from '../../lib/react-utils'; import { modules } from '../../store/api/reducer'; -import { LoyaltyReward } from '../../store/api/types'; +import { LoyaltyGoal, LoyaltyReward } from '../../store/api/types'; import AlertContent from '../components/AlertContent'; import DialogContent from '../components/DialogContent'; import Interval from '../components/Interval'; @@ -34,6 +34,7 @@ import { import { Alert, AlertTrigger } from '../theme/alert'; const RewardList = styled('div', { marginTop: '1rem' }); +const GoalList = styled('div', { marginTop: '1rem' }); const RewardItemContainer = styled('article', { backgroundColor: '$gray2', margin: '0.5rem 0', @@ -101,6 +102,13 @@ const RewardIcon = styled('div', { display: 'flex', alignItems: 'center', }); +const NoneText = styled('div', { + color: '$gray9', + fontSize: '1.2em', + textAlign: 'center', + fontStyle: 'italic', + paddingTop: '1rem', +}); interface RewardItemProps { name: string; @@ -179,6 +187,84 @@ function RewardItem({ ); } +interface GoalItemProps { + name: string; + item: LoyaltyGoal; + currency: string; + onToggle?: () => void; + onEdit?: () => void; + onDelete?: () => void; +} +function GoalItem({ + name, + item, + currency, + onToggle, + onEdit, + onDelete, +}: GoalItemProps): React.ReactElement { + const { t } = useTranslation(); + + return ( + + + + {item.image && ( + + )} + + + {item.name} ({name}) + + + {item.contributed} / {item.total} {currency} ( + {Math.round((item.contributed / item.total) * 100)}%) + + + + + + + + + + (onDelete ? onDelete() : null)} + /> + + + + + {item.description} + + ); +} + function RewardsPage() { const { t } = useTranslation(); const dispatch = useDispatch(); @@ -194,19 +280,16 @@ function RewardsPage() { enabled: false, text: '', }); + const filterLC = filter.toLowerCase(); const deleteReward = (id: string): void => { - dispatch( - setRewards({ - ...rewards.filter((r) => r.id !== id), - }), - ); + dispatch(setRewards(rewards?.filter((r) => r.id !== id) ?? [])); }; const toggleReward = (id: string): void => { dispatch( - setRewards({ - ...rewards.map((r) => { + setRewards( + rewards?.map((r) => { if (r.id === id) { return { ...r, @@ -214,8 +297,8 @@ function RewardsPage() { }; } return r; - }), - }), + }) ?? [], + ), ); }; @@ -245,13 +328,13 @@ function RewardsPage() { if (requiredInfo.enabled) { reward.required_info = requiredInfo.text; } - const index = rewards.findIndex((t) => t.id == reward.id); + const index = rewards?.findIndex((t) => t.id == reward.id); if (index >= 0) { const newRewards = rewards.slice(0); newRewards[index] = reward; dispatch(setRewards(newRewards)); } else { - dispatch(setRewards([...rewards, reward])); + dispatch(setRewards([...(rewards ?? []), reward])); } setDialogReward({ ...dialogReward, open: false }); }} @@ -480,24 +563,36 @@ function RewardsPage() { - {rewards?.map((r) => ( - - setDialogReward({ - open: true, - new: false, - reward: r, - }) - } - onDelete={() => deleteReward(r.id)} - onToggle={() => toggleReward(r.id)} - /> - ))} + {rewards ? ( + rewards + ?.filter( + (r) => + r.name.toLowerCase().includes(filterLC) || + r.id.toLowerCase().includes(filterLC) || + r.description.toLowerCase().includes(filterLC), + ) + .map((r) => ( + + setDialogReward({ + open: true, + new: false, + reward: r, + }) + } + onDelete={() => deleteReward(r.id)} + onToggle={() => toggleReward(r.id)} + /> + )) + ) : ( + {t('pages.loyalty-rewards.no-rewards')} + )} ); @@ -505,8 +600,273 @@ function RewardsPage() { function GoalsPage() { const { t } = useTranslation(); + const dispatch = useDispatch(); + const [config] = useModule(modules.loyaltyConfig); + const [goals, setGoals] = useModule(modules.loyaltyGoals); + const [filter, setFilter] = useState(''); + const [dialogGoal, setDialogGoal] = useState<{ + open: boolean; + new: boolean; + goal: LoyaltyGoal; + }>({ open: false, new: false, goal: null }); + const [requiredInfo, setRequiredInfo] = useState({ + enabled: false, + text: '', + }); + const filterLC = filter.toLowerCase(); - return <>; + const deleteGoal = (id: string): void => { + dispatch(setGoals(goals?.filter((r) => r.id !== id) ?? [])); + }; + + const toggleGoal = (id: string): void => { + dispatch( + setGoals( + goals?.map((r) => { + if (r.id === id) { + return { + ...r, + enabled: !r.enabled, + }; + } + return r; + }) ?? [], + ), + ); + }; + + return ( + <> + setDialogGoal({ ...dialogGoal, open: state })} + > + +
{ + e.preventDefault(); + if (!(e.target as HTMLFormElement).checkValidity()) { + return; + } + const goal = dialogGoal.goal; + const index = goals?.findIndex((t) => t.id == goal.id); + if (index >= 0) { + const newGoals = goals.slice(0); + newGoals[index] = goal; + dispatch(setGoals(newGoals)); + } else { + dispatch(setGoals([...(goals ?? []), goal])); + } + setDialogGoal({ ...dialogGoal, open: false }); + }} + > + + + { + setDialogGoal({ + ...dialogGoal, + goal: { + ...dialogGoal?.goal, + id: + e.target.value + ?.toLowerCase() + .replace(/[^a-zA-Z0-9]/gi, '-') ?? '', + }, + }); + if ( + dialogGoal.new && + goals.find((r) => r.id === e.target.value) + ) { + (e.target as HTMLInputElement).setCustomValidity( + t('pages.loyalty-rewards.id-already-in-use'), + ); + } else { + (e.target as HTMLInputElement).setCustomValidity(''); + } + }} + /> + {t('pages.loyalty-rewards.goal-id-hint')} + + + + { + setDialogGoal({ + ...dialogGoal, + goal: { + ...dialogGoal?.goal, + name: e.target.value, + }, + }); + }} + /> + {t('pages.loyalty-rewards.goal-name-hint')} + + + + { + setDialogGoal({ + ...dialogGoal, + goal: { + ...dialogGoal?.goal, + image: e.target.value, + }, + }); + }} + /> + + + + + + + + { + setDialogGoal({ + ...dialogGoal, + goal: { + ...dialogGoal?.goal, + total: parseInt(e.target.value), + }, + }); + }} + /> + + + + + +
+
+
+ + + + setFilter(e.target.value)} + /> + + + + {goals ? ( + goals + ?.filter( + (r) => + r.name.toLowerCase().includes(filterLC) || + r.id.toLowerCase().includes(filterLC) || + r.description.toLowerCase().includes(filterLC), + ) + .map((r) => ( + + setDialogGoal({ + open: true, + new: false, + goal: r, + }) + } + onDelete={() => deleteGoal(r.id)} + onToggle={() => toggleGoal(r.id)} + /> + )) + ) : ( + {t('pages.loyalty-rewards.no-goals')} + )} + + + ); } export default function LoyaltyRewardsPage(): React.ReactElement {