~ashkeel/strimertul

d276b734bf1656ecb63207e8f34a0e1225f7e901 — Ash Keel 6 months ago a1fab34
shuffle code around
21 files changed, 1016 insertions(+), 957 deletions(-)

M frontend/src/ui/App.tsx
M frontend/src/ui/components/utils/Channels.tsx
D frontend/src/ui/pages/LoyaltyRewards.tsx
R frontend/src/ui/pages/{LoyaltyConfig.tsx => loyalty/LoyaltyConfig.tsx}
R frontend/src/ui/pages/{LoyaltyQueue.tsx => loyalty/LoyaltyQueue.tsx}
A frontend/src/ui/pages/loyalty/Rewards/GoalsTab.tsx
A frontend/src/ui/pages/loyalty/Rewards/Page.tsx
A frontend/src/ui/pages/loyalty/Rewards/RewardsTab.tsx
A frontend/src/ui/pages/loyalty/Rewards/theme.tsx
R frontend/src/ui/pages/{Debug.tsx => system/Debug.tsx}
R frontend/src/ui/pages/{Extensions.tsx => system/Extensions.tsx}
R frontend/src/ui/pages/{ServerSettings.tsx => system/ServerSettings.tsx}
R frontend/src/ui/pages/{Strimertul.tsx => system/Strimertul.tsx}
R frontend/src/ui/pages/{UISettingsPage.tsx => system/UISettingsPage.tsx}
R frontend/src/ui/pages/{ChatAlerts.tsx => twitch/ChatAlerts.tsx}
R frontend/src/ui/pages/{ChatCommands.tsx => twitch/ChatCommands.tsx}
R frontend/src/ui/pages/{ChatTimers.tsx => twitch/ChatTimers.tsx}
R frontend/src/ui/pages/{TwitchSettings/Page.tsx => twitch/TwitchSettings/Page.tsx}
R frontend/src/ui/pages/{TwitchSettings/TwitchAPISettings.tsx => twitch/TwitchSettings/TwitchAPISettings.tsx}
R frontend/src/ui/pages/{TwitchSettings/TwitchChatSettings.tsx => twitch/TwitchSettings/TwitchChatSettings.tsx}
R frontend/src/ui/pages/{TwitchSettings/TwitchEventSubSettings.tsx => twitch/TwitchSettings/TwitchEventSubSettings.tsx}
M frontend/src/ui/App.tsx => frontend/src/ui/App.tsx +12 -12
@@ 31,20 31,20 @@ import { initializeServerInfo } from '~/store/server/reducer';
import LogViewer from './components/LogViewer';
import Sidebar, { RouteSection } from './components/Sidebar';
import Scrollbar from './components/utils/Scrollbar';
import TwitchChatCommandsPage from './pages/ChatCommands';
import TwitchChatTimersPage from './pages/ChatTimers';
import ChatAlertsPage from './pages/ChatAlerts';
import TwitchChatCommandsPage from './pages/twitch/ChatCommands';
import TwitchChatTimersPage from './pages/twitch/ChatTimers';
import ChatAlertsPage from './pages/twitch/ChatAlerts';
import Dashboard from './pages/Dashboard';
import DebugPage from './pages/Debug';
import LoyaltyConfigPage from './pages/LoyaltyConfig';
import LoyaltyQueuePage from './pages/LoyaltyQueue';
import LoyaltyRewardsPage from './pages/LoyaltyRewards';
import DebugPage from './pages/system/Debug';
import LoyaltyConfigPage from './pages/loyalty/LoyaltyConfig';
import LoyaltyQueuePage from './pages/loyalty/LoyaltyQueue';
import LoyaltyRewardsPage from './pages/loyalty/Rewards/Page';
import OnboardingPage from './pages/Onboarding';
import ServerSettingsPage from './pages/ServerSettings';
import StrimertulPage from './pages/Strimertul';
import TwitchSettingsPage from './pages/TwitchSettings/Page';
import UISettingsPage from './pages/UISettingsPage';
import ExtensionsPage from './pages/Extensions';
import ServerSettingsPage from './pages/system/ServerSettings';
import StrimertulPage from './pages/system/Strimertul';
import TwitchSettingsPage from './pages/twitch/TwitchSettings/Page';
import UISettingsPage from './pages/system/UISettingsPage';
import ExtensionsPage from './pages/system/Extensions';
import { getTheme, styled } from './theme';
import Loading from './components/Loading';
import InteractiveAuthDialog from './components/InteractiveAuthDialog';

M frontend/src/ui/components/utils/Channels.tsx => frontend/src/ui/components/utils/Channels.tsx +5 -1
@@ 3,7 3,11 @@ import {
  DiscordLogoIcon,
  EnvelopeClosedIcon,
} from '@radix-ui/react-icons';
import { ChannelList, Channel, ChannelLink } from '~/ui/pages/Strimertul';
import {
  ChannelList,
  Channel,
  ChannelLink,
} from '~/ui/pages/system/Strimertul';

export const Channels = (
  <ChannelList>

D frontend/src/ui/pages/LoyaltyRewards.tsx => frontend/src/ui/pages/LoyaltyRewards.tsx +0 -895
@@ 1,895 0,0 @@
import { CheckIcon, PlusIcon } from '@radix-ui/react-icons';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useModule } from '~/lib/react';
import { useAppDispatch } from '~/store';
import { modules } from '~/store/api/reducer';
import { LoyaltyGoal, LoyaltyReward } from '~/store/api/types';
import AlertContent from '../components/AlertContent';
import DialogContent from '../components/DialogContent';
import Interval from '../components/forms/Interval';
import {
  Button,
  Checkbox,
  CheckboxIndicator,
  ControlledInputBox,
  Dialog,
  DialogActions,
  Field,
  FieldNote,
  FlexRow,
  InputBox,
  Label,
  MultiButton,
  NoneText,
  PageContainer,
  PageHeader,
  PageTitle,
  styled,
  TabButton,
  TabContainer,
  TabContent,
  TabList,
  Textarea,
  TextBlock,
} from '../theme';
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',
  padding: '0.5rem',
  borderLeft: '5px solid $teal8',
  borderRadius: '0.25rem',
  borderBottom: '1px solid $gray4',
  transition: 'all 50ms',
  '&:hover': {
    backgroundColor: '$gray3',
  },
  variants: {
    status: {
      enabled: {},
      disabled: {
        borderLeftColor: '$red6',
        backgroundColor: '$gray3',
        color: '$gray10',
      },
    },
  },
});
const RewardHeader = styled('header', {
  display: 'flex',
  gap: '0.5rem',
  alignItems: 'center',
  marginBottom: '0.4rem',
});
const RewardName = styled('span', {
  color: '$gray12',
  flex: 1,
  fontWeight: 'bold',
  variants: {
    status: {
      enabled: {},
      disabled: {
        color: '$gray9',
      },
    },
  },
});
const RewardDescription = styled('span', {
  flex: 1,
  fontSize: '0.9rem',
  color: '$gray11',
});
const RewardActions = styled('div', {
  display: 'flex',
  alignItems: 'center',
  gap: '0.25rem',
});
const RewardID = styled('code', {
  fontFamily: 'Space Mono',
  color: '$teal11',
});
const RewardCost = styled('div', {
  fontSize: '0.9rem',
  marginRight: '0.5rem',
});
const RewardIcon = styled('div', {
  width: '32px',
  height: '32px',
  backgroundColor: '$gray4',
  borderRadius: '0.25rem',
  display: 'flex',
  alignItems: 'center',
});

