init push - laying out the project

This commit is contained in:
Mike Sutton
2022-11-12 02:27:46 +01:00
commit 14e163a1a5
183 changed files with 20069 additions and 0 deletions

View File

@ -0,0 +1,24 @@
import React from 'react';
import { render } from 'react-dom';
import { createRoot } from 'react-dom/client';
import { library } from '@fortawesome/fontawesome-svg-core';
import { fab } from '@fortawesome/free-brands-svg-icons';
import { fas } from '@fortawesome/free-solid-svg-icons';
import { far } from '@fortawesome/free-regular-svg-icons';
import TimeAgo from 'javascript-time-ago';
import en from 'javascript-time-ago/locale/en.json';
import '../src/assets/style';
import EntryPoint from '../src/entry_point';
library.add(fas, fab, far);
TimeAgo.addDefaultLocale(en);
document.addEventListener('DOMContentLoaded', () => {
const container = document.getElementById('app');
const root = createRoot(container);
root.render(<EntryPoint />);
});

View File

@ -0,0 +1,5 @@
import MAIN_BACKGROUND_IMG from './images/main-bg.jpg';
import LOGO from './images/logo.png';
import LINKEDIN from './images/linkedin.png';
export { MAIN_BACKGROUND_IMG, LINKEDIN, LOGO };

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@ -0,0 +1,9 @@
import 'bootstrap/dist/css/bootstrap.min.css';
import 'react-datetime/css/react-datetime.css';
import 'react-date-range/dist/styles.css';
import 'react-date-range/dist/theme/default.css';
import 'react-input-range/lib/css/index.css';
import 'react-toastify/dist/ReactToastify.css';
import 'react-confirm-alert/src/react-confirm-alert.css';
import './stylesheets/style.scss';

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
// Action Cable provides the framework to deal with WebSockets in Rails.
// You can generate new channels where WebSocket features live using the `rails generate channel` command.
import { createConsumer } from '@rails/actioncable';
export default createConsumer();

View File

@ -0,0 +1,15 @@
import axios from 'axios';
const axiosObj = () => {
const instance = axios.create({
headers: {
'cache-control': 'no-cache',
'Access-Control-Allow-Origin': '*',
'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content,
},
});
return instance;
};
export default axiosObj();

View File

@ -0,0 +1,19 @@
import {action, observable} from 'mobx';
import Pagination from '../entities/pagination';
const Filterable = {
pagination: null,
initializeFilterable: action(function(params) {
this.pagination = new Pagination(
{
page: 1,
params,
},
this
);
}),
};
export default Filterable;

View File

@ -0,0 +1,8 @@
import { toast } from 'react-toastify';
const notification = {
notifySuccess: message => toast(message, { type: 'success' }),
notifyError: message => toast(message, { type: 'error' }),
};
export default notification;

View File

@ -0,0 +1,67 @@
import {
action,
computed,
extendObservable,
flow,
makeObservable,
observable,
} from 'mobx';
import { camelCase, isEmpty, map } from 'lodash';
import client from '../axiosClient';
import notification from '../concerns/notfications';
class BaseEntity {
/* eslint-disable */
store;
client;
@observable dirty = false;
@observable json = false;
/* eslint-enable */
constructor(value, store) {
makeObservable(this);
this.store = store;
this.client = client;
this.json = value;
extendObservable(this, notification);
}
@computed
get currentUser() {
return this.store.rootStore.userStore.currentUser;
}
@action
initialize(params) {
this.update(params);
}
@action
update(params, updateServer) {
map(
Object.keys(params),
function(k) {
this[camelCase(k)] = params[k];
}.bind(this)
);
if (updateServer) this.updateServer(params);
}
@action
destroy() {
if (isEmpty(this.store)) return;
this.store.records.splice(this.store.records.indexOf(this), 1);
}
@flow
*updateServer(params) {
yield this.client.put(this.updateUrl, params);
}
}
export default BaseEntity;

View File

@ -0,0 +1,37 @@
import { action, computed, makeObservable, observable } from 'mobx';
import BaseEntity from './base_entity';
class Bet extends BaseEntity {
/* eslint-disable */
id;
outcome;
exchange;
exchangeEventName;
exchangeOdds;
tipProviderOdds;
stake;
expectedValue;
marketIdentifier;
exchangeMarketDetails;
betPlacementType;
executedOdds;
evPercent;
eventScheduledTime;
/* eslint-enable */
constructor(value, store) {
super(value, store);
makeObservable(this);
this.handleConstruction(value);
}
@action
handleConstruction(value) {
const val = { ...value };
this.initialize(val);
}
}
export default Bet;

View File

@ -0,0 +1,48 @@
import { action, computed, flow, makeObservable, observable } from 'mobx';
import BaseEntity from './base_entity';
import client from '../axiosClient';
class ExchangeAccount extends BaseEntity {
/* eslint-disable */
id;
exchangeName;
exchangeId;
contactEmail;
contactName;
stakeStrategyName;
sourceTypes;
stakeStrategyConfig;
stakeStrategy;
lastLogTime;
@observable isActive;
@observable canBet;
log;
@observable accountBalance;
/* eslint-enable */
constructor(value, store) {
super(value, store);
makeObservable(this);
this.handleConstruction(value);
}
@action
handleConstruction(value) {
const val = { ...value };
this.initialize(val);
}
@flow
*updateExchangeAccount(params) {
this.update(params);
yield client.put('/api/v1/exchange_account.json', {
exchange_account_id: this.id,
...params,
});
}
}
export default ExchangeAccount;

View File

@ -0,0 +1,58 @@
import { action, computed, makeObservable, observable } from 'mobx';
import { isNull } from 'lodash';
import BaseEntity from './base_entity';
class Pagination extends BaseEntity {
/* eslint-disable */
@observable count = 0;
@observable page = 1;
@observable pages = null;
@observable prev = null;
@observable next = null;
@observable last = null;
@observable from = null;
@observable to = null;
@observable params = {};
@observable filterType = 'fetch';
initialParams = {};
/* eslint-enable */
constructor(value, store) {
super(value, store);
makeObservable(this);
this.initialize(value);
this.initialParams = value.params;
}
@action
fetch() {
this.store.fetch({ ...this.params, page: this.page });
}
@action
updateParams(params) {
this.update({ params: { ...this.params, ...params } });
}
@action
gotoPage(page) {
this.update({ page });
this.fetch();
}
@action
resetPageAndFetch() {
this.gotoPage(1);
}
@action
reset() {
this.update({ page: 1, params: this.initialParams });
this.fetch();
}
}
export default Pagination;

