Skip to content

Commit

Permalink
feat: improve trainings layout, add favorites, refactor views
Browse files Browse the repository at this point in the history
  • Loading branch information
evermake committed Apr 14, 2024
1 parent 07b232b commit 518a118
Show file tree
Hide file tree
Showing 16 changed files with 609 additions and 529 deletions.
5 changes: 2 additions & 3 deletions backend/src/bot/handlers/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,8 @@ export default handler((composer) => {
})

composer.use(views.main)
composer.use(views.trainingsDaysList)
composer.use(views.trainingsDayTrainings)
composer.use(views.trainingsTraining)
composer.use(views.training)
composer.use(views.trainings)
composer.use(views.semestersSummary)

composer.use(commands.start)
Expand Down
18 changes: 4 additions & 14 deletions backend/src/bot/handlers/views/index.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,11 @@
import type { Context, MiddlewareFn } from 'grammy'
import type { TgxElement } from '@telegum/tgx'
import main from './main'
import trainingsDaysList from './trainings_days-list'
import trainingsDayTrainings from './trainings_day-trainings'
import trainingsTraining from './trainings_training'
import training from './training'
import trainings from './trainings'
import semestersSummary from './semesters_summary'

// eslint-disable-next-line ts/ban-types
export type View<C extends Context, P = {}> = {
render: (ctx: C, props: P) => Promise<TgxElement>
middleware: () => MiddlewareFn<C>
}

export default {
main,
trainingsDaysList,
trainingsDayTrainings,
trainingsTraining,
training,
trainings,
semestersSummary,
}
40 changes: 25 additions & 15 deletions backend/src/bot/handlers/views/main.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,61 @@
import { Composer } from 'grammy'
import { makeButton } from '@telegum/grammy-buttons'
import type { View } from '.'
import views from '.'
import type { Ctx } from '~/bot/context'
import { makeView } from '~/bot/utils/view'

const VIEW_ID = 'main'

const RefreshButton = makeButton({ id: `${VIEW_ID}:refresh` })
const TrainingsButton = makeButton({ id: `${VIEW_ID}:trainings` })
const MyTrainingsButton = makeButton({ id: `${VIEW_ID}:my-trainings` })
const AllTrainingsButton = makeButton({ id: `${VIEW_ID}:all-trainings` })
const SemestersButton = makeButton({ id: `${VIEW_ID}:semesters` })

export default {
export default makeView({
render: async ctx => (
<>
{ctx.t['Views.Main.Message'](await ctx.domain.getOngoingSemesterHoursForUser(ctx.from!.id))}
<keyboard>
<RefreshButton>{ctx.t['Views.Main.Buttons.Refresh']}</RefreshButton>
<br />
<TrainingsButton>{ctx.t['Views.Main.Buttons.Trainings']}</TrainingsButton>
<MyTrainingsButton>{ctx.t['Views.Main.Buttons.TrainingsMy']}</MyTrainingsButton>
<AllTrainingsButton>{ctx.t['Views.Main.Buttons.TrainingsAll']}</AllTrainingsButton>
<br />
<SemestersButton>{ctx.t['Views.Main.Buttons.Semesters']}</SemestersButton>
<br />
<button url="https://innohassle.ru/sport">{ctx.t['Views.Main.Buttons.Calendar']}</button>
<br />
<button url="https://sport.innopolis.university">{ctx.t['Views.Main.Buttons.Website']}</button>
</keyboard>
</>
),
middleware: () => {
const composer = new Composer<Ctx>()

setup: (composer) => {
composer
.filter(RefreshButton.filter)
.use(async (ctx) => {
await ctx
.edit(ctx.from.id, ctx.msg!.message_id)
.with({ ignoreNotModifiedError: true })
.to(await views.main.render(ctx, {}))
ctx.answerCallbackQuery(ctx.t['Views.Main.Alerts.Refreshed'])
await ctx.answerCallbackQuery(ctx.t['Views.Main.Alerts.Refreshed'])
})

composer
.filter(MyTrainingsButton.filter)
.use(async (ctx) => {
await ctx
.edit(ctx.from.id, ctx.msg!.message_id)
.with({ ignoreNotModifiedError: true })
.to(await views.trainings.render(ctx, { tab: { type: 'my' } }))
await ctx.answerCallbackQuery()
})

composer
.filter(TrainingsButton.filter)
.filter(AllTrainingsButton.filter)
.use(async (ctx) => {
await ctx
.edit(ctx.from.id, ctx.msg!.message_id)
.with({ ignoreNotModifiedError: true })
.to(await views.trainingsDaysList.render(ctx, {}))
ctx.answerCallbackQuery()
.to(await views.trainings.render(ctx, { tab: { type: 'all' } }))
await ctx.answerCallbackQuery()
})

composer
Expand All @@ -55,9 +65,9 @@ export default {
.edit(ctx.from.id, ctx.msg!.message_id)
.with({ ignoreNotModifiedError: true })
.to(await views.semestersSummary.render(ctx, {}))
ctx.answerCallbackQuery()
await ctx.answerCallbackQuery()
})

return composer.middleware()
},
} satisfies View<Ctx> as View<Ctx>
})
12 changes: 4 additions & 8 deletions backend/src/bot/handlers/views/semesters_summary.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { Composer } from 'grammy'
import { makeButton } from '@telegum/grammy-buttons'
import type { View } from '.'
import views from '.'
import type { Ctx } from '~/bot/context'
import { makeView } from '~/bot/utils/view'

