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();