View File

@ -0,0 +1,51 @@
import { action, computed, makeObservable, observable } from 'mobx';
import BaseEntity from './base_entity';
class User extends BaseEntity {
/* eslint-disable */
id;
email;
accountId;
accountName;
@observable status;
@observable firstName;
@observable lastName;
@observable strKey;
@observable fullname;
@observable onboarded = [];
@observable environment = 'production';
/* eslint-enable */
constructor(value, store) {
super(value, store);
makeObservable(this);
this.handleConstruction(value);
}
updateUrl = () => `/api/v1/users/${this.id}.json`;
@computed
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
@computed
get asSelectOption() {
return { label: this.fullName, value: this.id };
}
@action
logout = () => {
window.location.href = '/public';
};
@action
handleConstruction(value) {
const val = { ...value };
this.initialize(val);
}
}
export default User;

View File

@ -0,0 +1,15 @@
import { makeAutoObservable } from 'mobx';
import AppStore from './stores/app_store';
import UserStore from './stores/user_store';
import BetStore from './stores/bet_store';
export default class RootStore {
constructor() {
this.appStore = new AppStore(this);
this.userStore = new UserStore(this);
this.betStore = new BetStore(this);
makeAutoObservable(this);
}
}

View File

@ -0,0 +1,4 @@
import React from 'react';
export const StoresContext = React.createContext(null);
export const StoreProvider = StoresContext.Provider;

View File

@ -0,0 +1,7 @@
import React from 'react';
import { StoresContext } from './provider';
const useStore = () => React.useContext(StoresContext);
export default useStore;

View File

@ -0,0 +1,126 @@
import { action, flow, makeObservable, observable } from 'mobx';
import BaseStore from './base_store';
import client from '../axiosClient';
import ExchangeAccount from '../entities/exchange_account';
import consumer from '../../channels/consumer';
class AppStore extends BaseStore {
@observable sidebarOpened = true;
@observable exchangeAccount;
@observable currentExchangeAccountId;
@observable totalWinAmount = 0;
@observable totalLostAmount = 0;
@observable totalRiskedAmount = 0;
@observable totalTips = 0;
@observable totalTipsSkipped = 0;
@observable totalTipsProcessing = 0;
@observable totalTipsExpired = 0;
@observable totalTipsIgnored = 0;
@observable totalTipsVoided = 0;
@observable totalPlacedBets = 0;
@observable totalPlacedBetsWon = 0;
@observable totalPlacedBetsLost = 0;
@observable totalPlacedBetsOpen = 0;
@observable averageOddsWon = 0;
@observable averageOddsLost = 0;
@observable averageOdds = 0;
@observable appInitialised = false;
@observable autoRefreshResults = true;
@observable runningSince = null;
@observable exchangeAccounts = [];
constructor(store) {
super(store, null);
this.consumer = consumer;
makeObservable(this);
this.fetchConfigFromServer();
}
@action
asPercentOfTotal(val) {
if (val === 0 || this.totalPlacedBets === 0) return '0%';
return `${Math.round((val / this.totalPlacedBets) * 100)}%`;
}
@flow
*fetchConfigFromServer() {
const response = yield client.get('/api/v1/app/configuration.json');
if (response.data.success) {
this.update(response.data.config);
}
}
@flow
*fetchSummaryFiguresFromServer() {
const response = yield client.get('/api/v1/app/summary.json', {
params: {
exchange_account_id:
this.currentExchangeAccountId || this.exchangeAccount?.id || '',
},
});
if (response.data.success) {
this.updateSummary(response.data.summary);
}
}
updateSummary(summary) {
console.log('Refreshing dashboard');
this.update(summary);
const ea = new ExchangeAccount(summary.exchange_account);
this.update({
exchangeAccount: ea,
appInitialised: true,
currentExchangeAccountId: ea.id,
});
}
refresh() {
console.log('Refreshing filtered results');
this.rootStore.betStore.fetch(this.rootStore.betStore.params);
return null;
}
startListeningToAccountChannel() {
const obj = this;
this.consumer.subscriptions.create(
{ channel: 'ExchangeAccountChannel', id: obj.exchangeAccount.id },
{
connected() {
console.log('Connected to ExchangeAccountChannel');
},
disconnected() {
console.log('Disconnected from ExchangeAccountChannel');
},
received(data) {
console.log('Cable received');
return null;
},
}
);
}
}
export default AppStore;

View File

@ -0,0 +1,86 @@
import {
action,
computed,
extendObservable,
makeObservable,
observable,
} from 'mobx';
import {
camelCase,
filter,
find,
includes,
isEmpty,
map,
uniqBy,
} from 'lodash';
import notification from '../concerns/notfications';
import client from '../axiosClient';
class BaseStore {
/* eslint-disable */
rootStore;
client;
Entity;
@observable records = [];
@observable fetching = false;
@observable fetched = false;
/* eslint-enable */
constructor(store, entity) {
makeObservable(this);
this.rootStore = store;
this.client = client;
this.Entity = entity;
extendObservable(this, notification);
}
getById = id => this.getByParams({ id });
getByParams = params => find(this.records, params);
getMultipleByParams = params => filter(this.records, params);
getMultipleById = ids => filter(this.records, r => includes(ids, r.id));
@computed
get filteredRecords() {
return this.records;
}
@computed
get hasRecords() {
return !isEmpty(this.records);
}
@action
initialize(params) {
this.update(params);
}
@action
update(params) {
map(
Object.keys(params),
function(k) {
this[camelCase(k)] = params[k];
}.bind(this)
);
}
@action
addRecord(record) {
const obj = new this.Entity(record, this);
this.records = uniqBy(
[...this.records, new this.Entity(record, this)],
'id'
);
return obj;
}
}
export default BaseStore;

View File

