move to Donetick Org, First commit frontend
This commit is contained in:
commit
2657469964
105 changed files with 21572 additions and 0 deletions
130
src/views/Settings/APITokenSettings.jsx
Normal file
130
src/views/Settings/APITokenSettings.jsx
Normal file
|
@ -0,0 +1,130 @@
|
|||
import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import {
|
||||
CreateLongLiveToken,
|
||||
DeleteLongLiveToken,
|
||||
GetLongLiveTokens,
|
||||
} from '../../utils/Fetcher'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import TextModal from '../Modals/Inputs/TextModal'
|
||||
|
||||
const APITokenSettings = () => {
|
||||
const [tokens, setTokens] = useState([])
|
||||
const [isGetTokenNameModalOpen, setIsGetTokenNameModalOpen] = useState(false)
|
||||
const { userProfile, setUserProfile } = useContext(UserContext)
|
||||
useEffect(() => {
|
||||
GetLongLiveTokens().then(resp => {
|
||||
resp.json().then(data => {
|
||||
setTokens(data.res)
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSaveToken = name => {
|
||||
CreateLongLiveToken(name).then(resp => {
|
||||
if (resp.ok) {
|
||||
resp.json().then(data => {
|
||||
// add the token to the list:
|
||||
console.log(data)
|
||||
const newTokens = [...tokens]
|
||||
newTokens.push(data.res)
|
||||
setTokens(newTokens)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 py-4' id='apitokens'>
|
||||
<Typography level='h3'>Long Live Token</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-sm'>
|
||||
Create token to use with the API to update things that trigger task or
|
||||
chores
|
||||
</Typography>
|
||||
{!isPlusAccount(userProfile) && (
|
||||
<Chip variant='soft' color='warning'>
|
||||
Not available in Basic Plan
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{tokens.map(token => (
|
||||
<Card key={token.token} className='p-4'>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography level='body-md'>{token.name}</Typography>
|
||||
<Typography level='body-xs'>
|
||||
{moment(token.createdAt).fromNow()}(
|
||||
{moment(token.createdAt).format('lll')})
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
{token.token && (
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
sx={{ mr: 1 }}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(token.token)
|
||||
alert('Token copied to clipboard')
|
||||
}}
|
||||
>
|
||||
Copy Token
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='danger'
|
||||
onClick={() => {
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to remove ${token.name} ?`,
|
||||
)
|
||||
if (confirmed) {
|
||||
DeleteLongLiveToken(token.id).then(resp => {
|
||||
if (resp.ok) {
|
||||
alert('Token removed')
|
||||
const newTokens = tokens.filter(t => t.id !== token.id)
|
||||
setTokens(newTokens)
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant='soft'
|
||||
color='primary'
|
||||
disabled={!isPlusAccount(userProfile)}
|
||||
sx={{
|
||||
width: '210px',
|
||||
mb: 1,
|
||||
}}
|
||||
onClick={() => {
|
||||
setIsGetTokenNameModalOpen(true)
|
||||
}}
|
||||
>
|
||||
Generate New Token
|
||||
</Button>
|
||||
<TextModal
|
||||
isOpen={isGetTokenNameModalOpen}
|
||||
title='Give a name for your new token, something to remember it by.'
|
||||
onClose={() => {
|
||||
setIsGetTokenNameModalOpen(false)
|
||||
}}
|
||||
okText={'Generate Token'}
|
||||
onSave={handleSaveToken}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default APITokenSettings
|
90
src/views/Settings/NotificationSetting.jsx
Normal file
90
src/views/Settings/NotificationSetting.jsx
Normal file
|
@ -0,0 +1,90 @@
|
|||
import { Button, Divider, Input, Option, Select, Typography } from '@mui/joy'
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import { GetUserProfile, UpdateUserDetails } from '../../utils/Fetcher'
|
||||
|
||||
const NotificationSetting = () => {
|
||||
const { userProfile, setUserProfile } = useContext(UserContext)
|
||||
useEffect(() => {
|
||||
if (!userProfile) {
|
||||
GetUserProfile().then(resp => {
|
||||
resp.json().then(data => {
|
||||
setUserProfile(data.res)
|
||||
setChatID(data.res.chatID)
|
||||
})
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
const [chatID, setChatID] = useState(userProfile?.chatID)
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 py-4' id='notifications'>
|
||||
<Typography level='h3'>Notification Settings</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-md'>Manage your notification settings</Typography>
|
||||
|
||||
<Select defaultValue='telegram' sx={{ maxWidth: '200px' }} disabled>
|
||||
<Option value='telegram'>Telegram</Option>
|
||||
<Option value='discord'>Discord</Option>
|
||||
</Select>
|
||||
|
||||
<Typography level='body-xs'>
|
||||
You need to initiate a message to the bot in order for the Telegram
|
||||
notification to work{' '}
|
||||
<a
|
||||
style={{
|
||||
textDecoration: 'underline',
|
||||
color: '#0891b2',
|
||||
}}
|
||||
href='https://t.me/DonetickBot'
|
||||
>
|
||||
Click here
|
||||
</a>{' '}
|
||||
to start a chat
|
||||
</Typography>
|
||||
|
||||
<Input
|
||||
value={chatID}
|
||||
onChange={e => setChatID(e.target.value)}
|
||||
placeholder='User ID / Chat ID'
|
||||
sx={{
|
||||
width: '200px',
|
||||
}}
|
||||
/>
|
||||
<Typography mt={0} level='body-xs'>
|
||||
If you don't know your Chat ID, start chat with userinfobot and it will
|
||||
send you your Chat ID.{' '}
|
||||
<a
|
||||
style={{
|
||||
textDecoration: 'underline',
|
||||
color: '#0891b2',
|
||||
}}
|
||||
href='https://t.me/userinfobot'
|
||||
>
|
||||
Click here
|
||||
</a>{' '}
|
||||
to start chat with userinfobot{' '}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
sx={{
|
||||
width: '110px',
|
||||
mb: 1,
|
||||
}}
|
||||
onClick={() => {
|
||||
UpdateUserDetails({
|
||||
chatID: Number(chatID),
|
||||
}).then(resp => {
|
||||
resp.json().then(data => {
|
||||
setUserProfile(data)
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationSetting
|
384
src/views/Settings/Settings.jsx
Normal file
384
src/views/Settings/Settings.jsx
Normal file
|
@ -0,0 +1,384 @@
|
|||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Divider,
|
||||
Input,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import Logo from '../../Logo'
|
||||
import {
|
||||
AcceptCircleMemberRequest,
|
||||
CancelSubscription,
|
||||
DeleteCircleMember,
|
||||
GetAllCircleMembers,
|
||||
GetCircleMemberRequests,
|
||||
GetSubscriptionSession,
|
||||
GetUserCircle,
|
||||
GetUserProfile,
|
||||
JoinCircle,
|
||||
LeaveCircle,
|
||||
} from '../../utils/Fetcher'
|
||||
import APITokenSettings from './APITokenSettings'
|
||||
import NotificationSetting from './NotificationSetting'
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
|
||||
const Settings = () => {
|
||||
const { userProfile, setUserProfile } = useContext(UserContext)
|
||||
const [userCircles, setUserCircles] = useState([])
|
||||
const [circleMemberRequests, setCircleMemberRequests] = useState([])
|
||||
const [circleInviteCode, setCircleInviteCode] = useState('')
|
||||
const [circleMembers, setCircleMembers] = useState([])
|
||||
useEffect(() => {
|
||||
GetUserProfile().then(resp => {
|
||||
resp.json().then(data => {
|
||||
setUserProfile(data.res)
|
||||
})
|
||||
})
|
||||
GetUserCircle().then(resp => {
|
||||
resp.json().then(data => {
|
||||
setUserCircles(data.res ? data.res : [])
|
||||
})
|
||||
})
|
||||
GetCircleMemberRequests().then(resp => {
|
||||
resp.json().then(data => {
|
||||
setCircleMemberRequests(data.res ? data.res : [])
|
||||
})
|
||||
})
|
||||
GetAllCircleMembers()
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setCircleMembers(data.res ? data.res : [])
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash
|
||||
if (hash) {
|
||||
const sharingSection = document.getElementById(
|
||||
window.location.hash.slice(1),
|
||||
)
|
||||
if (sharingSection) {
|
||||
sharingSection.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getSubscriptionDetails = () => {
|
||||
if (userProfile?.subscription === 'active') {
|
||||
return `You are currently subscribed to the Plus plan. Your subscription will renew on ${moment(
|
||||
userProfile?.expiration,
|
||||
).format('MMM DD, YYYY')}.`
|
||||
} else if (userProfile?.subscription === 'canceled') {
|
||||
return `You have cancelled your subscription. Your account will be downgraded to the Free plan on ${moment(
|
||||
userProfile?.expiration,
|
||||
).format('MMM DD, YYYY')}.`
|
||||
} else {
|
||||
return `You are currently on the Free plan. Upgrade to the Plus plan to unlock more features.`
|
||||
}
|
||||
}
|
||||
const getSubscriptionStatus = () => {
|
||||
if (userProfile?.subscription === 'active') {
|
||||
return `Plus`
|
||||
} else if (userProfile?.subscription === 'canceled') {
|
||||
if (moment().isBefore(userProfile?.expiration)) {
|
||||
return `Plus(until ${moment(userProfile?.expiration).format(
|
||||
'MMM DD, YYYY',
|
||||
)})`
|
||||
}
|
||||
return `Free`
|
||||
} else {
|
||||
return `Free`
|
||||
}
|
||||
}
|
||||
|
||||
if (userProfile === null) {
|
||||
return (
|
||||
<Container className='flex h-full items-center justify-center'>
|
||||
<Box className='flex flex-col items-center justify-center'>
|
||||
<CircularProgress
|
||||
color='success'
|
||||
sx={{ '--CircularProgress-size': '200px' }}
|
||||
>
|
||||
<Logo />
|
||||
</CircularProgress>
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Container>
|
||||
<div className='grid gap-4 py-4' id='sharing'>
|
||||
<Typography level='h3'>Sharing settings</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-md'>
|
||||
Your account is automatically connected to a Circle when you create or
|
||||
join one. Easily invite friends by sharing the unique Circle code or
|
||||
link below. You'll receive a notification below when someone requests
|
||||
to join your Circle.
|
||||
</Typography>
|
||||
<Typography level='title-sm' mb={-1}>
|
||||
{userCircles[0]?.userRole === 'member'
|
||||
? `You part of ${userCircles[0]?.name} `
|
||||
: `You circle code is:`}
|
||||
|
||||
<Input
|
||||
value={userCircles[0]?.invite_code}
|
||||
disabled
|
||||
size='lg'
|
||||
sx={{
|
||||
width: '220px',
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant='soft'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(userCircles[0]?.invite_code)
|
||||
alert('Code Copied to clipboard')
|
||||
}}
|
||||
>
|
||||
Copy Code
|
||||
</Button>
|
||||
<Button
|
||||
variant='soft'
|
||||
sx={{ ml: 1 }}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
window.location.protocol +
|
||||
'//' +
|
||||
window.location.host +
|
||||
`/circle/join?code=${userCircles[0]?.invite_code}`,
|
||||
)
|
||||
alert('Link Copied to clipboard')
|
||||
}}
|
||||
>
|
||||
Copy Link
|
||||
</Button>
|
||||
{userCircles.length > 0 && userCircles[0]?.userRole === 'member' && (
|
||||
<Button
|
||||
sx={{ ml: 1 }}
|
||||
onClick={() => {
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to leave your circle?`,
|
||||
)
|
||||
if (confirmed) {
|
||||
LeaveCircle(userCircles[0]?.id).then(resp => {
|
||||
if (resp.ok) {
|
||||
alert('Left circle successfully.')
|
||||
} else {
|
||||
alert('Failed to leave circle.')
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Leave Circle
|
||||
</Button>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography level='title-md'>Circle Members</Typography>
|
||||
{circleMembers.map(member => (
|
||||
<Card key={member.id} className='p-4'>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography level='body-md'>
|
||||
{member.displayName.charAt(0).toUpperCase() +
|
||||
member.displayName.slice(1)}
|
||||
{member.userId === userProfile.id ? '(You)' : ''}{' '}
|
||||
<Chip>
|
||||
{' '}
|
||||
{member.isActive ? member.role : 'Pending Approval'}
|
||||
</Chip>
|
||||
</Typography>
|
||||
{member.isActive ? (
|
||||
<Typography level='body-sm'>
|
||||
Joined on {moment(member.createdAt).format('MMM DD, YYYY')}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography level='body-sm' color='danger'>
|
||||
Request to join{' '}
|
||||
{moment(member.updatedAt).format('MMM DD, YYYY')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{member.userId !== userProfile.id && member.isActive && (
|
||||
<Button
|
||||
disabled={
|
||||
circleMembers.find(m => userProfile.id == m.userId).role !==
|
||||
'admin'
|
||||
}
|
||||
variant='outlined'
|
||||
color='danger'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to remove ${member.displayName} from your circle?`,
|
||||
)
|
||||
if (confirmed) {
|
||||
DeleteCircleMember(member.circleId, member.userId).then(
|
||||
resp => {
|
||||
if (resp.ok) {
|
||||
alert('Removed member successfully.')
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{circleMemberRequests.length > 0 && (
|
||||
<Typography level='title-md'>Circle Member Requests</Typography>
|
||||
)}
|
||||
{circleMemberRequests.map(request => (
|
||||
<Card key={request.id} className='p-4'>
|
||||
<Typography level='body-md'>
|
||||
{request.displayName} wants to join your circle.
|
||||
</Typography>
|
||||
<Button
|
||||
variant='soft'
|
||||
color='success'
|
||||
onClick={() => {
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to accept ${request.displayName}(username:${request.username}) to join your circle?`,
|
||||
)
|
||||
if (confirmed) {
|
||||
AcceptCircleMemberRequest(request.id).then(resp => {
|
||||
if (resp.ok) {
|
||||
alert('Accepted request successfully.')
|
||||
// reload the page
|
||||
window.location.reload()
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
<Divider> or </Divider>
|
||||
|
||||
<Typography level='body-md'>
|
||||
if want to join someone else's Circle? Ask them for their unique
|
||||
Circle code or join link. Enter the code below to join their Circle.
|
||||
</Typography>
|
||||
|
||||
<Typography level='title-sm' mb={-1}>
|
||||
Enter Circle code:
|
||||
<Input
|
||||
placeholder='Enter code'
|
||||
value={circleInviteCode}
|
||||
onChange={e => setCircleInviteCode(e.target.value)}
|
||||
size='lg'
|
||||
sx={{
|
||||
width: '220px',
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant='soft'
|
||||
onClick={() => {
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to leave you circle and join '${circleInviteCode}'?`,
|
||||
)
|
||||
if (confirmed) {
|
||||
JoinCircle(circleInviteCode).then(resp => {
|
||||
if (resp.ok) {
|
||||
alert(
|
||||
'Joined circle successfully, wait for the circle owner to accept your request.',
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Join Circle
|
||||
</Button>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 py-4' id='account'>
|
||||
<Typography level='h3'>Account Settings</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-md'>
|
||||
Change your account settings, including your password, display name
|
||||
</Typography>
|
||||
<Typography level='title-md' mb={-1}>
|
||||
Account Type : {getSubscriptionStatus()}
|
||||
</Typography>
|
||||
<Typography level='body-sm'>{getSubscriptionDetails()}</Typography>
|
||||
<Box>
|
||||
<Button
|
||||
sx={{
|
||||
width: '110px',
|
||||
mb: 1,
|
||||
}}
|
||||
disabled={
|
||||
userProfile?.subscription === 'active' ||
|
||||
moment(userProfile?.expiration).isAfter(moment())
|
||||
}
|
||||
onClick={() => {
|
||||
GetSubscriptionSession().then(data => {
|
||||
data.json().then(data => {
|
||||
console.log(data)
|
||||
window.location.href = data.sessionURL
|
||||
// open in new window:
|
||||
// window.open(data.sessionURL, '_blank')
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
|
||||
{userProfile?.subscription === 'active' && (
|
||||
<Button
|
||||
sx={{
|
||||
width: '110px',
|
||||
mb: 1,
|
||||
ml: 1,
|
||||
}}
|
||||
variant='outlined'
|
||||
onClick={() => {
|
||||
CancelSubscription().then(resp => {
|
||||
if (resp.ok) {
|
||||
alert('Subscription cancelled.')
|
||||
window.location.reload()
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
<NotificationSetting />
|
||||
<APITokenSettings />
|
||||
<div className='grid gap-4 py-4'>
|
||||
<Typography level='h3'>Theme preferences</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-md'>
|
||||
Choose how the site looks to you. Select a single theme, or sync with
|
||||
your system and automatically switch between day and night themes.
|
||||
</Typography>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default Settings
|
0
src/views/Settings/Sharing.jsx
Normal file
0
src/views/Settings/Sharing.jsx
Normal file
0
src/views/Settings/SharingSettings.jsx
Normal file
0
src/views/Settings/SharingSettings.jsx
Normal file
62
src/views/Settings/ThemeToggle.jsx
Normal file
62
src/views/Settings/ThemeToggle.jsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import useStickyState from '@/hooks/useStickyState'
|
||||
import {
|
||||
DarkModeOutlined,
|
||||
LaptopOutlined,
|
||||
LightModeOutlined,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
ToggleButtonGroup,
|
||||
useColorScheme,
|
||||
} from '@mui/joy'
|
||||
|
||||
const ELEMENTID = 'select-theme-mode'
|
||||
|
||||
const ThemeToggle = () => {
|
||||
const { mode, setMode } = useColorScheme()
|
||||
const [themeMode, setThemeMode] = useStickyState(mode, 'themeMode')
|
||||
|
||||
const handleThemeModeChange = (_, newThemeMode) => {
|
||||
if (!newThemeMode) return
|
||||
setThemeMode(newThemeMode)
|
||||
setMode(newThemeMode)
|
||||
}
|
||||
|
||||
const FormThemeModeToggleLabel = () => (
|
||||
<FormLabel
|
||||
level='title-md'
|
||||
id={`${ELEMENTID}-label`}
|
||||
htmlFor='select-theme-mode'
|
||||
>
|
||||
Theme mode
|
||||
</FormLabel>
|
||||
)
|
||||
|
||||
return (
|
||||
<FormControl>
|
||||
<FormThemeModeToggleLabel />
|
||||
<div className='flex items-center gap-4'>
|
||||
<ToggleButtonGroup
|
||||
id={ELEMENTID}
|
||||
variant='outlined'
|
||||
value={themeMode}
|
||||
onChange={handleThemeModeChange}
|
||||
>
|
||||
<Button startDecorator={<LightModeOutlined />} value='light'>
|
||||
Light
|
||||
</Button>
|
||||
<Button startDecorator={<DarkModeOutlined />} value='dark'>
|
||||
Dark
|
||||
</Button>
|
||||
<Button startDecorator={<LaptopOutlined />} value='system'>
|
||||
System
|
||||
</Button>
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeToggle
|
Loading…
Add table
Add a link
Reference in a new issue