diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index 48fef56..e101083 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -397,6 +397,17 @@ class Postgres(DatabaseBase): 'project', 'project__channel', 'channel', 'creative' ) + @staticmethod + async def count_placements_by_project_and_channel( + project_id: uuid.UUID, + channel_id: uuid.UUID, + ) -> int: + """Count placements for a project in a specific channel.""" + return await domain.Placement.filter( + project_id=project_id, + channel_id=channel_id, + ).count() + @staticmethod async def update_placement(placement: domain.Placement) -> None: await placement.save() diff --git a/src/dto/purchase.py b/src/dto/purchase.py index 86f0671..8d525b0 100644 --- a/src/dto/purchase.py +++ b/src/dto/purchase.py @@ -9,6 +9,12 @@ from src.domain.placement_post import PlacementPostStatus from .channel import ChannelOutput +class ProjectOutput(pydantic.BaseModel): + id: uuid.UUID + status: str + channel: ChannelOutput + + class CostInfo(pydantic.BaseModel): type: CostType value: float @@ -30,11 +36,14 @@ class PlacementOutput(pydantic.BaseModel): id: uuid.UUID status: PlacementStatus creative_id: uuid.UUID | None = None + creative_name: str | None = None comment: str | None = None invite_link: str | None invite_link_created_at: datetime.datetime | None = None invite_link_type: InviteLinkType channel: ChannelOutput + project: ProjectOutput | None = None + placement_number: int details: PlacementDetails | None = None diff --git a/src/usecase/purchase/create_placements.py b/src/usecase/purchase/create_placements.py index 052cadd..7a9c774 100644 --- a/src/usecase/purchase/create_placements.py +++ b/src/usecase/purchase/create_placements.py @@ -31,16 +31,41 @@ def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetail return None -def _build_placement_output(placement: domain.Placement) -> dto.PlacementOutput: +def _build_placement_output( + placement: domain.Placement, + project: domain.Project | None = None, + placement_number: int = 0, + creative_name: str | None = None, +) -> dto.PlacementOutput: channel = placement.channel if channel is None: log.error('Placement %s has no channel prefetched', placement.id) raise ValueError(f'Placement {placement.id} has no channel') + # Build project output if project is provided + project_output: dto.ProjectOutput | None = None + if project is not None: + project_channel = project.channel + if project_channel is None: + log.error('Project %s has no channel', project.id) + raise ValueError(f'Project {project.id} has no channel') + + project_output = dto.ProjectOutput( + id=project.id, + status=project.status.value, + channel=dto.ChannelOutput( + id=project_channel.id, + telegram_id=project_channel.telegram_id, + title=project_channel.title, + username=project_channel.username, + ), + ) + return dto.PlacementOutput( id=placement.id, status=placement.status, creative_id=placement.creative_id, + creative_name=creative_name, comment=placement.comment, invite_link=placement.invite_link, invite_link_type=placement.invite_link_type, @@ -50,6 +75,8 @@ def _build_placement_output(placement: domain.Placement) -> dto.PlacementOutput: title=channel.title, username=channel.username, ), + project=project_output, + placement_number=placement_number, details=_build_placement_details(placement), ) @@ -73,7 +100,6 @@ async def create_placements( if project.channel.telegram_id is None: raise domain.ChannelNotFound(project.channel.id) - invite_link_type = project.purchase_invite_type_default details = input.details placements: list[domain.Placement] = [] creatives_by_id: dict[uuid.UUID, domain.Creative] = {} @@ -106,7 +132,9 @@ async def create_placements( creative_id=creative.id if creative else None, channel_id=channel.id, invite_link=None, - invite_link_type=invite_link_type, + invite_link_type=(channel_details.invite_link_type if channel_details and channel_details.invite_link_type else None) + or (details.invite_link_type if details and details.invite_link_type else None) + or project.purchase_invite_type_default, status=channel_input.status or domain.PlacementStatus.NO_STATUS, comment=channel_input.comment, # Приоритет: channel details > общие details @@ -138,7 +166,7 @@ async def create_placements( placement_outputs = [] for placement in placements: - placement_output = _build_placement_output(placement) + placement_output = _build_placement_output(placement, project=project) placement_outputs.append( dto.PlacementWithPostsOutput( **placement_output.model_dump(), diff --git a/src/usecase/purchase/get_placement.py b/src/usecase/purchase/get_placement.py index 9944401..6c09dc3 100644 --- a/src/usecase/purchase/get_placement.py +++ b/src/usecase/purchase/get_placement.py @@ -77,6 +77,30 @@ async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> d channel = await self.database.get_channel(channel_id=placement.channel_id) placement.channel = channel + # Prefetch project channel for output building + if project.channel is None: + project_channel = await self.database.get_channel(channel_id=project.channel_id) + project.channel = project_channel + + # Get creative name if exists + creative_name = None + if placement.creative_id: + creative = await self.database.get_creative(input.workspace_id, placement.creative_id) + if creative: + creative_name = creative.name + + # Calculate placement number (ordinal number of this placement) + placement_number = await self.database.count_placements_by_project_and_channel( + project.id, placement.channel_id + ) + # Count placements created before this one + placements_before = await domain.Placement.filter( + project_id=project.id, + channel_id=placement.channel_id, + created_at__lt=placement.created_at, + ).count() + placement_number = placements_before + 1 + placement_posts = await self.database.get_workspace_placement_posts( input.workspace_id, placement_id=placement.id, @@ -112,7 +136,12 @@ async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> d if placement_post_output is not None: break - placement_output = _build_placement_output(placement) + placement_output = _build_placement_output( + placement, + project=project, + placement_number=placement_number, + creative_name=creative_name, + ) return dto.PlacementWithPostsOutput( **placement_output.model_dump(), placement_post=placement_post_output, diff --git a/src/usecase/purchase/get_placements.py b/src/usecase/purchase/get_placements.py index d83a98d..a9770db 100644 --- a/src/usecase/purchase/get_placements.py +++ b/src/usecase/purchase/get_placements.py @@ -82,7 +82,7 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto. placement_outputs = [] for placement in placements: - placement_output = _build_placement_output(placement) + placement_output = _build_placement_output(placement, project=project) placement_post_output = None placement_posts_for_placement = placement_posts_by_placement_id.get(placement.id, []) if placement_posts_for_placement: diff --git a/tg_bot/backend/backend_client.go b/tg_bot/backend/backend_client.go index 6ce249b..058274c 100644 --- a/tg_bot/backend/backend_client.go +++ b/tg_bot/backend/backend_client.go @@ -550,6 +550,7 @@ type Channel struct { TelegramID *int64 `json:"telegram_id"` Title *string `json:"title"` Username *string `json:"username"` + InviteLink *string `json:"invite_link"` } type CreateChannelInput struct { @@ -568,6 +569,12 @@ type CreateChannelResult struct { Error *string `json:"error,omitempty"` } +type ProjectOutput struct { + ID string `json:"id"` + Status string `json:"status"` + Channel Channel `json:"channel"` +} + type CreateChannelsOutput struct { Results []CreateChannelResult `json:"results"` } @@ -586,24 +593,30 @@ type PlacementDetails struct { Format *string `json:"format,omitempty"` Comment *string `json:"comment,omitempty"` CreativeID *string `json:"creative_id,omitempty"` + CreativeName *string `json:"creative_name,omitempty"` + InviteLinkType *string `json:"invite_link_type,omitempty"` } type PlacementOutput struct { - ID string `json:"id"` - Status string `json:"status"` - CreativeID *string `json:"creative_id,omitempty"` - Comment *string `json:"comment,omitempty"` - InviteLink *string `json:"invite_link,omitempty"` - InviteLinkType string `json:"invite_link_type"` - Channel Channel `json:"channel"` - Details *PlacementDetails `json:"details,omitempty"` - PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"` + ID string `json:"id"` + Status string `json:"status"` + CreativeID *string `json:"creative_id,omitempty"` + CreativeName *string `json:"creative_name,omitempty"` + Comment *string `json:"comment,omitempty"` + InviteLink *string `json:"invite_link,omitempty"` + InviteLinkType string `json:"invite_link_type"` + Channel Channel `json:"channel"` + Project *ProjectOutput `json:"project"` + PlacementNumber int `json:"placement_number"` + Details *PlacementDetails `json:"details,omitempty"` + PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"` } type PlacementPostOutput struct { SubscriptionsCount int `json:"subscriptions_count"` ViewsCount *int `json:"views_count,omitempty"` CreatedAt string `json:"created_at"` + TimeOnTop *int `json:"time_on_top,omitempty"` Post PostOutput `json:"post"` } diff --git a/tg_bot/screens/placement_details.go b/tg_bot/screens/placement_details.go index 06fd172..8b3e35e 100644 --- a/tg_bot/screens/placement_details.go +++ b/tg_bot/screens/placement_details.go @@ -32,62 +32,164 @@ func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) { text := "Детали размещения\n\n" - channelName := formatChannelName(placement.Channel) - text += fmt.Sprintf("• Канал: %s\n", channelName) - text += fmt.Sprintf("• Статус: %s\n", formatPlacementStatus(placement.Status)) - text += fmt.Sprintf("• Ссылка: %s\n", formatInviteLinkType(placement.InviteLinkType)) + // Размещение в канале + text += "📺 Размещение в канале:\n" + text += formatChannelWithName(placement.Channel) + text += "\n" + // Рекламируемый проект + if placement.Project != nil { + text += "🎯 Рекламируемый проект:\n" + text += formatProject(*placement.Project) + text += "\n" + } + + // Ссылка на пост + if placementPost != nil && placementPost.Post.URL != nil && *placementPost.Post.URL != "" { + text += fmt.Sprintf("🔗 Ссылка на пост: %s\n", *placementPost.Post.URL) + } + + // Пригласительная ссылка + if placement.InviteLink != nil && *placement.InviteLink != "" { + text += fmt.Sprintf("📩 Пригласительная ссылка: %s\n", *placement.InviteLink) + } + + // Тип ссылки + text += fmt.Sprintf("🔓 Тип ссылки: %s\n", formatInviteLinkType(placement.InviteLinkType)) + + // Порядковый номер размещения + if placement.PlacementNumber > 0 { + text += fmt.Sprintf("🔢 Размещение №%d в этом канале\n", placement.PlacementNumber) + } + + // Креатив + if placement.Details != nil && placement.Details.CreativeName != nil && *placement.Details.CreativeName != "" { + text += fmt.Sprintf("✨ Креатив: %s\n", *placement.Details.CreativeName) + } else if placement.CreativeName != nil && *placement.CreativeName != "" { + text += fmt.Sprintf("✨ Креатив: %s\n", *placement.CreativeName) + } + + text += "\n" + + // Финансовая информация if placement.Details != nil { - if placement.Details.PlacementType != nil { - text += fmt.Sprintf("• Тип: %s\n", formatPlacementType(*placement.Details.PlacementType)) - } - if placement.Details.PlacementAt != nil && *placement.Details.PlacementAt != "" { - text += fmt.Sprintf("• Дата размещения: %s\n", formatDateTime(*placement.Details.PlacementAt)) - } - if placement.Details.PaymentAt != nil && *placement.Details.PaymentAt != "" { - text += fmt.Sprintf("• Дата оплаты: %s\n", formatDateTime(*placement.Details.PaymentAt)) - } + // Стоимость if placement.Details.Cost != nil { - text += fmt.Sprintf("• Стоимость: %s %.0f₽\n", - formatCostType(placement.Details.Cost.Type), - placement.Details.Cost.Value, - ) + costType := formatCostType(placement.Details.Cost.Type) + text += fmt.Sprintf("💰 Стоимость: %s %.0f₽\n", costType, placement.Details.Cost.Value) } + + // Стоимость до торга if placement.Details.CostBeforeBargain != nil { - text += fmt.Sprintf("• До торга: %s %.0f₽\n", - formatCostType(placement.Details.CostBeforeBargain.Type), - placement.Details.CostBeforeBargain.Value, - ) + costType := formatCostType(placement.Details.CostBeforeBargain.Type) + text += fmt.Sprintf("💸 До торга: %s %.0f₽\n", costType, placement.Details.CostBeforeBargain.Value) } + + // Дата размещения + if placement.Details.PlacementAt != nil && *placement.Details.PlacementAt != "" { + text += fmt.Sprintf("📅 Дата размещения: %s\n", formatDateTime(*placement.Details.PlacementAt)) + } + + // Дата оплаты + if placement.Details.PaymentAt != nil && *placement.Details.PaymentAt != "" { + text += fmt.Sprintf("💳 Дата оплаты: %s\n", formatDateTime(*placement.Details.PaymentAt)) + } + + // Формат if placement.Details.Format != nil && *placement.Details.Format != "" { - text += fmt.Sprintf("• Формат: %s\n", *placement.Details.Format) + text += fmt.Sprintf("📐 Формат: %s\n", *placement.Details.Format) } - if placement.Details.Comment != nil && *placement.Details.Comment != "" { - text += fmt.Sprintf("• Комментарий: %s\n", *placement.Details.Comment) + + // Тип закупа + if placement.Details.PlacementType != nil { + text += fmt.Sprintf("🔄 Тип закупа: %s\n", formatPlacementType(*placement.Details.PlacementType)) } } + text += "\n" + + // Статистика if placementPost != nil { - text += "\nПост\n" - if placementPost.Post.URL != nil && *placementPost.Post.URL != "" { - text += fmt.Sprintf("• Ссылка на пост: %s\n", *placementPost.Post.URL) - } else { - text += "• Ссылка на пост: не найдена\n" + // Кол-во подписчиков + if placementPost.SubscriptionsCount > 0 { + text += fmt.Sprintf("👥 Подписчики: %d\n", placementPost.SubscriptionsCount) } + + // Просмотры поста + if placementPost.ViewsCount != nil { + text += fmt.Sprintf("👁️ Просмотры: %d\n", *placementPost.ViewsCount) + } + + // CPF (стоимость подписчика) + if placementPost.SubscriptionsCount > 0 && placement.Details != nil && placement.Details.Cost != nil { + cpf := calculateCPF(placement.Details.Cost.Value, placementPost.SubscriptionsCount) + text += fmt.Sprintf("💵 CPF: %.2f₽\n", cpf) + } + + // Конверсия + if placementPost.ViewsCount != nil && *placementPost.ViewsCount > 0 && placementPost.SubscriptionsCount > 0 { + conversion := calculateConversion(placementPost.SubscriptionsCount, *placementPost.ViewsCount) + text += fmt.Sprintf("📊 Конверсия: %.1f%%\n", conversion) + } + } + + // Комментарий + if placement.Details != nil && placement.Details.Comment != nil && *placement.Details.Comment != "" { + text += fmt.Sprintf("\n📝 Комментарий: %s\n", *placement.Details.Comment) } keyboard := Keyboard(Row(Button("← Назад", "back"))) b.Render(text, keyboard, mode) } -func formatChannelName(channel backend.Channel) string { +func formatChannelWithName(channel backend.Channel) string { + var name string if channel.Title != nil && *channel.Title != "" { - return *channel.Title + name = *channel.Title + } else if channel.Username != nil && *channel.Username != "" { + name = "@" + *channel.Username + } else { + name = "Без названия" } + + // Добавляем ссылку если есть username или invite_link if channel.Username != nil && *channel.Username != "" { - return "@" + *channel.Username + return fmt.Sprintf(" %s (@%s)\n", name, *channel.Username) + } else if channel.InviteLink != nil && *channel.InviteLink != "" { + return fmt.Sprintf(" %s\n", name) } - return "Без названия" + return fmt.Sprintf(" %s\n", name) +} + +func formatProject(project backend.ProjectOutput) string { + var name string + if project.Channel.Title != nil && *project.Channel.Title != "" { + name = *project.Channel.Title + } else if project.Channel.Username != nil && *project.Channel.Username != "" { + name = "@" + *project.Channel.Username + } else { + name = "Без названия" + } + + // Добавляем ссылку если есть username + if project.Channel.Username != nil && *project.Channel.Username != "" { + return fmt.Sprintf(" %s (@%s)\n", name, *project.Channel.Username) + } + return fmt.Sprintf(" %s\n", name) +} + +func calculateCPF(cost float64, subscriptions int) float64 { + if subscriptions == 0 { + return 0 + } + return cost / float64(subscriptions) +} + +func calculateConversion(subscriptions int, views int) float64 { + if views == 0 { + return 0 + } + return float64(subscriptions) / float64(views) * 100 } func formatInviteLinkType(value string) string { diff --git a/tg_bot/screens/purchase_optional_details.go b/tg_bot/screens/purchase_optional_details.go index f432a22..a8cd945 100644 --- a/tg_bot/screens/purchase_optional_details.go +++ b/tg_bot/screens/purchase_optional_details.go @@ -15,48 +15,52 @@ import ( ) type PurchaseOptionalDetails struct { - ProjectID string - ProjectTitle 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 - Comment 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 - 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 - PlacementCopy *time.Time - PaymentDateCopy *time.Time - CostCopy *CostEntry - CostBeforeCopy *CostEntry - PurchaseTypeCopy *string - CommentCopy *string - FormatCopy *string - ChannelEditMode string // "edit" или "copy" - BackState bot.State + ProjectID string + ProjectTitle 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 + 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 { @@ -104,6 +108,7 @@ func (s *PurchaseOptionalDetails) Enter(b *bot.Bot, mode bot.RenderMode) { )) rows = append(rows, Row( s.formatButton(), + s.inviteLinkTypeButton(), )) if row := s.costBeforeDeleteRow(); row != nil { @@ -182,6 +187,16 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) 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 { @@ -332,6 +347,19 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) 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) } } @@ -433,6 +461,9 @@ func (s *PurchaseOptionalDetails) formatOptionalSummary() string { 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())) } @@ -449,8 +480,7 @@ func (s *PurchaseOptionalDetails) formatDateTime(value *time.Time) string { return "—" } local := value.In(ui2.MskLocation) - text := fmt.Sprintf("%s %02d %s %s", ui2.WeekdayName(local.Weekday()), local.Day(), ui2.MonthShort(local.Month()), local.Format("15:04")) - return fmt.Sprintf("%s", text) + 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 { @@ -458,23 +488,21 @@ func (s *PurchaseOptionalDetails) formatDate(value *time.Time) string { return "—" } local := value.In(ui2.MskLocation) - text := fmt.Sprintf("%s %02d %s", ui2.WeekdayName(local.Weekday()), local.Day(), ui2.MonthShort(local.Month())) - return fmt.Sprintf("%s", text) + 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 fmt.Sprintf("%s", value) + return value } func (s *PurchaseOptionalDetails) formatCostValue() string { if s.CostValue == nil { return "—" } - text := fmt.Sprintf("%s %.0f₽", s.costTypeLabel(), *s.CostValue) - return fmt.Sprintf("%s", text) + return fmt.Sprintf("%s %.0f₽", s.costTypeLabel(), *s.CostValue) } func (s *PurchaseOptionalDetails) formatCostBefore() string { @@ -482,8 +510,7 @@ func (s *PurchaseOptionalDetails) formatCostBefore() string { return "—" } label := s.costBeforeTypeLabelForEntry(*s.CostBeforeBargain) - text := fmt.Sprintf("%s %.0f₽", label, *s.CostBeforeBargain.Value) - return fmt.Sprintf("%s", text) + return fmt.Sprintf("%s %.0f₽", label, *s.CostBeforeBargain.Value) } func (s *PurchaseOptionalDetails) costTypeLabel() string { @@ -576,6 +603,22 @@ func (s *PurchaseOptionalDetails) commentButton() echotron.InlineKeyboardButton 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") } @@ -686,6 +729,25 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM return case "format_custom": text += "Введите формат\n\nНапример: пост" + 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 += "Введите комментарий" default: @@ -761,6 +823,15 @@ func (s *PurchaseOptionalDetails) renderModeSelect(b *bot.Bot, mode bot.RenderMo } 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) @@ -789,6 +860,9 @@ func (s *PurchaseOptionalDetails) renderParamScreens(b *bot.Bot, mode bot.Render 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 } @@ -928,7 +1002,7 @@ func (s *PurchaseOptionalDetails) renderParamChannelSummary(param string) string // Паддинг вычисляем так, чтобы все значения начинались с одной позиции const ( marker = "· " // Маркер пункта - valueIndent = 2 // Отступ после самого длинного названия + valueIndent = 6 // Отступ после самого длинного названия (увеличен для лучшей читаемости) ) var result []string @@ -936,7 +1010,7 @@ func (s *PurchaseOptionalDetails) renderParamChannelSummary(param string) string labelLen := len([]rune(line.label)) // Паддинг = (макс.длина - тек.длина) + отступ после названия paddingLen := maxLabelLen - labelLen + valueIndent - padding := strings.Repeat(" ", paddingLen) + padding := strings.Repeat("\u00A0", paddingLen) formattedLine := fmt.Sprintf("%s%s%s%s", marker, line.label, padding, line.value) result = append(result, formattedLine) @@ -953,6 +1027,9 @@ func (s *PurchaseOptionalDetails) renderParamChannelSummary(param string) string finalResult := strings.Join(result, "\n") + // Оборачиваем в
 для моноширинного шрифта (выравнивание работает только в monospace)
