change domen

This commit is contained in:
Artem Tsyrulnikov
2026-01-02 15:51:54 +03:00
parent c05ac84b38
commit 0577d2a00e
63 changed files with 1941 additions and 2186 deletions

View File

@@ -141,12 +141,12 @@ type Workspace struct {
}
type Project struct {
ID string `json:"id"`
TelegramID int64 `json:"telegram_id"`
Title string `json:"title"`
Username *string `json:"username"`
Status string `json:"status"`
InviteLinkType string `json:"invite_link_type"` // "public" или "approval"
ID string `json:"id"`
TelegramID int64 `json:"telegram_id"`
Title string `json:"title"`
Username *string `json:"username"`
Status string `json:"status"`
PurchaseInviteTypeDefault string `json:"purchase_invite_type_default"`
}
type Page struct {
@@ -231,7 +231,7 @@ func (c *Client) UpdateProjectInviteLinkType(
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/invite-link-type", workspaceID, projectID)
payload := map[string]string{
"invite_link_type": inviteLinkType,
"purchase_invite_type_default": inviteLinkType,
}
var project Project
@@ -306,13 +306,14 @@ func (c *Client) AcceptWorkspaceInvite(
// ============================================================================
type Creative struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Name string `json:"name"`
Text string `json:"text"`
IsArchived bool `json:"is_archived"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"`
Name string `json:"name"`
Text string `json:"text"`
ProjectID string `json:"project_id"`
ProjectChannelTitle string `json:"project_channel_title"`
CreatedAt string `json:"created_at"`
Status string `json:"status"`
PlacementsCount int `json:"placements_count"`
}
type CreativesPage struct {
@@ -406,9 +407,9 @@ func (c *Client) CreateCreative(
}
type UpdateCreativeInput struct {
Name *string `json:"name,omitempty"`
Text *string `json:"text,omitempty"`
IsArchived *bool `json:"is_archived,omitempty"`
Name *string `json:"name,omitempty"`
Text *string `json:"text,omitempty"`
Status *string `json:"status,omitempty"`
}
func (c *Client) UpdateCreative(
@@ -456,17 +457,24 @@ func (c *Client) DeleteCreative(
// ============================================================================
type Placement struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
PlacementChannelID string `json:"placement_channel_id"`
CreativeID *string `json:"creative_id"`
Name string `json:"name"`
Price *int `json:"price"`
Status string `json:"status"`
InviteLink *string `json:"invite_link"`
IsArchived bool `json:"is_archived"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"`
ProjectID string `json:"project_id"`
ProjectChannelTitle *string `json:"project_channel_title"`
PlacementChannelID string `json:"placement_channel_id"`
PlacementChannelTitle *string `json:"placement_channel_title"`
CreativeID string `json:"creative_id"`
CreativeName string `json:"creative_name"`
PlacementDate string `json:"placement_date"`
Cost *float64 `json:"cost"`
Comment *string `json:"comment"`
AdPostURL *string `json:"ad_post_url"`
InviteLinkType string `json:"invite_link_type"`
InviteLink string `json:"invite_link"`
Status string `json:"status"`
SubscriptionsCount int `json:"subscriptions_count"`
PurchaseID *string `json:"purchase_id"`
PurchaseChannelID *string `json:"purchase_channel_id"`
CreatedAt string `json:"created_at"`
}
type PlacementsPage struct {
@@ -537,84 +545,10 @@ func (c *Client) GetPlacement(
return &placement, err
}
type CreatePlacementInput struct {
ProjectID string `json:"project_id"`
PlacementChannelID string `json:"placement_channel_id"`
CreativeID *string `json:"creative_id,omitempty"`
Name string `json:"name"`
Price *int `json:"price,omitempty"`
}
func (c *Client) CreatePlacement(
ctx context.Context,
jwt string,
workspaceID string,
input CreatePlacementInput,
) (*Placement, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/placements", workspaceID)
var placement Placement
err := c.do(
ctx,
http.MethodPost,
path,
input,
&placement,
withBearer(jwt),
)
return &placement, err
}
type UpdatePlacementInput struct {
Name *string `json:"name,omitempty"`
Price *int `json:"price,omitempty"`
Status *string `json:"status,omitempty"`
IsArchived *bool `json:"is_archived,omitempty"`
}
func (c *Client) UpdatePlacement(
ctx context.Context,
jwt string,
workspaceID string,
placementID string,
input UpdatePlacementInput,
) (*Placement, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/placements/%s", workspaceID, placementID)
var placement Placement
err := c.do(
ctx,
http.MethodPatch,
path,
input,
&placement,
withBearer(jwt),
)
return &placement, err
}
func (c *Client) DeletePlacement(
ctx context.Context,
jwt string,
workspaceID string,
placementID string,
) error {
path := fmt.Sprintf("api/v1/workspaces/%s/placements/%s", workspaceID, placementID)
return c.do(
ctx,
http.MethodDelete,
path,
nil,
nil,
withBearer(jwt),
)
}
// Note: Placement creation/updates are handled by worker; API exposes only read endpoints.
// ============================================================================
// Purchase Plan
// Purchases (Закупы)
// ============================================================================
type Channel struct {
@@ -624,24 +558,67 @@ type Channel struct {
Username *string `json:"username"`
}
type PurchasePlanChannel struct {
ID string `json:"id"`
Channel Channel `json:"channel"`
Status string `json:"status"`
PlannedCost *float64 `json:"planned_cost"`
Comment *string `json:"comment"`
type Purchase struct {
ID string `json:"id"`
Status string `json:"status"`
CreativeID string `json:"creative_id"`
Channels []PurchaseChannelOut `json:"channels"`
}
func (c *Client) GetPurchasePlanChannels(
type PurchaseChannelOut struct {
ID string `json:"id"`
Status string `json:"status"`
PlannedCost *float64 `json:"planned_cost"`
Comment *string `json:"comment"`
InviteLink string `json:"invite_link"`
InviteLinkType string `json:"invite_link_type"`
Channel Channel `json:"channel"`
}
type CreatePurchaseChannelInput struct {
Username string `json:"username"`
Status *string `json:"status,omitempty"`
PlannedCost *float64 `json:"planned_cost,omitempty"`
Comment *string `json:"comment,omitempty"`
}
type CreatePurchaseInput struct {
CreativeID string `json:"creative_id"`
Channels []CreatePurchaseChannelInput `json:"channels"`
}
func (c *Client) CreatePurchase(
ctx context.Context,
jwt string,
workspaceID string,
projectID string,
) ([]PurchasePlanChannel, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels", workspaceID, projectID)
input CreatePurchaseInput,
) (*Purchase, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase", workspaceID, projectID)
var purchase Purchase
err := c.do(
ctx,
http.MethodPost,
path,
input,
&purchase,
withBearer(jwt),
)
return &purchase, err
}
func (c *Client) GetPurchases(
ctx context.Context,
jwt string,
workspaceID string,
projectID string,
) ([]Purchase, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase", workspaceID, projectID)
var resp struct {
Items []PurchasePlanChannel `json:"items"`
Items []Purchase `json:"items"`
}
err := c.do(
@@ -656,81 +633,26 @@ func (c *Client) GetPurchasePlanChannels(
return resp.Items, err
}
type AttachChannelToPurchasePlanInput struct {
Username string `json:"username"`
Status *string `json:"status,omitempty"`
PlannedCost *float64 `json:"planned_cost,omitempty"`
Comment *string `json:"comment,omitempty"`
}
func (c *Client) AttachChannelToPurchasePlan(
func (c *Client) GetPurchase(
ctx context.Context,
jwt string,
workspaceID string,
projectID string,
input AttachChannelToPurchasePlanInput,
) (*PurchasePlanChannel, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels", workspaceID, projectID)
purchaseID string,
) (*Purchase, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase/%s", workspaceID, projectID, purchaseID)
var planChannel PurchasePlanChannel
var purchase Purchase
err := c.do(
ctx,
http.MethodPost,
path,
input,
&planChannel,
withBearer(jwt),
)
return &planChannel, err
}
type UpdatePurchasePlanChannelInput struct {
Status *string `json:"status,omitempty"`
PlannedCost *float64 `json:"planned_cost,omitempty"`
Comment *string `json:"comment,omitempty"`
}
func (c *Client) UpdatePurchasePlanChannel(
ctx context.Context,
jwt string,
workspaceID string,
projectID string,
channelID string,
input UpdatePurchasePlanChannelInput,
) (*PurchasePlanChannel, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels/%s", workspaceID, projectID, channelID)
var planChannel PurchasePlanChannel
err := c.do(
ctx,
http.MethodPatch,
path,
input,
&planChannel,
withBearer(jwt),
)
return &planChannel, err
}
func (c *Client) RemoveChannelFromPurchasePlan(
ctx context.Context,
jwt string,
workspaceID string,
projectID string,
channelID string,
) error {
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels/%s", workspaceID, projectID, channelID)
return c.do(
ctx,
http.MethodDelete,
http.MethodGet,
path,
nil,
nil,
&purchase,
withBearer(jwt),
)
return &purchase, err
}
// ============================================================================

View File

@@ -101,6 +101,7 @@ func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode R
func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
// Удаляем кнопки у предыдущего сообщения перед отправкой нового
if b.LastMessageID != 0 {
log.Info().Int("cleanup_msg_id", b.LastMessageID).Msg("Cleaning up keyboard before SendNew")
b.cleanupKeyboard(b.LastMessageID)
}
@@ -120,6 +121,7 @@ func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
if res.Result != nil {
b.LastMessageID = res.Result.ID
log.Info().Int("new_last_msg_id", b.LastMessageID).Msg("Updated LastMessageID in SendNew")
}
}
@@ -278,6 +280,7 @@ func (b *Bot) cleanupCallbackUI(u *echotron.Update) {
if u.CallbackQuery.Message != nil {
callbackMessageID := u.CallbackQuery.Message.ID
// Если это сообщение последнее, не удаляем клавиатуру
if b.LastMessageID == callbackMessageID {
return
}

View File

@@ -1,122 +0,0 @@
package screens
import (
"fmt"
"example.com/m/adapter/backend"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
)
type AddPlacement struct {
WorkspaceID string
BackState bot.State
// Выбранные данные
SelectedProject *backend.Project
SelectedCreative *backend.Creative
}
func (s *AddPlacement) Enter(b *bot.Bot, mode bot.RenderMode) {
text := `<b> Создать закуп</b>
Шаг 1: Выберите проект и креатив
Нажмите на кнопки ниже для выбора:`
var buttons [][]echotron.InlineKeyboardButton
// Кнопка выбора проекта
projectButtonText := "Проект: Не выбран"
if s.SelectedProject != nil {
projectButtonText = fmt.Sprintf("Проект: %s", s.SelectedProject.Title)
}
buttons = append(buttons, bot.Row(
bot.Button(projectButtonText, "select_project"),
))
// Кнопка выбора креатива
creativeButtonText := "Креатив: Не выбран"
if s.SelectedCreative != nil {
creativeButtonText = fmt.Sprintf("Креатив: %s", s.SelectedCreative.Name)
}
buttons = append(buttons, bot.Row(
bot.Button(creativeButtonText, "select_creative"),
))
// Кнопки навигации
var navRow []echotron.InlineKeyboardButton
navRow = append(navRow, bot.Button("« Назад", "cancel"))
// Кнопка "Далее" активна только если выбран проект
if s.SelectedProject != nil {
navRow = append(navRow, bot.Button("Далее →", "next"))
}
buttons = append(buttons, navRow)
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *AddPlacement) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch data {
case "cancel":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
case "select_project":
// Переходим к выбору проекта
b.SetState(&SelectProjectForPlacement{
WorkspaceID: s.WorkspaceID,
AddPlacementState: s,
}, bot.NewMessage)
case "select_creative":
if s.SelectedProject == nil {
// Если проект не выбран, показываем предупреждение
b.Edit(`<b>⚠️ Сначала выберите проект</b>
Для выбора креатива необходимо сначала выбрать проект, так как креативы привязаны к проектам.`, bot.Keyboard(
bot.Row(bot.Button("« Назад", "back_to_add_placement")),
))
return
}
// Переходим к выбору креатива
b.SetState(&SelectCreativeForPlacement{
WorkspaceID: s.WorkspaceID,
ProjectID: s.SelectedProject.ID,
AddPlacementState: s,
}, bot.NewMessage)
case "back_to_add_placement":
s.Enter(b, bot.EditMessage)
case "next":
if s.SelectedProject == nil {
return
}
// Переходим к управлению планом закупа (добавление/удаление каналов)
b.SetState(&PlacementPurchasePlan{
WorkspaceID: s.WorkspaceID,
ProjectID: s.SelectedProject.ID,
AddPlacementState: s,
}, bot.EditMessage)
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *AddPlacement) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *AddPlacement) Handle(_ *bot.Bot, _ *echotron.Update) { return }

View File

@@ -0,0 +1,160 @@
package screens
import (
"context"
"fmt"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
const addPurchaseCreativesPerPage = 5
type AddPurchase struct {
WorkspaceID string
ProjectID string
CurrentPage int
BackState bot.State
}
func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
return
}
s.renderCreativeSelection(b, mode, jwt)
}
func (s *AddPurchase) renderCreativeSelection(b *bot.Bot, mode bot.RenderMode, jwt string) {
// Получаем список активных креативов
page, err := b.Backend.GetCreatives(
context.Background(),
jwt,
s.WorkspaceID,
&s.ProjectID,
false, // только активные
s.CurrentPage+1,
addPurchaseCreativesPerPage,
)
if err != nil {
log.Error().Err(err).Msg("Failed to get creatives")
b.SendNew("❌ Не удалось загрузить креативы", bot.Keyboard())
return
}
text := "<b> Создание закупа</b>\n\n"
var buttons [][]echotron.InlineKeyboardButton
if len(page.Items) == 0 {
text += `Для создания закупа сначала нужно создать креатив.
<i>Вернитесь назад и создайте креатив в разделе "Креативы"</i>`
buttons = append(buttons, bot.Row(
bot.Button("← Назад", "back"),
))
} else {
pageInfo := ""
if page.Pages > 1 {
pageInfo = fmt.Sprintf(" (стр. %d/%d)", page.Page, page.Pages)
}
text += fmt.Sprintf(`<b>Шаг 1/2: Выбор креатива</b>%s
<i>Выберите креатив для закупа:</i>
`, pageInfo)
// Кнопки с креативами
for _, creative := range page.Items {
buttons = append(buttons, bot.Row(
bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)),
))
}
// Пагинация
var navRow []echotron.InlineKeyboardButton
if s.CurrentPage > 0 {
navRow = append(navRow, bot.Button("←", "prev"))
}
if s.CurrentPage+1 < page.Pages {
navRow = append(navRow, bot.Button("→", "next"))
}
if len(navRow) > 0 {
buttons = append(buttons, navRow)
}
// Кнопка назад
buttons = append(buttons, bot.Row(
bot.Button("← Отмена", "back"),
))
}
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *AddPurchase) ensureJWT(b *bot.Bot) (string, error) {
if b.Session.JWT != "" {
return b.Session.JWT, nil
}
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
if err != nil {
return "", err
}
b.Session.JWT = jwt
return jwt, nil
}
func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch data {
case "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
case "prev":
if s.CurrentPage > 0 {
s.CurrentPage--
}
s.Enter(b, bot.EditMessage)
case "next":
s.CurrentPage++
s.Enter(b, bot.EditMessage)
default:
// Проверяем, не выбрали ли креатив
if len(data) > 9 && data[:9] == "creative:" {
creativeID := data[9:]
// Переходим к выбору каналов
b.SetState(&SelectChannelsForPurchase{
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
CreativeID: creativeID,
Channels: []PurchaseChannelInput{},
BackState: s.BackState,
}, bot.EditMessage)
} else {
s.Enter(b, bot.NewMessage)
}
}
}
func (s *AddPurchase) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *AddPurchase) Handle(_ *bot.Bot, _ *echotron.Update) { return }

View File

@@ -46,9 +46,19 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
s.Name = &creative.Name
if creative.Text != "" {
s.Text = &creative.Text
log.Info().Str("creative_text", creative.Text).Msg("Set creative text from backend")
} else {
log.Info().Msg("Creative text is empty from backend")
}
// Buttons, MediaType, MediaFileID остаются пустыми (не сохраняются в БД)
log.Info().
Str("creative_id", s.CreativeID).
Str("name", creative.Name).
Bool("has_text", creative.Text != "").
Bool("s_text_is_nil", s.Text == nil).
Msg("Sending creative preview and panel")
// Отправляем превью и панель управления
s.SendCreativePreview(b)
s.showManagementPanelWithDelete(b)

View File

@@ -2,6 +2,7 @@ package screens
import (
"fmt"
"strings"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
@@ -43,8 +44,24 @@ type CreativeEditorFields struct {
// GetCreativeText возвращает текст для превью креатива
func (e *CreativeEditorFields) GetCreativeText() string {
if e.Text != nil && *e.Text != "" {
// Проверяем что текст не является "визуально пустым" HTML
// Например: "<a class=\"tg-link\"></a>" - пустая ссылка
text := strings.TrimSpace(*e.Text)
// Убираем пустые HTML-теги (простая проверка)
if text == "<a class=\"tg-link\"></a>" || text == "" {
log.Info().Str("text", *e.Text).Msg("GetCreativeText: text is visually empty, using default")
return `<i>💭 Я твой креатив
У меня пока нет текста, добавь его ниже.
</i>`
}
log.Info().Str("text", *e.Text).Msg("GetCreativeText: returning creative text")
return *e.Text
}
log.Info().Bool("text_is_nil", e.Text == nil).Msg("GetCreativeText: returning default text")
return `<i>💭 Я твой креатив
У меня пока нет текста, добавь его ниже.
@@ -136,7 +153,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
}
} else {
// Отправляем обычное текстовое сообщение напрямую (не через SendNew)
// чтобы не обновлять LastMessageID и не затронуть cleanup
// чтобы не затронуть LastMessageID и не затронуть cleanup
res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{
ReplyMarkup: keyboard,
ParseMode: echotron.HTML,
@@ -319,12 +336,15 @@ func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons
} else {
// Редактируем существующую панель
log.Info().Int("control_panel_msg_id", *e.ControlPanelMessageID).Msg("Editing control panel")
// Временно заменяем LastMessageID на ID панели управления
oldLastMessageID := b.LastMessageID
b.LastMessageID = *e.ControlPanelMessageID
keyboard := bot.Keyboard(buttons...)
b.Edit(text, keyboard)
// Восстанавливаем оригинальный LastMessageID
b.LastMessageID = oldLastMessageID
log.Info().Msg("Control panel updated")
}

View File

@@ -104,24 +104,20 @@ func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string)
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"))
}
// Кнопка "Добавить креатив" всегда в центре
navRow = append(navRow, bot.Button(" Добавить", "add_creative"))
// Правая стрелка (или пустая кнопка)
// Правая стрелка (только если есть следующая страница)
if s.CurrentPage+1 < page.Pages {
navRow = append(navRow, bot.Button("→", "next"))
} else {
navRow = append(navRow, bot.Button(" ", "empty"))
}
buttons = append(buttons, navRow)

View File

@@ -19,11 +19,11 @@ func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) {
func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) {
switch u.CallbackQuery.Data {
case "my_projects":
b.SetState(&MyProjects{BackState: &MainMenu{}}, bot.EditMessage)
b.SetState(&MyProjects{BackState: &MainMenu{}}, bot.NewMessage)
case "support":
// b.SetState(&Support{}, bot.NewMessage)
case "placements":
b.SetState(&MyPlacements{BackState: &MainMenu{}}, bot.EditMessage)
b.SetState(&MyPlacements{BackState: &MainMenu{}}, bot.NewMessage)
case "dashboard":
// b.SetState(&Dashboard{}, bot.NewMessage)
default:

View File

@@ -66,27 +66,37 @@ func (s *MyPlacements) renderPlacements(b *bot.Bot, mode bot.RenderMode, jwt str
return
}
text := "Мои закупы • Управление размещениями\n\n"
text := "<b>📍 Мои размещения</b>\n\n"
var buttons [][]echotron.InlineKeyboardButton
if len(page.Items) == 0 {
text += "У вас пока нет закупов"
} else {
text += fmt.Sprintf("Выберите закуп для управления (стр. %d из %d):", s.CurrentPage+1, page.Pages)
text += `У вас пока нет размещений.
// Закупы по 2 в ряду
<i>Размещения создаются автоматически при создании закупа.</i>`
} else {
text += fmt.Sprintf("Выберите размещение для деталей (стр. %d из %d):\n\n", s.CurrentPage+1, page.Pages)
// Размещения по 2 в ряду
for i := 0; i < len(page.Items); i += 2 {
var row []echotron.InlineKeyboardButton
// Первый закуп в ряду
// Первый placement в ряду
placement := page.Items[i]
row = append(row, bot.Button(placement.Name, fmt.Sprintf("placement:%s", placement.ID)))
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]
row = append(row, bot.Button(placement.Name, fmt.Sprintf("placement:%s", placement.ID)))
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)))
} else {
// Добавляем пустую кнопку для выравнивания
row = append(row, bot.Button(" ", "empty"))
@@ -121,12 +131,11 @@ func (s *MyPlacements) renderPlacements(b *bot.Bot, mode bot.RenderMode, jwt str
// Кнопки управления
buttons = append(buttons, bot.Row(
bot.Button("Статистика", "statistics"),
bot.Button("Создать закуп", "add_placement"),
bot.Button("📊 Статистика", "statistics"),
))
// Кнопка возврата в главное меню
buttons = append(buttons, bot.Row(bot.Button("🏠 Главное меню", "menu")))
buttons = append(buttons, bot.Row(bot.Button("← Назад", "back")))
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
@@ -167,8 +176,12 @@ func (s *MyPlacements) HandleCallback(b *bot.Bot, u *echotron.Update) {
s.CurrentPage++
s.Enter(b, bot.EditMessage)
case data == "menu":
b.SetState(&MainMenu{}, bot.EditMessage)
case data == "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
} else {
b.SetState(&MainMenu{}, bot.EditMessage)
}
case data == "page_info", data == "empty":
// Неактивные кнопки - ничего не делаем
@@ -176,16 +189,10 @@ func (s *MyPlacements) HandleCallback(b *bot.Bot, u *echotron.Update) {
case data == "statistics":
// TODO: реализовать экран статистики
b.Edit("📊 Статистика закупов\n\nЭта функция в разработке", bot.Keyboard(
b.Edit("📊 Статистика размещений\n\nЭта функция в разработке", bot.Keyboard(
bot.Row(bot.Button("« Назад", "back_to_placements")),
))
case data == "add_placement":
b.SetState(&AddPlacement{
WorkspaceID: s.WorkspaceID,
BackState: s,
}, bot.EditMessage)
case data == "back_to_placements":
s.Enter(b, bot.EditMessage)

View File

@@ -115,24 +115,20 @@ func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string)
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"))
}
// Кнопка "Добавить проект" всегда в центре
navRow = append(navRow, bot.Button(" Добавить", "add_project"))
// Правая стрелка (или пустая кнопка)
// Правая стрелка (только если есть следующая страница)
if s.CurrentPage+1 < page.Pages {
navRow = append(navRow, bot.Button("→", "next"))
} else {
navRow = append(navRow, bot.Button(" ", "empty"))
}
buttons = append(buttons, navRow)
@@ -224,7 +220,7 @@ func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) {
Project: project,
WorkspaceID: s.WorkspaceID,
BackState: &MyProjects{WorkspaceID: s.WorkspaceID},
}, bot.EditMessage)
}, bot.NewMessage)
}
case strings.HasPrefix(data, "workspace:"):

View File

@@ -1,222 +0,0 @@
package screens
import (
"context"
"fmt"
"strings"
"example.com/m/adapter/backend"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
type PlacementPurchasePlan struct {
WorkspaceID string
ProjectID string
AddPlacementState *AddPlacement
}
func (s *PlacementPurchasePlan) Enter(b *bot.Bot, mode bot.RenderMode) {
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
return
}
s.renderChannels(b, mode, jwt)
}
func (s *PlacementPurchasePlan) renderChannels(b *bot.Bot, mode bot.RenderMode, jwt string) {
channels, err := b.Backend.GetPurchasePlanChannels(
context.Background(),
jwt,
s.WorkspaceID,
s.ProjectID,
)
if err != nil {
log.Error().Err(err).Msg("Failed to get purchase plan channels")
b.SendNew("❌ Не удалось загрузить каналы", bot.Keyboard())
return
}
text := "<b>📋 План закупа — Каналы</b>\n\n"
var buttons [][]echotron.InlineKeyboardButton
if len(channels) == 0 {
text += "Список каналов пуст.\n\n"
} else {
text += "Нажмите на канал для удаления:\n\n"
for _, ch := range channels {
channelName := "Без названия"
if ch.Channel.Title != nil {
channelName = *ch.Channel.Title
} else if ch.Channel.Username != nil {
channelName = "@" + *ch.Channel.Username
}
// Показываем статус и стоимость
statusEmoji := "📌"
switch ch.Status {
case "planned":
statusEmoji = "📝"
case "approved":
statusEmoji = "✅"
case "rejected":
statusEmoji = "❌"
case "in_progress":
statusEmoji = "🔄"
case "completed":
statusEmoji = "✔️"
}
buttonText := fmt.Sprintf("%s %s", statusEmoji, channelName)
if ch.PlannedCost != nil {
buttonText += fmt.Sprintf(" (%.0f₽)", *ch.PlannedCost)
}
buttons = append(buttons, bot.Row(
bot.Button(buttonText, fmt.Sprintf("remove:%s", ch.ID)),
))
}
}
// Инструкция по добавлению канала
text += "\n<b>Добавление канала:</b>\nОтправьте username канала (без @)"
// Навигация
var navRow []echotron.InlineKeyboardButton
navRow = append(navRow, bot.Button("« Назад", "back"))
navRow = append(navRow, bot.Button("Далее →", "next"))
buttons = append(buttons, navRow)
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *PlacementPurchasePlan) ensureJWT(b *bot.Bot) (string, error) {
if b.Session.JWT != "" {
return b.Session.JWT, nil
}
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
if err != nil {
return "", err
}
b.Session.JWT = jwt
return jwt, nil
}
func (s *PlacementPurchasePlan) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "back":
if s.AddPlacementState != nil {
b.SetState(s.AddPlacementState, bot.EditMessage)
}
case data == "next":
// TODO: переход к следующему шагу (детали размещения)
b.Edit("🚧 Следующий шаг в разработке", bot.Keyboard(
bot.Row(bot.Button("« Назад", "back_to_purchase_plan")),
))
case data == "back_to_purchase_plan":
s.Enter(b, bot.EditMessage)
case strings.HasPrefix(data, "remove:"):
parts := strings.Split(data, ":")
if len(parts) == 2 {
purchasePlanChannelID := parts[1] // ID записи PurchasePlanChannel
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
return
}
// Удаляем канал из плана закупа
err = b.Backend.RemoveChannelFromPurchasePlan(
context.Background(),
jwt,
s.WorkspaceID,
s.ProjectID,
purchasePlanChannelID,
)
if err != nil {
log.Error().Err(err).Str("purchase_plan_channel_id", purchasePlanChannelID).Msg("Failed to remove channel")
b.Edit("❌ Не удалось удалить канал", bot.Keyboard(
bot.Row(bot.Button("« Назад", "back_to_purchase_plan")),
))
return
}
// Обновляем экран
s.Enter(b, bot.EditMessage)
}
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *PlacementPurchasePlan) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message == nil || u.Message.Text == "" {
return
}
username := strings.TrimSpace(u.Message.Text)
// Убираем @ если пользователь его добавил
username = strings.TrimPrefix(username, "@")
if username == "" {
return
}
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
return
}
// Отправляем запрос на добавление канала
input := backend.AttachChannelToPurchasePlanInput{
Username: username,
}
_, err = b.Backend.AttachChannelToPurchasePlan(
context.Background(),
jwt,
s.WorkspaceID,
s.ProjectID,
input,
)
if err != nil {
log.Error().Err(err).Str("username", username).Msg("Failed to attach channel")
// Проверяем, содержит ли ошибка информацию о том, что канал не найден
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "404") {
b.SendNew(fmt.Sprintf("❌ Канал @%s не найден в Telegram.\n\nПроверьте правильность username и попробуйте еще раз.", username), bot.Keyboard())
} else {
b.SendNew("❌ Не удалось добавить канал. Попробуйте позже.", bot.Keyboard())
}
return
}
// Обновляем экран со списком каналов
s.Enter(b, bot.NewMessage)
}
func (s *PlacementPurchasePlan) Handle(_ *bot.Bot, _ *echotron.Update) { return }

View File

@@ -68,37 +68,32 @@ func (s *ProjectDetails) formatProjectDetails() string {
func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup {
var buttons [][]echotron.InlineKeyboardButton
// Первый ряд: Креативы и Создать закуп
// Первый ряд: Креативы и Закупы
buttons = append(buttons, bot.Row(
bot.Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)),
bot.Button("Создать закуп", fmt.Sprintf("create_placement:%s", s.Project.ID)),
bot.Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)),
bot.Button("Закупы", fmt.Sprintf("purchases:%s", s.Project.ID)),
))
// Второй ряд: План закупов
buttons = append(buttons, bot.Row(
bot.Button("План закупов", fmt.Sprintf("purchase_plan:%s", s.Project.ID)),
))
// Третий ряд: Тип приглашений (с подтверждением)
// Второй ряд: Тип вступления по умолчанию для закупов (с подтверждением)
var linkTypeButtonText string
if s.confirmingLinkTypeChange {
// Показываем подтверждение
var newType string
if s.Project.InviteLinkType == "public" {
newType = "С одобрением"
if s.Project.PurchaseInviteTypeDefault == "public" {
newType = "По заявке"
} else {
newType = "Открытая"
newType = "Свободное"
}
linkTypeButtonText = fmt.Sprintf("Подтвердить → %s", newType)
linkTypeButtonText = fmt.Sprintf("Изменить → %s", newType)
} else {
// Показываем текущий тип приглашений
// Показываем текущий тип вступления
var currentType string
if s.Project.InviteLinkType == "public" {
currentType = "Открытая"
if s.Project.PurchaseInviteTypeDefault == "public" {
currentType = "Свободное"
} else {
currentType = "С одобрением"
currentType = "По заявке"
}
linkTypeButtonText = fmt.Sprintf("Приглашения: %s", currentType)
linkTypeButtonText = fmt.Sprintf("Вступление: %s", currentType)
}
buttons = append(buttons, bot.Row(
bot.Button(linkTypeButtonText, fmt.Sprintf("link_type:%s", s.Project.ID)),
@@ -135,12 +130,13 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
BackState: s,
}, bot.NewMessage)
case strings.HasPrefix(data, "create_placement:"):
case strings.HasPrefix(data, "purchases:"):
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
// TODO: реализовать создание закупа
b.Edit("🚧 Создать закуп\n\nФункция в разработке", bot.Keyboard(
bot.Row(bot.Button("← Назад", "back_to_project")),
))
b.SetState(&Purchases{
WorkspaceID: s.WorkspaceID,
ProjectID: s.Project.ID,
BackState: s,
}, bot.NewMessage)
case data == "back_to_project":
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
@@ -154,7 +150,7 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
} else {
// Второе нажатие - меняем тип ссылки
var newType string
if s.Project.InviteLinkType == "public" {
if s.Project.PurchaseInviteTypeDefault == "public" {
newType = "approval"
} else {
newType = "public"
@@ -163,7 +159,7 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
log.Info().
Str("workspace_id", s.WorkspaceID).
Str("project_id", s.Project.ID).
Str("old_type", s.Project.InviteLinkType).
Str("old_type", s.Project.PurchaseInviteTypeDefault).
Str("new_type", newType).
Msg("Updating project invite link type")

View File

@@ -0,0 +1,170 @@
package screens
import (
"context"
"fmt"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
type PurchaseDetails struct {
WorkspaceID string
ProjectID string
PurchaseID string
BackState bot.State
}
func (s *PurchaseDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
return
}
s.renderDetails(b, mode, jwt)
}
func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt string) {
purchase, err := b.Backend.GetPurchase(
context.Background(),
jwt,
s.WorkspaceID,
s.ProjectID,
s.PurchaseID,
)
if err != nil {
log.Error().Err(err).Msg("Failed to get purchase")
b.SendNew("❌ Не удалось загрузить закуп", bot.Keyboard())
return
}
// Получаем информацию о креативе
creative, err := b.Backend.GetCreative(
context.Background(),
jwt,
s.WorkspaceID,
purchase.CreativeID,
)
if err != nil {
log.Error().Err(err).Msg("Failed to get creative")
creative = nil
}
text := "<b>📦 Детали закупа</b>\n\n"
// Информация о креативе
if creative != nil {
text += fmt.Sprintf("<b>Креатив:</b> %s\n", creative.Name)
} else {
text += fmt.Sprintf("<b>Креатив ID:</b> %s\n", purchase.CreativeID)
}
// Статус закупа
statusText := "Активный"
if purchase.Status == "archived" {
statusText = "Архивирован"
}
text += fmt.Sprintf("<b>Статус:</b> %s\n\n", statusText)
// Информация о каналах
if len(purchase.Channels) == 0 {
text += "<i>Каналы не добавлены</i>\n"
} else {
text += fmt.Sprintf("<b>Каналы (%d):</b>\n\n", len(purchase.Channels))
for i, ch := range purchase.Channels {
// Определяем статус emoji
statusEmoji := "📌"
switch ch.Status {
case "planned":
statusEmoji = "📝"
case "approved":
statusEmoji = "✅"
case "rejected":
statusEmoji = "❌"
case "in_progress":
statusEmoji = "🔄"
case "completed":
statusEmoji = "✔️"
}
// Название канала
channelName := "Без названия"
if ch.Channel.Title != nil {
channelName = *ch.Channel.Title
} else if ch.Channel.Username != nil {
channelName = "@" + *ch.Channel.Username
}
text += fmt.Sprintf("%d. %s <b>%s</b>\n", i+1, statusEmoji, channelName)
// Стоимость
if ch.PlannedCost != nil {
text += fmt.Sprintf(" 💰 Стоимость: %.0f₽\n", *ch.PlannedCost)
}
// Комментарий
if ch.Comment != nil && *ch.Comment != "" {
text += fmt.Sprintf(" 💬 %s\n", *ch.Comment)
}
// Тип invite link
inviteLinkTypeText := "публичная"
if ch.InviteLinkType == "approval" {
inviteLinkTypeText = "с одобрением"
}
text += fmt.Sprintf(" 🔗 Ссылка: %s\n", inviteLinkTypeText)
text += "\n"
}
}
var buttons [][]echotron.InlineKeyboardButton
// Кнопка назад
buttons = append(buttons, bot.Row(
bot.Button("← Назад", "back"),
))
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *PurchaseDetails) ensureJWT(b *bot.Bot) (string, error) {
if b.Session.JWT != "" {
return b.Session.JWT, nil
}
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
if err != nil {
return "", err
}
b.Session.JWT = jwt
return jwt, nil
}
func (s *PurchaseDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch data {
case "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *PurchaseDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *PurchaseDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }

View File

@@ -0,0 +1,158 @@
package screens
import (
"context"
"fmt"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
type Purchases struct {
WorkspaceID string
ProjectID string
BackState bot.State
}
func (s *Purchases) Enter(b *bot.Bot, mode bot.RenderMode) {
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
return
}
s.renderPurchases(b, mode, jwt)
}
func (s *Purchases) renderPurchases(b *bot.Bot, mode bot.RenderMode, jwt string) {
purchases, err := b.Backend.GetPurchases(
context.Background(),
jwt,
s.WorkspaceID,
s.ProjectID,
)
if err != nil {
log.Error().Err(err).Msg("Failed to get purchases")
b.SendNew("❌ Не удалось загрузить закупы", bot.Keyboard())
return
}
text := "<b>📦 Закупы</b>\n\n"
var buttons [][]echotron.InlineKeyboardButton
if len(purchases) == 0 {
text += `Список закупов пуст.
<i>Закуп — это план размещения рекламы в каналах:</i>
▸ Выбор креатива
▸ Список каналов для размещения
▸ Планируемая стоимость
▸ Статус выполнения
<i>Создайте первый закуп 👇</i>`
} else {
text += "Управление закупами для проекта.\n\n"
text += "<i>Выберите закуп для деталей:</i>\n\n"
for i, purchase := range purchases {
// Определяем статус emoji
statusEmoji := "📌"
switch purchase.Status {
case "active":
statusEmoji = "🟢"
case "archived":
statusEmoji = "📦"
}
// Считаем каналы по статусам
totalChannels := len(purchase.Channels)
completedChannels := 0
for _, ch := range purchase.Channels {
if ch.Status == "completed" {
completedChannels++
}
}
buttonText := fmt.Sprintf("%s Закуп #%d (%d/%d каналов)",
statusEmoji,
i+1,
completedChannels,
totalChannels,
)
buttons = append(buttons, bot.Row(
bot.Button(buttonText, fmt.Sprintf("purchase:%s", purchase.ID)),
))
}
}
// Кнопка создания нового закупа
buttons = append(buttons, bot.Row(
bot.Button(" Создать закуп", "add_purchase"),
))
// Кнопка назад
buttons = append(buttons, bot.Row(
bot.Button("← Назад", "back"),
))
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *Purchases) ensureJWT(b *bot.Bot) (string, error) {
if b.Session.JWT != "" {
return b.Session.JWT, nil
}
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
if err != nil {
return "", err
}
b.Session.JWT = jwt
return jwt, nil
}
func (s *Purchases) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch data {
case "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
case "add_purchase":
b.SetState(&AddPurchase{
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
BackState: s,
}, bot.EditMessage)
default:
// Проверяем, не нажали ли на конкретный закуп
if len(data) > 9 && data[:9] == "purchase:" {
purchaseID := data[9:]
b.SetState(&PurchaseDetails{
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
PurchaseID: purchaseID,
BackState: s,
}, bot.NewMessage)
} else {
s.Enter(b, bot.NewMessage)
}
}
}
func (s *Purchases) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *Purchases) Handle(_ *bot.Bot, _ *echotron.Update) { return }

View File

@@ -0,0 +1,214 @@
package screens
import (
"context"
"fmt"
"strings"
"example.com/m/adapter/backend"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
type PurchaseChannelInput struct {
Username string
PlannedCost *float64
Comment *string
}
type SelectChannelsForPurchase struct {
WorkspaceID string
ProjectID string
CreativeID string
Channels []PurchaseChannelInput
BackState bot.State
}
func (s *SelectChannelsForPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
s.renderChannelSelection(b, mode)
}
func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.RenderMode) {
text := "<b> Создание закупа</b>\n\n"
text += "<b>Шаг 2/2: Добавление каналов</b>\n\n"
var buttons [][]echotron.InlineKeyboardButton
if len(s.Channels) == 0 {
text += `Добавьте каналы для размещения рекламы.
<i>Отправьте username канала (без @)</i>
Например: <code>channel_name</code>
После добавления всех каналов нажмите "Создать закуп"`
} else {
text += fmt.Sprintf("<b>Добавлено каналов: %d</b>\n\n", len(s.Channels))
for i, ch := range s.Channels {
channelText := fmt.Sprintf("%d. @%s", i+1, ch.Username)
if ch.PlannedCost != nil {
channelText += fmt.Sprintf(" (%.0f₽)", *ch.PlannedCost)
}
text += channelText + "\n"
}
text += "\n<i>Добавьте еще каналы или создайте закуп</i>"
// Кнопка создания закупа (доступна только если есть каналы)
buttons = append(buttons, bot.Row(
bot.Button("✅ Создать закуп", "create"),
))
}
// Кнопки навигации
buttons = append(buttons, bot.Row(
bot.Button("← Назад", "back"),
))
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *SelectChannelsForPurchase) ensureJWT(b *bot.Bot) (string, error) {
if b.Session.JWT != "" {
return b.Session.JWT, nil
}
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
if err != nil {
return "", err
}
b.Session.JWT = jwt
return jwt, nil
}
func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch data {
case "back":
// Возвращаемся к выбору креатива
b.SetState(&AddPurchase{
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
BackState: s.BackState,
}, bot.EditMessage)
case "create":
if len(s.Channels) == 0 {
b.Edit("❌ Добавьте хотя бы один канал", bot.Keyboard(
bot.Row(bot.Button("← Назад", "back_to_select")),
))
return
}
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
b.Edit("❌ Ошибка авторизации", bot.Keyboard())
return
}
// Создаем закуп
s.createPurchase(b, jwt)
case "back_to_select":
s.Enter(b, bot.EditMessage)
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
// Преобразуем каналы в формат API
var apiChannels []backend.CreatePurchaseChannelInput
for _, ch := range s.Channels {
apiChannels = append(apiChannels, backend.CreatePurchaseChannelInput{
Username: ch.Username,
PlannedCost: ch.PlannedCost,
Comment: ch.Comment,
})
}
input := backend.CreatePurchaseInput{
CreativeID: s.CreativeID,
Channels: apiChannels,
}
purchase, err := b.Backend.CreatePurchase(
context.Background(),
jwt,
s.WorkspaceID,
s.ProjectID,
input,
)
if err != nil {
log.Error().Err(err).Msg("Failed to create purchase")
b.Edit("❌ Не удалось создать закуп\n\nВозможные причины:\n• Один из каналов не найден\n• Ошибка сервера", bot.Keyboard(
bot.Row(bot.Button("← Назад", "back_to_select")),
))
return
}
// Показываем успешное создание
successText := fmt.Sprintf(`✅ <b>Закуп создан!</b>
<b>ID:</b> %s
<b>Креатив:</b> %s
<b>Каналов:</b> %d
Закуп создан и готов к работе.`, purchase.ID, purchase.CreativeID, len(purchase.Channels))
b.Edit(successText, bot.Keyboard(
bot.Row(bot.Button("✅ Готово", "done")),
))
}
func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message == nil || u.Message.Text == "" {
return
}
username := strings.TrimSpace(u.Message.Text)
// Убираем @ если пользователь его добавил
username = strings.TrimPrefix(username, "@")
if username == "" {
return
}
// Проверяем, не добавлен ли уже этот канал
for _, ch := range s.Channels {
if ch.Username == username {
b.SendNew(fmt.Sprintf("⚠️ Канал @%s уже добавлен в список", username), bot.Keyboard())
return
}
}
// Добавляем канал в список
s.Channels = append(s.Channels, PurchaseChannelInput{
Username: username,
})
// Обновляем экран
s.Enter(b, bot.NewMessage)
}
func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) {
// Обрабатываем callback после успешного создания
if u.CallbackQuery != nil && u.CallbackQuery.Data == "done" {
// Возвращаемся к списку закупов
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
}
}

View File

@@ -1,188 +0,0 @@
package screens
import (
"context"
"fmt"
"strings"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
const creativesForPlacementPerPage = 6
type SelectCreativeForPlacement struct {
WorkspaceID string
ProjectID string
CurrentPage int
AddPlacementState *AddPlacement
}
func (s *SelectCreativeForPlacement) Enter(b *bot.Bot, mode bot.RenderMode) {
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
return
}
s.renderCreatives(b, mode, jwt)
}
func (s *SelectCreativeForPlacement) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string) {
page, err := b.Backend.GetCreatives(
context.Background(),
jwt,
s.WorkspaceID,
&s.ProjectID,
false, // includeArchived
s.CurrentPage+1,
creativesForPlacementPerPage,
)
if err != nil {
log.Error().Err(err).Msg("Failed to get creatives")
b.SendNew("❌ Не удалось загрузить креативы", bot.Keyboard())
return
}
text := "<b>Выбор креатива</b>\n\n"
var buttons [][]echotron.InlineKeyboardButton
if len(page.Items) == 0 {
text += "У выбранного проекта пока нет креативов.\n\nСоздайте креатив через меню проекта."
} else {
text += fmt.Sprintf("Выберите креатив (стр. %d из %d):", s.CurrentPage+1, page.Pages)
// Креативы по 2 в ряду
for i := 0; i < len(page.Items); i += 2 {
var row []echotron.InlineKeyboardButton
// Первый креатив в ряду
creative := page.Items[i]
row = append(row, bot.Button(creative.Name, fmt.Sprintf("select:%s", creative.ID)))
// Второй креатив в ряду (если есть)
if i+1 < len(page.Items) {
creative = page.Items[i+1]
row = append(row, bot.Button(creative.Name, fmt.Sprintf("select:%s", creative.ID)))
} 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"))
}
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)
}
// Опция пропустить выбор креатива (креатив опционален)
buttons = append(buttons, bot.Row(bot.Button("Без креатива", "skip")))
// Кнопка возврата
buttons = append(buttons, bot.Row(bot.Button("« Назад", "back")))
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *SelectCreativeForPlacement) ensureJWT(b *bot.Bot) (string, error) {
if b.Session.JWT != "" {
return b.Session.JWT, nil
}
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
if err != nil {
return "", err
}
b.Session.JWT = jwt
return jwt, nil
}
func (s *SelectCreativeForPlacement) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "prev":
if s.CurrentPage > 0 {
s.CurrentPage--
}
s.Enter(b, bot.EditMessage)
case data == "next":
s.CurrentPage++
s.Enter(b, bot.EditMessage)
case data == "page_info", data == "empty":
return
case data == "skip":
// Пропускаем выбор креатива
if s.AddPlacementState != nil {
s.AddPlacementState.SelectedCreative = nil
b.SetState(s.AddPlacementState, bot.EditMessage)
}
case data == "back":
if s.AddPlacementState != nil {
b.SetState(s.AddPlacementState, bot.EditMessage)
}
case strings.HasPrefix(data, "select:"):
parts := strings.Split(data, ":")
if len(parts) == 2 {
creativeID := parts[1]
// Получаем JWT
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
return
}
// Загружаем полные данные креатива
creative, err := b.Backend.GetCreative(context.Background(), jwt, s.WorkspaceID, creativeID)
if err != nil {
log.Error().Err(err).Str("creative_id", creativeID).Msg("Failed to get creative")
return
}
// Сохраняем выбранный креатив в состояние AddPlacement
if s.AddPlacementState != nil {
s.AddPlacementState.SelectedCreative = creative
b.SetState(s.AddPlacementState, bot.EditMessage)
}
}
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *SelectCreativeForPlacement) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *SelectCreativeForPlacement) Handle(_ *bot.Bot, _ *echotron.Update) { return }

View File

@@ -1,164 +0,0 @@
package screens
import (
"context"
"fmt"
"strings"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
const projectsForPlacementPerPage = 6
type SelectProjectForPlacement struct {
WorkspaceID string
CurrentPage int
AddPlacementState *AddPlacement
}
func (s *SelectProjectForPlacement) Enter(b *bot.Bot, mode bot.RenderMode) {
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
return
}
s.renderProjects(b, mode, jwt)
}
func (s *SelectProjectForPlacement) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string) {
page, err := b.Backend.GetProjects(context.Background(), jwt, s.WorkspaceID, s.CurrentPage+1, projectsForPlacementPerPage)
if err != nil {
log.Error().Err(err).Msg("Failed to get projects")
b.SendNew("❌ Не удалось загрузить проекты", bot.Keyboard())
return
}
text := "<b>Выбор проекта</b>\n\n"
var buttons [][]echotron.InlineKeyboardButton
if len(page.Items) == 0 {
text += "У вас пока нет проектов"
} else {
text += fmt.Sprintf("Выберите проект (стр. %d из %d):", s.CurrentPage+1, page.Pages)
// Проекты по 2 в ряду
for i := 0; i < len(page.Items); i += 2 {
var row []echotron.InlineKeyboardButton
// Первый проект в ряду
project := page.Items[i]
row = append(row, bot.Button(project.Title, fmt.Sprintf("select:%s", project.ID)))
// Второй проект в ряду (если есть)
if i+1 < len(page.Items) {
project = page.Items[i+1]
row = append(row, bot.Button(project.Title, fmt.Sprintf("select:%s", project.ID)))
} 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"))
}
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)
}
// Кнопка возврата
buttons = append(buttons, bot.Row(bot.Button("« Назад", "back")))
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *SelectProjectForPlacement) ensureJWT(b *bot.Bot) (string, error) {
if b.Session.JWT != "" {
return b.Session.JWT, nil
}
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
if err != nil {
return "", err
}
b.Session.JWT = jwt
return jwt, nil
}
func (s *SelectProjectForPlacement) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "prev":
if s.CurrentPage > 0 {
s.CurrentPage--
}
s.Enter(b, bot.EditMessage)
case data == "next":
s.CurrentPage++
s.Enter(b, bot.EditMessage)
case data == "page_info", data == "empty":
return
case data == "back":
if s.AddPlacementState != nil {
b.SetState(s.AddPlacementState, bot.EditMessage)
}
case strings.HasPrefix(data, "select:"):
parts := strings.Split(data, ":")
if len(parts) == 2 {
projectID := parts[1]
// Загружаем полные данные проекта
project, err := b.Backend.GetProject(context.Background(), s.WorkspaceID, projectID)
if err != nil {
log.Error().Err(err).Str("project_id", projectID).Msg("Failed to get project")
return
}
// Сохраняем выбранный проект в состояние AddPlacement
if s.AddPlacementState != nil {
s.AddPlacementState.SelectedProject = project
// Сбрасываем креатив при смене проекта
s.AddPlacementState.SelectedCreative = nil
b.SetState(s.AddPlacementState, bot.EditMessage)
}
}
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *SelectProjectForPlacement) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *SelectProjectForPlacement) Handle(_ *bot.Bot, _ *echotron.Update) { return }