const VIEW_ID = 'semesters/summary'

const BackButton = makeButton({ id: `${VIEW_ID}:back` })

export default {
export default makeView({
render: async (ctx) => {
const semesters = await ctx.domain.getSemestersSummary(ctx.from!.id)
return (
Expand All @@ -20,9 +18,7 @@ export default {
</>
)
},
middleware: () => {
const composer = new Composer<Ctx>()

setup: (composer) => {
composer
.filter(BackButton.filter)
.use(async (ctx) => {
Expand All @@ -35,4 +31,4 @@ export default {

return composer.middleware()
},
} satisfies View<Ctx> as View<Ctx>
})
218 changes: 218 additions & 0 deletions backend/src/bot/handlers/views/training.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { makeButton } from '@telegum/grammy-buttons'
import type { TrainingsTab } from './trainings'
import { TrainingsTabButton, decodeTrainingsTab, encodeTrainingsTab } from './trainings'
import views from '.'
import { makeView } from '~/bot/utils/view'
import { split } from '~/utils/strings'

const VIEW_ID = 'training'

export const ToggleCheckInButton = makeButton<{
trainingId: number
action: 'check-in' | 'cancel-check-in'
/**
* Whether to render the training details view after the action is performed
* or the trainings list view.
*/
renderDetails: boolean
backTab: TrainingsTab
}>({
id: `${VIEW_ID}:toggle-check-in`,
encode: ({ trainingId, action, renderDetails, backTab }) => `${trainingId}:${action}:${renderDetails ? 1 : 0}:${encodeTrainingsTab(backTab)}`,
decode: (data) => {
const [trainingId, action, renderDetails, backTab] = split(data, ':', 3)
switch (action) {
case 'check-in':
case 'cancel-check-in':
break
default:
throw new Error(`Invalid action: ${action}.`)
}
return {
trainingId: Number(trainingId),
action: action,
renderDetails: Boolean(Number(renderDetails)),
backTab: decodeTrainingsTab(backTab),
}
},
})
const ToggleFavoriteButton = makeButton<{
trainingId: number
action: 'add' | 'remove'
backTab: TrainingsTab
}>({
id: `${VIEW_ID}:toggle-favorite`,
encode: ({ trainingId, action, backTab }) => `${trainingId}:${action}:${encodeTrainingsTab(backTab)}`,
decode: (data) => {
const [trainingId, action, backTab] = split(data, ':', 2)
switch (action) {
case 'add':
case 'remove':
break
default:
throw new Error(`Invalid action: ${action}.`)
}
return {
trainingId: Number(trainingId),
action: action,
backTab: decodeTrainingsTab(backTab),
}
},
})

export type Props = {
trainingId: number
backTab: TrainingsTab
}

export default makeView<Props>({
render: async (ctx, { trainingId, backTab }) => {
const user = ctx.user!
const training = await ctx.domain.getTrainingForUser({
telegramId: user.telegramId,
trainingId: trainingId,
})

return (
<>
{ctx.t['Views.Training.Message'](training)}
<keyboard>
{training.checkedIn && (
<ToggleCheckInButton
trainingId={training.id}
action="cancel-check-in"
renderDetails
backTab={backTab}
>
{ctx.t['Views.Training.Buttons.CancelCheckIn']}
</ToggleCheckInButton>
)}
{training.checkInAvailable && (
<ToggleCheckInButton
trainingId={training.id}
action="check-in"
renderDetails
backTab={backTab}
>
{ctx.t['Views.Training.Buttons.CheckIn']}
</ToggleCheckInButton>
)}
<br />
<ToggleFavoriteButton
trainingId={training.id}
action={user.favoriteGroupIds.includes(training.groupId) ? 'remove' : 'add'}
backTab={backTab}
>
{user.favoriteGroupIds.includes(training.groupId)
? ctx.t['Views.Training.Buttons.RemoveFromFavorites']
: ctx.t['Views.Training.Buttons.AddToFavorites']}
</ToggleFavoriteButton>
<br />
<TrainingsTabButton {...backTab}>
{ctx.t['Buttons.Back']}
</TrainingsTabButton>
</keyboard>
</>
)
},
setup: (composer, view) => {
composer
.filter(ToggleCheckInButton.filter)
.use(async (ctx) => {
const telegramId = ctx.user!.telegramId
const { trainingId, action, renderDetails, backTab } = ctx.payload
let alertMessage
switch (action) {
case 'check-in': {
const result = await ctx.domain.checkInUserForTraining({ telegramId, trainingId })

switch (result.type) {
case 'already-checked-in':
alertMessage = ctx.t['Alert.AlreadyCheckedIn']
break
case 'check-in-unavailable':
alertMessage = ctx.t['Alert.CheckInUnavailable']
break
case 'checked-in':
alertMessage = ctx.t['Alert.CheckInSuccessful'](result.training)
break
case 'failed':
alertMessage = ctx.t['Alert.CheckInError']
break
}

break
}
case 'cancel-check-in': {
const result = await ctx.domain.cancelCheckInUserForTraining({ telegramId, trainingId })

switch (result.type) {
case 'not-checked-in':
alertMessage = ctx.t['Alert.NotCheckedIn']
break
case 'cancelled':
alertMessage = ctx.t['Alert.CheckInCancelled'](result.training)
break
case 'failed':
alertMessage = ctx.t['Alert.CancelCheckInError']
break
}

break
}
}
if (renderDetails) {
await ctx
.edit(ctx.from.id, ctx.msg!.message_id)
.with({ ignoreNotModifiedError: true })
.to(await view.render(ctx, { trainingId, backTab }))
} else {
await ctx
.edit(ctx.from.id, ctx.msg!.message_id)
.with({ ignoreNotModifiedError: true })
.to(await views.trainings.render(ctx, { tab: backTab }))
}
await ctx.answerCallbackQuery({ text: alertMessage, show_alert: true })
})

composer
.filter(ToggleFavoriteButton.filter)
.use(async (ctx) => {
const { trainingId, action, backTab } = ctx.payload
const user = ctx.user!
let alertMessage

const { title, groupId } = await ctx.domain.getTrainingForUser({
telegramId: user.telegramId,
trainingId: trainingId,
})

switch (action) {
case 'add':
if (!user.favoriteGroupIds.includes(groupId)) {
ctx.user = await ctx.domain.updateUser({
telegramId: user.telegramId,
favoriteGroupIds: [...user.favoriteGroupIds, groupId],
})
}
alertMessage = ctx.t['Views.Training.Alerts.AddedToFavorites'](title)
break
case 'remove':
if (user.favoriteGroupIds.includes(groupId)) {
ctx.user = await ctx.domain.updateUser({
telegramId: user.telegramId,
favoriteGroupIds: user.favoriteGroupIds.filter(id => id !== groupId),
})
}
alertMessage = ctx.t['Views.Training.Alerts.RemovedFromFavorites'](title)
break
}

await ctx.answerCallbackQuery({ text: alertMessage })
await ctx
.edit(ctx.from.id, ctx.msg!.message_id)
.with({ ignoreNotModifiedError: true })
.to(await view.render(ctx, { trainingId, backTab }))
})
},
})
Loading

0 comments on commit 518a118

Please sign in to comment.