Files
tgex-backend/tg_bot/screens/purchase_optional_details.go
2026-02-28 13:06:30 +03:00

2739 lines
76 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package screens
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
ui2 "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log"
)
type PurchaseOptionalDetails struct {
ProjectID string
ProjectTitle string
ProjectDefaultLinkType string
CreativeID string
CreativeTitle string
Channels []PurchaseChannelInput
PlacementDateTime *time.Time
PaymentDate *time.Time
CostType string
CostValue *float64
CostBeforeType string
CostBeforeBargain *CostEntry
PurchaseType string
Format string
TopTimeMinutes *int
FeedTimeMinutes *int // nil = не указан, 0 = без удаления
TopTimeByChannel map[string]*int
FeedTimeByChannel map[string]*int
CustomFormatTopUnit string // "hours" | "minutes"
CustomFormatFeedUnit string // "hours" | "days"
CustomFormatTopValue *int // промежуточное значение (в минутах)
Comment string
InviteLinkType string
InputMode string
CurrentParam string
CurrentChannel string
ParamPage int
ReturnMode string
PlacementMode string
PaymentDateMode string
CostMode string
CostBeforeMode string
PurchaseTypeMode string
CommentMode string
FormatMode string
InviteLinkTypeMode string
PlacementByChannel map[string]*time.Time
PaymentDateByChannel map[string]*time.Time
CostByChannel map[string]CostEntry
CostBeforeByChannel map[string]CostEntry
PurchaseTypeByChannel map[string]string
CommentByChannel map[string]string
FormatByChannel map[string]string
InviteLinkTypeByChannel map[string]string
PlacementCopy *time.Time
PaymentDateCopy *time.Time
CostCopy *CostEntry
CostBeforeCopy *CostEntry
PurchaseTypeCopy *string
CommentCopy *string
FormatCopy *string
InviteLinkTypeCopy *string
ChannelEditMode string // "edit" или "copy"
BackState bot.State
}
type CostEntry struct {
Type string
Value *float64
}
func (s *PurchaseOptionalDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
s.ensureDefaults()
if s.InputMode != "" {
if s.InputMode == "mode_select" {
s.renderModeSelect(b, mode)
return
}
if s.renderParamScreens(b, mode) {
return
}
s.renderInputPrompt(b, mode)
return
}
text := "<b>Страница создания закупа и необязательные составляющие</b>"
text += "\n\n"
text += s.formatOptionalSummary()
var rows [][]echotron.InlineKeyboardButton
// Кнопка переключения режима (только если несколько каналов) - на первой строке
if len(s.Channels) > 1 {
rows = append(rows, Row(s.globalModeButton()))
}
rows = append(rows, Row(
s.placementButton(),
s.paymentButton(),
))
rows = append(rows, Row(
s.typeButton(),
s.commentButton(),
))
rows = append(rows, Row(
s.costButton(),
s.costBeforeButton(),
))
rows = append(rows, Row(
s.formatButton(),
s.inviteLinkTypeButton(),
))
rows = append(rows, Row(Button("Назад", "back"), Button("Далее", "next")))
keyboard := Keyboard(rows...)
b.Render(text, keyboard, mode)
}
func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
s.ensureDefaults()
switch u.CallbackQuery.Data {
case "opt_datetime":
// Открываем редактор в текущем режиме
if s.PlacementMode == "per_channel" && len(s.Channels) > 1 {
s.InputMode = "placement_channels"
s.Enter(b, bot.EditMessage)
} else {
s.setParamMode("placement", "common")
s.openCommonEditor(b, "placement")
}
case "opt_payment_date":
// Открываем редактор в текущем режиме
if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 {
s.InputMode = "payment_date_channels"
s.Enter(b, bot.EditMessage)
} else {
s.setParamMode("payment_date", "common")
b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
Title: "Дата оплаты",
Key: "payment_date",
IncludeTime: false,
AllowPast: true,
Selected: s.PaymentDate,
BackState: s,
}), bot.EditMessage)
}
case "opt_cost":
// Открываем редактор в текущем режиме
if s.CostMode == "per_channel" && len(s.Channels) > 1 {
s.InputMode = "cost_channels"
s.Enter(b, bot.EditMessage)
} else {
s.setParamMode("cost", "common")
s.openCommonEditor(b, "cost")
}
case "opt_type":
// Открываем редактор в текущем режиме
if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 {
s.InputMode = "purchase_type_channels"
s.Enter(b, bot.EditMessage)
} else {
s.InputMode = "type"
s.Enter(b, bot.EditMessage)
}
case "opt_format":
// Открываем редактор в текущем режиме
if s.FormatMode == "per_channel" && len(s.Channels) > 1 {
s.InputMode = "format_channels"
s.Enter(b, bot.EditMessage)
} else {
s.setParamMode("format", "common")
s.openCommonEditor(b, "format")
}
case "opt_invite_link_type":
// Открываем редактор в текущем режиме
if s.InviteLinkTypeMode == "per_channel" && len(s.Channels) > 1 {
s.InputMode = "invite_link_type_channels"
s.Enter(b, bot.EditMessage)
} else {
s.InputMode = "invite_link_type"
s.Enter(b, bot.EditMessage)
}
case "opt_comment":
// Открываем редактор в текущем режиме
if s.CommentMode == "per_channel" && len(s.Channels) > 1 {
s.InputMode = "comment_channels"
s.Enter(b, bot.EditMessage)
} else {
s.InputMode = "comment"
s.Enter(b, bot.EditMessage)
}
case "delete_comment":
s.Comment = ""
s.Enter(b, bot.EditMessage)
case "format_custom":
s.InputMode = "format_custom"
s.CustomFormatTopUnit = "hours"
s.CustomFormatFeedUnit = "hours"
s.CustomFormatTopValue = nil
s.Enter(b, bot.EditMessage)
case "cost_type_toggle":
s.toggleCostType()
s.Enter(b, bot.EditMessage)
case "cost_before_type_toggle":
s.toggleCostBeforeType()
s.Enter(b, bot.EditMessage)
case "cost_value":
s.InputMode = "cost_value"
s.Enter(b, bot.EditMessage)
case "cost_before":
// Открываем редактор в текущем режиме
if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 {
s.InputMode = "cost_before_channels"
s.Enter(b, bot.EditMessage)
} else {
s.setParamMode("cost_before", "common")
s.openCommonEditor(b, "cost_before")
}
case "delete_cost_before":
s.CostBeforeBargain = nil
s.Enter(b, bot.EditMessage)
case "back_to_optional":
s.InputMode = ""
s.ReturnMode = ""
s.CurrentChannel = ""
s.CurrentParam = ""
s.Enter(b, bot.EditMessage)
case "back_to_return":
if s.ReturnMode != "" {
s.InputMode = s.ReturnMode
s.ReturnMode = ""
} else {
s.InputMode = ""
}
s.CurrentChannel = ""
s.Enter(b, bot.EditMessage)
case "back":
// Сохраняем текущее состояние перед возвратом назад
b.SetState(&SelectChannelsForPurchase{
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
ProjectDefaultLinkType: s.ProjectDefaultLinkType,
CreativeID: s.CreativeID,
CreativeTitle: s.CreativeTitle,
Channels: s.Channels,
Duplicates: []string{},
ParsingErrors: []ParseError{},
OptionalDetailsState: s, // Сохраняем текущее состояние
BackState: s.BackState,
}, bot.EditMessage)
case "next":
jwt := b.Session.JWT
if jwt == "" {
log.Error().Msg("JWT is empty in session")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
return
}
s.createPurchase(b, jwt)
case "done":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
default:
if u.CallbackQuery.Data == "noop" {
// Пустая кнопка - ничего не делаем
return
}
if s.handleParamCallback(b, u.CallbackQuery.Data) {
return
}
if u.CallbackQuery.Data == "toggle_global_mode" {
// Открываем меню выбора параметра для переключения режима
s.InputMode = "mode_select"
s.Enter(b, bot.EditMessage)
return
}
if strings.HasPrefix(u.CallbackQuery.Data, "channel_mode:") {
// Переключаем режим редактирования каналов
mode := strings.TrimPrefix(u.CallbackQuery.Data, "channel_mode:")
s.ChannelEditMode = mode
s.Enter(b, bot.EditMessage)
return
}
if strings.HasPrefix(u.CallbackQuery.Data, "toggle_param_mode:") {
// Переключаем режим конкретного параметра
param := strings.TrimPrefix(u.CallbackQuery.Data, "toggle_param_mode:")
currentMode := s.paramMode(param)
if currentMode == "common" {
s.setParamMode(param, "per_channel")
// Копируем значение из общего во все каналы
s.copyCommonValueToChannels(param)
} else {
s.setParamMode(param, "common")
}
// Остаемся в меню выбора режима
s.Enter(b, bot.EditMessage)
return
}
if strings.HasPrefix(u.CallbackQuery.Data, "type:") {
value := strings.TrimPrefix(u.CallbackQuery.Data, "type:")
// Нормализуем значение для БД
switch value {
case "mutual_pr":
s.PurchaseType = "взаимный пиар"
case "standard":
s.PurchaseType = "стандарт"
}
s.InputMode = ""
s.Enter(b, bot.EditMessage)
return
}
if strings.HasPrefix(u.CallbackQuery.Data, "format_preset:") {
parts := strings.Split(strings.TrimPrefix(u.CallbackQuery.Data, "format_preset:"), ":")
if len(parts) == 2 {
topMin, err1 := strconv.Atoi(parts[0])
feedMin, err2 := strconv.Atoi(parts[1])
if err1 == nil && err2 == nil {
s.setFormatPreset(topMin, feedMin)
}
}
if s.ReturnMode != "" {
s.InputMode = s.ReturnMode
s.ReturnMode = ""
} else {
s.InputMode = ""
}
s.CurrentChannel = ""
s.Enter(b, bot.EditMessage)
return
}
if strings.HasPrefix(u.CallbackQuery.Data, "format_custom_top:") {
valStr := strings.TrimPrefix(u.CallbackQuery.Data, "format_custom_top:")
minutes, err := strconv.Atoi(valStr)
if err == nil && minutes > 0 {
s.CustomFormatTopValue = &minutes
s.InputMode = "format_custom_feed"
}
s.Enter(b, bot.EditMessage)
return
}
if strings.HasPrefix(u.CallbackQuery.Data, "format_custom_feed:") {
valStr := strings.TrimPrefix(u.CallbackQuery.Data, "format_custom_feed:")
feedMinutes, err := strconv.Atoi(valStr)
if err == nil && s.CustomFormatTopValue != nil {
s.setFormatPreset(*s.CustomFormatTopValue, feedMinutes)
s.CustomFormatTopValue = nil
if s.ReturnMode != "" {
s.InputMode = s.ReturnMode
s.ReturnMode = ""
} else {
s.InputMode = ""
}
s.CurrentChannel = ""
}
s.Enter(b, bot.EditMessage)
return
}
if u.CallbackQuery.Data == "format_custom_top_toggle_unit" {
if s.CustomFormatTopUnit == "hours" {
s.CustomFormatTopUnit = "minutes"
} else {
s.CustomFormatTopUnit = "hours"
}
s.Enter(b, bot.EditMessage)
return
}
if u.CallbackQuery.Data == "format_custom_feed_toggle_unit" {
if s.CustomFormatFeedUnit == "hours" {
s.CustomFormatFeedUnit = "days"
} else {
s.CustomFormatFeedUnit = "hours"
}
s.Enter(b, bot.EditMessage)
return
}
if u.CallbackQuery.Data == "format_custom_top_input_mode" {
s.InputMode = "format_custom_top_input"
s.Enter(b, bot.EditMessage)
return
}
if u.CallbackQuery.Data == "format_custom_feed_input_mode" {
s.InputMode = "format_custom_feed_input"
s.Enter(b, bot.EditMessage)
return
}
if u.CallbackQuery.Data == "format_custom_back_to_select" {
s.InputMode = "format_select"
s.Enter(b, bot.EditMessage)
return
}
if u.CallbackQuery.Data == "format_custom_back_to_top" {
s.InputMode = "format_custom"
s.Enter(b, bot.EditMessage)
return
}
if strings.HasPrefix(u.CallbackQuery.Data, "format:") {
value := strings.TrimPrefix(u.CallbackQuery.Data, "format:")
s.setFormatValue(value)
if s.ReturnMode != "" {
s.InputMode = s.ReturnMode
s.ReturnMode = ""
} else {
s.InputMode = ""
}
s.CurrentChannel = ""
s.Enter(b, bot.EditMessage)
return
}
if strings.HasPrefix(u.CallbackQuery.Data, "invite_link_type:") {
value := strings.TrimPrefix(u.CallbackQuery.Data, "invite_link_type:")
s.setInviteLinkTypeValue(value)
if s.ReturnMode != "" {
s.InputMode = s.ReturnMode
s.ReturnMode = ""
} else {
s.InputMode = ""
}
s.CurrentChannel = ""
s.Enter(b, bot.EditMessage)
return
}
s.Enter(b, bot.EditMessage)
}
}
func (s *PurchaseOptionalDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
if s.InputMode == "" || u.Message == nil || u.Message.Text == "" {
return
}
s.ensureDefaults()
text := strings.TrimSpace(u.Message.Text)
if text == "" {
return
}
switch s.InputMode {
case "format_custom_top_input":
value, err := strconv.Atoi(text)
if err != nil || value <= 0 {
s.renderInputPrompt(b, bot.EditMessage)
return
}
// Конвертируем в минуты по текущей единице
if s.CustomFormatTopUnit == "hours" {
value = value * 60
}
s.CustomFormatTopValue = &value
s.InputMode = "format_custom_feed"
s.Enter(b, bot.NewMessage)
return
case "format_custom_feed_input":
value, err := strconv.Atoi(text)
if err != nil || value <= 0 {
s.renderInputPrompt(b, bot.EditMessage)
return
}
// Конвертируем в минуты по текущей единице
if s.CustomFormatFeedUnit == "days" {
value = value * 24 * 60
} else {
value = value * 60
}
if s.CustomFormatTopValue != nil {
s.setFormatPreset(*s.CustomFormatTopValue, value)
s.CustomFormatTopValue = nil
}
case "comment":
if s.CurrentChannel != "" {
s.CommentByChannel[s.CurrentChannel] = text
} else {
s.Comment = text
}
case "cost_value":
value, err := strconv.ParseFloat(strings.ReplaceAll(text, ",", "."), 64)
if err != nil {
s.renderInputPrompt(b, bot.EditMessage)
return
}
s.setCostValue(value)
case "cost_before_value":
value, err := strconv.ParseFloat(strings.ReplaceAll(text, ",", "."), 64)
if err != nil {
s.renderInputPrompt(b, bot.EditMessage)
return
}
s.setCostBeforeValue(value)
}
if s.ReturnMode != "" {
s.InputMode = s.ReturnMode
s.ReturnMode = ""
} else {
s.InputMode = ""
}
s.CurrentChannel = ""
s.Enter(b, bot.NewMessage)
}
func (s *PurchaseOptionalDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *PurchaseOptionalDetails) Exit() {}
func (s *PurchaseOptionalDetails) SetDateTimeSelection(key string, value time.Time) {
switch key {
case "placement_datetime":
s.PlacementDateTime = &value
case "payment_date":
s.PaymentDate = &value
default:
if strings.HasPrefix(key, "placement_datetime:") {
username := strings.TrimPrefix(key, "placement_datetime:")
s.ensureDefaults()
s.PlacementByChannel[username] = &value
}
if strings.HasPrefix(key, "payment_date:") {
username := strings.TrimPrefix(key, "payment_date:")
s.ensureDefaults()
s.PaymentDateByChannel[username] = &value
}
}
}
func (s *PurchaseOptionalDetails) formatOptionalSummary() string {
var lines []string
if s.hasPlacementValue() {
lines = append(lines, fmt.Sprintf("<i>Дата размещения:</i> <b>%s</b>%s", s.formatPlacementSummary(), s.formatPlacementDetails()))
}
if s.hasPaymentDateValue() {
lines = append(lines, fmt.Sprintf("<i>Дата оплаты:</i> <b>%s</b>%s", s.formatPaymentDateSummary(), s.formatPaymentDateDetails()))
}
if s.hasCostValue() {
lines = append(lines, fmt.Sprintf("<i>Стоимость:</i> <b>%s</b>%s", s.formatCostSummary(), s.formatCostDetails()))
}
if s.hasCostBeforeValue() {
lines = append(lines, fmt.Sprintf("<i>Стоимость до торга:</i> <b>%s</b>%s", s.formatCostBeforeSummary(), s.formatCostBeforeDetails()))
}
if s.hasPurchaseTypeValue() {
lines = append(lines, fmt.Sprintf("<i>Тип закупа:</i> <b>%s</b>%s", s.formatPurchaseTypeSummary(), s.formatPurchaseTypeDetails()))
}
if s.hasFormatValue() {
lines = append(lines, fmt.Sprintf("<i>Формат:</i> <b>%s</b>%s", s.formatFormatSummary(), s.formatFormatDetails()))
}
if s.hasInviteLinkTypeValue() {
lines = append(lines, fmt.Sprintf("<i>Тип ссылки:</i> <b>%s</b>%s", s.formatInviteLinkTypeSummary(), s.formatInviteLinkTypeDetails()))
}
if s.hasCommentValue() {
lines = append(lines, fmt.Sprintf("<i>Комментарий:</i> <b>%s</b>%s", s.formatCommentSummary(), s.formatCommentDetails()))
}
if len(lines) == 0 {
return "<i>Добавьте параметры ниже</i>"
}
return strings.Join(lines, "\n\n")
}
func (s *PurchaseOptionalDetails) formatDateTime(value *time.Time) string {
if value == nil {
return "—"
}
local := value.In(ui2.MskLocation)
return fmt.Sprintf("%s %02d %s %s", ui2.WeekdayName(local.Weekday()), local.Day(), ui2.MonthShort(local.Month()), local.Format("15:04"))
}
func (s *PurchaseOptionalDetails) formatDate(value *time.Time) string {
if value == nil {
return "—"
}
local := value.In(ui2.MskLocation)
return fmt.Sprintf("%s %02d %s", ui2.WeekdayName(local.Weekday()), local.Day(), ui2.MonthShort(local.Month()))
}
func (s *PurchaseOptionalDetails) formatText(value string) string {
if value == "" {
return "—"
}
return value
}
func (s *PurchaseOptionalDetails) formatCostValue() string {
if s.CostValue == nil {
return "—"
}
return fmt.Sprintf("%s %.0f₽", s.costTypeLabel(), *s.CostValue)
}
func (s *PurchaseOptionalDetails) formatCostBefore() string {
if s.CostBeforeBargain == nil || s.CostBeforeBargain.Value == nil {
return "—"
}
label := s.costBeforeTypeLabelForEntry(*s.CostBeforeBargain)
return fmt.Sprintf("%s %.0f₽", label, *s.CostBeforeBargain.Value)
}
func (s *PurchaseOptionalDetails) costTypeLabel() string {
if s.CostType == "cpm" {
return "СРМ"
}
return "Фикс"
}
func (s *PurchaseOptionalDetails) costBeforeTypeLabel() string {
costType := s.CostBeforeType
if costType == "" && s.CostBeforeBargain != nil {
costType = s.CostBeforeBargain.Type
}
if costType == "cpm" {
return "СРМ"
}
return "Фикс"
}
func (s *PurchaseOptionalDetails) placementButton() echotron.InlineKeyboardButton {
icon := ""
if s.hasPlacementValue() {
icon = "✎"
}
modeIcon := ""
if len(s.Channels) > 1 && s.PlacementMode == "per_channel" {
modeIcon = " 👥"
}
return Button(fmt.Sprintf("%s Дата размещения%s", icon, modeIcon), "opt_datetime")
}
func (s *PurchaseOptionalDetails) paymentButton() echotron.InlineKeyboardButton {
icon := ""
if s.hasPaymentDateValue() {
icon = "✎"
}
modeIcon := ""
if len(s.Channels) > 1 && s.PaymentDateMode == "per_channel" {
modeIcon = " 👥"
}
return Button(fmt.Sprintf("%s Дата оплаты%s", icon, modeIcon), "opt_payment_date")
}
func (s *PurchaseOptionalDetails) costButton() echotron.InlineKeyboardButton {
icon := ""
if s.hasCostValue() {
icon = "✎"
}
return Button(fmt.Sprintf("%s Стоимость", icon), "opt_cost")
}
func (s *PurchaseOptionalDetails) costBeforeButton() echotron.InlineKeyboardButton {
icon := ""
if s.hasCostBeforeValue() {
icon = "✎"
}
return Button(fmt.Sprintf("%s До торга", icon), "cost_before")
}
func (s *PurchaseOptionalDetails) typeButton() echotron.InlineKeyboardButton {
icon := ""
if s.PurchaseType != "" {
icon = "✎"
}
modeIcon := ""
if len(s.Channels) > 1 && s.PurchaseTypeMode == "per_channel" {
modeIcon = " 👥"
}
return Button(fmt.Sprintf("%s Тип%s", icon, modeIcon), "opt_type")
}
func (s *PurchaseOptionalDetails) formatButton() echotron.InlineKeyboardButton {
icon := ""
if s.hasFormatValue() {
icon = "✎"
}
modeIcon := ""
if len(s.Channels) > 1 && s.FormatMode == "per_channel" {
modeIcon = " 👥"
}
return Button(fmt.Sprintf("%s Формат%s", icon, modeIcon), "opt_format")
}
func (s *PurchaseOptionalDetails) commentButton() echotron.InlineKeyboardButton {
icon := ""
if s.Comment != "" {
icon = "✎"
}
modeIcon := ""
if len(s.Channels) > 1 && s.CommentMode == "per_channel" {
modeIcon = " 👥"
}
return Button(fmt.Sprintf("%s Комментарий%s", icon, modeIcon), "opt_comment")
}
func (s *PurchaseOptionalDetails) inviteLinkTypeButton() echotron.InlineKeyboardButton {
icon := ""
if s.hasInviteLinkTypeValue() {
icon = "✎"
}
modeIcon := ""
if len(s.Channels) > 1 && s.InviteLinkTypeMode == "per_channel" {
modeIcon = " 👥"
}
label := "Тип ссылки"
if s.InviteLinkType != "" {
label += ": " + s.inviteLinkTypeLabel(s.InviteLinkType)
}
return Button(fmt.Sprintf("%s %s%s", icon, label, modeIcon), "opt_invite_link_type")
}
func (s *PurchaseOptionalDetails) globalModeButton() echotron.InlineKeyboardButton {
return Button("⚙️ Режимы параметров", "toggle_global_mode")
}
func (s *PurchaseOptionalDetails) commentDeleteRow() []echotron.InlineKeyboardButton {
if s.Comment == "" {
return nil
}
return Row(Button("⌫ Удалить комментарий", "delete_comment"))
}
func (s *PurchaseOptionalDetails) costBeforeDeleteRow() []echotron.InlineKeyboardButton {
if s.CostBeforeBargain == nil || s.CostBeforeMode == "per_channel" {
return nil
}
return Row(Button("⌫ Удалить до торга", "delete_cost_before"))
}
func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderMode) {
text := "<b>Дополнительно</b>\n\n"
switch s.InputMode {
case "type":
text += "<b>Выберите тип закупа</b>"
keyboard := Keyboard(
Row(Button("Взаимный пиар", "type:mutual_pr"), Button("Стандарт", "type:standard")),
Row(Button("← Назад", "back_to_optional")),
)
if mode == bot.EditMessage {
b.Edit(text, keyboard)
} else {
b.SendNew(text, keyboard)
}
return
case "cost_value":
text += "<b>Ввод стоимости</b>\n\nНапример: <code>15000</code>"
if s.CurrentChannel != "" {
text += fmt.Sprintf("\n\nКанал: <b>%s</b>", channelLabelByKey(s.Channels, s.CurrentChannel))
}
typeLabel := s.costTypeLabelForCurrent()
keyboard := Keyboard(
Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_type_toggle")),
Row(Button("← Назад", s.backAction())),
)
if mode == bot.EditMessage {
b.Edit(text, keyboard)
} else {
b.SendNew(text, keyboard)
}
return
case "cost_before_value":
text += "<b>Ввод стоимости до торга</b>\n\nНапример: <code>20000</code>"
if s.CurrentChannel != "" {
text += fmt.Sprintf("\n\nКанал: <b>%s</b>", channelLabelByKey(s.Channels, s.CurrentChannel))
}
typeLabel := s.costBeforeTypeLabelForCurrent()
var keyboard echotron.InlineKeyboardMarkup
// Показываем кнопку удаления только если есть значение и это не режим per-channel
if s.CurrentChannel == "" && s.CostBeforeBargain != nil && s.CostBeforeBargain.Value != nil {
keyboard = Keyboard(
Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")),
Row(Button("⌫ Удалить до торга", "delete_cost_before")),
Row(Button("← Назад", s.backAction())),
)
} else {
keyboard = Keyboard(
Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")),
Row(Button("← Назад", s.backAction())),
)
}
if mode == bot.EditMessage {
b.Edit(text, keyboard)
} else {
b.SendNew(text, keyboard)
}
return
case "format_select":
text = "<i>Страница ввода формата размещения</i>\n\n"
if s.CurrentChannel != "" {
text += fmt.Sprintf("Канал: <b>%s</b>\n\n", channelLabelByKey(s.Channels, s.CurrentChannel))
}
text += "Выберите формат\n\n"
keyboard := Keyboard(
Row(
Button("1ч / 24ч", "format_preset:60:1440"),
Button("1ч / 36ч", "format_preset:60:2160"),
Button("1ч / 48ч", "format_preset:60:2880"),
Button("1ч / 72ч", "format_preset:60:4320"),
),
Row(
Button("1ч / 7д", "format_preset:60:10080"),
Button("1ч / 30д", "format_preset:60:43200"),
Button("1ч / 60д", "format_preset:60:86400"),
Button("1ч / 90д", "format_preset:60:129600"),
),
Row(
Button("1ч / без удаления", "format_preset:60:0"),
),
Row(
Button("2ч / 24ч", "format_preset:120:1440"),
Button("2ч / 36ч", "format_preset:120:2160"),
Button("2ч / 48ч", "format_preset:120:2880"),
Button("2ч / 72ч", "format_preset:120:4320"),
),
Row(
Button("2ч / 7д", "format_preset:120:10080"),
Button("2ч / 30д", "format_preset:120:43200"),
Button("2ч / 60д", "format_preset:120:86400"),
Button("2ч / 90д", "format_preset:120:129600"),
),
Row(
Button("← Назад", s.backAction()),
Button("Свой формат", "format_custom"),
),
)
if mode == bot.EditMessage {
b.Edit(text, keyboard)
} else {
b.SendNew(text, keyboard)
}
return
case "format_custom":
s.renderCustomFormatTop(b, mode)
return
case "format_custom_top_input":
text += "<b>Введите число для времени в топе</b>\n\n"
unit := "часах"
if s.CustomFormatTopUnit == "minutes" {
unit = "минутах"
}
text += fmt.Sprintf("Единица: <b>%s</b>", unit)
case "format_custom_feed":
s.renderCustomFormatFeed(b, mode)
return
case "format_custom_feed_input":
text += "<b>Введите число для времени в ленте</b>\n\n"
unit := "часах"
if s.CustomFormatFeedUnit == "days" {
unit = "днях"
}
text += fmt.Sprintf("Единица: <b>%s</b>", unit)
case "invite_link_type":
text = "<i>Страница выбора типа ссылки</i>\n\n"
if s.CurrentChannel != "" {
text += fmt.Sprintf("Канал: <b>%s</b>\n\n", channelLabelByKey(s.Channels, s.CurrentChannel))
}
text += "Выберите тип ссылки\n\n"
keyboard := Keyboard(
Row(
Button("Открытая", "invite_link_type:public"),
Button("С заявками", "invite_link_type:approval"),
),
Row(Button("← Назад", s.backAction())),
)
if mode == bot.EditMessage {
b.Edit(text, keyboard)
} else {
b.SendNew(text, keyboard)
}
return
case "comment":
text += "<b>Введите комментарий</b>"
keyboard := Keyboard(Row(Button("← Назад", s.backAction())))
if s.Comment != "" {
keyboard = Keyboard(
Row(Button("⌫ Удалить комментарий", "delete_comment")),
Row(Button("← Назад", s.backAction())),
)
}
if mode == bot.EditMessage {
b.Edit(text, keyboard)
} else {
b.SendNew(text, keyboard)
}
return
default:
s.InputMode = ""
s.Enter(b, mode)
return
}
keyboard := Keyboard(
Row(Button("← Назад", s.backAction())),
)
if mode == bot.EditMessage {
b.Edit(text, keyboard)
} else {
b.SendNew(text, keyboard)
}
}
func (s *PurchaseOptionalDetails) renderCustomFormatTop(b *bot.Bot, mode bot.RenderMode) {
text := "<b>Свой формат — время в топе</b>\n\n"
if s.CurrentChannel != "" {
text += fmt.Sprintf("Канал: <b>%s</b>\n\n", channelLabelByKey(s.Channels, s.CurrentChannel))
}
var rows [][]echotron.InlineKeyboardButton
if s.CustomFormatTopUnit == "minutes" {
text += "⏱ Единица: <b>минуты</b>\n\n"
text += "Выберите время в топе"
rows = append(rows,
Row(
Button("10", "format_custom_top:10"),
Button("15", "format_custom_top:15"),
Button("20", "format_custom_top:20"),
Button("30", "format_custom_top:30"),
),
Row(
Button("45", "format_custom_top:45"),
Button("60", "format_custom_top:60"),
Button("90", "format_custom_top:90"),
Button("120", "format_custom_top:120"),
),
Row(
Button("↔ Часы", "format_custom_top_toggle_unit"),
Button("Ввести число", "format_custom_top_input_mode"),
),
)
} else {
text += "⏱ Единица: <b>часы</b>\n\n"
text += "Выберите время в топе"
rows = append(rows,
Row(
Button("1", "format_custom_top:60"),
Button("2", "format_custom_top:120"),
Button("3", "format_custom_top:180"),
Button("4", "format_custom_top:240"),
),
Row(
Button("5", "format_custom_top:300"),
Button("6", "format_custom_top:360"),
Button("8", "format_custom_top:480"),
Button("12", "format_custom_top:720"),
),
Row(
Button("↔ Минуты", "format_custom_top_toggle_unit"),
Button("Ввести число", "format_custom_top_input_mode"),
),
)
}
rows = append(rows, Row(Button("← Назад", "format_custom_back_to_select")))
b.Render(text, Keyboard(rows...), mode)
}
func (s *PurchaseOptionalDetails) renderCustomFormatFeed(b *bot.Bot, mode bot.RenderMode) {
text := "<b>Свой формат — время в ленте</b>\n\n"
if s.CurrentChannel != "" {
text += fmt.Sprintf("Канал: <b>%s</b>\n\n", channelLabelByKey(s.Channels, s.CurrentChannel))
}
if s.CustomFormatTopValue != nil {
text += fmt.Sprintf("Время в топе: <b>%s</b> ✓\n\n", formatDuration(*s.CustomFormatTopValue, "top"))
}
var rows [][]echotron.InlineKeyboardButton
if s.CustomFormatFeedUnit == "days" {
text += "⏱ Единица: <b>дни</b>\n\n"
text += "Выберите время в ленте"
rows = append(rows,
Row(
Button("7", "format_custom_feed:10080"),
Button("14", "format_custom_feed:20160"),
Button("30", "format_custom_feed:43200"),
Button("60", "format_custom_feed:86400"),
),
Row(
Button("90", "format_custom_feed:129600"),
Button("120", "format_custom_feed:172800"),
Button("180", "format_custom_feed:259200"),
Button("365", "format_custom_feed:525600"),
),
Row(
Button("↔ Часы", "format_custom_feed_toggle_unit"),
Button("Без удаления", "format_custom_feed:0"),
),
)
} else {
text += "⏱ Единица: <b>часы</b>\n\n"
text += "Выберите время в ленте"
rows = append(rows,
Row(
Button("24", "format_custom_feed:1440"),
Button("36", "format_custom_feed:2160"),
Button("48", "format_custom_feed:2880"),
Button("72", "format_custom_feed:4320"),
),
Row(
Button("96", "format_custom_feed:5760"),
Button("120", "format_custom_feed:7200"),
Button("144", "format_custom_feed:8640"),
Button("168", "format_custom_feed:10080"),
),
Row(
Button("↔ Дни", "format_custom_feed_toggle_unit"),
Button("Без удаления", "format_custom_feed:0"),
),
)
}
rows = append(rows,
Row(Button("Ввести число", "format_custom_feed_input_mode")),
Row(Button("← Назад", "format_custom_back_to_top")),
)
b.Render(text, Keyboard(rows...), mode)
}
func (s *PurchaseOptionalDetails) renderModeSelect(b *bot.Bot, mode bot.RenderMode) {
text := "<b>⚙ Переключить режим параметра</b>\n\n"
// Показываем текущую информацию о параметрах
text += s.formatOptionalSummary()
text += "\n\n"
text += "Выберите параметр для изменения режима:\n\n"
var rows [][]echotron.InlineKeyboardButton
// Дата размещения
placementLabel := "Дата размещения: "
if s.PlacementMode == "per_channel" {
placementLabel += "👥"
} else {
placementLabel += "общий"
}
rows = append(rows, Row(Button(placementLabel, "toggle_param_mode:placement")))
// Дата оплаты
paymentDateLabel := "Дата оплаты: "
if s.PaymentDateMode == "per_channel" {
paymentDateLabel += "👥"
} else {
paymentDateLabel += "общий"
}
rows = append(rows, Row(Button(paymentDateLabel, "toggle_param_mode:payment_date")))
// Тип закупа
purchaseTypeLabel := "Тип закупа: "
if s.PurchaseTypeMode == "per_channel" {
purchaseTypeLabel += "👥"
} else {
purchaseTypeLabel += "общий"
}
rows = append(rows, Row(Button(purchaseTypeLabel, "toggle_param_mode:purchase_type")))
// Комментарий
commentLabel := "Комментарий: "
if s.CommentMode == "per_channel" {
commentLabel += "👥"
} else {
commentLabel += "общий"
}
rows = append(rows, Row(Button(commentLabel, "toggle_param_mode:comment")))
// Формат
formatLabel := "Формат: "
if s.FormatMode == "per_channel" {
formatLabel += "👥"
} else {
formatLabel += "общий"
}
rows = append(rows, Row(Button(formatLabel, "toggle_param_mode:format")))
// Тип ссылки
inviteLinkTypeLabel := "Тип ссылки: "
if s.InviteLinkTypeMode == "per_channel" {
inviteLinkTypeLabel += "👥"
} else {
inviteLinkTypeLabel += "общий"
}
rows = append(rows, Row(Button(inviteLinkTypeLabel, "toggle_param_mode:invite_link_type")))
rows = append(rows, Row(Button("← Назад", "back_to_optional")))
b.Render(text, Keyboard(rows...), mode)
}
func (s *PurchaseOptionalDetails) renderParamScreens(b *bot.Bot, mode bot.RenderMode) bool {
switch s.InputMode {
case "placement_channels":
s.renderParamChannels(b, mode, "placement")
return true
case "payment_date_channels":
s.renderParamChannels(b, mode, "payment_date")
return true
case "cost_channels":
s.renderParamChannels(b, mode, "cost")
return true
case "cost_before_channels":
s.renderParamChannels(b, mode, "cost_before")
return true
case "purchase_type_channels":
s.renderParamChannels(b, mode, "purchase_type")
return true
case "comment_channels":
s.renderParamChannels(b, mode, "comment")
return true
case "format_channels":
s.renderParamChannels(b, mode, "format")
return true
case "invite_link_type_channels":
s.renderParamChannels(b, mode, "invite_link_type")
return true
default:
return false
}
}
func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.RenderMode, param string) {
s.CurrentParam = param
text := fmt.Sprintf("<b>%s — по каналам</b>\n\n", s.paramTitle(param))
text += s.renderParamChannelSummary(param) + "\n\n"
const perPage = 5
total := len(s.Channels)
if total == 0 {
text += "\nКаналы не выбраны"
b.Render(text, Keyboard(Row(Button("← Назад", "back_to_optional"))), mode)
return
}
if s.ParamPage*perPage >= total {
s.ParamPage = 0
}
start, end := ui2.GetPageBounds(s.ParamPage, perPage, total)
// Устанавливаем режим по умолчанию
if s.ChannelEditMode == "" {
s.ChannelEditMode = "edit"
}
var rows [][]echotron.InlineKeyboardButton
// Кнопки переключения режима
editLabel := "Редактирование"
copyLabel := "Копирование"
if s.ChannelEditMode == "edit" {
editLabel = "● " + editLabel
} else {
copyLabel = "● " + copyLabel
}
rows = append(rows, Row(
Button(editLabel, "channel_mode:edit"),
Button(copyLabel, "channel_mode:copy"),
))
// Список каналов с кнопками в зависимости от режима
for i := start; i < end; i++ {
label := fmt.Sprintf("%d", i+1)
channelKey := channelKey(s.Channels[i])
if s.ChannelEditMode == "copy" {
// Режим копирования
var channelRow []echotron.InlineKeyboardButton
channelRow = append(channelRow, Button(label, fmt.Sprintf("param_edit:%s:%d", param, i)))
channelRow = append(channelRow, Button("⧉", fmt.Sprintf("param_copy:%s:%d", param, i)))
// Кнопка вставки - только если есть данные в буфере
if s.hasCopyBuffer(param) {
channelRow = append(channelRow, Button("⇲", fmt.Sprintf("param_paste:%s:%d", param, i)))
} else {
channelRow = append(channelRow, Button(" ", "noop"))
}
// Кнопка удаления - только если есть данные у канала
if s.hasChannelValue(param, channelKey) {
channelRow = append(channelRow, Button("⌫", fmt.Sprintf("param_clear:%s:%d", param, i)))
} else {
channelRow = append(channelRow, Button(" ", "noop"))
}
rows = append(rows, channelRow)
} else {
// Обычный режим редактирования
var channelRow []echotron.InlineKeyboardButton
channelRow = append(channelRow, Button(label, fmt.Sprintf("param_edit:%s:%d", param, i)))
// Кнопка "Добавить" или "Изменить" в зависимости от наличия данных
hasValue := s.hasChannelValue(param, channelKey)
if hasValue {
channelRow = append(channelRow, Button("Изменить", fmt.Sprintf("param_edit:%s:%d", param, i)))
} else {
channelRow = append(channelRow, Button("Добавить", fmt.Sprintf("param_edit:%s:%d", param, i)))
}
// Кнопка удаления - только если есть данные у канала
if hasValue {
channelRow = append(channelRow, Button("⌫", fmt.Sprintf("param_clear:%s:%d", param, i)))
} else {
channelRow = append(channelRow, Button(" ", "noop"))
}
rows = append(rows, channelRow)
}
}
pages := ui2.CalculatePages(total, perPage)
if navRow := ui2.BuildNavigationRow(ui2.PaginationConfig{
CurrentPage: s.ParamPage,
TotalPages: pages,
}); navRow != nil {
rows = append(rows, navRow)
}
rows = append(rows, Row(
Button("← Назад", fmt.Sprintf("param_back:%s", param)),
))
b.Render(text, Keyboard(rows...), mode)
}
func (s *PurchaseOptionalDetails) renderParamChannelSummary(param string) string {
// Собираем строки и вычисляем максимальную длину названия канала
type channelLine struct {
label string
value string
}
var lines []channelLine
maxLabelLen := 0
for _, ch := range s.Channels {
label := channelLabel(ch)
labelLen := len([]rune(label)) // Считаем руны для Unicode
if labelLen > maxLabelLen {
maxLabelLen = labelLen
}
channelKey := channelKey(ch)
value := s.formatParamValue(param, channelKey)
lines = append(lines, channelLine{label: label, value: value})
}
// Логирование для отладки (INFO уровень)
log.Info().
Str("param", param).
Int("maxLabelLen", maxLabelLen).
Int("channelsCount", len(lines)).
Msg("🔍 renderParamChannelSummary")
// Форматируем: маркер + название + паддинг + значение
// Паддинг вычисляем так, чтобы все значения начинались с одной позиции
const (
marker = "· " // Маркер пункта
valueIndent = 6 // Отступ после самого длинного названия (увеличен для лучшей читаемости)
)
var result []string
for i, line := range lines {
labelLen := len([]rune(line.label))
// Паддинг = (макс.длина - тек.длина) + отступ после названия
paddingLen := maxLabelLen - labelLen + valueIndent
padding := strings.Repeat("\u00A0", paddingLen)
formattedLine := fmt.Sprintf("%s%s%s%s", marker, line.label, padding, line.value)
result = append(result, formattedLine)
// Логируем каждую строку (INFO уровень)
log.Info().
Int("index", i).
Str("label", line.label).
Int("labelLen", labelLen).
Int("paddingLen", paddingLen).
Str("formattedLine", formattedLine).
Msg("📝 Channel line")
}
finalResult := strings.Join(result, "\n")
// Оборачиваем в <pre> для моноширинного шрифта (выравнивание работает только в monospace)
finalResult = fmt.Sprintf("<pre>%s</pre>", finalResult)
// Логируем итоговый результат
log.Info().
Str("param", param).
Str("result", finalResult).
Msg("✅ Final summary")
return finalResult
}
func (s *PurchaseOptionalDetails) handleParamCallback(b *bot.Bot, data string) bool {
switch {
case strings.HasPrefix(data, "param_edit:"):
param, index, ok := s.parseParamIndex(data, "param_edit:")
if !ok {
s.Enter(b, bot.EditMessage)
return true
}
if index >= 0 && index < len(s.Channels) {
s.CurrentParam = param
s.CurrentChannel = channelKey(s.Channels[index])
s.openChannelEditor(b, param, s.CurrentChannel)
return true
}
s.Enter(b, bot.EditMessage)
return true
case strings.HasPrefix(data, "param_copy:"):
param, index, ok := s.parseParamIndex(data, "param_copy:")
if !ok {
s.Enter(b, bot.EditMessage)
return true
}
if index >= 0 && index < len(s.Channels) {
channelKey := channelKey(s.Channels[index])
s.copyParamValue(param, channelKey)
s.Enter(b, bot.EditMessage)
return true
}
s.Enter(b, bot.EditMessage)
return true
case strings.HasPrefix(data, "param_paste:"):
param, index, ok := s.parseParamIndex(data, "param_paste:")
if !ok {
s.Enter(b, bot.EditMessage)
return true
}
if index >= 0 && index < len(s.Channels) {
channelKey := channelKey(s.Channels[index])
s.pasteParamValue(param, channelKey)
s.Enter(b, bot.EditMessage)
return true
}
s.Enter(b, bot.EditMessage)
return true
case strings.HasPrefix(data, "param_clear:"):
param, index, ok := s.parseParamIndex(data, "param_clear:")
if !ok {
s.Enter(b, bot.EditMessage)
return true
}
if index >= 0 && index < len(s.Channels) {
channelKey := channelKey(s.Channels[index])
s.clearParamValue(param, channelKey)
s.Enter(b, bot.EditMessage)
return true
}
s.Enter(b, bot.EditMessage)
return true
case data == "prev" && strings.HasSuffix(s.InputMode, "_channels"):
if s.ParamPage > 0 {
s.ParamPage--
}
s.Enter(b, bot.EditMessage)
return true
case data == "next" && strings.HasSuffix(s.InputMode, "_channels"):
s.ParamPage++
s.Enter(b, bot.EditMessage)
return true
case strings.HasPrefix(data, "param_back:"):
// Возвращаемся на главный экран
s.InputMode = ""
s.Enter(b, bot.EditMessage)
return true
}
return false
}
func (s *PurchaseOptionalDetails) parseParamIndex(data, prefix string) (string, int, bool) {
rest := strings.TrimPrefix(data, prefix)
parts := strings.Split(rest, ":")
if len(parts) != 2 {
return "", 0, false
}
param := parts[0]
index, err := strconv.Atoi(parts[1])
if err != nil {
return "", 0, false
}
return param, index, true
}
func (s *PurchaseOptionalDetails) openCommonEditor(b *bot.Bot, param string) {
switch param {
case "placement":
s.InputMode = ""
s.ReturnMode = ""
b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
Title: "Дата и время размещения",
Key: "placement_datetime",
IncludeTime: true,
AllowPast: true,
Selected: s.PlacementDateTime,
BackState: s,
}), bot.EditMessage)
case "payment_date":
s.InputMode = ""
s.ReturnMode = ""
b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
Title: "Дата оплаты",
Key: "payment_date",
IncludeTime: false,
AllowPast: true,
Selected: s.PaymentDate,
BackState: s,
}), bot.EditMessage)
case "purchase_type":
s.InputMode = "type"
s.ReturnMode = ""
s.CurrentChannel = ""
s.Enter(b, bot.EditMessage)
case "comment":
s.InputMode = "comment"
s.ReturnMode = ""
s.CurrentChannel = ""
s.Enter(b, bot.EditMessage)
case "cost":
s.InputMode = "cost_value"
s.ReturnMode = ""
s.CurrentChannel = ""
s.Enter(b, bot.EditMessage)
case "cost_before":
s.InputMode = "cost_before_value"
s.ReturnMode = ""
s.CurrentChannel = ""
s.Enter(b, bot.EditMessage)
case "format":
s.InputMode = "format_select"
s.ReturnMode = ""
s.CurrentChannel = ""
s.Enter(b, bot.EditMessage)
case "invite_link_type":
s.InputMode = "invite_link_type"
s.ReturnMode = ""
s.CurrentChannel = ""
s.Enter(b, bot.EditMessage)
default:
s.InputMode = ""
s.Enter(b, bot.EditMessage)
}
}
func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, channelKey string) {
switch param {
case "placement":
s.InputMode = "placement_channels"
b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
Title: "Дата и время размещения",
Key: "placement_datetime:" + channelKey,
IncludeTime: true,
AllowPast: true,
Selected: s.PlacementByChannel[channelKey],
BackState: s,
}), bot.EditMessage)
case "payment_date":
s.InputMode = "payment_date_channels"
b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
Title: "Дата оплаты",
Key: "payment_date:" + channelKey,
IncludeTime: false,
AllowPast: true,
Selected: s.PaymentDateByChannel[channelKey],
BackState: s,
}), bot.EditMessage)
case "purchase_type":
s.InputMode = "type"
s.ReturnMode = "purchase_type_channels"
s.CurrentChannel = channelKey
s.Enter(b, bot.EditMessage)
case "comment":
s.InputMode = "comment"
s.ReturnMode = "comment_channels"
s.CurrentChannel = channelKey
s.Enter(b, bot.EditMessage)
case "cost":
s.InputMode = "cost_value"
s.ReturnMode = "cost_channels"
s.CurrentChannel = channelKey
s.Enter(b, bot.EditMessage)
case "cost_before":
s.InputMode = "cost_before_value"
s.ReturnMode = "cost_before_channels"
s.CurrentChannel = channelKey
s.Enter(b, bot.EditMessage)
case "format":
s.InputMode = "format_select"
s.ReturnMode = "format_channels"
s.CurrentChannel = channelKey
s.Enter(b, bot.EditMessage)
case "invite_link_type":
s.InputMode = "invite_link_type"
s.ReturnMode = "invite_link_type_channels"
s.CurrentChannel = channelKey
s.Enter(b, bot.EditMessage)
default:
s.InputMode = ""
s.Enter(b, bot.EditMessage)
}
}
func (s *PurchaseOptionalDetails) copyParamValue(param, channelKey string) {
switch param {
case "placement":
s.PlacementCopy = s.PlacementByChannel[channelKey]
case "payment_date":
s.PaymentDateCopy = s.PaymentDateByChannel[channelKey]
case "cost":
entry := s.CostByChannel[channelKey]
copied := entry
if entry.Value == nil {
copied.Value = nil
} else {
value := *entry.Value
copied.Value = &value
}
s.CostCopy = &copied
case "cost_before":
entry := s.CostBeforeByChannel[channelKey]
copied := entry
if entry.Value == nil {
copied.Value = nil
} else {
value := *entry.Value
copied.Value = &value
}
s.CostBeforeCopy = &copied
case "purchase_type":
if value, ok := s.PurchaseTypeByChannel[channelKey]; ok {
copied := value
s.PurchaseTypeCopy = &copied
} else {
s.PurchaseTypeCopy = nil
}
case "comment":
if value, ok := s.CommentByChannel[channelKey]; ok {
copied := value
s.CommentCopy = &copied
} else {
s.CommentCopy = nil
}
case "format":
if value, ok := s.FormatByChannel[channelKey]; ok {
copied := value
s.FormatCopy = &copied
} else {
s.FormatCopy = nil
}
case "invite_link_type":
if value, ok := s.InviteLinkTypeByChannel[channelKey]; ok {
copied := value
s.InviteLinkTypeCopy = &copied
} else {
s.InviteLinkTypeCopy = nil
}
}
}
func (s *PurchaseOptionalDetails) copyCommonValueToChannels(param string) {
switch param {
case "placement":
if s.PlacementDateTime == nil {
return
}
for _, ch := range s.Channels {
key := channelKey(ch)
value := s.PlacementDateTime
s.PlacementByChannel[key] = value
}
case "payment_date":
if s.PaymentDate == nil {
return
}
for _, ch := range s.Channels {
key := channelKey(ch)
value := s.PaymentDate
s.PaymentDateByChannel[key] = value
}
case "cost":
if s.CostValue == nil {
return
}
for _, ch := range s.Channels {
key := channelKey(ch)
entry := CostEntry{
Type: s.CostType,
Value: s.CostValue,
}
s.CostByChannel[key] = entry
}
case "cost_before":
if s.CostBeforeBargain == nil || s.CostBeforeBargain.Value == nil {
return
}
for _, ch := range s.Channels {
key := channelKey(ch)
entry := CostEntry{
Type: s.CostBeforeBargain.Type,
Value: s.CostBeforeBargain.Value,
}
s.CostBeforeByChannel[key] = entry
}
case "purchase_type":
if s.PurchaseType == "" {
return
}
for _, ch := range s.Channels {
key := channelKey(ch)
s.PurchaseTypeByChannel[key] = s.PurchaseType
}
case "comment":
if s.Comment == "" {
return
}
for _, ch := range s.Channels {
key := channelKey(ch)
s.CommentByChannel[key] = s.Comment
}
case "format":
if s.Format == "" {
return
}
for _, ch := range s.Channels {
key := channelKey(ch)
s.FormatByChannel[key] = s.Format
s.TopTimeByChannel[key] = s.TopTimeMinutes
s.FeedTimeByChannel[key] = s.FeedTimeMinutes
}
case "invite_link_type":
if s.InviteLinkType == "" {
return
}
for _, ch := range s.Channels {
key := channelKey(ch)
s.InviteLinkTypeByChannel[key] = s.InviteLinkType
}
}
}
func (s *PurchaseOptionalDetails) pasteParamValue(param, channelKey string) {
switch param {
case "placement":
if s.PlacementCopy == nil {
s.PlacementByChannel[channelKey] = nil
return
}
value := s.PlacementCopy.In(ui2.MskLocation)
s.PlacementByChannel[channelKey] = &value
case "payment_date":
if s.PaymentDateCopy == nil {
s.PaymentDateByChannel[channelKey] = nil
return
}
value := s.PaymentDateCopy.In(ui2.MskLocation)
s.PaymentDateByChannel[channelKey] = &value
case "cost":
if s.CostCopy == nil {
delete(s.CostByChannel, channelKey)
return
}
copied := *s.CostCopy
if copied.Value != nil {
value := *copied.Value
copied.Value = &value
}
s.CostByChannel[channelKey] = copied
case "cost_before":
if s.CostBeforeCopy == nil {
delete(s.CostBeforeByChannel, channelKey)
return
}
copied := *s.CostBeforeCopy
if copied.Value != nil {
value := *copied.Value
copied.Value = &value
}
s.CostBeforeByChannel[channelKey] = copied
case "purchase_type":
if s.PurchaseTypeCopy == nil {
delete(s.PurchaseTypeByChannel, channelKey)
return
}
s.PurchaseTypeByChannel[channelKey] = *s.PurchaseTypeCopy
case "comment":
if s.CommentCopy == nil {
delete(s.CommentByChannel, channelKey)
return
}
s.CommentByChannel[channelKey] = *s.CommentCopy
case "format":
if s.FormatCopy == nil {
delete(s.FormatByChannel, channelKey)
return
}
s.FormatByChannel[channelKey] = *s.FormatCopy
case "invite_link_type":
if s.InviteLinkTypeCopy == nil {
delete(s.InviteLinkTypeByChannel, channelKey)
return
}
s.InviteLinkTypeByChannel[channelKey] = *s.InviteLinkTypeCopy
}
}
func (s *PurchaseOptionalDetails) applyParamToAll(param string) {
switch param {
case "placement":
for _, ch := range s.Channels {
s.pasteParamValue(param, channelKey(ch))
}
case "payment_date":
for _, ch := range s.Channels {
s.pasteParamValue(param, channelKey(ch))
}
case "cost":
for _, ch := range s.Channels {
s.pasteParamValue(param, channelKey(ch))
}
case "cost_before":
for _, ch := range s.Channels {
s.pasteParamValue(param, channelKey(ch))
}
case "purchase_type":
for _, ch := range s.Channels {
s.pasteParamValue(param, channelKey(ch))
}
case "comment":
for _, ch := range s.Channels {
s.pasteParamValue(param, channelKey(ch))
}
case "format":
for _, ch := range s.Channels {
s.pasteParamValue(param, channelKey(ch))
}
case "invite_link_type":
for _, ch := range s.Channels {
s.pasteParamValue(param, channelKey(ch))
}
}
}
func (s *PurchaseOptionalDetails) clearParamValue(param, channelKey string) {
switch param {
case "placement":
s.PlacementByChannel[channelKey] = nil
case "payment_date":
s.PaymentDateByChannel[channelKey] = nil
case "cost":
delete(s.CostByChannel, channelKey)
case "cost_before":
delete(s.CostBeforeByChannel, channelKey)
case "purchase_type":
delete(s.PurchaseTypeByChannel, channelKey)
case "comment":
delete(s.CommentByChannel, channelKey)
case "format":
delete(s.FormatByChannel, channelKey)
delete(s.TopTimeByChannel, channelKey)
delete(s.FeedTimeByChannel, channelKey)
case "invite_link_type":
delete(s.InviteLinkTypeByChannel, channelKey)
}
}
func (s *PurchaseOptionalDetails) paramTitle(param string) string {
switch param {
case "placement":
return "Дата и время размещения"
case "payment_date":
return "Дата оплаты"
case "cost":
return "Стоимость"
case "cost_before":
return "Стоимость до торга"
case "purchase_type":
return "Тип закупа"
case "comment":
return "Комментарий"
case "format":
return "Формат"
case "invite_link_type":
return "Тип ссылки"
default:
return ""
}
}
func (s *PurchaseOptionalDetails) paramMode(param string) string {
switch param {
case "placement":
if s.PlacementMode == "" {
return "common"
}
return s.PlacementMode
case "payment_date":
if s.PaymentDateMode == "" {
return "common"
}
return s.PaymentDateMode
case "cost":
if s.CostMode == "" {
return "per_channel"
}
return s.CostMode
case "cost_before":
if s.CostBeforeMode == "" {
return "per_channel"
}
return s.CostBeforeMode
case "purchase_type":
if s.PurchaseTypeMode == "" {
return "common"
}
return s.PurchaseTypeMode
case "comment":
if s.CommentMode == "" {
return "common"
}
return s.CommentMode
case "format":
if s.FormatMode == "" {
return "common"
}
return s.FormatMode
case "invite_link_type":
if s.InviteLinkTypeMode == "" {
return "common"
}
return s.InviteLinkTypeMode
default:
return "common"
}
}
func (s *PurchaseOptionalDetails) setParamMode(param, mode string) {
switch param {
case "placement":
s.PlacementMode = mode
case "payment_date":
s.PaymentDateMode = mode
case "cost":
s.CostMode = mode
case "cost_before":
s.CostBeforeMode = mode
case "purchase_type":
s.PurchaseTypeMode = mode
case "comment":
s.CommentMode = mode
case "format":
s.FormatMode = mode
case "invite_link_type":
s.InviteLinkTypeMode = mode
}
}
func (s *PurchaseOptionalDetails) ensureDefaults() {
if s.PlacementMode == "" {
s.PlacementMode = "common"
}
if s.PaymentDateMode == "" {
s.PaymentDateMode = "common"
}
if s.CostMode == "" {
s.CostMode = "per_channel"
}
if s.CostBeforeMode == "" {
s.CostBeforeMode = "per_channel"
}
if s.PurchaseTypeMode == "" {
s.PurchaseTypeMode = "common"
}
if s.CommentMode == "" {
s.CommentMode = "common"
}
if s.FormatMode == "" {
s.FormatMode = "common"
}
if s.InviteLinkTypeMode == "" {
s.InviteLinkTypeMode = "common"
}
if s.InviteLinkType == "" {
s.InviteLinkType = s.ProjectDefaultLinkType
if s.InviteLinkType == "" {
s.InviteLinkType = "approval"
}
}
if s.PlacementByChannel == nil {
s.PlacementByChannel = make(map[string]*time.Time)
}
if s.PaymentDateByChannel == nil {
s.PaymentDateByChannel = make(map[string]*time.Time)
}
if s.CostByChannel == nil {
s.CostByChannel = make(map[string]CostEntry)
}
if s.CostBeforeByChannel == nil {
s.CostBeforeByChannel = make(map[string]CostEntry)
}
if s.PurchaseTypeByChannel == nil {
s.PurchaseTypeByChannel = make(map[string]string)
}
if s.CommentByChannel == nil {
s.CommentByChannel = make(map[string]string)
}
if s.FormatByChannel == nil {
s.FormatByChannel = make(map[string]string)
}
if s.TopTimeByChannel == nil {
s.TopTimeByChannel = make(map[string]*int)
}
if s.FeedTimeByChannel == nil {
s.FeedTimeByChannel = make(map[string]*int)
}
if s.CustomFormatTopUnit == "" {
s.CustomFormatTopUnit = "hours"
}
if s.CustomFormatFeedUnit == "" {
s.CustomFormatFeedUnit = "hours"
}
if s.InviteLinkTypeByChannel == nil {
s.InviteLinkTypeByChannel = make(map[string]string)
}
s.syncChannelMaps()
}
func (s *PurchaseOptionalDetails) syncChannelMaps() {
valid := make(map[string]struct{}, len(s.Channels))
for _, ch := range s.Channels {
key := channelKey(ch)
if key != "" {
valid[key] = struct{}{}
}
}
for key := range s.PlacementByChannel {
if _, ok := valid[key]; !ok {
delete(s.PlacementByChannel, key)
}
}
for key := range s.PaymentDateByChannel {
if _, ok := valid[key]; !ok {
delete(s.PaymentDateByChannel, key)
}
}
for key := range s.CostByChannel {
if _, ok := valid[key]; !ok {
delete(s.CostByChannel, key)
}
}
for key := range s.CostBeforeByChannel {
if _, ok := valid[key]; !ok {
delete(s.CostBeforeByChannel, key)
}
}
for key := range s.PurchaseTypeByChannel {
if _, ok := valid[key]; !ok {
delete(s.PurchaseTypeByChannel, key)
}
}
for key := range s.CommentByChannel {
if _, ok := valid[key]; !ok {
delete(s.CommentByChannel, key)
}
}
for key := range s.FormatByChannel {
if _, ok := valid[key]; !ok {
delete(s.FormatByChannel, key)
}
}
for key := range s.TopTimeByChannel {
if _, ok := valid[key]; !ok {
delete(s.TopTimeByChannel, key)
}
}
for key := range s.FeedTimeByChannel {
if _, ok := valid[key]; !ok {
delete(s.FeedTimeByChannel, key)
}
}
for key := range s.InviteLinkTypeByChannel {
if _, ok := valid[key]; !ok {
delete(s.InviteLinkTypeByChannel, key)
}
}
}
func (s *PurchaseOptionalDetails) formatPlacementSummary() string {
if s.PlacementMode == "per_channel" && len(s.Channels) > 1 {
return "👥"
}
return s.formatDateTime(s.PlacementDateTime)
}
func (s *PurchaseOptionalDetails) formatPlacementDetails() string {
if s.PlacementMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return s.renderParamChannelSummary("placement")
}
func (s *PurchaseOptionalDetails) formatPaymentDateSummary() string {
if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 {
return "👥"
}
return s.formatDate(s.PaymentDate)
}
func (s *PurchaseOptionalDetails) formatPaymentDateDetails() string {
if s.PaymentDateMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return s.renderParamChannelSummary("payment_date")
}
func (s *PurchaseOptionalDetails) formatCostSummary() string {
if s.CostMode == "per_channel" && len(s.Channels) > 1 {
return "👥"
}
return s.formatCostValue()
}
func (s *PurchaseOptionalDetails) formatCostDetails() string {
if s.CostMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return s.renderParamChannelSummary("cost")
}
func (s *PurchaseOptionalDetails) formatCostBeforeSummary() string {
if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 {
return "👥"
}
return s.formatCostBefore()
}
func (s *PurchaseOptionalDetails) formatCostBeforeDetails() string {
if s.CostBeforeMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return s.renderParamChannelSummary("cost_before")
}
func (s *PurchaseOptionalDetails) formatPurchaseTypeSummary() string {
if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 {
return "👥"
}
return s.formatText(s.PurchaseType)
}
func (s *PurchaseOptionalDetails) formatPurchaseTypeDetails() string {
if s.PurchaseTypeMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return s.renderParamChannelSummary("purchase_type")
}
func (s *PurchaseOptionalDetails) formatCommentSummary() string {
if s.CommentMode == "per_channel" && len(s.Channels) > 1 {
return "👥"
}
return s.formatText(s.Comment)
}
func (s *PurchaseOptionalDetails) formatCommentDetails() string {
if s.CommentMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return s.renderParamChannelSummary("comment")
}
func (s *PurchaseOptionalDetails) formatFormatSummary() string {
if s.FormatMode == "per_channel" && len(s.Channels) > 1 {
return "👥"
}
return s.formatText(s.Format)
}
func (s *PurchaseOptionalDetails) formatFormatDetails() string {
if s.FormatMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return s.renderParamChannelSummary("format")
}
func (s *PurchaseOptionalDetails) formatInviteLinkTypeSummary() string {
if s.InviteLinkTypeMode == "per_channel" && len(s.Channels) > 1 {
return "👥"
}
return s.inviteLinkTypeLabel(s.InviteLinkType)
}
func (s *PurchaseOptionalDetails) formatInviteLinkTypeDetails() string {
if s.InviteLinkTypeMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return s.renderParamChannelSummary("invite_link_type")
}
func (s *PurchaseOptionalDetails) formatParamValue(param, channelKey string) string {
switch param {
case "placement":
value := s.PlacementByChannel[channelKey]
return s.formatDateTime(value)
case "payment_date":
value := s.PaymentDateByChannel[channelKey]
return s.formatDate(value)
case "cost":
return s.formatCostForChannel(channelKey)
case "cost_before":
return s.formatCostBeforeForChannel(channelKey)
case "purchase_type":
if value, ok := s.PurchaseTypeByChannel[channelKey]; ok && value != "" {
return value
}
return "—"
case "comment":
if value, ok := s.CommentByChannel[channelKey]; ok && value != "" {
return value
}
return "—"
case "format":
if value, ok := s.FormatByChannel[channelKey]; ok && value != "" {
return value
}
return "—"
case "invite_link_type":
if value, ok := s.InviteLinkTypeByChannel[channelKey]; ok && value != "" {
return s.inviteLinkTypeLabel(value)
}
return "—"
default:
return "—"
}
}
func (s *PurchaseOptionalDetails) formatCostForChannel(channelKey string) string {
entry, ok := s.CostByChannel[channelKey]
if !ok || entry.Value == nil {
return "—"
}
label := s.costTypeLabelForEntry(entry)
return fmt.Sprintf("%s %.0f₽", label, *entry.Value)
}
func (s *PurchaseOptionalDetails) formatCostBeforeForChannel(channelKey string) string {
entry, ok := s.CostBeforeByChannel[channelKey]
if !ok || entry.Value == nil {
return "—"
}
label := s.costBeforeTypeLabelForEntry(entry)
return fmt.Sprintf("%s %.0f₽", label, *entry.Value)
}
func (s *PurchaseOptionalDetails) hasCopyBuffer(param string) bool {
switch param {
case "placement":
return s.PlacementCopy != nil
case "payment_date":
return s.PaymentDateCopy != nil
case "cost":
return s.CostCopy != nil
case "cost_before":
return s.CostBeforeCopy != nil
case "purchase_type":
return s.PurchaseTypeCopy != nil
case "comment":
return s.CommentCopy != nil
case "format":
return s.FormatCopy != nil
case "invite_link_type":
return s.InviteLinkTypeCopy != nil
default:
return false
}
}
func (s *PurchaseOptionalDetails) hasChannelValue(param, channelKey string) bool {
switch param {
case "placement":
value, ok := s.PlacementByChannel[channelKey]
return ok && value != nil
case "payment_date":
value, ok := s.PaymentDateByChannel[channelKey]
return ok && value != nil
case "cost":
entry, ok := s.CostByChannel[channelKey]
return ok && entry.Value != nil
case "cost_before":
entry, ok := s.CostBeforeByChannel[channelKey]
return ok && entry.Value != nil
case "purchase_type":
value, ok := s.PurchaseTypeByChannel[channelKey]
return ok && value != ""
case "comment":
value, ok := s.CommentByChannel[channelKey]
return ok && value != ""
case "format":
value, ok := s.FormatByChannel[channelKey]
return ok && value != ""
case "invite_link_type":
value, ok := s.InviteLinkTypeByChannel[channelKey]
return ok && value != ""
default:
return false
}
}
func (s *PurchaseOptionalDetails) hasPlacementValue() bool {
if s.PlacementMode == "per_channel" && len(s.Channels) > 1 {
for _, value := range s.PlacementByChannel {
if value != nil {
return true
}
}
return false
}
return s.PlacementDateTime != nil
}
func (s *PurchaseOptionalDetails) hasPaymentDateValue() bool {
if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 {
for _, value := range s.PaymentDateByChannel {
if value != nil {
return true
}
}
return false
}
return s.PaymentDate != nil
}
func (s *PurchaseOptionalDetails) hasCostValue() bool {
if s.CostMode == "per_channel" && len(s.Channels) > 1 {
for _, entry := range s.CostByChannel {
if entry.Value != nil {
return true
}
}
return false
}
return s.CostValue != nil
}
func (s *PurchaseOptionalDetails) hasCostBeforeValue() bool {
if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 {
for _, entry := range s.CostBeforeByChannel {
if entry.Value != nil {
return true
}
}
return false
}
return s.CostBeforeBargain != nil && s.CostBeforeBargain.Value != nil
}
func (s *PurchaseOptionalDetails) hasFormatValue() bool {
if s.FormatMode == "per_channel" && len(s.Channels) > 1 {
for _, value := range s.FormatByChannel {
if value != "" {
return true
}
}
return false
}
return s.Format != ""
}
func (s *PurchaseOptionalDetails) hasPurchaseTypeValue() bool {
if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 {
for _, value := range s.PurchaseTypeByChannel {
if value != "" {
return true
}
}
return false
}
return s.PurchaseType != ""
}
func (s *PurchaseOptionalDetails) hasCommentValue() bool {
if s.CommentMode == "per_channel" && len(s.Channels) > 1 {
for _, value := range s.CommentByChannel {
if value != "" {
return true
}
}
return false
}
return s.Comment != ""
}
func (s *PurchaseOptionalDetails) hasInviteLinkTypeValue() bool {
if s.InviteLinkTypeMode == "per_channel" && len(s.Channels) > 1 {
for _, value := range s.InviteLinkTypeByChannel {
if value != "" {
return true
}
}
return false
}
return s.InviteLinkType != ""
}
func (s *PurchaseOptionalDetails) inviteLinkTypeLabel(linkType string) string {
if linkType == "public" {
return "Открытая"
}
if linkType == "approval" {
return "С заявками"
}
return "—"
}
func (s *PurchaseOptionalDetails) costTypeLabelForEntry(entry CostEntry) string {
if entry.Type == "cpm" {
return "СРМ"
}
return "Фикс"
}
func (s *PurchaseOptionalDetails) costBeforeTypeLabelForEntry(entry CostEntry) string {
if entry.Type == "cpm" {
return "СРМ"
}
return "Фикс"
}
func (s *PurchaseOptionalDetails) costTypeLabelForCurrent() string {
if s.CurrentChannel == "" {
return s.costTypeLabel()
}
entry, ok := s.CostByChannel[s.CurrentChannel]
if !ok || entry.Type == "" {
return s.costTypeLabel()
}
return s.costTypeLabelForEntry(entry)
}
func (s *PurchaseOptionalDetails) costBeforeTypeLabelForCurrent() string {
if s.CurrentChannel == "" {
return s.costBeforeTypeLabel()
}
entry, ok := s.CostBeforeByChannel[s.CurrentChannel]
if !ok || entry.Type == "" {
return s.costBeforeTypeLabel()
}
return s.costBeforeTypeLabelForEntry(entry)
}
func (s *PurchaseOptionalDetails) toggleCostType() {
if s.CurrentChannel == "" {
if s.costTypeLabel() == "СРМ" {
s.CostType = "fixed"
} else {
s.CostType = "cpm"
}
return
}
entry := s.CostByChannel[s.CurrentChannel]
if entry.Type == "" {
entry.Type = s.CostType
}
if s.costTypeLabelForEntry(entry) == "СРМ" {
entry.Type = "fixed"
} else {
entry.Type = "cpm"
}
s.CostByChannel[s.CurrentChannel] = entry
}
func (s *PurchaseOptionalDetails) toggleCostBeforeType() {
if s.CurrentChannel == "" {
if s.costBeforeTypeLabel() == "СРМ" {
s.CostBeforeType = "fixed"
} else {
s.CostBeforeType = "cpm"
}
if s.CostBeforeBargain != nil {
s.CostBeforeBargain.Type = s.CostBeforeType
}
return
}
entry := s.CostBeforeByChannel[s.CurrentChannel]
if entry.Type == "" {
entry.Type = s.CostBeforeType
}
if s.costBeforeTypeLabelForEntry(entry) == "СРМ" {
entry.Type = "fixed"
} else {
entry.Type = "cpm"
}
s.CostBeforeByChannel[s.CurrentChannel] = entry
}
func (s *PurchaseOptionalDetails) setCostValue(value float64) {
if s.CurrentChannel == "" {
s.CostValue = &value
return
}
entry := s.CostByChannel[s.CurrentChannel]
entry.Value = &value
if entry.Type == "" {
entry.Type = s.CostType
}
s.CostByChannel[s.CurrentChannel] = entry
}
func (s *PurchaseOptionalDetails) setCostBeforeValue(value float64) {
if s.CurrentChannel == "" {
if s.CostBeforeBargain == nil {
s.CostBeforeBargain = &CostEntry{}
}
s.CostBeforeBargain.Value = &value
if s.CostBeforeBargain.Type == "" {
s.CostBeforeBargain.Type = s.CostBeforeType
}
return
}
entry := s.CostBeforeByChannel[s.CurrentChannel]
entry.Value = &value
if entry.Type == "" {
entry.Type = s.CostBeforeType
}
s.CostBeforeByChannel[s.CurrentChannel] = entry
}
func formatDuration(minutes int, unit string) string {
switch unit {
case "top":
if minutes > 0 && minutes%60 == 0 {
return fmt.Sprintf("%dч", minutes/60)
}
return fmt.Sprintf("%dмин", minutes)
case "feed":
if minutes == 0 {
return "без удаления"
}
if minutes >= 7*24*60 && minutes%(24*60) == 0 {
return fmt.Sprintf("%dд", minutes/(24*60))
}
if minutes%60 == 0 {
return fmt.Sprintf("%dч", minutes/60)
}
return fmt.Sprintf("%dмин", minutes)
}
return fmt.Sprintf("%d", minutes)
}
func formatLabel(topMinutes, feedMinutes int) string {
return formatDuration(topMinutes, "top") + " / " + formatDuration(feedMinutes, "feed")
}
func (s *PurchaseOptionalDetails) setFormatPreset(topMinutes int, feedMinutes int) {
label := formatLabel(topMinutes, feedMinutes)
topPtr := &topMinutes
var feedPtr *int
if feedMinutes == 0 {
// 0 = без удаления — сохраняем как 0
feedPtr = &feedMinutes
} else {
feedPtr = &feedMinutes
}
if s.CurrentChannel == "" {
s.Format = label
s.TopTimeMinutes = topPtr
s.FeedTimeMinutes = feedPtr
} else {
s.FormatByChannel[s.CurrentChannel] = label
s.TopTimeByChannel[s.CurrentChannel] = topPtr
s.FeedTimeByChannel[s.CurrentChannel] = feedPtr
}
}
func (s *PurchaseOptionalDetails) setFormatValue(value string) {
if s.CurrentChannel == "" {
s.Format = value
s.TopTimeMinutes = nil
s.FeedTimeMinutes = nil
return
}
s.FormatByChannel[s.CurrentChannel] = value
delete(s.TopTimeByChannel, s.CurrentChannel)
delete(s.FeedTimeByChannel, s.CurrentChannel)
}
func (s *PurchaseOptionalDetails) setInviteLinkTypeValue(value string) {
if s.CurrentChannel == "" {
s.InviteLinkType = value
return
}
s.InviteLinkTypeByChannel[s.CurrentChannel] = value
}
func (s *PurchaseOptionalDetails) backAction() string {
if s.ReturnMode != "" {
return "back_to_return"
}
return "back_to_optional"
}
func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) {
if len(s.Channels) == 0 {
b.SendNew("❌ Добавьте хотя бы один канал", Keyboard(
Row(Button("← Назад", "back")),
))
return
}
placementType, ok := s.normalizePurchaseType()
if !ok {
b.SendNew("❌ Тип закупа: используйте «самопиар» или «стандарт»", Keyboard(
Row(Button("← Назад", "back")),
))
return
}
apiChannels := make([]backend.CreatePlacementChannelInput, 0, len(s.Channels))
for _, ch := range s.Channels {
channelDetails := s.buildChannelDetails(channelKey(ch), placementType)
if channelDetails != nil && s.isChannelDetailsEmpty(channelDetails) {
channelDetails = nil
}
apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{
ChannelID: ch.ChannelID,
Comment: ch.Comment,
Details: channelDetails,
})
}
input := backend.CreatePlacementsInput{
CreativeID: &s.CreativeID,
Channels: apiChannels,
}
placements, err := b.Backend.CreatePlacements(context.Background(), jwt, b.Session.WorkspaceID, s.ProjectID, input)
if err != nil {
log.Error().Err(err).Msg("Failed to create placements")
b.SendNew("❌ Не удалось создать размещения", Keyboard(
Row(Button("← Назад", "back")),
))
return
}
for _, placement := range placements.Placements {
_, err := b.Backend.BuildPlacementCreative(
context.Background(),
jwt,
b.Session.WorkspaceID,
s.ProjectID,
placement.ID,
)
if err != nil {
log.Error().Err(err).Msg("Failed to build placement creative")
continue
}
}
// Очищаем сохранённое состояние после успешного создания
// Находим SelectChannelsForPurchase в BackState и очищаем его OptionalDetailsState
if selectState, ok := s.BackState.(*SelectChannelsForPurchase); ok {
selectState.OptionalDetailsState = nil
}
b.SetState(&Placements{
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
BackState: s.BackState,
}, bot.NewMessage)
}
func (s *PurchaseOptionalDetails) buildChannelDetails(channelKey string, placementType *string) *backend.PlacementDetails {
details := &backend.PlacementDetails{}
// Placement date
if s.PlacementMode == "per_channel" && len(s.Channels) > 1 {
if value, ok := s.PlacementByChannel[channelKey]; ok && value != nil {
formatted := value.UTC().Format(time.RFC3339)
details.PlacementAt = &formatted
}
} else if s.PlacementDateTime != nil {
formatted := s.PlacementDateTime.UTC().Format(time.RFC3339)
details.PlacementAt = &formatted
}
// Payment date
if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 {
if value, ok := s.PaymentDateByChannel[channelKey]; ok && value != nil {
formatted := value.UTC().Format(time.RFC3339)
details.PaymentAt = &formatted
}
} else if s.PaymentDate != nil {
formatted := s.PaymentDate.UTC().Format(time.RFC3339)
details.PaymentAt = &formatted
}
// Cost
if s.CostMode == "per_channel" && len(s.Channels) > 1 {
entry := s.CostByChannel[channelKey]
details.Cost = s.buildCostInfo(entry.Type, entry.Value)
} else {
details.Cost = s.buildCostInfo(s.CostType, s.CostValue)
}
// Cost before bargain
if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 {
if entry, ok := s.CostBeforeByChannel[channelKey]; ok && entry.Value != nil {
details.CostBeforeBargain = s.buildCostInfo(entry.Type, entry.Value)
}
} else if s.CostBeforeBargain != nil && s.CostBeforeBargain.Value != nil {
details.CostBeforeBargain = s.buildCostInfo(s.CostBeforeBargain.Type, s.CostBeforeBargain.Value)
}
// Placement type
if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 {
if value, ok := s.PurchaseTypeByChannel[channelKey]; ok && value != "" {
details.PlacementType = &value
}
} else if placementType != nil {
details.PlacementType = placementType
}
// Comment
if s.CommentMode == "per_channel" && len(s.Channels) > 1 {
if value, ok := s.CommentByChannel[channelKey]; ok && value != "" {
details.Comment = &value
}
} else if s.Comment != "" {
details.Comment = &s.Comment
}
// Format
if s.FormatMode == "per_channel" && len(s.Channels) > 1 {
if value, ok := s.FormatByChannel[channelKey]; ok && value != "" {
details.Format = &value
}
if value, ok := s.TopTimeByChannel[channelKey]; ok && value != nil {
details.TopTimeMinutes = value
}
if value, ok := s.FeedTimeByChannel[channelKey]; ok && value != nil {
details.FeedTimeMinutes = value
}
} else {
if s.Format != "" {
details.Format = &s.Format
}
details.TopTimeMinutes = s.TopTimeMinutes
details.FeedTimeMinutes = s.FeedTimeMinutes
}
// Invite link type
if s.InviteLinkTypeMode == "per_channel" && len(s.Channels) > 1 {
if value, ok := s.InviteLinkTypeByChannel[channelKey]; ok && value != "" {
details.InviteLinkType = &value
}
} else if s.InviteLinkType != "" {
details.InviteLinkType = &s.InviteLinkType
}
return details
}
func (s *PurchaseOptionalDetails) buildCostInfo(costType string, value *float64) *backend.CostInfo {
if value == nil {
return nil
}
normalized := "fixed"
if costType == "cpm" {
normalized = "cpm"
}
return &backend.CostInfo{
Type: normalized,
Value: *value,
}
}
func (s *PurchaseOptionalDetails) isChannelDetailsEmpty(details *backend.PlacementDetails) bool {
return details.PlacementAt == nil &&
details.PaymentAt == nil &&
details.Cost == nil &&
details.CostBeforeBargain == nil &&
details.PlacementType == nil &&
details.Format == nil &&
details.TopTimeMinutes == nil &&
details.FeedTimeMinutes == nil &&
details.Comment == nil &&
details.InviteLinkType == nil
}
func (s *PurchaseOptionalDetails) normalizePurchaseType() (*string, bool) {
raw := strings.TrimSpace(s.PurchaseType)
if raw == "" {
return nil, true
}
normalized := strings.ToLower(raw)
normalized = strings.TrimSpace(strings.Trim(normalized, "."))
switch normalized {
case "взаимный пиар", "взаимнопиар", "mutual_pr", "mutual pr", "вп", "vp":
value := "mutual_pr"
return &value, true
case "стандарт", "standard":
value := "standard"
return &value, true
default:
return nil, false
}
}