diff --git a/telegram_bot/bot/bot.go b/telegram_bot/bot/bot.go
index dab2da6..75c1309 100644
--- a/telegram_bot/bot/bot.go
+++ b/telegram_bot/bot/bot.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
+ "sync"
"example.com/m/adapter/backend"
"github.com/NicoNex/echotron/v3"
@@ -37,6 +38,7 @@ type Exec struct {
type Bot struct {
echotron.API
+ mu sync.Mutex
ChatID int64
exec *Exec
CurrentState State
@@ -205,6 +207,9 @@ func (b *Bot) SendMessageWithURLButton(text, buttonText, url string) {
}
func (b *Bot) Update(u *echotron.Update) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
log.Info().
Int64("chat_id", b.ChatID).
Str("current_state", fmt.Sprintf("%T", b.CurrentState)).
diff --git a/telegram_bot/screens/accept_workspace_invite.go b/telegram_bot/screens/accept_workspace_invite.go
index 81a9731..d2a35f4 100644
--- a/telegram_bot/screens/accept_workspace_invite.go
+++ b/telegram_bot/screens/accept_workspace_invite.go
@@ -12,6 +12,11 @@ type AcceptWorkspaceInvite struct {
InviteID string
}
+const msgAcceptWorkspaceInviteSuccessfully = `
+✅ Вы присоединились к рабочему пространству!
+Администратор сможет выдать вам необходимые права в веб-интерфейсе.
+`
+
func (s *AcceptWorkspaceInvite) Enter(b *bot.Bot, mode bot.RenderMode) {
// Получаем JWT токен
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
@@ -29,11 +34,7 @@ func (s *AcceptWorkspaceInvite) Enter(b *bot.Bot, mode bot.RenderMode) {
return
}
- // Успешно принято
- text := "✅ Вы присоединились к рабочему пространству!\n\n" +
- "Администратор сможет выдать вам необходимые права в веб-интерфейсе."
-
- b.SendNew(text, bot.Keyboard(
+ b.SendNew(msgAcceptWorkspaceInviteSuccessfully, bot.Keyboard(
bot.Row(bot.Button("🏠 Главное меню", "menu")),
))
}
diff --git a/telegram_bot/screens/add_purchase.go b/telegram_bot/screens/add_purchase.go
index 0af80a0..0354594 100644
--- a/telegram_bot/screens/add_purchase.go
+++ b/telegram_bot/screens/add_purchase.go
@@ -5,6 +5,7 @@ import (
"fmt"
"example.com/m/bot"
+ "example.com/m/ui"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
@@ -84,26 +85,31 @@ func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode, jwt strin
if len(page.Items) == 0 {
text += "У вас нет проектов. Создайте проект и попробуйте снова.\n\n"
} else {
- pageInfo := ""
- if page.Pages > 1 {
- pageInfo = fmt.Sprintf(" (стр. %d/%d)", page.Page, page.Pages)
- }
+ text += fmt.Sprintf("Проекты%s\n\n", ui.FormatPageInfo(s.ProjectPage, page.Pages))
- text += fmt.Sprintf("Проекты%s\n\n", pageInfo)
+ var slots []echotron.InlineKeyboardButton
for _, project := range page.Items {
- buttons = append(buttons, bot.Row(
- bot.Button(project.Title, fmt.Sprintf("project:%s", project.ID)),
- ))
+ slots = append(slots, bot.Button(project.Title, fmt.Sprintf("project:%s", project.ID)))
+ }
+ if page.Pages >= 2 {
+ for len(slots) < addPurchaseProjectsPerPage {
+ slots = append(slots, bot.Button(" ", "empty"))
+ }
+ }
+ for i := 0; i < len(slots); i += 2 {
+ row := []echotron.InlineKeyboardButton{slots[i]}
+ if i+1 < len(slots) {
+ row = append(row, slots[i+1])
+ } else {
+ row = append(row, bot.Button(" ", "empty"))
+ }
+ buttons = append(buttons, row)
}
- var navRow []echotron.InlineKeyboardButton
- if s.ProjectPage > 0 {
- navRow = append(navRow, bot.Button("←", "prev_project"))
- }
- if s.ProjectPage+1 < page.Pages {
- navRow = append(navRow, bot.Button("→", "next_project"))
- }
- if len(navRow) > 0 {
+ if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
+ CurrentPage: s.ProjectPage,
+ TotalPages: page.Pages,
+ }); navRow != nil {
buttons = append(buttons, navRow)
}
}
@@ -146,27 +152,31 @@ func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode, jwt strin
Вернитесь назад и создайте креатив в разделе Креативы`
} else {
- pageInfo := ""
- if page.Pages > 1 {
- pageInfo = fmt.Sprintf(" (стр. %d/%d)", page.Page, page.Pages)
- }
-
- text += fmt.Sprintf("Креативы%s\n\n", pageInfo)
+ text += fmt.Sprintf("Креативы%s\n\n", ui.FormatPageInfo(s.CreativePage, page.Pages))
+ var slots []echotron.InlineKeyboardButton
for _, creative := range page.Items {
- buttons = append(buttons, bot.Row(
- bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)),
- ))
+ slots = append(slots, bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)))
+ }
+ if page.Pages >= 2 {
+ for len(slots) < addPurchaseCreativesPerPage {
+ slots = append(slots, bot.Button(" ", "empty"))
+ }
+ }
+ for i := 0; i < len(slots); i += 2 {
+ row := []echotron.InlineKeyboardButton{slots[i]}
+ if i+1 < len(slots) {
+ row = append(row, slots[i+1])
+ } else {
+ row = append(row, bot.Button(" ", "empty"))
+ }
+ buttons = append(buttons, row)
}
- var navRow []echotron.InlineKeyboardButton
- if s.CreativePage > 0 {
- navRow = append(navRow, bot.Button("←", "prev_creative"))
- }
- if s.CreativePage+1 < page.Pages {
- navRow = append(navRow, bot.Button("→", "next_creative"))
- }
- if len(navRow) > 0 {
+ if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
+ CurrentPage: s.CreativePage,
+ TotalPages: page.Pages,
+ }); navRow != nil {
buttons = append(buttons, navRow)
}
}
@@ -232,27 +242,33 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
s.ActivePicker = ""
s.Enter(b, bot.EditMessage)
- case "prev_project":
- if s.ProjectPage > 0 {
- s.ProjectPage--
+ case "prev":
+ if s.ActivePicker == "project_list" {
+ if s.ProjectPage > 0 {
+ s.ProjectPage--
+ }
+ s.Enter(b, bot.EditMessage)
+ return
}
- s.Enter(b, bot.EditMessage)
-
- case "next_project":
- s.ProjectPage++
- s.Enter(b, bot.EditMessage)
-
- case "prev_creative":
- if s.CreativePage > 0 {
- s.CreativePage--
+ if s.ActivePicker == "creative_list" {
+ if s.CreativePage > 0 {
+ s.CreativePage--
+ }
+ s.Enter(b, bot.EditMessage)
+ return
}
- s.Enter(b, bot.EditMessage)
-
- case "next_creative":
- s.CreativePage++
- s.Enter(b, bot.EditMessage)
case "next":
+ if s.ActivePicker == "project_list" {
+ s.ProjectPage++
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ if s.ActivePicker == "creative_list" {
+ s.CreativePage++
+ s.Enter(b, bot.EditMessage)
+ return
+ }
if s.ProjectID != "" && s.CreativeID != "" {
b.SetState(&SelectChannelsForPurchase{
WorkspaceID: s.WorkspaceID,
diff --git a/telegram_bot/screens/creatives.go b/telegram_bot/screens/creatives.go
index d2c5f00..fa8b009 100644
--- a/telegram_bot/screens/creatives.go
+++ b/telegram_bot/screens/creatives.go
@@ -6,6 +6,7 @@ import (
"strings"
"example.com/m/bot"
+ "example.com/m/ui"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
@@ -71,11 +72,6 @@ func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string)
Начните с добавления первого креатива`, navPath)
} else {
- pageInfo := ""
- if page.Pages > 1 {
- pageInfo = fmt.Sprintf(" (стр. %d/%d)", page.Page, page.Pages)
- }
-
text = fmt.Sprintf(`%s
Креативы%s
@@ -84,7 +80,7 @@ func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string)
Выберите креатив`,
navPath,
- pageInfo)
+ ui.FormatPageInfo(s.CurrentPage, page.Pages))
}
// Создаем слоты для креативов
@@ -117,23 +113,14 @@ func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string)
}
// Ряд: Пагинация + Добавить (динамическая ширина)
- var navRow []echotron.InlineKeyboardButton
-
- // Левая стрелка (только если есть предыдущая страница)
- if s.CurrentPage > 0 {
- navRow = append(navRow, bot.Button("←", "prev"))
+ if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
+ CurrentPage: s.CurrentPage,
+ TotalPages: page.Pages,
+ MiddleButtons: []echotron.InlineKeyboardButton{bot.Button("+ Добавить", "add_creative")},
+ }); navRow != nil {
+ buttons = append(buttons, navRow)
}
- // Кнопка "Добавить креатив" всегда в центре
- navRow = append(navRow, bot.Button("+ Добавить", "add_creative"))
-
- // Правая стрелка (только если есть следующая страница)
- if s.CurrentPage+1 < page.Pages {
- navRow = append(navRow, bot.Button("→", "next"))
- }
-
- buttons = append(buttons, navRow)
-
// Нижний ряд: Назад + Архив
buttons = append(buttons, bot.Row(
bot.Button("← Назад", "back"),
diff --git a/telegram_bot/screens/my_placements.go b/telegram_bot/screens/my_placements.go
index 9d82801..03343fd 100644
--- a/telegram_bot/screens/my_placements.go
+++ b/telegram_bot/screens/my_placements.go
@@ -6,6 +6,7 @@ import (
"strings"
"example.com/m/bot"
+ "example.com/m/ui"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
@@ -75,62 +76,40 @@ func (s *MyPlacements) renderPlacements(b *bot.Bot, mode bot.RenderMode, jwt str
Размещения создаются автоматически при создании закупа`
} else {
- pageInfo := ""
- if page.Pages > 1 {
- pageInfo = fmt.Sprintf(" (стр. %d/%d)", s.CurrentPage+1, page.Pages)
- }
- text += fmt.Sprintf("Выберите размещение для деталей%s\n\n", pageInfo)
+ text += fmt.Sprintf("Выберите размещение для деталей%s\n\n", ui.FormatPageInfo(s.CurrentPage, page.Pages))
// Размещения по 2 в ряду
- for i := 0; i < len(page.Items); i += 2 {
- var row []echotron.InlineKeyboardButton
-
- // Первый placement в ряду
- placement := page.Items[i]
+ var slots []echotron.InlineKeyboardButton
+ for _, placement := range page.Items {
placementName := placement.CreativeName
if placement.PlacementChannelTitle != nil && *placement.PlacementChannelTitle != "" {
placementName = fmt.Sprintf("%s → %s", placement.CreativeName, *placement.PlacementChannelTitle)
}
- row = append(row, bot.Button(placementName, fmt.Sprintf("placement:%s", placement.ID)))
-
- // Второй placement в ряду (если есть)
- if i+1 < len(page.Items) {
- placement = page.Items[i+1]
- placementName = placement.CreativeName
- if placement.PlacementChannelTitle != nil && *placement.PlacementChannelTitle != "" {
- placementName = fmt.Sprintf("%s → %s", placement.CreativeName, *placement.PlacementChannelTitle)
- }
- row = append(row, bot.Button(placementName, fmt.Sprintf("placement:%s", placement.ID)))
+ slots = append(slots, bot.Button(placementName, fmt.Sprintf("placement:%s", placement.ID)))
+ }
+ if page.Pages >= 2 {
+ for len(slots) < placementsPerPage {
+ slots = append(slots, bot.Button(" ", "empty"))
+ }
+ }
+ for i := 0; i < len(slots); i += 2 {
+ row := []echotron.InlineKeyboardButton{slots[i]}
+ if i+1 < len(slots) {
+ row = append(row, slots[i+1])
} else {
- // Добавляем пустую кнопку для выравнивания
row = append(row, bot.Button(" ", "empty"))
}
-
buttons = append(buttons, row)
}
// Кнопки пагинации с номером страницы посередине (только если есть закупы)
- var navRow []echotron.InlineKeyboardButton
-
- // Левая стрелка (активная или неактивная)
- if s.CurrentPage > 0 {
- navRow = append(navRow, bot.Button("←", "prev"))
- } else {
- navRow = append(navRow, bot.Button("·", "empty"))
+ if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
+ CurrentPage: s.CurrentPage,
+ TotalPages: page.Pages,
+ MiddleButtons: []echotron.InlineKeyboardButton{bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info")},
+ }); navRow != nil {
+ buttons = append(buttons, navRow)
}
-
- // Кнопка с номером страницы (неактивная)
- pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info")
- navRow = append(navRow, pageButton)
-
- // Правая стрелка (активная или неактивная)
- if s.CurrentPage+1 < page.Pages {
- navRow = append(navRow, bot.Button("→", "next"))
- } else {
- navRow = append(navRow, bot.Button("·", "empty"))
- }
-
- buttons = append(buttons, navRow)
}
// Кнопки управления
diff --git a/telegram_bot/screens/my_projects.go b/telegram_bot/screens/my_projects.go
index f27c924..6047e1a 100644
--- a/telegram_bot/screens/my_projects.go
+++ b/telegram_bot/screens/my_projects.go
@@ -6,6 +6,7 @@ import (
"strings"
"example.com/m/bot"
+ "example.com/m/ui"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
@@ -107,11 +108,6 @@ func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string)
%s`, navTitle, headerTitle, emptyStateText)
} else {
- pageInfo := ""
- if page.Pages > 1 {
- pageInfo = fmt.Sprintf(" (стр. %d/%d)", page.Page, page.Pages)
- }
-
text = fmt.Sprintf(`%s
%s%s
@@ -119,7 +115,7 @@ func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string)
%s`,
navTitle,
headerTitle,
- pageInfo,
+ ui.FormatPageInfo(s.CurrentPage, page.Pages),
nonEmptyText)
}
@@ -153,23 +149,14 @@ func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string)
}
// Ряд: Пагинация + Добавить (динамическая ширина)
- var navRow []echotron.InlineKeyboardButton
-
- // Левая стрелка (только если есть предыдущая страница)
- if s.CurrentPage > 0 {
- navRow = append(navRow, bot.Button("←", "prev"))
+ if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
+ CurrentPage: s.CurrentPage,
+ TotalPages: page.Pages,
+ MiddleButtons: []echotron.InlineKeyboardButton{bot.Button("+ Добавить", "add_project")},
+ }); navRow != nil {
+ buttons = append(buttons, navRow)
}
- // Кнопка "Добавить проект" всегда в центре
- navRow = append(navRow, bot.Button("+ Добавить", "add_project"))
-
- // Правая стрелка (только если есть следующая страница)
- if s.CurrentPage+1 < page.Pages {
- navRow = append(navRow, bot.Button("→", "next"))
- }
-
- buttons = append(buttons, navRow)
-
// Нижний ряд: Назад + Архив
buttons = append(buttons, bot.Row(
bot.Button("← Назад", "menu"),
diff --git a/telegram_bot/screens/purchase_optional_details.go b/telegram_bot/screens/purchase_optional_details.go
index 51c8438..8c2717a 100644
--- a/telegram_bot/screens/purchase_optional_details.go
+++ b/telegram_bot/screens/purchase_optional_details.go
@@ -7,6 +7,7 @@ import (
"time"
"example.com/m/bot"
+ "example.com/m/ui"
"github.com/NicoNex/echotron/v3"
)
@@ -555,15 +556,10 @@ func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.Rende
return
}
- start := s.ParamPage * perPage
- if start >= total {
+ if s.ParamPage*perPage >= total {
s.ParamPage = 0
- start = 0
- }
- end := start + perPage
- if end > total {
- end = total
}
+ start, end := ui.GetPageBounds(s.ParamPage, perPage, total)
var rows [][]echotron.InlineKeyboardButton
for i := start; i < end; i++ {
@@ -577,20 +573,11 @@ func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.Rende
))
}
- pages := (total + perPage - 1) / perPage
- if pages > 1 {
- var navRow []echotron.InlineKeyboardButton
- if s.ParamPage > 0 {
- navRow = append(navRow, bot.Button("←", "param_prev"))
- } else {
- navRow = append(navRow, bot.Button("·", "empty"))
- }
- navRow = append(navRow, bot.Button(fmt.Sprintf("• %d/%d •", s.ParamPage+1, pages), "page_info"))
- if s.ParamPage+1 < pages {
- navRow = append(navRow, bot.Button("→", "param_next"))
- } else {
- navRow = append(navRow, bot.Button("·", "empty"))
- }
+ pages := ui.CalculatePages(total, perPage)
+ if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
+ CurrentPage: s.ParamPage,
+ TotalPages: pages,
+ }); navRow != nil {
rows = append(rows, navRow)
}
@@ -688,13 +675,13 @@ func (s *PurchaseOptionalDetails) handleParamCallback(b *bot.Bot, data string) b
s.applyParamToAll(param)
s.Enter(b, bot.EditMessage)
return true
- case data == "param_prev":
+ case data == "prev" && strings.HasSuffix(s.InputMode, "_channels"):
if s.ParamPage > 0 {
s.ParamPage--
}
s.Enter(b, bot.EditMessage)
return true
- case data == "param_next":
+ case data == "next" && strings.HasSuffix(s.InputMode, "_channels"):
s.ParamPage++
s.Enter(b, bot.EditMessage)
return true
diff --git a/telegram_bot/screens/select_channels_for_purchase.go b/telegram_bot/screens/select_channels_for_purchase.go
index 93bb92d..cb58908 100644
--- a/telegram_bot/screens/select_channels_for_purchase.go
+++ b/telegram_bot/screens/select_channels_for_purchase.go
@@ -8,6 +8,7 @@ import (
"example.com/m/adapter/backend"
"example.com/m/bot"
+ "example.com/m/ui"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
@@ -63,15 +64,10 @@ func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.
text += "\n"
const channelsPerPage = 6
- start := s.CurrentPage * channelsPerPage
- if start >= len(s.Channels) {
+ if s.CurrentPage*channelsPerPage >= len(s.Channels) {
s.CurrentPage = 0
- start = 0
- }
- end := start + channelsPerPage
- if end > len(s.Channels) {
- end = len(s.Channels)
}
+ start, end := ui.GetPageBounds(s.CurrentPage, channelsPerPage, len(s.Channels))
var slots []echotron.InlineKeyboardButton
for i := start; i < end; i++ {
@@ -99,20 +95,12 @@ func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.
buttons = append(buttons, row)
}
- pages := (len(s.Channels) + channelsPerPage - 1) / channelsPerPage
- if pages > 1 {
- var navRow []echotron.InlineKeyboardButton
- if s.CurrentPage > 0 {
- navRow = append(navRow, bot.Button("←", "prev_page"))
- } else {
- navRow = append(navRow, bot.Button("·", "empty"))
- }
- navRow = append(navRow, bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, pages), "page_info"))
- if s.CurrentPage+1 < pages {
- navRow = append(navRow, bot.Button("→", "next_page"))
- } else {
- navRow = append(navRow, bot.Button("·", "empty"))
- }
+ pages := ui.CalculatePages(len(s.Channels), channelsPerPage)
+ navRow := ui.BuildNavigationRow(ui.PaginationConfig{
+ CurrentPage: s.CurrentPage,
+ TotalPages: pages,
+ })
+ if navRow != nil {
buttons = append(buttons, navRow)
}
@@ -130,7 +118,7 @@ func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.
if len(s.Channels) > 0 {
buttons = append(buttons, bot.Row(
bot.Button("← Назад", "back"),
- bot.Button("→ Далее", "next"),
+ bot.Button("→ Далее", "next_step"),
))
} else {
buttons = append(buttons, bot.Row(
@@ -176,7 +164,7 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
BackState: s.BackState,
}, bot.EditMessage)
- case "next":
+ case "next_step":
if len(s.Channels) == 0 {
b.Edit("❌ Добавьте хотя бы один канал", bot.Keyboard(
bot.Row(bot.Button("← Назад", "back_to_select")),
@@ -197,13 +185,13 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
case "back_to_select":
s.Enter(b, bot.EditMessage)
- case "prev_page":
+ case "prev":
if s.CurrentPage > 0 {
s.CurrentPage--
}
s.Enter(b, bot.EditMessage)
- case "next_page":
+ case "next":
s.CurrentPage++
s.Enter(b, bot.EditMessage)
@@ -215,7 +203,7 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
s.Channels = append(s.Channels[:index], s.Channels[index+1:]...)
if s.CurrentPage > 0 {
channelsPerPage := 6
- pages := (len(s.Channels) + channelsPerPage - 1) / channelsPerPage
+ pages := ui.CalculatePages(len(s.Channels), channelsPerPage)
if s.CurrentPage >= pages {
s.CurrentPage = pages - 1
}
@@ -262,7 +250,8 @@ func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
}
// Показываем успешное создание
- successText := fmt.Sprintf(`… › Создание › Готово
+ successText := fmt.Sprintf(`
+… › Создание › Готово
Закуп создан
@@ -270,7 +259,8 @@ func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
▪ Креатив: %s
▪ Каналов: %d
-Закуп создан и готов к работе`, purchase.ID, purchase.CreativeID, len(purchase.Channels))
+Закуп создан и готов к работе
+`, purchase.ID, purchase.CreativeID, len(purchase.Channels))
b.Edit(successText, bot.Keyboard(
bot.Row(bot.Button("● Готово", "done")),
diff --git a/telegram_bot/ui/pagination.go b/telegram_bot/ui/pagination.go
new file mode 100644
index 0000000..45c5bb3
--- /dev/null
+++ b/telegram_bot/ui/pagination.go
@@ -0,0 +1,93 @@
+package ui
+
+import (
+ "fmt"
+
+ "example.com/m/bot"
+ "github.com/NicoNex/echotron/v3"
+)
+
+// PaginationConfig конфигурация для построения навигационного ряда пагинации
+type PaginationConfig struct {
+ CurrentPage int
+ TotalPages int // Для API пагинации (из page.Pages)
+ TotalItems int // Для локальной пагинации (len(array))
+ ItemsPerPage int // Для расчета TotalPages из TotalItems
+ PrevCallback string // По умолчанию "prev"
+ NextCallback string // По умолчанию "next"
+ MiddleButtons []echotron.InlineKeyboardButton // Опциональные кнопки в центре (например, "+ Добавить")
+}
+
+// BuildNavigationRow создает навигационный ряд с динамической шириной.
+// Показывает ряд только если страниц > 1.
+// Возвращает nil если пагинация не нужна.
+func BuildNavigationRow(config PaginationConfig) []echotron.InlineKeyboardButton {
+ // Вычисляем TotalPages из TotalItems если не задан
+ totalPages := config.TotalPages
+ if totalPages == 0 && config.TotalItems > 0 && config.ItemsPerPage > 0 {
+ totalPages = CalculatePages(config.TotalItems, config.ItemsPerPage)
+ }
+
+ // Устанавливаем значения по умолчанию для callbacks
+ prevCallback := config.PrevCallback
+ if prevCallback == "" {
+ prevCallback = "prev"
+ }
+ nextCallback := config.NextCallback
+ if nextCallback == "" {
+ nextCallback = "next"
+ }
+
+ var row []echotron.InlineKeyboardButton
+
+ // Левая стрелка (только если есть предыдущая страница)
+ if totalPages > 1 && config.CurrentPage > 0 {
+ row = append(row, bot.Button("←", prevCallback))
+ }
+
+ // Средние кнопки (если есть)
+ if len(config.MiddleButtons) > 0 {
+ row = append(row, config.MiddleButtons...)
+ }
+
+ // Правая стрелка (только если есть следующая страница)
+ if totalPages > 1 && config.CurrentPage+1 < totalPages {
+ row = append(row, bot.Button("→", nextCallback))
+ }
+
+ if len(row) == 0 {
+ return nil
+ }
+
+ return row
+}
+
+// CalculatePages вычисляет количество страниц из общего количества элементов
+func CalculatePages(totalItems, itemsPerPage int) int {
+ if itemsPerPage <= 0 {
+ return 0
+ }
+ return (totalItems + itemsPerPage - 1) / itemsPerPage
+}
+
+// GetPageBounds возвращает start/end индексы для текущей страницы.
+// Автоматически сбрасывает на первую страницу если currentPage выходит за границы.
+func GetPageBounds(currentPage, itemsPerPage, totalItems int) (start, end int) {
+ start = currentPage * itemsPerPage
+ if start >= totalItems {
+ start = 0
+ }
+ end = start + itemsPerPage
+ if end > totalItems {
+ end = totalItems
+ }
+ return start, end
+}
+
+// FormatPageInfo форматирует текст индикатора страницы для заголовка
+func FormatPageInfo(currentPage, totalPages int) string {
+ if totalPages <= 1 {
+ return ""
+ }
+ return fmt.Sprintf(" (стр. %d/%d)", currentPage+1, totalPages)
+}