@ -0,0 +1,70 @@
import {
extendObservable,
flow,
makeObservable,
observable,
override,
} from 'mobx';
import { map, orderBy } from 'lodash';
import BaseStore from './base_store';
import Bet from '../entities/bet';
import Filterable from '../concerns/filterable';
import Pagination from '../entities/pagination';
class BetStore extends BaseStore {
@observable params = {
event_name: '',
outcome: '',
created_at: { from: null, to: null },
};
constructor(store) {
super(store, Bet);
extendObservable(this, Filterable);
this.initializeFilterable(this.params);
makeObservable(this);
}
@override
get filteredRecords() {
return orderBy(this.records, ['createdAt'], ['desc']);
}
@flow
*fetch(pms) {
this.params = pms;
const response = yield this.client.get('/api/v1/bets.json', {
params: {
...this.params,
exchange_account_id: this.rootStore.appStore.currentExchangeAccountId,
},
});
if (response.data.summary) {
this.rootStore.appStore.updateSummary(response.data.summary);
}
if (response.data.bets) {
this.update({
records: map(response.data.bets, bet => this.addRecord(bet)),
});
}
if (response.data.pagy) {
const p = new Pagination({ ...response.data.pagy }, this);
this.pagination.count = p.count;
this.pagination.prev = p.prev;
this.pagination.next = p.next;
this.pagination.last = p.last;
this.pagination.from = p.from;
this.pagination.to = p.to;
this.pagination.page = p.page;
this.pagination.pages = p.pages;
}
}
}
export default BetStore;

View File

@ -0,0 +1,36 @@
import { computed, flow, makeObservable, observable } from 'mobx';
import { isEmpty } from 'lodash';
import BaseStore from './base_store';
import User from '../entities/user';
class UserStore extends BaseStore {
/* eslint-disable */
@observable currentUser = {};
@observable fetchingCurrentUser = true;
/* eslint-enable */
constructor(store) {
super(store, User);
makeObservable(this);
}
@computed
get userSignedIn() {
return !isEmpty(this.currentUser);
}
@flow
*fetchCurrentUser() {
const response = yield this.client.get('/api/v1/users/auth.json');
if (response.data.success) {
this.update({ currentUser: new User(response.data.user, this) });
}
this.fetchingCurrentUser = false;
}
}
export default UserStore;

View File

@ -0,0 +1,22 @@
import React from 'react';
import { BrowserRouter, Switch } from 'react-router-dom';
import { ToastContainer } from 'react-toastify';
import { StoreProvider } from '../data/provider';
import RootStore from '../data';
import AppRoutes from '../routes';
const EntryPoint = () => (
<StoreProvider value={new RootStore()}>
<BrowserRouter>
<div className="main-app">
<ToastContainer />
<Switch>
<AppRoutes />
</Switch>
</div>
</BrowserRouter>
</StoreProvider>
);
export default EntryPoint;

View File

@ -0,0 +1,66 @@
import React from 'react';
import {
DateRange,
InputRangeTag,
ReactSelectTag,
SearchInput,
SelectTag,
} from '../views/shared/form_components';
export const renderFilter = (filter, handleChange) => {
switch (filter.type) {
case 'search':
return <SearchInput handleChange={handleChange} input={filter} />;
case 'select':
return <SelectTag handleChange={handleChange} input={filter} />;
case 'date-range':
return <DateRange handleChange={handleChange} input={filter} />;
case 'input-range':
return <InputRangeTag handleChange={handleChange} input={filter} />;
case 'react-select':
return <ReactSelectTag handleChange={handleChange} input={filter} />;
default:
return null;
}
};
export const betFilters = [
{
name: 'outcome',
type: 'react-select',
placeholder: 'Select...',
label: 'Outcome',
isMulti: true,
options: [
{ label: 'All', value: '' },
{ label: 'Open', value: 'open' },
{ label: 'Lost', value: 'lost' },
{ label: 'Won', value: 'won' },
{ label: 'Processing', value: 'processing' },
{ label: 'Expired', value: 'expired' },
{ label: 'Skipped', value: 'skipped' },
{ label: 'Ignored', value: 'ignored' },
{ label: 'Errored', value: 'errored' },
{ label: 'Voided', value: 'voided' },
{ label: 'Cancelled', value: 'cancelled' },
],
},
{
name: 'created_at',
type: 'date-range',
placeholder: 'Select Date',
label: 'Created Date',
},
{
name: 'event_name',
type: 'search',
placeholder: 'Search...',
label: 'Event Name',
},
];

View File