interface RewardItemProps {
  name: string;
  item: LoyaltyReward;
  currency: string;
  onToggle?: () => void;
  onEdit?: () => void;
  onDelete?: () => void;
}
function RewardItem({
  name,
  item,
  currency,
  onToggle,
  onEdit,
  onDelete,
}: RewardItemProps): React.ReactElement {
  const { t } = useTranslation();

  return (
    <RewardItemContainer status={item.enabled ? 'enabled' : 'disabled'}>
      <RewardHeader>
        <RewardIcon>
          {item.image && (
            <img
              src={item.image}
              style={{ width: '32px', borderRadius: '0.25rem' }}
            />
          )}
        </RewardIcon>
        <RewardName status={item.enabled ? 'enabled' : 'disabled'}>
          {item.name} (<RewardID>{name}</RewardID>)
        </RewardName>
        <RewardCost>
          {item.price} {currency}
        </RewardCost>
        <RewardActions>
          <MultiButton>
            <Button
              styling="multi"
              size="small"
              onClick={() => (onToggle ? onToggle() : null)}
            >
              {item.enabled
                ? t('form-actions.disable')
                : t('form-actions.enable')}
            </Button>
            <Button
              styling="multi"
              size="small"
              onClick={() => (onEdit ? onEdit() : null)}
            >
              {t('form-actions.edit')}
            </Button>
            <Alert>
              <AlertTrigger asChild>
                <Button styling="multi" size="small">
                  {t('form-actions.delete')}
                </Button>
              </AlertTrigger>
              <AlertContent
                variation="danger"
                title={t('pages.loyalty-rewards.remove-reward-title', {
                  name: item.name,
                })}
                description={t('form-actions.warning-delete')}
                actionText={t('form-actions.delete')}
                actionButtonProps={{ variation: 'danger' }}
                showCancel={true}
                onAction={() => (onDelete ? onDelete() : null)}
              />
            </Alert>
          </MultiButton>
        </RewardActions>
      </RewardHeader>
      <RewardDescription>{item.description}</RewardDescription>
    </RewardItemContainer>
  );
}

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 (
    <RewardItemContainer status={item.enabled ? 'enabled' : 'disabled'}>
      <RewardHeader>
        <RewardIcon>
          {item.image && (
            <img
              src={item.image}
              style={{ width: '32px', borderRadius: '0.25rem' }}
            />
          )}
        </RewardIcon>
        <RewardName status={item.enabled ? 'enabled' : 'disabled'}>
          {item.name} (<RewardID>{name}</RewardID>)
        </RewardName>
        <RewardCost>
          {item.contributed} / {item.total} {currency} (
          {Math.round((item.contributed / item.total) * 100)}%)
        </RewardCost>
        <RewardActions>
          <MultiButton>
            <Button
              styling="multi"
              size="small"
              onClick={() => (onToggle ? onToggle() : null)}
            >
              {item.enabled
                ? t('form-actions.disable')
                : t('form-actions.enable')}
            </Button>
            <Button
              styling="multi"
              size="small"
              onClick={() => (onEdit ? onEdit() : null)}
            >
              {t('form-actions.edit')}
            </Button>
            <Alert>
              <AlertTrigger asChild>
                <Button styling="multi" size="small">
                  {t('form-actions.delete')}
                </Button>
              </AlertTrigger>
              <AlertContent
                variation="danger"
                title={t('pages.loyalty-rewards.remove-reward-title', {
                  name: item.name,
                })}
                description={t('form-actions.warning-delete')}
                actionText={t('form-actions.delete')}
                actionButtonProps={{ variation: 'danger' }}
                showCancel={true}
                onAction={() => (onDelete ? onDelete() : null)}
              />
            </Alert>
          </MultiButton>
        </RewardActions>
      </RewardHeader>
      <RewardDescription>{item.description}</RewardDescription>
    </RewardItemContainer>
  );
}

function RewardsPage() {
  const { t } = useTranslation();
  const dispatch = useAppDispatch();
  const [cursorPosition, setCursorPosition] = useState(0);
  const [config] = useModule(modules.loyaltyConfig);
  const [rewards, setRewards] = useModule(modules.loyaltyRewards);
  const [filter, setFilter] = useState('');
  const [dialogReward, setDialogReward] = useState<{
    open: boolean;
    new: boolean;
    reward: LoyaltyReward;
  }>({ open: false, new: false, reward: null });
  const [requiredInfo, setRequiredInfo] = useState({
    enabled: false,
    text: '',
  });
  const filterLC = filter.toLowerCase();

  const deleteReward = (id: string) => {
    void dispatch(setRewards(rewards?.filter((r) => r.id !== id) ?? []));
  };

  const toggleReward = (id: string) => {
    void dispatch(
      setRewards(
        rewards?.map((r) => {
          if (r.id === id) {
            return {
              ...r,
              enabled: !r.enabled,
            };
          }
          return r;
        }) ?? [],
      ),
    );
  };

  return (
    <>
      <Dialog
        open={dialogReward.open}
        onOpenChange={(state) =>
          setDialogReward({ ...dialogReward, open: state })
        }
      >
        <DialogContent
          title={
            dialogReward.new
              ? t('pages.loyalty-rewards.create-reward')
              : t('pages.loyalty-rewards.edit-reward')
          }
          closeButton={true}
        >
          <form
            onSubmit={(e) => {
              e.preventDefault();
              if (!(e.target as HTMLFormElement).checkValidity()) {
                return;
              }
              const { reward } = dialogReward;
              if (requiredInfo.enabled) {
                reward.required_info = requiredInfo.text;
              }
              const index = rewards?.findIndex((r) => r.id === reward.id);
              if (index >= 0) {
                const newRewards = rewards.slice(0);
                newRewards[index] = reward;
                void dispatch(setRewards(newRewards));
              } else {
                void dispatch(setRewards([...(rewards ?? []), reward]));
              }
              setDialogReward({ ...dialogReward, open: false });
            }}
          >
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="reward-id">
                {t('pages.loyalty-rewards.reward-id')}
              </Label>
              <ControlledInputBox
                id="reward-id"
                type="text"
                required
                disabled={!dialogReward.new}
                value={dialogReward?.reward?.id}
                onFocus={(e) => {
                  e.target.selectionStart = cursorPosition;
                }}
                onChange={(e) => {
                  setCursorPosition(e.target.selectionStart);
                  setDialogReward({
                    ...dialogReward,
                    reward: {
                      ...dialogReward?.reward,
                      id:
                        e.target.value
                          ?.toLowerCase()
                          .replace(/[^a-z0-9]/gi, '-') ?? '',
                    },
                  });
                  if (
                    dialogReward.new &&
                    rewards.find((r) => r.id === e.target.value)
                  ) {
                    e.target.setCustomValidity(
                      t('pages.loyalty-rewards.id-already-in-use'),
                    );
                  } else {
                    e.target.setCustomValidity('');
                  }
                }}
              />
              <FieldNote>{t('pages.loyalty-rewards.reward-id-hint')}</FieldNote>
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="reward-name">
                {t('pages.loyalty-rewards.reward-name')}
              </Label>
              <InputBox
                id="reward-name"
                type="text"
                required
                value={dialogReward?.reward?.name ?? ''}
                onChange={(e) => {
                  setDialogReward({
                    ...dialogReward,
                    reward: {
                      ...dialogReward?.reward,
                      name: e.target.value,
                    },
                  });
                }}
              />
              <FieldNote>
                {t('pages.loyalty-rewards.reward-name-hint')}
              </FieldNote>
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="reward-icon">
                {t('pages.loyalty-rewards.reward-icon')}
              </Label>
              <InputBox
                id="reward-icon"
                type="text"
                value={dialogReward?.reward?.image ?? ''}
                onChange={(e) => {
                  setDialogReward({
                    ...dialogReward,
                    reward: {
                      ...dialogReward?.reward,
                      image: e.target.value,
                    },
                  });
                }}
              />
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="reward-desc">
                {t('pages.loyalty-rewards.reward-desc')}
              </Label>
              <Textarea
                id="reward-desc"
                value={dialogReward?.reward?.description ?? ''}
                onChange={(e) => {
                  setDialogReward({
                    ...dialogReward,
                    reward: {
                      ...dialogReward?.reward,
                      description: e.target.value,
                    },
                  });
                }}
              >
                {dialogReward?.reward?.description ?? ''}
              </Textarea>
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="reward-cost">
                {t('pages.loyalty-rewards.reward-cost')}
              </Label>
              <InputBox
                id="reward-cost"
                type="number"
                required
                defaultValue={dialogReward?.reward?.price}
                onChange={(e) => {
                  setDialogReward({
                    ...dialogReward,
                    reward: {
                      ...dialogReward?.reward,
                      price: parseInt(e.target.value, 10),
                    },
                  });
                }}
              />
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="reward-cooldown">
                {t('pages.loyalty-rewards.reward-cooldown')}
              </Label>
              <FlexRow align="left">
                <Interval
                  value={dialogReward?.reward?.cooldown ?? 0}
                  active={true}
                  onChange={(cooldown) => {
                    setDialogReward({
                      ...dialogReward,
                      reward: {
                        ...dialogReward?.reward,
                        cooldown,
                      },
                    });
                  }}
                />
              </FlexRow>
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <FlexRow align="left" spacing="1">
                <Checkbox
                  id="reward-details"
                  checked={requiredInfo.enabled}
                  onCheckedChange={(e) => {
                    setRequiredInfo({
                      ...requiredInfo,
                      enabled: !!e,
                    });
                  }}
                >
                  <CheckboxIndicator>
                    {requiredInfo.enabled && <CheckIcon />}
                  </CheckboxIndicator>
                </Checkbox>
                <Label htmlFor="reward-details">
                  {t('pages.loyalty-rewards.reward-details')}
                </Label>
              </FlexRow>
              <InputBox
                id="reward-details-text"
                type="text"
                disabled={!requiredInfo.enabled}
                required={requiredInfo.enabled}
                value={dialogReward?.reward?.required_info ?? ''}
                placeholder={t(
                  'pages.loyalty-rewards.reward-details-placeholder',
                )}
                onChange={(e) => {
                  setRequiredInfo({ ...requiredInfo, text: e.target.value });
                }}
              />
            </Field>
            <DialogActions>
              <Button variation="primary" type="submit">
                {dialogReward.new
                  ? t('form-actions.create')
                  : t('form-actions.edit')}
              </Button>
              <Button
                type="button"
                onClick={() =>
                  setDialogReward({ ...dialogReward, open: false })
                }
              >
                {t('form-actions.cancel')}
              </Button>
            </DialogActions>
          </form>
        </DialogContent>
      </Dialog>
      <Field size="fullWidth" spacing="none">
        <FlexRow css={{ flex: 1, alignItems: 'stretch' }} spacing="1">
          <Button
            variation="primary"
            onClick={() => {
              setRequiredInfo({
                enabled: false,
                text: '',
              });
              setDialogReward({
                open: true,
                new: true,
                reward: {
                  id: '',
                  enabled: true,
                  name: '',
                  description: '',
                  image: '',
                  price: 0,
                  cooldown: 0,
                },
              });
            }}
          >
            <PlusIcon /> {t('pages.loyalty-rewards.create-reward')}
          </Button>
          <InputBox
            css={{ flex: 1 }}
            placeholder={t('pages.loyalty-rewards.reward-filter')}
            value={filter}
            onChange={(e) => setFilter(e.target.value)}
          />
        </FlexRow>
      </Field>
      <RewardList>
        {rewards && rewards.length > 0 ? (
          rewards
            ?.filter(
              (r) =>
                r.name.toLowerCase().includes(filterLC) ||
                r.id.toLowerCase().includes(filterLC) ||
                r.description.toLowerCase().includes(filterLC),
            )
            .map((r) => (
              <RewardItem
                key={r.id}
                name={r.id}
                item={r}
                currency={(
                  config?.currency || t('pages.loyalty-queue.points')
                ).toLowerCase()}
                onEdit={() =>
                  setDialogReward({
                    open: true,
                    new: false,
                    reward: r,
                  })
                }
                onDelete={() => deleteReward(r.id)}
                onToggle={() => toggleReward(r.id)}
              />
            ))
        ) : (
          <NoneText>{t('pages.loyalty-rewards.no-rewards')}</NoneText>
        )}
      </RewardList>
    </>
  );
}

