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 := "Страница создания закупа и необязательные составляющие" 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:") // Нормализуем значение для БД var normalized string switch value { case "mutual_pr": normalized = "взаимный пиар" case "standard": normalized = "стандарт" } if s.CurrentChannel != "" { s.PurchaseTypeByChannel[s.CurrentChannel] = normalized } else { s.PurchaseType = normalized } 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_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_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": value, err := strconv.Atoi(text) if err != nil || value <= 0 { 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": value, err := strconv.Atoi(text) if err != nil || value <= 0 { 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("Дата размещения: %s%s", s.formatPlacementSummary(), s.formatPlacementDetails())) } if s.hasPaymentDateValue() { lines = append(lines, fmt.Sprintf("Дата оплаты: %s%s", s.formatPaymentDateSummary(), s.formatPaymentDateDetails())) } if s.hasCostValue() { lines = append(lines, fmt.Sprintf("Стоимость: %s%s", s.formatCostSummary(), s.formatCostDetails())) } if s.hasCostBeforeValue() { lines = append(lines, fmt.Sprintf("Стоимость до торга: %s%s", s.formatCostBeforeSummary(), s.formatCostBeforeDetails())) } if s.hasPurchaseTypeValue() { lines = append(lines, fmt.Sprintf("Тип закупа: %s%s", s.formatPurchaseTypeSummary(), s.formatPurchaseTypeDetails())) } if s.hasFormatValue() { lines = append(lines, fmt.Sprintf("Формат: %s%s", s.formatFormatSummary(), s.formatFormatDetails())) } if s.hasInviteLinkTypeValue() { lines = append(lines, fmt.Sprintf("Тип ссылки: %s%s", s.formatInviteLinkTypeSummary(), s.formatInviteLinkTypeDetails())) } if s.hasCommentValue() { lines = append(lines, fmt.Sprintf("Комментарий: %s%s", s.formatCommentSummary(), s.formatCommentDetails())) } if len(lines) == 0 { return "Добавьте параметры ниже" } 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 := "Дополнительно\n\n" switch s.InputMode { case "type": text += "Выберите тип закупа" 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 += "Ввод стоимости\n\nНапример: 15000" if s.CurrentChannel != "" { text += fmt.Sprintf("\n\nКанал: %s", 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 += "Ввод стоимости до торга\n\nНапример: 20000" if s.CurrentChannel != "" { text += fmt.Sprintf("\n\nКанал: %s", 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 = "Страница ввода формата размещения\n\n" if s.CurrentChannel != "" { text += fmt.Sprintf("Канал: %s\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_feed": s.renderCustomFormatFeed(b, mode) return case "invite_link_type": text = "Страница выбора типа ссылки\n\n" if s.CurrentChannel != "" { text += fmt.Sprintf("Канал: %s\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 += "Введите комментарий" 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 := "Свой формат — время в топе\n\n" if s.CurrentChannel != "" { text += fmt.Sprintf("Канал: %s\n\n", channelLabelByKey(s.Channels, s.CurrentChannel)) } var rows [][]echotron.InlineKeyboardButton if s.CustomFormatTopUnit == "minutes" { 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")), ) } else { 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")), ) } 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 := "Свой формат — время в ленте\n\n" if s.CurrentChannel != "" { text += fmt.Sprintf("Канал: %s\n\n", channelLabelByKey(s.Channels, s.CurrentChannel)) } if s.CustomFormatTopValue != nil { text += fmt.Sprintf("Время в топе: %s ✓\n\n", formatDuration(*s.CustomFormatTopValue, "top")) } var rows [][]echotron.InlineKeyboardButton if s.CustomFormatFeedUnit == "days" { 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 += "Выберите или введите число (в часах):" 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_back_to_top"))) b.Render(text, Keyboard(rows...), mode) } func (s *PurchaseOptionalDetails) renderModeSelect(b *bot.Bot, mode bot.RenderMode) { text := "⚙ Переключить режим параметра\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) { // Сбрасываем на редактирование при смене параметра if s.CurrentParam != param || s.ChannelEditMode == "" { s.ChannelEditMode = "edit" } s.CurrentParam = param text := fmt.Sprintf("%s — по каналам\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) 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") // Оборачиваем в
 для моноширинного шрифта (выравнивание работает только в monospace)
	finalResult = fmt.Sprintf("
%s
", 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 != "" { normalized := normalizePurchaseTypeValue(value) if normalized != nil { details.PlacementType = normalized } } } 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 normalizePurchaseTypeValue(raw string) *string { raw = strings.TrimSpace(raw) if raw == "" { return nil } normalized := strings.ToLower(raw) normalized = strings.TrimSpace(strings.Trim(normalized, ".")) switch normalized { case "взаимный пиар", "взаимнопиар", "self_promo", "mutual_pr", "mutual pr", "вп", "vp": value := "self_promo" return &value case "стандарт", "standard": value := "standard" return &value default: return nil } } func (s *PurchaseOptionalDetails) normalizePurchaseType() (*string, bool) { result := normalizePurchaseTypeValue(s.PurchaseType) if s.PurchaseType == "" { return nil, true } return result, result != nil }