@ -0,0 +1,75 @@
import { useEffect, useState } from 'react';
export const toSentence = arr => {
if (arr.length > 1)
return `${arr.slice(0, arr.length - 1).join(', ')}, and ${arr.slice(-1)}`;
return arr[0];
};
export const amountFormatter = number => {
const unitlist = ['', 'K', 'M', 'G'];
let num = number || 0;
const sign = Math.sign(num);
let unit = 0;
while (Math.abs(num) > 1000) {
unit += 1;
num = Math.floor(Math.abs(num) / 100) / 10;
}
return sign * num + unitlist[unit];
};
export const useWindowSize = () => {
// Initialize state with undefined width/height so server and client renders match
// Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/
const [windowSize, setWindowSize] = useState({
width: undefined,
height: undefined,
});
useEffect(() => {
// Handler to call on window resize
function handleResize() {
// Set window width/height to state
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
// Add event listener
window.addEventListener('resize', handleResize);
// Call handler right away so state gets updated with initial window size
handleResize();
// Remove event listener on cleanup
return () => window.removeEventListener('resize', handleResize);
}, []); // Empty array ensures that effect is only run on mount
return windowSize;
};
export const basicEditorToolbar = [
['bold', 'italic', 'underline'], // ['strike'] toggled buttons
['blockquote', 'code-block'],
['link', 'image'],
['emoji'],
// [{ header: 1 }, { header: 2 }], // custom button values
[{ list: 'ordered' }, { list: 'bullet' }],
// [{ script: 'sub' }, { script: 'super' }], // superscript/subscript
// [{ indent: '-1' }, { indent: '+1' }], // outdent/indent
// [{ direction: 'rtl' }], // text direction
// [{ size: ['small', false, 'large', 'huge'] }], // custom dropdown
[{ header: [1, 2, 3, 4, 5, 6, false] }],
[{ color: [] }, { background: [] }], // dropdown with defaults from theme
[{ font: [] }],
[{ align: [] }],
// ['clean'],
];
export const commentEditorToolbar = [
['bold', 'italic', 'underline'],
[{ font: [] }],
['emoji'],
];

View File

@ -0,0 +1,61 @@
export const primaryLinks = [
{
linkName: 'dashboard',
iconName: 'tachometer-alt',
},
];
const controlStyle = {
paddingLeft: 15,
backgroundColor: '#6D7E8F',
color: '#fff',
boxShadow: 'none',
border: 'none',
};
const commonStyles = {
placeholder: provided => ({
...provided,
color: '#ffffff',
}),
input: (provided, st) => ({
...provided,
color: '#fff',
}),
singleValue: provided => ({
...provided,
color: '#ffffff',
}),
option: (provided, state) => ({
...provided,
backgroundColor: state.isSelected ? '#57B8D7' : '#1f252b',
color: '#ffffff',
paddingLeft: 20,
fontSize: 14,
}),
menu: provided => ({
...provided,
backgroundColor: '#1f252b',
borderRadius: 15,
overflow: 'hidden',
padding: 0,
}),
};
export const customStylesAlt = {
...commonStyles,
control: (provided, st) => ({
...provided,
...controlStyle,
borderRadius: '30px 0px 0px 30px',
}),
};
export const customStyles = {
...commonStyles,
control: (provided, st) => ({
...provided,
...controlStyle,
borderRadius: 30,
}),
};

View File

@ -0,0 +1,41 @@
import React from 'react';
import { Route } from 'react-router-dom';
import PropTypes from 'prop-types';
import { observer } from 'mobx-react';
import useStore from '../data/store';
export const KEYCLOAK_AUTH_URL = '/users/auth/keycloakopenid';
const AuthenticatedRoute = ({ component, path }) => {
const { userStore } = useStore();
const Component = component;
const HandleAuth = () => {
localStorage.setItem(
'redirectURL',
`${window.location.pathname}${window.location.search}`
);
window.location.href = KEYCLOAK_AUTH_URL;
return null;
};
return (
<Route
exact
path={path}
component={userStore.userSignedIn ? Component : HandleAuth}
/>
);
};
AuthenticatedRoute.propTypes = {
component: PropTypes.func,
path: PropTypes.string,
};
export default observer(AuthenticatedRoute);

View File

@ -0,0 +1,36 @@
import React, { useEffect } from 'react';
import { Route } from 'react-router-dom';
import { isEmpty, map } from 'lodash';
import { observer } from 'mobx-react';
import AuthenticatedRoute from './AuthenticatedRoute';
import * as allRoutes from './routes';
import useStore from '../data/store';
const AppRoutes = () => {
const { userStore, appStore } = useStore();
useEffect(() => {
userStore.fetchCurrentUser().then(() => {
console.log('Fetched user');
if (userStore.userSignedIn) appStore.startListeningToAccountChannel();
});
}, []);
if (userStore.fetchingCurrentUser) return null;
return (
<>
{map(allRoutes.normalRoutes, (r, i) => (
<Route key={i} exact path={r.route} component={r.component} />
))}
{map(allRoutes.protectedRoutes, (r, i) => (
<AuthenticatedRoute key={i} path={r.route} component={r.component} />
))}
</>
);
};
export default observer(AppRoutes);

View File

@ -0,0 +1,5 @@
import Dashboard from '../views/dashboard';
import Public from '../views/public';
export const normalRoutes = [{ route: '/', component: Public }];
export const protectedRoutes = [{ route: '/dashboard', component: Dashboard }];

44
portal/client/src/t.js Normal file
View File

@ -0,0 +1,44 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { isObject, map } from 'lodash';
import en from '../../config/locales/en.yml';
const checkForDynamicValues = data => {
let updatedData = data;
if (isObject(data)) {
map(Object.keys(updatedData), k => {
updatedData[k] = checkForDynamicValues(updatedData[k]);
});
return updatedData;
}
updatedData ||= '';
updatedData = updatedData.replaceAll('%', '');
updatedData = updatedData.replaceAll('{', '{{');
updatedData = updatedData.replaceAll('}', '}}');
return updatedData;
};
const createLocaleData = (data, key) => ({
translation: checkForDynamicValues(data[key]),
});
const resources = {
en: createLocaleData(en, 'en'),
};
i18n
.use(initReactI18next) // passes i18n down to react-i18next
.init({
resources,
lng: 'en',
interpolation: {
escapeValue: false, // react already safes from xss
},
});
export default i18n.t;

View File

@ -0,0 +1,18 @@
import { store } from 'react-notifications-component';
const Notifier = (type, title, message) =>
store.addNotification({
title,
message,
type,
insert: 'top',
container: 'top-right',
animationIn: ['animate__animated', 'animate__fadeIn'],
animationOut: ['animate__animated', 'animate__fadeOut'],
dismiss: {
duration: 5000,
onScreen: true,
},
});
export default Notifier;

View File

@ -0,0 +1,2 @@
export const getInitials = u => `${u.firstName[0]}${u.lastName[0]}`;
export const getFullName = u => `${u.firstName} ${u.lastName}`;

View File

@ -0,0 +1,84 @@
import { isEmpty, map } from 'lodash';
import React from 'react';
import PropTypes from 'prop-types';
import { observer } from 'mobx-react';
import ReactTimeAgo from 'react-time-ago';
import { Table } from 'reactstrap';
import moment from 'moment';
const BetsList = ({ records }) => {
if (isEmpty(records))
return <p className="mt-5 font-weight-bold"> No bets </p>;
return (
<Table striped>
<thead>
<tr className="small">
<th>Event</th>
<th>Tip Market / Exchange Market Info</th>
<th>Tip EV</th>
<th>Tip Odds</th>
<th>Exchange Odds</th>
<th>Executed Odds</th>
<th>Stake</th>
<th>Projected Profit</th>
<th>Outcome</th>
<th>Updated</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{map(records, (record, i) => (
<tr key={i}>
<td>
{record.exchangeEventName}
<br />
{!isEmpty(record.eventScheduledTime) && (
<>
<span className="small">
{moment(new Date(record.eventScheduledTime)).format(
'DD.MMM.YYYY HH:mm:ss'
)}
</span>
<br />
</>
)}
<span className="small">
<span className="text-muted small">
{record.exchange}|{record.id}
</span>
</span>
</td>
<td>
{' '}
{record.marketIdentifier} <br />
<span className="text-muted small">
{record.exchangeMarketDetails}
</span>
</td>
<td> {record.evPercent} </td>
<td> {record.tipProviderOdds} </td>
<td style={{ fontSize: 12 }}> {record.exchangeOdds} </td>
<td> {record.executedOdds} </td>
<td> {record.stake} </td>
<td> {record.expectedValue} </td>
<td>
<span className="btn-link" title={record.log}>
{record.outcome}{' '}
</span>
</td>
<td>
<ReactTimeAgo date={new Date(record.createdAt)} />
</td>
<td> {record.betPlacementType} </td>
</tr>
))}
</tbody>
</Table>
);
};
BetsList.propTypes = {
records: PropTypes.instanceOf(Array).isRequired,
};
export default observer(BetsList);

View File

@ -0,0 +1,39 @@
import React, { useEffect, useState } from 'react';
import { observer } from 'mobx-react';
import { Card, CardBody } from 'reactstrap';
import PropTypes from 'prop-types';
import FilteredList from '../modules/filtered_list';
import useStore from '../../data/store';
import { betFilters } from '../../helpers/filter_helpers';
import BetsList from './_bets_list';
const BetsIndex = ({ match }) => {
const { betStore } = useStore();
useEffect(() => {
if (betStore.fetched) {
console.log('Bets fetched');
}
}, [betStore.fetched]);
return (
<Card>
<CardBody>
<FilteredList
listComponent={BetsList}
store={betStore}
filters={betFilters}
recordClassName="col-xl-4"
/>
</CardBody>
</Card>
);
};
BetsIndex.propTypes = {
match: PropTypes.instanceOf(Object).isRequired,
};
export default observer(BetsIndex);

View File

@ -0,0 +1,33 @@
import React, { useEffect } from 'react';
import { Card, CardBody, Col, Row } from 'reactstrap';
import { observer } from 'mobx-react';
import ReactTimeAgo from 'react-time-ago';
import { map } from 'lodash';
import PublicLayout from '../layouts/public_layout';
import BetsIndex from '../bets';
import useStore from '../../data/store';
import CountdownRefresh from '../modules/countdown_refresh';
import { SelectTag } from '../shared/form_components';
import AuthenticatedLayout from '../layouts/authenticate';
const Dashboard = () => {
const { appStore } = useStore();
const renderBody = () => (
<>
{!appStore.appInitialised && <div>Refreshing...</div>}
{appStore.appInitialised && (
<div>
<Card color="light" className="top-0">
<CardBody>
<BetsIndex />
</CardBody>
</Card>
</div>
)}
</>
);
return <AuthenticatedLayout>{renderBody()}</AuthenticatedLayout>;
};
export default observer(Dashboard);

View File

@ -0,0 +1,96 @@
import React from 'react';
import { observer } from 'mobx-react';
import {
Navbar,
Nav,
UncontrolledDropdown,
DropdownToggle,
DropdownMenu,
DropdownItem,
Button,
Row,
Col,
} from 'reactstrap';
import { Initial } from 'react-initial';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useHistory } from 'react-router-dom';
import { isEmpty } from 'lodash';
import useStore from '../../../data/store';
import { useWindowSize } from '../../../helpers/shared_helpers';
const _header = () => {
const { appStore } = useStore();
return (
<header className="container-lg pt-4">
<Row className="sticky-top ">
<div>
<strong> Results Dashboard</strong>
<hr />
</div>
<Col size={3}>
<div className="font-weight-bold">
Total Risked: £{appStore.totalRiskedAmount}
</div>
<div>Total Won: £{appStore.totalWinAmount}</div>
<div>Total Lost: £{appStore.totalLostAmount}</div>
<div>
<strong>
Total Profit:{' '}
<span
className={
appStore.totalWinAmount - appStore.totalLostAmount >= 0
? 'text-success'
: 'text-danger'
}
>
£
{Math.round(appStore.totalWinAmount - appStore.totalLostAmount)}
</span>
</strong>
</div>
</Col>
<Col size={3}>
<div>Total Tips: {appStore.totalTips}</div>
<div>Total Tips Executed: {appStore.totalPlacedBets}</div>
<div>Total Tips Skipped: {appStore.totalTipsSkipped}</div>
</Col>
<Col size={3}>
<div className="fw-bold">
Total Bets Placed: {appStore.totalPlacedBets}
</div>
<div>Total Tips Expired: {appStore.totalTipsExpired}</div>
<div>Total Tips Ignored: {appStore.totalTipsIgnored}</div>
<div>Total Tips Voided: {appStore.totalTipsVoided}</div>
</Col>
<Col size={3}>
<div>Total Bets Open: {appStore.totalPlacedBetsOpen}</div>
<div>
Total Bets Won: {appStore.totalPlacedBetsWon}
<span className="small text-info">
{' '}
({appStore.asPercentOfTotal(appStore.totalPlacedBetsWon)})
</span>
</div>
<div className="mx-3 text-muted small">
Average Odds Won at: {appStore.averageOddsWon}
</div>
<div>
Total Bets Lost: {appStore.totalPlacedBetsLost}
<span className="small text-info">
{' '}
({appStore.asPercentOfTotal(appStore.totalPlacedBetsLost)})
</span>
</div>
<div className="mx-3 text-muted small">
Average Odds Lost at: {appStore.averageOddsLost}
</div>
</Col>
</Row>
<hr />
</header>
);
};
export default observer(_header);