function GoalsPage() {
  const { t } = useTranslation();
  const dispatch = useAppDispatch();
  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 filterLC = filter.toLowerCase();

  const deleteGoal = (id: string): void => {
    void dispatch(setGoals(goals?.filter((r) => r.id !== id) ?? []));
  };

  const toggleGoal = (id: string): void => {
    void dispatch(
      setGoals(
        goals?.map((r) => {
          if (r.id === id) {
            return {
              ...r,
              enabled: !r.enabled,
            };
          }
          return r;
        }) ?? [],
      ),
    );
  };

  return (
    <>
      <Dialog
        open={dialogGoal.open}
        onOpenChange={(state) => setDialogGoal({ ...dialogGoal, open: state })}
      >
        <DialogContent
          title={
            dialogGoal.new
              ? t('pages.loyalty-rewards.create-goal')
              : t('pages.loyalty-rewards.edit-goal')
          }
          closeButton={true}
        >
          <form
            onSubmit={(e) => {
              e.preventDefault();
              if (!(e.target as HTMLFormElement).checkValidity()) {
                return;
              }
              const { goal } = dialogGoal;
              const index = goals?.findIndex((g) => g.id === goal.id);
              if (index >= 0) {
                const newGoals = goals.slice(0);
                newGoals[index] = goal;
                void dispatch(setGoals(newGoals));
              } else {
                void dispatch(setGoals([...(goals ?? []), goal]));
              }
              setDialogGoal({ ...dialogGoal, open: false });
            }}
          >
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="goal-id">
                {t('pages.loyalty-rewards.goal-id')}
              </Label>
              <ControlledInputBox
                id="goal-id"
                type="text"
                required
                disabled={!dialogGoal.new}
                value={dialogGoal?.goal?.id}
                onChange={(e) => {
                  setDialogGoal({
                    ...dialogGoal,
                    goal: {
                      ...dialogGoal?.goal,
                      id:
                        e.target.value
                          ?.toLowerCase()
                          .replace(/[^a-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('');
                  }
                }}
              />
              <FieldNote>{t('pages.loyalty-rewards.goal-id-hint')}</FieldNote>
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="goal-name">
                {t('pages.loyalty-rewards.goal-name')}
              </Label>
              <InputBox
                id="goal-name"
                type="text"
                required
                value={dialogGoal?.goal?.name ?? ''}
                onChange={(e) => {
                  setDialogGoal({
                    ...dialogGoal,
                    goal: {
                      ...dialogGoal?.goal,
                      name: e.target.value,
                    },
                  });
                }}
              />
              <FieldNote>{t('pages.loyalty-rewards.goal-name-hint')}</FieldNote>
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="goal-icon">
                {t('pages.loyalty-rewards.goal-icon')}
              </Label>
              <InputBox
                id="goal-icon"
                type="text"
                value={dialogGoal?.goal?.image ?? ''}
                onChange={(e) => {
                  setDialogGoal({
                    ...dialogGoal,
                    goal: {
                      ...dialogGoal?.goal,
                      image: e.target.value,
                    },
                  });
                }}
              />
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="goal-desc">
                {t('pages.loyalty-rewards.goal-desc')}
              </Label>
              <Textarea
                id="goal-desc"
                value={dialogGoal?.goal?.description ?? ''}
                onChange={(e) => {
                  setDialogGoal({
                    ...dialogGoal,
                    goal: {
                      ...dialogGoal?.goal,
                      description: e.target.value,
                    },
                  });
                }}
              >
                {dialogGoal?.goal?.description ?? ''}
              </Textarea>
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="goal-cost">
                {t('pages.loyalty-rewards.goal-cost')}
              </Label>
              <InputBox
                id="goal-cost"
                type="number"
                required
                defaultValue={dialogGoal?.goal?.total}
                onChange={(e) => {
                  setDialogGoal({
                    ...dialogGoal,
                    goal: {
                      ...dialogGoal?.goal,
                      total: parseInt(e.target.value, 10),
                    },
                  });
                }}
              />
            </Field>
            <DialogActions>
              <Button variation="primary" type="submit">
                {dialogGoal.new
                  ? t('form-actions.create')
                  : t('form-actions.edit')}
              </Button>
              <Button
                type="button"
                onClick={() => setDialogGoal({ ...dialogGoal, open: false })}
              >
                {t('form-actions.cancel')}
              </Button>
            </DialogActions>
          </form>
        </DialogContent>
      </Dialog>
      <Field size="fullWidth" spacing="none">
        <FlexRow css={{ flex: 1, alignItems: 'stretch' }} spacing="1">
          <Button
            variation="primary"
            onClick={() => {
              setDialogGoal({
                open: true,
                new: true,
                goal: {
                  id: '',
                  enabled: true,
                  name: '',
                  description: '',
                  image: '',
                  total: 0,
                  contributed: 0,
                  contributors: {},
                },
              });
            }}
          >
            <PlusIcon /> {t('pages.loyalty-rewards.create-goal')}
          </Button>
          <InputBox
            css={{ flex: 1 }}
            placeholder={t('pages.loyalty-rewards.goal-filter')}
            value={filter}
            onChange={(e) => setFilter(e.target.value)}
          />
        </FlexRow>
      </Field>
      <GoalList>
        {goals && goals.length > 0 ? (
          goals
            ?.filter(
              (r) =>
                r.name.toLowerCase().includes(filterLC) ||
                r.id.toLowerCase().includes(filterLC) ||
                r.description.toLowerCase().includes(filterLC),
            )
            .map((r) => (
              <GoalItem
                key={r.id}
                name={r.id}
                item={r}
                currency={(
                  config?.currency || t('pages.loyalty-queue.points')
                ).toLowerCase()}
                onEdit={() =>
                  setDialogGoal({
                    open: true,
                    new: false,
                    goal: r,
                  })
                }
                onDelete={() => deleteGoal(r.id)}
                onToggle={() => toggleGoal(r.id)}
              />
            ))
        ) : (
          <NoneText>{t('pages.loyalty-rewards.no-goals')}</NoneText>
        )}
      </GoalList>
    </>
  );
}

export default function LoyaltyRewardsPage(): React.ReactElement {
  const { t } = useTranslation();

  return (
    <PageContainer>
      <PageHeader>
        <PageTitle>{t('pages.loyalty-rewards.title')}</PageTitle>
        <TextBlock>{t('pages.loyalty-rewards.subtitle')}</TextBlock>
      </PageHeader>
      <TabContainer defaultValue="rewards">
        <TabList>
          <TabButton value="rewards">
            {t('pages.loyalty-rewards.rewards-tab')}
          </TabButton>
          <TabButton value="goals">
            {t('pages.loyalty-rewards.goals-tab')}
          </TabButton>
        </TabList>
        <TabContent value="rewards">
          <RewardsPage />
        </TabContent>
        <TabContent value="goals">
          <GoalsPage />
        </TabContent>
      </TabContainer>
    </PageContainer>
  );
}

R frontend/src/ui/pages/LoyaltyConfig.tsx => frontend/src/ui/pages/loyalty/LoyaltyConfig.tsx +3 -3
@@ 16,9 16,9 @@ import {
  CheckboxIndicator,
  InputBox,
  FieldNote,
} from '../theme';
import SaveButton from '../components/forms/SaveButton';
import Interval from '../components/forms/Interval';
} from '../../theme';
import SaveButton from '../../components/forms/SaveButton';
import Interval from '../../components/forms/Interval';

export default function LoyaltySettingsPage(): React.ReactElement {
  const { t } = useTranslation();

R frontend/src/ui/pages/LoyaltyQueue.tsx => frontend/src/ui/pages/loyalty/LoyaltyQueue.tsx +4 -4
@@ 5,8 5,8 @@ import { SortFunction } from '~/lib/types';
import { useAppDispatch } from '~/store';
import { modules, removeRedeem, setUserPoints } from '~/store/api/reducer';
import { LoyaltyRedeem } from '~/store/api/types';
import { DataTable } from '../components/DataTable';
import DialogContent from '../components/DialogContent';
import { DataTable } from '../../components/DataTable';
import DialogContent from '../../components/DialogContent';
import {
  Button,
  Dialog,


@@ 24,8 24,8 @@ import {
  TabContent,
  TabList,
  TextBlock,
} from '../theme';
import { TableCell, TableRow } from '../theme/table';
} from '../../theme';
import { TableCell, TableRow } from '../../theme/table';

function RewardQueueRow({ data }: { data: LoyaltyRedeem & { date: Date } }) {
  const dispatch = useAppDispatch();

A frontend/src/ui/pages/loyalty/Rewards/GoalsTab.tsx => frontend/src/ui/pages/loyalty/Rewards/GoalsTab.tsx +380 -0
@@ 0,0 1,380 @@
import { PlusIcon } from '@radix-ui/react-icons';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useModule } from '~/lib/react';
import { useAppDispatch } from '~/store';
import { modules } from '~/store/api/reducer';
import { LoyaltyGoal } from '~/store/api/types';
import AlertContent from '../../../components/AlertContent';
import DialogContent from '../../../components/DialogContent';
import {
  Button,
  ControlledInputBox,
  Dialog,
  DialogActions,
  Field,
  FieldNote,
  FlexRow,
  InputBox,
  Label,
  MultiButton,
  NoneText,
  styled,
  Textarea,
} from '../../../theme';
import { Alert, AlertTrigger } from '../../../theme/alert';
import {
  RewardActions,
  RewardCost,
  RewardDescription,
  RewardHeader,
  RewardID,
  RewardIcon,
  RewardItemContainer,
  RewardName,
} from './theme';

const GoalList = styled('div', { marginTop: '1rem' });

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 (
    <RewardItemContainer status={item.enabled ? 'enabled' : 'disabled'}>
      <RewardHeader>
        <RewardIcon>
          {item.image && (
            <img
              src={item.image}
              style={{ width: '32px', borderRadius: '0.25rem' }}
            />
          )}
        </RewardIcon>
        <RewardName status={item.enabled ? 'enabled' : 'disabled'}>
          {item.name} (<RewardID>{name}</RewardID>)
        </RewardName>
        <RewardCost>
          {item.contributed} / {item.total} {currency} (
          {Math.round((item.contributed / item.total) * 100)}%)
        </RewardCost>
        <RewardActions>
          <MultiButton>
            <Button
              styling="multi"
              size="small"
              onClick={() => (onToggle ? onToggle() : null)}
            >
              {item.enabled
                ? t('form-actions.disable')
                : t('form-actions.enable')}
            </Button>
            <Button
              styling="multi"
              size="small"
              onClick={() => (onEdit ? onEdit() : null)}
            >
              {t('form-actions.edit')}
            </Button>
            <Alert>
              <AlertTrigger asChild>
                <Button styling="multi" size="small">
                  {t('form-actions.delete')}
                </Button>
              </AlertTrigger>
              <AlertContent
                variation="danger"
                title={t('pages.loyalty-rewards.remove-reward-title', {
                  name: item.name,
                })}
                description={t('form-actions.warning-delete')}
                actionText={t('form-actions.delete')}
                actionButtonProps={{ variation: 'danger' }}
                showCancel={true}
                onAction={() => (onDelete ? onDelete() : null)}
              />
            </Alert>
          </MultiButton>
        </RewardActions>
      </RewardHeader>
      <RewardDescription>{item.description}</RewardDescription>
    </RewardItemContainer>
  );
}

export function GoalsTab() {
  const { t } = useTranslation();
  const dispatch = useAppDispatch();
  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 filterLC = filter.toLowerCase();

  const deleteGoal = (id: string): void => {
    void dispatch(setGoals(goals?.filter((r) => r.id !== id) ?? []));
  };

  const toggleGoal = (id: string): void => {
    void dispatch(
      setGoals(
        goals?.map((r) => {
          if (r.id === id) {
            return {
              ...r,
              enabled: !r.enabled,
            };
          }
          return r;
        }) ?? [],
      ),
    );
  };

  return (
    <>
      <Dialog
        open={dialogGoal.open}
        onOpenChange={(state) => setDialogGoal({ ...dialogGoal, open: state })}
      >
        <DialogContent
          title={
            dialogGoal.new
              ? t('pages.loyalty-rewards.create-goal')
              : t('pages.loyalty-rewards.edit-goal')
          }
          closeButton={true}
        >
          <form
            onSubmit={(e) => {
              e.preventDefault();
              if (!(e.target as HTMLFormElement).checkValidity()) {
                return;
              }
              const { goal } = dialogGoal;
              const index = goals?.findIndex((g) => g.id === goal.id);
              if (index >= 0) {
                const newGoals = goals.slice(0);
                newGoals[index] = goal;
                void dispatch(setGoals(newGoals));
              } else {
                void dispatch(setGoals([...(goals ?? []), goal]));
              }
              setDialogGoal({ ...dialogGoal, open: false });
            }}
          >
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="goal-id">
                {t('pages.loyalty-rewards.goal-id')}
              </Label>
              <ControlledInputBox
                id="goal-id"
                type="text"
                required
                disabled={!dialogGoal.new}
                value={dialogGoal?.goal?.id}
                onChange={(e) => {
                  setDialogGoal({
                    ...dialogGoal,
                    goal: {
                      ...dialogGoal?.goal,
                      id:
                        e.target.value
                          ?.toLowerCase()
                          .replace(/[^a-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('');
                  }
                }}
              />
              <FieldNote>{t('pages.loyalty-rewards.goal-id-hint')}</FieldNote>
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="goal-name">
                {t('pages.loyalty-rewards.goal-name')}
              </Label>
              <InputBox
                id="goal-name"
                type="text"
                required
                value={dialogGoal?.goal?.name ?? ''}
                onChange={(e) => {
                  setDialogGoal({
                    ...dialogGoal,
                    goal: {
                      ...dialogGoal?.goal,
                      name: e.target.value,
                    },
                  });
                }}
              />
              <FieldNote>{t('pages.loyalty-rewards.goal-name-hint')}</FieldNote>
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="goal-icon">
                {t('pages.loyalty-rewards.goal-icon')}
              </Label>
              <InputBox
                id="goal-icon"
                type="text"
                value={dialogGoal?.goal?.image ?? ''}
                onChange={(e) => {
                  setDialogGoal({
                    ...dialogGoal,
                    goal: {
                      ...dialogGoal?.goal,
                      image: e.target.value,
                    },
                  });
                }}
              />
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="goal-desc">
                {t('pages.loyalty-rewards.goal-desc')}
              </Label>
              <Textarea
                id="goal-desc"
                value={dialogGoal?.goal?.description ?? ''}
                onChange={(e) => {
                  setDialogGoal({
                    ...dialogGoal,
                    goal: {
                      ...dialogGoal?.goal,
                      description: e.target.value,
                    },
                  });
                }}
              >
                {dialogGoal?.goal?.description ?? ''}
              </Textarea>
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="goal-cost">
                {t('pages.loyalty-rewards.goal-cost')}
              </Label>
              <InputBox
                id="goal-cost"
                type="number"
                required
                defaultValue={dialogGoal?.goal?.total}
                onChange={(e) => {
                  setDialogGoal({
                    ...dialogGoal,
                    goal: {
                      ...dialogGoal?.goal,
                      total: parseInt(e.target.value, 10),
                    },
                  });
                }}
              />
            </Field>
            <DialogActions>
              <Button variation="primary" type="submit">
                {dialogGoal.new
                  ? t('form-actions.create')
                  : t('form-actions.edit')}
              </Button>
              <Button
                type="button"
                onClick={() => setDialogGoal({ ...dialogGoal, open: false })}
              >
                {t('form-actions.cancel')}
              </Button>
            </DialogActions>
          </form>
        </DialogContent>
      </Dialog>
      <Field size="fullWidth" spacing="none">
        <FlexRow css={{ flex: 1, alignItems: 'stretch' }} spacing="1">
          <Button
            variation="primary"
            onClick={() => {
              setDialogGoal({
                open: true,
                new: true,
                goal: {
                  id: '',
                  enabled: true,
                  name: '',
                  description: '',
                  image: '',
                  total: 0,
                  contributed: 0,
                  contributors: {},
                },
              });
            }}
          >
            <PlusIcon /> {t('pages.loyalty-rewards.create-goal')}
          </Button>
          <InputBox
            css={{ flex: 1 }}
            placeholder={t('pages.loyalty-rewards.goal-filter')}
            value={filter}
            onChange={(e) => setFilter(e.target.value)}
          />
        </FlexRow>
      </Field>
      <GoalList>
        {goals && goals.length > 0 ? (
          goals
            ?.filter(
              (r) =>
                r.name.toLowerCase().includes(filterLC) ||
                r.id.toLowerCase().includes(filterLC) ||
                r.description.toLowerCase().includes(filterLC),
            )
            .map((r) => (
              <GoalItem
                key={r.id}
                name={r.id}
                item={r}
                currency={(
                  config?.currency || t('pages.loyalty-queue.points')
                ).toLowerCase()}
                onEdit={() =>
                  setDialogGoal({
                    open: true,
                    new: false,
                    goal: r,
                  })
                }
                onDelete={() => deleteGoal(r.id)}
                onToggle={() => toggleGoal(r.id)}
              />
            ))
        ) : (
          <NoneText>{t('pages.loyalty-rewards.no-goals')}</NoneText>
        )}
      </GoalList>
    </>
  );
}

A frontend/src/ui/pages/loyalty/Rewards/Page.tsx => frontend/src/ui/pages/loyalty/Rewards/Page.tsx +42 -0
@@ 0,0 1,42 @@
import { useTranslation } from 'react-i18next';
import {
  PageContainer,
  PageHeader,
  PageTitle,
  TabButton,
  TabContainer,
  TabContent,
  TabList,
  TextBlock,
} from '../../../theme';
import { GoalsTab } from './GoalsTab';
import { RewardsTab } from './RewardsTab';

export default function LoyaltyRewardsPage(): React.ReactElement {
  const { t } = useTranslation();

  return (
    <PageContainer>
      <PageHeader>
        <PageTitle>{t('pages.loyalty-rewards.title')}</PageTitle>
        <TextBlock>{t('pages.loyalty-rewards.subtitle')}</TextBlock>
      </PageHeader>
      <TabContainer defaultValue="rewards">
        <TabList>
          <TabButton value="rewards">
            {t('pages.loyalty-rewards.rewards-tab')}
          </TabButton>
          <TabButton value="goals">
            {t('pages.loyalty-rewards.goals-tab')}
          </TabButton>
        </TabList>
        <TabContent value="rewards">
          <RewardsTab />
        </TabContent>
        <TabContent value="goals">
          <GoalsTab />
        </TabContent>
      </TabContainer>
    </PageContainer>
  );
}

A frontend/src/ui/pages/loyalty/Rewards/RewardsTab.tsx => frontend/src/ui/pages/loyalty/Rewards/RewardsTab.tsx +457 -0
@@ 0,0 1,457 @@
import { CheckIcon, PlusIcon } from '@radix-ui/react-icons';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useModule } from '~/lib/react';
import { useAppDispatch } from '~/store';
import { modules } from '~/store/api/reducer';
import { LoyaltyReward } from '~/store/api/types';
import AlertContent from '../../../components/AlertContent';
import DialogContent from '../../../components/DialogContent';
import Interval from '../../../components/forms/Interval';
import {
  Button,
  Checkbox,
  CheckboxIndicator,
  ControlledInputBox,
  Dialog,
  DialogActions,
  Field,
  FieldNote,
  FlexRow,
  InputBox,
  Label,
  MultiButton,
  NoneText,
  styled,
  Textarea,
} from '../../../theme';
import { Alert, AlertTrigger } from '../../../theme/alert';
import {
  RewardItemContainer,
  RewardHeader,
  RewardIcon,
  RewardName,
  RewardID,
  RewardCost,
  RewardActions,
  RewardDescription,
} from './theme';

const RewardList = styled('div', { marginTop: '1rem' });

interface RewardItemProps {
  name: string;
  item: LoyaltyReward;
  currency: string;
  onToggle?: () => void;
  onEdit?: () => void;
  onDelete?: () => void;
}
function RewardItem({
  name,
  item,
  currency,
  onToggle,
  onEdit,
  onDelete,
}: RewardItemProps): React.ReactElement {
  const { t } = useTranslation();

  return (
    <RewardItemContainer status={item.enabled ? 'enabled' : 'disabled'}>
      <RewardHeader>
        <RewardIcon>
          {item.image && (
            <img
              src={item.image}
              style={{ width: '32px', borderRadius: '0.25rem' }}
            />
          )}
        </RewardIcon>
        <RewardName status={item.enabled ? 'enabled' : 'disabled'}>
          {item.name} (<RewardID>{name}</RewardID>)
        </RewardName>
        <RewardCost>
          {item.price} {currency}
        </RewardCost>
        <RewardActions>
          <MultiButton>
            <Button
              styling="multi"
              size="small"
              onClick={() => (onToggle ? onToggle() : null)}
            >
              {item.enabled
                ? t('form-actions.disable')
                : t('form-actions.enable')}
            </Button>
            <Button
              styling="multi"
              size="small"
              onClick={() => (onEdit ? onEdit() : null)}
            >
              {t('form-actions.edit')}
            </Button>
            <Alert>
              <AlertTrigger asChild>
                <Button styling="multi" size="small">
                  {t('form-actions.delete')}
                </Button>
              </AlertTrigger>
              <AlertContent
                variation="danger"
                title={t('pages.loyalty-rewards.remove-reward-title', {
                  name: item.name,
                })}
                description={t('form-actions.warning-delete')}
                actionText={t('form-actions.delete')}
                actionButtonProps={{ variation: 'danger' }}
                showCancel={true}
                onAction={() => (onDelete ? onDelete() : null)}
              />
            </Alert>
          </MultiButton>
        </RewardActions>
      </RewardHeader>
      <RewardDescription>{item.description}</RewardDescription>
    </RewardItemContainer>
  );
}

export function RewardsTab() {
  const { t } = useTranslation();
  const dispatch = useAppDispatch();
  const [cursorPosition, setCursorPosition] = useState(0);
  const [config] = useModule(modules.loyaltyConfig);
  const [rewards, setRewards] = useModule(modules.loyaltyRewards);
  const [filter, setFilter] = useState('');
  const [dialogReward, setDialogReward] = useState<{
    open: boolean;
    new: boolean;
    reward: LoyaltyReward;
  }>({ open: false, new: false, reward: null });
  const [requiredInfo, setRequiredInfo] = useState({
    enabled: false,
    text: '',
  });
  const filterLC = filter.toLowerCase();

  const deleteReward = (id: string) => {
    void dispatch(setRewards(rewards?.filter((r) => r.id !== id) ?? []));
  };

  const toggleReward = (id: string) => {
    void dispatch(
      setRewards(
        rewards?.map((r) => {
          if (r.id === id) {
            return {
              ...r,
              enabled: !r.enabled,
            };
          }
          return r;
        }) ?? [],
      ),
    );
  };

  return (
    <>
      <Dialog
        open={dialogReward.open}
        onOpenChange={(state) =>
          setDialogReward({ ...dialogReward, open: state })
        }
      >
        <DialogContent
          title={
            dialogReward.new
              ? t('pages.loyalty-rewards.create-reward')
              : t('pages.loyalty-rewards.edit-reward')
          }
          closeButton={true}
        >
          <form
            onSubmit={(e) => {
              e.preventDefault();
              if (!(e.target as HTMLFormElement).checkValidity()) {
                return;
              }
              const { reward } = dialogReward;
              if (requiredInfo.enabled) {
                reward.required_info = requiredInfo.text;
              }
              const index = rewards?.findIndex((r) => r.id === reward.id);
              if (index >= 0) {
                const newRewards = rewards.slice(0);
                newRewards[index] = reward;
                void dispatch(setRewards(newRewards));
              } else {
                void dispatch(setRewards([...(rewards ?? []), reward]));
              }
              setDialogReward({ ...dialogReward, open: false });
            }}
          >
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="reward-id">
                {t('pages.loyalty-rewards.reward-id')}
              </Label>
              <ControlledInputBox
                id="reward-id"
                type="text"
                required
                disabled={!dialogReward.new}
                value={dialogReward?.reward?.id}
                onFocus={(e) => {
                  e.target.selectionStart = cursorPosition;
                }}
                onChange={(e) => {
                  setCursorPosition(e.target.selectionStart);
                  setDialogReward({
                    ...dialogReward,
                    reward: {
                      ...dialogReward?.reward,
                      id:
                        e.target.value
                          ?.toLowerCase()
                          .replace(/[^a-z0-9]/gi, '-') ?? '',
                    },
                  });
                  if (
                    dialogReward.new &&
                    rewards.find((r) => r.id === e.target.value)
                  ) {
                    e.target.setCustomValidity(
                      t('pages.loyalty-rewards.id-already-in-use'),
                    );
                  } else {
                    e.target.setCustomValidity('');
                  }
                }}
              />
              <FieldNote>{t('pages.loyalty-rewards.reward-id-hint')}</FieldNote>
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="reward-name">
                {t('pages.loyalty-rewards.reward-name')}
              </Label>
              <InputBox
                id="reward-name"
                type="text"
                required
                value={dialogReward?.reward?.name ?? ''}
                onChange={(e) => {
                  setDialogReward({
                    ...dialogReward,
                    reward: {
                      ...dialogReward?.reward,
                      name: e.target.value,
                    },
                  });
                }}
              />
              <FieldNote>
                {t('pages.loyalty-rewards.reward-name-hint')}
              </FieldNote>
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="reward-icon">
                {t('pages.loyalty-rewards.reward-icon')}
              </Label>
              <InputBox
                id="reward-icon"
                type="text"
                value={dialogReward?.reward?.image ?? ''}
                onChange={(e) => {
                  setDialogReward({
                    ...dialogReward,
                    reward: {
                      ...dialogReward?.reward,
                      image: e.target.value,
                    },
                  });
                }}
              />
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="reward-desc">
                {t('pages.loyalty-rewards.reward-desc')}
              </Label>
              <Textarea
                id="reward-desc"
                value={dialogReward?.reward?.description ?? ''}
                onChange={(e) => {
                  setDialogReward({
                    ...dialogReward,
                    reward: {
                      ...dialogReward?.reward,
                      description: e.target.value,
                    },
                  });
                }}
              >
                {dialogReward?.reward?.description ?? ''}
              </Textarea>
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="reward-cost">
                {t('pages.loyalty-rewards.reward-cost')}
              </Label>
              <InputBox
                id="reward-cost"
                type="number"
                required
                defaultValue={dialogReward?.reward?.price}
                onChange={(e) => {
                  setDialogReward({
                    ...dialogReward,
                    reward: {
                      ...dialogReward?.reward,
                      price: parseInt(e.target.value, 10),
                    },
                  });
                }}
              />
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <Label htmlFor="reward-cooldown">
                {t('pages.loyalty-rewards.reward-cooldown')}
              </Label>
              <FlexRow align="left">
                <Interval
                  value={dialogReward?.reward?.cooldown ?? 0}
                  active={true}
                  onChange={(cooldown) => {
                    setDialogReward({
                      ...dialogReward,
                      reward: {
                        ...dialogReward?.reward,
                        cooldown,
                      },
                    });
                  }}
                />
              </FlexRow>
            </Field>
            <Field size="fullWidth" spacing="narrow">
              <FlexRow align="left" spacing="1">
                <Checkbox
                  id="reward-details"
                  checked={requiredInfo.enabled}
                  onCheckedChange={(e) => {
                    setRequiredInfo({
                      ...requiredInfo,
                      enabled: !!e,
                    });
                  }}
                >
                  <CheckboxIndicator>
                    {requiredInfo.enabled && <CheckIcon />}
                  </CheckboxIndicator>
                </Checkbox>
                <Label htmlFor="reward-details">
                  {t('pages.loyalty-rewards.reward-details')}
                </Label>
              </FlexRow>
              <InputBox
                id="reward-details-text"
                type="text"
                disabled={!requiredInfo.enabled}
                required={requiredInfo.enabled}
                value={dialogReward?.reward?.required_info ?? ''}
                placeholder={t(
                  'pages.loyalty-rewards.reward-details-placeholder',
                )}
                onChange={(e) => {
                  setRequiredInfo({ ...requiredInfo, text: e.target.value });
                }}
              />
            </Field>
            <DialogActions>
              <Button variation="primary" type="submit">
                {dialogReward.new
                  ? t('form-actions.create')
                  : t('form-actions.edit')}
              </Button>
              <Button
                type="button"
                onClick={() =>
                  setDialogReward({ ...dialogReward, open: false })
                }
              >
                {t('form-actions.cancel')}
              </Button>
            </DialogActions>
          </form>
        </DialogContent>
      </Dialog>
      <Field size="fullWidth" spacing="none">
        <FlexRow css={{ flex: 1, alignItems: 'stretch' }} spacing="1">
          <Button
            variation="primary"
            onClick={() => {
              setRequiredInfo({
                enabled: false,
                text: '',
              });
              setDialogReward({
                open: true,
                new: true,
                reward: {
                  id: '',
                  enabled: true,
                  name: '',
                  description: '',
                  image: '',
                  price: 0,
                  cooldown: 0,
                },
              });
            }}
          >
            <PlusIcon /> {t('pages.loyalty-rewards.create-reward')}
          </Button>
          <InputBox
            css={{ flex: 1 }}
            placeholder={t('pages.loyalty-rewards.reward-filter')}
            value={filter}
            onChange={(e) => setFilter(e.target.value)}
          />
        </FlexRow>
      </Field>
      <RewardList>
        {rewards && rewards.length > 0 ? (
          rewards
            ?.filter(
              (r) =>
                r.name.toLowerCase().includes(filterLC) ||
                r.id.toLowerCase().includes(filterLC) ||
                r.description.toLowerCase().includes(filterLC),
            )
            .map((r) => (
              <RewardItem
                key={r.id}
                name={r.id}
                item={r}
                currency={(
                  config?.currency || t('pages.loyalty-queue.points')
                ).toLowerCase()}
                onEdit={() =>
                  setDialogReward({
                    open: true,
                    new: false,
                    reward: r,
                  })
                }
                onDelete={() => deleteReward(r.id)}
                onToggle={() => toggleReward(r.id)}
              />
            ))
        ) : (
          <NoneText>{t('pages.loyalty-rewards.no-rewards')}</NoneText>
        )}
      </RewardList>
    </>
  );
}

A frontend/src/ui/pages/loyalty/Rewards/theme.tsx => frontend/src/ui/pages/loyalty/Rewards/theme.tsx +71 -0
@@ 0,0 1,71 @@
import { styled } from '~/ui/theme';

export const RewardItemContainer = styled('article', {
  backgroundColor: '$gray2',
  margin: '0.5rem 0',
  padding: '0.5rem',
  borderLeft: '5px solid $teal8',
  borderRadius: '0.25rem',
  borderBottom: '1px solid $gray4',
  transition: 'all 50ms',
  '&:hover': {
    backgroundColor: '$gray3',
  },
  variants: {
    status: {
      enabled: {},
      disabled: {
        borderLeftColor: '$red6',
        backgroundColor: '$gray3',
        color: '$gray10',
      },
    },
  },
});

export const RewardHeader = styled('header', {
  display: 'flex',
  gap: '0.5rem',
  alignItems: 'center',
  marginBottom: '0.4rem',
});

export const RewardName = styled('span', {
  color: '$gray12',
  flex: 1,
  fontWeight: 'bold',
  variants: {
    status: {
      enabled: {},
      disabled: {
        color: '$gray9',
      },
    },
  },
});
export const RewardDescription = styled('span', {
  flex: 1,
  fontSize: '0.9rem',
  color: '$gray11',
});
export const RewardActions = styled('div', {
  display: 'flex',
  alignItems: 'center',
  gap: '0.25rem',
});
export const RewardID = styled('code', {
  fontFamily: 'Space Mono',
  color: '$teal11',
});
export const RewardCost = styled('div', {
  fontSize: '0.9rem',
  marginRight: '0.5rem',
});
export const RewardIcon = styled('div', {
  width: '32px',
  height: '32px',
  backgroundColor: '$gray4',
  borderRadius: '0.25rem',
  display: 'flex',
  alignItems: 'center',
});

R frontend/src/ui/pages/Debug.tsx => frontend/src/ui/pages/system/Debug.tsx +1 -1
@@ 14,7 14,7 @@ import {
  PageTitle,
  styled,
  Textarea,
} from '../theme';
} from '../../theme';

const Disclaimer = styled('div', {
  display: 'flex',

R frontend/src/ui/pages/Extensions.tsx => frontend/src/ui/pages/system/Extensions.tsx +5 -5
@@ 28,9 28,9 @@ import extensionsReducer, {
} from '~/store/extensions/reducer';
import { useModule } from '~/lib/react';
import { modules } from '~/store/api/reducer';
import AlertContent from '../components/AlertContent';
import DialogContent from '../components/DialogContent';
import Loading from '../components/Loading';
import AlertContent from '../../components/AlertContent';
import DialogContent from '../../components/DialogContent';
import Loading from '../../components/Loading';
import {
  Button,
  ComboBox,


@@ 51,8 51,8 @@ import {
  TabContainer,
  TabContent,
  TabList,
} from '../theme';
import { Alert, AlertTrigger } from '../theme/alert';
} from '../../theme';
import { Alert, AlertTrigger } from '../../theme/alert';

const ExtensionRow = styled('article', {
  marginBottom: '0.4rem',

R frontend/src/ui/pages/ServerSettings.tsx => frontend/src/ui/pages/system/ServerSettings.tsx +5 -5
@@ 3,9 3,9 @@ import { useTranslation } from 'react-i18next';
import { useModule, useStatus } from '~/lib/react';
import { useAppDispatch } from '~/store';
import apiReducer, { modules } from '~/store/api/reducer';
import AlertContent from '../components/AlertContent';
import RevealLink from '../components/utils/RevealLink';
import SaveButton from '../components/forms/SaveButton';
import AlertContent from '../../components/AlertContent';
import RevealLink from '../../components/utils/RevealLink';
import SaveButton from '../../components/forms/SaveButton';
import {
  Field,
  FieldNote,


@@ 15,8 15,8 @@ import {
  PageHeader,
  PageTitle,
  PasswordInputBox,
} from '../theme';
import { Alert } from '../theme/alert';
} from '../../theme';
import { Alert } from '../../theme/alert';

export default function ServerSettingsPage(): React.ReactElement {
  const [serverConfig, setServerConfig, loadStatus] = useModule(

R frontend/src/ui/pages/Strimertul.tsx => frontend/src/ui/pages/system/Strimertul.tsx +3 -3
@@ 6,9 6,9 @@ import { useNavigate } from 'react-router-dom';
// @ts-expect-error Asset import
import logo from '~/assets/icon-logo.svg';

import { APPNAME, PageContainer, PageHeader, styled } from '../theme';
import BrowserLink from '../components/BrowserLink';
import Channels from '../components/utils/Channels';
import { APPNAME, PageContainer, PageHeader, styled } from '../../theme';
import BrowserLink from '../../components/BrowserLink';
import Channels from '../../components/utils/Channels';

const gradientAnimation = keyframes({
  '0%': {

R frontend/src/ui/pages/UISettingsPage.tsx => frontend/src/ui/pages/system/UISettingsPage.tsx +2 -2
@@ 4,7 4,7 @@ import { useModule } from '~/lib/react';
import { languages } from '~/locale/languages';
import { useAppDispatch } from '~/store';
import { modules } from '~/store/api/reducer';
import RadioGroup from '../components/forms/RadioGroup';
import RadioGroup from '../../components/forms/RadioGroup';
import {
  Button,
  Field,


@@ 14,7 14,7 @@ import {
  PageTitle,
  styled,
  themes,
} from '../theme';
} from '../../theme';

const PartialWarning = styled('small', {
  color: '$yellow11',

R frontend/src/ui/pages/ChatAlerts.tsx => frontend/src/ui/pages/twitch/ChatAlerts.tsx +3 -3
@@ 4,7 4,7 @@ import { CheckIcon } from '@radix-ui/react-icons';
import { useModule, useStatus } from '~/lib/react';
import apiReducer, { modules } from '~/store/api/reducer';
import { useAppDispatch } from '~/store';
import MultiInput from '../components/forms/MultiInput';
import MultiInput from '../../components/forms/MultiInput';
import {
  Checkbox,
  CheckboxIndicator,


@@ 19,8 19,8 @@ import {
  TabContent,
  TabList,
  TextBlock,
} from '../theme';
import SaveButton from '../components/forms/SaveButton';
} from '../../theme';
import SaveButton from '../../components/forms/SaveButton';

export default function ChatAlertsPage(): React.ReactElement {
  const { t } = useTranslation();

R frontend/src/ui/pages/ChatCommands.tsx => frontend/src/ui/pages/twitch/ChatCommands.tsx +4 -4
@@ 11,8 11,8 @@ import {
  TwitchChatCustomCommand,
} from '~/store/api/types';
import { TestCommandTemplate } from '@wailsapp/go/main/App';
import AlertContent from '../components/AlertContent';
import DialogContent from '../components/DialogContent';
import AlertContent from '../../components/AlertContent';
import DialogContent from '../../components/DialogContent';
import {
  Button,
  ComboBox,


@@ 34,8 34,8 @@ import {
  styled,
  Textarea,
  TextBlock,
} from '../theme';
import { Alert, AlertTrigger } from '../theme/alert';
} from '../../theme';
import { Alert, AlertTrigger } from '../../theme/alert';

const CommandList = styled('div', { marginTop: '1rem' });
const CommandItemContainer = styled('article', {

R frontend/src/ui/pages/ChatTimers.tsx => frontend/src/ui/pages/twitch/ChatTimers.tsx +7 -7
@@ 6,11 6,11 @@ import { useModule } from '~/lib/react';
import { useAppDispatch } from '~/store';
import { modules } from '~/store/api/reducer';
import { TwitchChatTimer } from '~/store/api/types';
import AlertContent from '../components/AlertContent';
import DialogContent from '../components/DialogContent';
import Interval from '../components/forms/Interval';
import { hours, minutes } from '../components/forms/units';
import MultiInput from '../components/forms/MultiInput';
import AlertContent from '../../components/AlertContent';
import DialogContent from '../../components/DialogContent';
import Interval from '../../components/forms/Interval';
import { hours, minutes } from '../../components/forms/units';
import MultiInput from '../../components/forms/MultiInput';
import {
  Button,
  Dialog,


@@ 27,8 27,8 @@ import {
  PageTitle,
  styled,
  TextBlock,
} from '../theme';
import { Alert, AlertTrigger } from '../theme/alert';
} from '../../theme';
import { Alert, AlertTrigger } from '../../theme/alert';

const TimerList = styled('div', { marginTop: '1rem' });
const TimerItemContainer = styled('article', {

R frontend/src/ui/pages/TwitchSettings/Page.tsx => frontend/src/ui/pages/twitch/TwitchSettings/Page.tsx +1 -1
@@ 18,7 18,7 @@ import {
  TabContent,
  TabList,
  TextBlock,
} from '../../theme';
} from '../../../theme';
import TwitchAPISettings from './TwitchAPISettings';
import TwitchEventSubSettings from './TwitchEventSubSettings';
import TwitchChatSettings from './TwitchChatSettings';

R frontend/src/ui/pages/TwitchSettings/TwitchAPISettings.tsx => frontend/src/ui/pages/twitch/TwitchSettings/TwitchAPISettings.tsx +7 -7
@@ 4,10 4,10 @@ import { useModule, useStatus } from '~/lib/react';
import { useAppDispatch } from '~/store';
import apiReducer, { modules } from '~/store/api/reducer';
import { checkTwitchKeys } from '~/lib/twitch';
import BrowserLink from '../../components/BrowserLink';
import DefinitionTable from '../../components/DefinitionTable';
import RevealLink from '../../components/utils/RevealLink';
import SaveButton from '../../components/forms/SaveButton';
import BrowserLink from '../../../components/BrowserLink';
import DefinitionTable from '../../../components/DefinitionTable';
import RevealLink from '../../../components/utils/RevealLink';
import SaveButton from '../../../components/forms/SaveButton';
import {
  Button,
  ButtonGroup,


@@ 18,9 18,9 @@ import {
  SectionHeader,
  styled,
  TextBlock,
} from '../../theme';
import AlertContent from '../../components/AlertContent';
import { Alert } from '../../theme/alert';
} from '../../../theme';
import AlertContent from '../../../components/AlertContent';
import { Alert } from '../../../theme/alert';

const StepList = styled('ul', {
  lineHeight: '1.5',

R frontend/src/ui/pages/TwitchSettings/TwitchChatSettings.tsx => frontend/src/ui/pages/twitch/TwitchSettings/TwitchChatSettings.tsx +2 -2
@@ 5,7 5,7 @@ import apiReducer, { modules } from '~/store/api/reducer';
import { startAuthFlow } from '~/lib/twitch';
import TwitchUserBlock from '~/ui/components/TwitchUserBlock';
import { ExternalLinkIcon } from '@radix-ui/react-icons';
import SaveButton from '../../components/forms/SaveButton';
import SaveButton from '../../../components/forms/SaveButton';
import {
  Button,
  Field,


@@ 14,7 14,7 @@ import {
  Label,
  SectionHeader,
  TextBlock,
} from '../../theme';
} from '../../../theme';

export default function TwitchChatSettings() {
  const [chatConfig, setChatConfig, loadStatus] = useModule(

R frontend/src/ui/pages/TwitchSettings/TwitchEventSubSettings.tsx => frontend/src/ui/pages/twitch/TwitchSettings/TwitchEventSubSettings.tsx +2 -2
@@ 3,8 3,8 @@ import { useTranslation } from 'react-i18next';
import eventsubTests from '~/data/eventsub-tests';
import { useAppSelector } from '~/store';
import { startAuthFlow } from '~/lib/twitch';
import { Button, ButtonGroup, SectionHeader, TextBlock } from '../../theme';
import TwitchUserBlock from '../../components/TwitchUserBlock';
import { Button, ButtonGroup, SectionHeader, TextBlock } from '../../../theme';
import TwitchUserBlock from '../../../components/TwitchUserBlock';

export default function TwitchEventSubSettings() {
  const { t } = useTranslation();