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
}