View File

@ -0,0 +1,151 @@
import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { observer } from 'mobx-react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import ReactTimeAgo from 'react-time-ago';
import { map } from 'lodash';
import moment from 'moment';
import { Form, FormGroup, Input, Label } from 'reactstrap';
import LOGO from '../../../assets/images/logo.svg';
import useStore from '../../../data/store';
import { SelectTag } from '../../shared/form_components';
import CountdownRefresh from '../../modules/countdown_refresh';
import EditableHash from '../../modules/editable_hash';
import Toggler from '../../modules/toggler.js';
const _sidebar = () => {
const { appStore } = useStore();
useEffect(() => {
appStore.refresh();
}, []);
const changeExchangeAccount = e => {
appStore.update({ currentExchangeAccountId: e.target.value });
appStore.refresh();
};
const exchangeAccountsAsOptions = () => ({
name: 'exchangeAccounts',
value: appStore.currentExchangeAccountId,
type: 'select',
placeholder: 'Select...',
label: 'Available Accounts',
options: map(appStore.exchangeAccounts, r => ({ label: r, value: r })),
});
return (
<>
{!appStore.appInitialised && <div>Refreshing...</div>}
{appStore.appInitialised && (
<div className="sidebar bg-light">
<div className="sidebar-width">
<div className="logo text-center">
<Link to="/dashboard">
<img src={LOGO} alt="logo" />
</Link>
</div>
<div className="separator" />
<div className="sidebar-links">
<div className="p-0 small">
<div className="text-primary mb-3">
<div className="mb-1">Available Accounts:</div>
<SelectTag
handleChange={changeExchangeAccount}
input={exchangeAccountsAsOptions()}
/>
</div>
<div>
<Toggler
prompt
state={appStore.exchangeAccount.isActive}
onText="Activate account"
offText="Deactivate account"
onChangeHandler={() => {
appStore.exchangeAccount.updateExchangeAccount({
isActive: !appStore.exchangeAccount.isActive,
});
}}
/>
</div>
<div>
<Toggler
prompt
disabled={!appStore.exchangeAccount.isActive}
state={appStore.exchangeAccount.canBet}
onText="Enable live betting"
offText="Disable live betting"
onChangeHandler={() => {
appStore.exchangeAccount.updateExchangeAccount({
canBet: !appStore.exchangeAccount.canBet,
});
}}
/>
</div>
<div>
<CountdownRefresh
refreshTrigger={appStore.autoRefreshResults}
refreshFunction={appStore.refresh}
/>
</div>
<hr />
<div className="small">
<div className="font-weight-bold text-primary mb-1">
Account Balance: £{appStore.exchangeAccount.accountBalance}
</div>
<div className="text-primary">
Running since:{' '}
<ReactTimeAgo date={new Date(appStore.runningSince)} />
</div>
<div className="text-primary">
Feed type: {appStore.exchangeAccount.sourceTypes}
</div>
<div className="text-primary">
Stake Strategy: {appStore.exchangeAccount.stakeStrategyName}
</div>
<div className="text-primary mb-1">
Strategy Config:
<EditableHash
source={appStore.exchangeAccount.stakeStrategyConfig}
updateFunction={() => {
alert('this');
}}
/>
</div>
<div className="mt-3">
<div className="small text-secondary">
<strong>
Last log was at{' '}
{moment(
new Date(appStore.exchangeAccount.lastLogTime)
).format('DD.MMM.YYYY HH:mm:ss')}
</strong>
</div>
<code className="small text-muted mb-1">
{appStore.exchangeAccount.log}
</code>
</div>
</div>
</div>
</div>
<div className="separator" />
<div className="sidebar-links">
<ul>
<li className="d-flex">
<a href="/admin/sidekiq" target="sidekiq">
<FontAwesomeIcon icon="cogs" /> Sidekiq
</a>
</li>
</ul>
</div>
</div>
</div>
)}
;
</>
);
};
export default observer(_sidebar);