+	finalResult = fmt.Sprintf("
%s
", finalResult) + // Логируем итоговый результат log.Info(). Str("param", param). @@ -1100,6 +1177,11 @@ func (s *PurchaseOptionalDetails) openCommonEditor(b *bot.Bot, param string) { 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) @@ -1151,6 +1233,11 @@ func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, channelKe 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) @@ -1204,6 +1291,13 @@ func (s *PurchaseOptionalDetails) copyParamValue(param, channelKey string) { } else { s.FormatCopy = nil } + case "invite_link_type": + if value, ok := s.InviteLinkTypeByChannel[channelKey]; ok { + copied := value + s.InviteLinkTypeCopy = &copied + } else { + s.InviteLinkTypeCopy = nil + } } } @@ -1275,6 +1369,14 @@ func (s *PurchaseOptionalDetails) copyCommonValueToChannels(param string) { key := channelKey(ch) s.FormatByChannel[key] = s.Format } + case "invite_link_type": + if s.InviteLinkType == "" { + return + } + for _, ch := range s.Channels { + key := channelKey(ch) + s.InviteLinkTypeByChannel[key] = s.InviteLinkType + } } } @@ -1334,6 +1436,12 @@ func (s *PurchaseOptionalDetails) pasteParamValue(param, channelKey string) { return } s.FormatByChannel[channelKey] = *s.FormatCopy + case "invite_link_type": + if s.InviteLinkTypeCopy == nil { + delete(s.InviteLinkTypeByChannel, channelKey) + return + } + s.InviteLinkTypeByChannel[channelKey] = *s.InviteLinkTypeCopy } } @@ -1367,6 +1475,10 @@ func (s *PurchaseOptionalDetails) applyParamToAll(param string) { for _, ch := range s.Channels { s.pasteParamValue(param, channelKey(ch)) } + case "invite_link_type": + for _, ch := range s.Channels { + s.pasteParamValue(param, channelKey(ch)) + } } } @@ -1386,6 +1498,8 @@ func (s *PurchaseOptionalDetails) clearParamValue(param, channelKey string) { delete(s.CommentByChannel, channelKey) case "format": delete(s.FormatByChannel, channelKey) + case "invite_link_type": + delete(s.InviteLinkTypeByChannel, channelKey) } } @@ -1405,6 +1519,8 @@ func (s *PurchaseOptionalDetails) paramTitle(param string) string { return "Комментарий" case "format": return "Формат" + case "invite_link_type": + return "Тип ссылки" default: return "" } @@ -1447,6 +1563,11 @@ func (s *PurchaseOptionalDetails) paramMode(param string) string { return "common" } return s.FormatMode + case "invite_link_type": + if s.InviteLinkTypeMode == "" { + return "common" + } + return s.InviteLinkTypeMode default: return "common" } @@ -1468,6 +1589,8 @@ func (s *PurchaseOptionalDetails) setParamMode(param, mode string) { s.CommentMode = mode case "format": s.FormatMode = mode + case "invite_link_type": + s.InviteLinkTypeMode = mode } } @@ -1493,6 +1616,9 @@ func (s *PurchaseOptionalDetails) ensureDefaults() { if s.FormatMode == "" { s.FormatMode = "common" } + if s.InviteLinkTypeMode == "" { + s.InviteLinkTypeMode = "common" + } if s.PlacementByChannel == nil { s.PlacementByChannel = make(map[string]*time.Time) } @@ -1514,6 +1640,9 @@ func (s *PurchaseOptionalDetails) ensureDefaults() { if s.FormatByChannel == nil { s.FormatByChannel = make(map[string]string) } + if s.InviteLinkTypeByChannel == nil { + s.InviteLinkTypeByChannel = make(map[string]string) + } s.syncChannelMaps() } @@ -1560,6 +1689,11 @@ func (s *PurchaseOptionalDetails) syncChannelMaps() { delete(s.FormatByChannel, key) } } + for key := range s.InviteLinkTypeByChannel { + if _, ok := valid[key]; !ok { + delete(s.InviteLinkTypeByChannel, key) + } + } } func (s *PurchaseOptionalDetails) formatPlacementSummary() string { @@ -1573,7 +1707,7 @@ func (s *PurchaseOptionalDetails) formatPlacementDetails() string { if s.PlacementMode != "per_channel" || len(s.Channels) <= 1 { return "" } - return "\n" + s.renderParamChannelSummary("placement") + return s.renderParamChannelSummary("placement") } func (s *PurchaseOptionalDetails) formatPaymentDateSummary() string { @@ -1587,7 +1721,7 @@ func (s *PurchaseOptionalDetails) formatPaymentDateDetails() string { if s.PaymentDateMode != "per_channel" || len(s.Channels) <= 1 { return "" } - return "\n" + s.renderParamChannelSummary("payment_date") + return s.renderParamChannelSummary("payment_date") } func (s *PurchaseOptionalDetails) formatCostSummary() string { @@ -1601,7 +1735,7 @@ func (s *PurchaseOptionalDetails) formatCostDetails() string { if s.CostMode != "per_channel" || len(s.Channels) <= 1 { return "" } - return "\n" + s.renderParamChannelSummary("cost") + return s.renderParamChannelSummary("cost") } func (s *PurchaseOptionalDetails) formatCostBeforeSummary() string { @@ -1615,7 +1749,7 @@ func (s *PurchaseOptionalDetails) formatCostBeforeDetails() string { if s.CostBeforeMode != "per_channel" || len(s.Channels) <= 1 { return "" } - return "\n" + s.renderParamChannelSummary("cost_before") + return s.renderParamChannelSummary("cost_before") } func (s *PurchaseOptionalDetails) formatPurchaseTypeSummary() string { @@ -1629,7 +1763,7 @@ func (s *PurchaseOptionalDetails) formatPurchaseTypeDetails() string { if s.PurchaseTypeMode != "per_channel" || len(s.Channels) <= 1 { return "" } - return "\n" + s.renderParamChannelSummary("purchase_type") + return s.renderParamChannelSummary("purchase_type") } func (s *PurchaseOptionalDetails) formatCommentSummary() string { @@ -1643,7 +1777,7 @@ func (s *PurchaseOptionalDetails) formatCommentDetails() string { if s.CommentMode != "per_channel" || len(s.Channels) <= 1 { return "" } - return "\n" + s.renderParamChannelSummary("comment") + return s.renderParamChannelSummary("comment") } func (s *PurchaseOptionalDetails) formatFormatSummary() string { @@ -1657,7 +1791,21 @@ func (s *PurchaseOptionalDetails) formatFormatDetails() string { if s.FormatMode != "per_channel" || len(s.Channels) <= 1 { return "" } - return "\n" + s.renderParamChannelSummary("format") + 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 { @@ -1674,17 +1822,22 @@ func (s *PurchaseOptionalDetails) formatParamValue(param, channelKey string) str return s.formatCostBeforeForChannel(channelKey) case "purchase_type": if value, ok := s.PurchaseTypeByChannel[channelKey]; ok && value != "" { - return fmt.Sprintf("%s", value) + return value } return "—" case "comment": if value, ok := s.CommentByChannel[channelKey]; ok && value != "" { - return fmt.Sprintf("%s", value) + return value } return "—" case "format": if value, ok := s.FormatByChannel[channelKey]; ok && value != "" { - return fmt.Sprintf("%s", value) + return value + } + return "—" + case "invite_link_type": + if value, ok := s.InviteLinkTypeByChannel[channelKey]; ok && value != "" { + return s.inviteLinkTypeLabel(value) } return "—" default: @@ -1698,8 +1851,7 @@ func (s *PurchaseOptionalDetails) formatCostForChannel(channelKey string) string return "—" } label := s.costTypeLabelForEntry(entry) - text := fmt.Sprintf("%s %.0f₽", label, *entry.Value) - return fmt.Sprintf("%s", text) + return fmt.Sprintf("%s %.0f₽", label, *entry.Value) } func (s *PurchaseOptionalDetails) formatCostBeforeForChannel(channelKey string) string { @@ -1708,8 +1860,7 @@ func (s *PurchaseOptionalDetails) formatCostBeforeForChannel(channelKey string) return "—" } label := s.costBeforeTypeLabelForEntry(entry) - text := fmt.Sprintf("%s %.0f₽", label, *entry.Value) - return fmt.Sprintf("%s", text) + return fmt.Sprintf("%s %.0f₽", label, *entry.Value) } func (s *PurchaseOptionalDetails) hasCopyBuffer(param string) bool { @@ -1728,6 +1879,8 @@ func (s *PurchaseOptionalDetails) hasCopyBuffer(param string) bool { return s.CommentCopy != nil case "format": return s.FormatCopy != nil + case "invite_link_type": + return s.InviteLinkTypeCopy != nil default: return false } @@ -1756,6 +1909,9 @@ func (s *PurchaseOptionalDetails) hasChannelValue(param, channelKey string) bool 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 } @@ -1845,6 +2001,28 @@ func (s *PurchaseOptionalDetails) hasCommentValue() bool { 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 "СРМ" @@ -1966,6 +2144,14 @@ func (s *PurchaseOptionalDetails) setFormatValue(value string) { s.FormatByChannel[s.CurrentChannel] = value } +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" @@ -2081,6 +2267,11 @@ func (s *PurchaseOptionalDetails) buildPlacementDetails(placementType *string) * details.Format = &s.Format } } + if s.InviteLinkTypeMode != "per_channel" || len(s.Channels) <= 1 { + if s.InviteLinkType != "" { + details.InviteLinkType = &s.InviteLinkType + } + } if placementType != nil && (s.PurchaseTypeMode == "per_channel" || len(s.Channels) <= 1) { details.PlacementType = placementType } @@ -2130,6 +2321,11 @@ func (s *PurchaseOptionalDetails) buildChannelDetails(channelKey string) *backen details.Format = &value } } + if s.InviteLinkTypeMode == "per_channel" { + if value, ok := s.InviteLinkTypeByChannel[channelKey]; ok && value != "" { + details.InviteLinkType = &value + } + } return details }