View File

@ -0,0 +1,44 @@
import React from 'react';
import { observer } from 'mobx-react';
import PropTypes from 'prop-types';
import { Button } from 'reactstrap';
import classnames from 'classnames';
import Header from './_header';
import Sidebar from './_sidebar';
import useStore from '../../../data/store';
const AuthenticatedLayout = ({ children, loading }) => {
const { appStore } = useStore();
return (
<div className="authenticated-layout">
<div
className={classnames('bootstrap-layout', {
'sidebar-opened': appStore.sidebarOpened,
'sidebar-closed': !appStore.sidebarOpened,
})}
>
<div className="bl-sidebar">
<Sidebar />
</div>
<div className="bl-content">
<Header />
<main>{loading ? 'Loading' : children}</main>
</div>
</div>
</div>
);
};
AuthenticatedLayout.defaultProps = {
loading: false,
};
AuthenticatedLayout.propTypes = {
children: PropTypes.node,
loading: PropTypes.bool,
};
export default observer(AuthenticatedLayout);

View File

@ -0,0 +1,40 @@
import React from 'react';
import { observer } from 'mobx-react';
import PropTypes from 'prop-types';
import { Container, Navbar, NavbarBrand } from 'reactstrap';
import LOGO from '../../assets/images/logo.svg';
import useStore from '../../data/store';
const PublicLayout = ({ children }) => {
const { userStore } = useStore();
return (
<div
className="bg-light pt-5"
style={{ height: '100vh', overflowY: 'auto' }}
>
<Navbar fixed="top" light className="bg-light justify-content-lg-center">
<NavbarBrand>
<img src={LOGO} alt="logo" />
</NavbarBrand>
{userStore.userSignedIn && (
<div>
<a href="/admin/sidekiq" target="sidekiq">
Sidekiq
</a>
</div>
)}
</Navbar>
<Container className="mt-5 container-xl border-top border-dark">
{children}
</Container>
</div>
);
};
PublicLayout.propTypes = {
children: PropTypes.node,
};
export default observer(PublicLayout);

View File

@ -0,0 +1,54 @@
import React, { useEffect, useState } from 'react';
import { observer } from 'mobx-react';
import { Form, FormGroup, Input, Label } from 'reactstrap';
import useStore from '../../../data/store';
import Toggler from '../toggler.js';
const CountdownRefresh = () => {
const { appStore } = useStore();
const refreshTime = 1;
const [countdownToNextRefresh, setCountdownToNextRefresh] = useState(
refreshTime
);
const [enableCountdown, setEnableCountdown] = useState(
appStore.autoRefreshResults
);
const [intervalId, setIntervalId] = useState();
let counter = refreshTime;
const startCountdown = () => {
setIntervalId(
setInterval(() => {
counter -= 1;
if (counter === 0) {
counter = refreshTime;
appStore.refresh();
}
setCountdownToNextRefresh(counter);
}, 1000)
);
};
useEffect(() => {
if (enableCountdown) {
startCountdown();
}
}, [enableCountdown]);
const handleChange = () => {
setEnableCountdown(!enableCountdown);
console.log('Stopping counter refresh');
clearInterval(intervalId);
};
return (
<Toggler
state={enableCountdown}
offText={'Disable auto-refresh'}
onText="Enable auto-refresh"
onChangeHandler={handleChange}
/>
);
};
export default observer(CountdownRefresh);

View File

@ -0,0 +1,67 @@
import React, { useEffect } from 'react';
import { observer } from 'mobx-react';
import { forEach, includes, isEmpty, isNull } from 'lodash';
import { Button } from 'reactstrap';
import PropTypes from 'prop-types';
const EditableHash = ({ source, updateFunction }) => {
const editableValues = [];
//
// const editableValues = [
// 'max_ev',
// 'min_ev',
// 'odds_margin',
// 'max_odds_to_bet',
// 'min_odds_to_bet',
// 'stake_sizing',
// 'max_bankroll_per_bet',
// ];
const showOnlyEditable = false;
const divOfValues = hash => {
if (Object.keys(hash).length === 0 || typeof hash === 'string') {
return hash;
}
const x = [];
// forEach(Object.keys(hash), key => {
// if (includes(editableValues, key)){
// const v = divOfValues(hash[key])
// const vEntry = <input id={key} value={v}/>
// x.push(
// <div>{key}: {vEntry}</div>
// );
// }
// });
forEach(Object.keys(hash), key => {
const v = divOfValues(hash[key]);
const vEntry = includes(editableValues, key) ? (
<input id={key} value={v} />
) : (
v
);
const hideThis = showOnlyEditable && !includes(editableValues, key)
if (!hideThis) {
x.push(
<div>
{key}: {vEntry}
</div>
);
}
});
return <div className="small px-2 border-start">{x}</div>;
};
return (
<div className="mt-2 small px-2 text-secondary">{divOfValues(source)}</div>
);
};
EditableHash.propTypes = {
source: PropTypes.instanceOf(Object).isRequired,
updateFunction: PropTypes.func.isRequired,
};
export default observer(EditableHash);

View File

@ -0,0 +1,55 @@
import React, { Fragment, useState } from 'react';
import PropTypes from 'prop-types';
import { isBoolean, map } from 'lodash';
import { Col, FormGroup, Label, Row } from 'reactstrap';
import { observer } from 'mobx-react';
import { renderFilter } from '../../../helpers/filter_helpers';
const Filters = ({ pagination, filters }) => {
const [timeout, setTimeOut] = useState(null);
const handleChange = e => {
let timer = null;
setTimeOut(clearTimeout(timeout));
pagination.update({
params: { ...pagination.params, [e.target.name]: e.target.value },
});
timer = setTimeout(() => {
pagination.resetPageAndFetch();
}, 1000);
setTimeOut(timer);
};
return (
<Row className="">
{map(filters, (filter, i) => (
<Fragment>
{isBoolean(filter.show) && !filter.show ? null : (
<Col key={i}>
<FormGroup>
<Label>{filter.label}</Label>
{renderFilter(
{ ...filter, value: pagination.params[filter.name] },
handleChange
)}
</FormGroup>
</Col>
)}
</Fragment>
))}
</Row>
);
};
Filters.propTypes = {
pagination: PropTypes.instanceOf(Object).isRequired,
filters: PropTypes.instanceOf(Array).isRequired,
};
export default observer(Filters);

View File

@ -0,0 +1,111 @@
import React, { useEffect } from 'react';
import { observer } from 'mobx-react';
import { isEmpty, isNull } from 'lodash';
import { Button } from 'reactstrap';
import PropTypes from 'prop-types';
import Filters from './_filters';
const FilteredList = ({
store,
listComponent,
gridComponent,
filters,
changeTrigger,
listClassName,
recordClassName,
}) => {
const ListComponent = listComponent;
// const Component = gridComponent;
useEffect(() => {
store.pagination.fetch();
}, [changeTrigger]);
return (
<div className="filtered-lists">
<Filters filters={filters} pagination={store.pagination} />
<nav aria-label="Page navigation example">
<ul className="pagination justify-content-start">
<li className="small text-muted mt-2" style={{marginRight: 10}}>
<strong>
{store.pagination.from} to {store.pagination.to} of{' '}
{store.pagination.count}{' '}
</strong>
</li>
<li className="">
<Button
className="btn-link page-link"
onClick={() => store.pagination.gotoPage(1)}
disabled={isNull(store.pagination.prev)}
>
First
</Button>
</li>
<li className="page-item">
<Button
className="btn-link page-link"
disabled={isNull(store.pagination.prev)}
onClick={() => store.pagination.gotoPage(store.pagination.prev)}
>
Prev
</Button>
</li>
<li className="page-item">
<Button
className="btn-link page-link"
disabled={isNull(store.pagination.next)}
onClick={() => store.pagination.gotoPage(store.pagination.next)}
>
Next
</Button>
</li>
<li className="page-item">
<Button
className="btn-link page-link"
disabled={
isNull(store.pagination.last) || store.pagination.count === 0
}
onClick={() => store.pagination.gotoPage(store.pagination.last)}
>
Last
</Button>
</li>
</ul>
</nav>
{isEmpty(store.filteredRecords) && (
<>
<hr />
<strong className="text-muted">Nothing here.</strong>
</>
)}
{!isEmpty(store.filteredRecords) && (
<div className="records mt-4">
<div className={listClassName}>
<div className="table-responsive">
<ListComponent records={store.filteredRecords} />
</div>
</div>
</div>
)}
</div>
);
};
FilteredList.defaultProps = {
listClassName: '',
recordClassName: '',
};
FilteredList.propTypes = {
store: PropTypes.instanceOf(Object).isRequired,
listComponent: PropTypes.func.isRequired,
gridComponent: PropTypes.func,
filters: PropTypes.instanceOf(Array).isRequired,
changeTrigger: PropTypes.string.isRequired,
listClassName: PropTypes.string,
recordClassName: PropTypes.string,
};
export default observer(FilteredList);

View File

@ -0,0 +1,58 @@
import React from 'react';
import { observer } from 'mobx-react';
import { Form, FormGroup, Input, Label } from 'reactstrap';
import PropTypes from 'prop-types';
const Toggler = ({
prompt,
disabled,
state,
onText,
offText,
onChangeHandler,
}) => {
const handleChange = () => {
if (prompt) {
// eslint-disable-next-line no-restricted-globals
if (confirm('Are you sure?') === false) return false;
}
onChangeHandler();
};
return (
<Form>
<FormGroup switch disabled={disabled}>
<Label
style={{ fontSize: 13 }}
className={state ? 'text-primary' : 'text-danger'}
check
>
{state ? offText : onText}
</Label>
<Input
disabled={disabled}
type="switch"
role="switch"
checked={state}
onClick={handleChange}
/>
</FormGroup>
</Form>
);
};
Toggler.defaultProps = {
disabled: false,
prompt: false,
};
Toggler.propTypes = {
disabled: PropTypes.bool,
prompt: PropTypes.bool,
state: PropTypes.bool.isRequired,
onText: PropTypes.string.isRequired,
offText: PropTypes.string.isRequired,
onChangeHandler: PropTypes.func.isRequired,
};
export default observer(Toggler);

View File

@ -0,0 +1,47 @@
import React from 'react';
import { Button } from 'reactstrap';
import { observer } from 'mobx-react';
import { Link } from 'react-router-dom';
import useStore from '../../data/store';
import { KEYCLOAK_AUTH_URL } from '../../routes/AuthenticatedRoute';
import PublicLayout from '../layouts/public_layout';
const Public = () => {
const { userStore } = useStore();
const handleSignInSignUp = () => {
localStorage.setItem(
'redirectURL',
`${window.location.pathname}${window.location.search}`
);
window.location.href = KEYCLOAK_AUTH_URL;
};
const handleSignOut = () => {
window.location.href = '/sign_out';
};
const renderBody = () => (
<div>
<div className="mt-3 w-100 text-center">Welcome to BetBeast.</div>
<div className="mt-5 text-center">
{userStore.userSignedIn && (
<div>
<Link to="/dashboard" className="btn btn-primary mr-2">
Go to Dashboard
</Link>
<Button onClick={handleSignOut}> Sign Out</Button>
</div>
)}
{!userStore.userSignedIn && (
<Button onClick={handleSignInSignUp}> Sign In</Button>
)}
</div>
</div>
);
return <PublicLayout>{renderBody()}</PublicLayout>;
};
export default observer(Public);

View File

@ -0,0 +1,182 @@
import React, { useState } from 'react';
import { Button, Input, Popover, PopoverBody } from 'reactstrap';
import PropTypes from 'prop-types';
import { find, filter, isDate, isObject, map, includes } from 'lodash';
import { DateRangePicker } from 'react-date-range';
import InputRange from 'react-input-range';
import Select from 'react-select';
import moment from 'moment';
export const SearchInput = ({ input, handleChange }) => (
<Input
placeholder={input.placeholder}
name={input.name}
onChange={handleChange}
value={input.value}
type="search"
/>
);
export const SelectTag = ({ input, handleChange }) => (
<Input
name={input.name}
id={input.name}
onChange={handleChange}
value={input.value}
type="select"
>
{map(input.options, (opt, i) => (
<option key={i} value={opt.value}>
{opt.label}
</option>
))}
</Input>
);
export const DateRange = ({ input, handleChange }) => {
const [isOpen, setIsOpen] = useState(false);
const togglePopover = () => setIsOpen(!isOpen);
const dateLabel = () => {
if (!isDate(input.value.from) && !isDate(input.value.to))
return input.placeholder;
const startDate = isDate(input.value.from)
? moment(input.value.from).format('MMM/DD/YYYY')
: 'NA';
const endDate = isDate(input.value.to)
? moment(input.value.to).format('MMM/DD/YYYY')
: 'NA';
return `${startDate} - ${endDate}`;
};
return (
<div className="dt-range-pick">
<Input
value={dateLabel()}
disabled
id="date-range-picker-popover"
onClick={togglePopover}
/>
{isDate(input.value.from) && (
<Button
color="link"
onClick={() =>
handleChange({
target: {
name: input.name,
value: {
from: null,
to: null,
},
},
})
}
>
&times;
</Button>
)}
<Popover
className="no-max-width"
toggle={togglePopover}
isOpen={isOpen}
target="date-range-picker-popover"
trigger="legacy"
placement="bottom"
>
<PopoverBody>
<DateRangePicker
ranges={[
{
startDate: input.value.from || new Date(),
endDate: input.value.to || new Date(),
key: input.name,
},
]}
onChange={d =>
handleChange({
target: {
name: input.name,
value: {
from: d[input.name].startDate,
to: d[input.name].endDate,
},
},
})
}
/>
</PopoverBody>
</Popover>
</div>
);
};
export const InputRangeTag = ({ input, handleChange }) => (
<InputRange
maxValue={input.max || 1000}
minValue={input.min || 0}
step={input.step || 1}
value={
isObject(input.value)
? { min: input.value.min, max: input.value.max }
: input.value
}
onChange={value =>
handleChange({
target: {
name: input.name,
value,
},
})
}
/>
);
export const ReactSelectTag = ({ input, handleChange }) => (
<Select
options={input.options}
value={
input.isMulti
? filter(input.options, o => includes(input.value, o.value))
: find(input.options, { value: input.value })
}
isMulti={input.isMulti}
onChange={e => {
handleChange({
target: {
name: input.name,
value: input.isMulti ? map(e, v => v.value) : e.value,
},
});
}}
/>
);
SelectTag.propTypes = {
input: PropTypes.instanceOf(Object).isRequired,
handleChange: PropTypes.func.isRequired,
};
SearchInput.propTypes = {
input: PropTypes.instanceOf(Object).isRequired,
handleChange: PropTypes.func.isRequired,
};
DateRange.propTypes = {
input: PropTypes.instanceOf(Object).isRequired,
handleChange: PropTypes.func.isRequired,
};
InputRangeTag.propTypes = {
input: PropTypes.instanceOf(Object).isRequired,
handleChange: PropTypes.func.isRequired,
};
ReactSelectTag.propTypes = {
input: PropTypes.instanceOf(Object).isRequired,
handleChange: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import { observer } from 'mobx-react';
import {
Button,
PopoverBody,
PopoverHeader,
UncontrolledPopover,
} from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
const InfoPoint = ({ children }) => {
return (
<>
<FontAwesomeIcon
icon="info-circle"
className="text-info pl-1 pt-1"
/>{' '}
<UncontrolledPopover trigger="focus">
<PopoverBody>{children}</PopoverBody>
</UncontrolledPopover>
</>
);
};
InfoPoint.propTypes = {
children: PropTypes.string.isRequired,
};
export default observer(InfoPoint);