//! Promo code management screen — list, create, delete codes. use makeover_tui::makeover_layout::{Column, Priority, Width}; use makeover_tui::table::{self, Cell, Sizing, TableStyle}; use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, TableState}; use super::App; use crate::currency::Currency; use crate::format; /// The promo code columns. A code and what it takes off are the row; what it /// applies to and how many times it has been used qualify it. const CODE_COLUMNS: [Column<'static>; 4] = [ Column { name: "Code", width: Width::Fill, priority: Priority::Essential, sortable: false, sorted: None, }, Column { name: "Discount", width: Width::Fixed, priority: Priority::Essential, sortable: false, sorted: None, }, Column { name: "Scope", width: Width::Fixed, priority: Priority::Secondary, sortable: false, sorted: None, }, Column { name: "Uses", width: Width::Fixed, priority: Priority::Optional, sortable: false, sorted: None, }, ]; /// The tracks the hand-written `Constraint`s carried. const CODE_SIZING: Sizing<'static> = Sizing { lengths: &[("Code", 16), ("Discount", 12), ("Scope", 20), ("Uses", 10)], fallback: 10, }; pub(crate) fn render(frame: &mut Frame, app: &App) { let area = frame.area(); let title = Line::from(vec![ Span::styled( " Makenot.work ", Style::default().add_modifier(Modifier::BOLD), ), Span::raw(" -- "), Span::styled("Promo Codes", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" "), ]); let block = Block::default() .title(title) .borders(Borders::ALL) .border_style(Style::default().fg(app.theme.line_border)); let inner = block.inner(area); frame.render_widget(block, area); let chunks = Layout::vertical([ Constraint::Length(1), // spacer Constraint::Length(1), // section header Constraint::Min(3), // code list Constraint::Length(1), // status line Constraint::Length(1), // keybindings ]) .split(inner); // Section header let count = app.promo_codes.len(); let header = Paragraph::new(Line::from(vec![ Span::raw(" "), Span::styled("Codes", Style::default().add_modifier(Modifier::BOLD)), if count == 0 { Span::raw("") } else { Span::raw(format!(" ({count})")) }, ])); frame.render_widget(header, chunks[1]); // Code list if app.loading { let loading = Paragraph::new(" Loading..."); frame.render_widget(loading, chunks[2]); } else if app.promo_codes.is_empty() { let empty = Paragraph::new(" No promo codes. Press [n] to create one."); frame.render_widget(empty, chunks[2]); } else { render_code_table(frame, app, chunks[2]); } // Status line if let Some(ref status) = app.promo_status { let style = if status.starts_with("Error") { Style::default().fg(app.theme.status_danger) } else { Style::default().fg(app.theme.status_success) }; let status_line = Paragraph::new(Line::from(vec![ Span::raw(" "), Span::styled(status.as_str(), style), ])); frame.render_widget(status_line, chunks[3]); } // Keybindings let editing = app.promo_editing_step.is_some(); let mut key_spans = vec![Span::raw(" ")]; if editing { key_spans.extend([ Span::styled("[Enter]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Confirm "), Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Cancel"), ]); } else { key_spans.extend([ Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Nav "), Span::styled("[n]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" New "), ]); if !app.promo_codes.is_empty() { key_spans.extend([ Span::styled("[d]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Delete "), ]); } key_spans.extend([ Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Refresh "), Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Back"), ]); } let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(app.theme.content_muted)); frame.render_widget(keys, chunks[4]); } fn render_code_table(frame: &mut Frame, app: &App, area: Rect) { let area = super::indent(area, 2); let rows: Vec> = app .promo_codes .iter() .map(|code| { let discount = format_discount( code.discount_type.as_deref(), code.discount_value, app.currency(), ); let scope = code .item_title .as_deref() .or(code.project_title.as_deref()) .unwrap_or("All items"); let uses = match code.max_uses { Some(max) => format!("{}/{}", code.use_count, max), None => code.use_count.to_string(), }; vec![ Cell::new("Code", code.code.as_str()), Cell::new("Discount", discount), Cell::new("Scope", scope), Cell::new("Uses", uses), ] }) .collect(); let style = TableStyle::from_theme(&app.theme); let table = table::table(&CODE_COLUMNS, &rows, &CODE_SIZING, &style, area.width); let mut state = TableState::default().with_selected(Some(app.selected_index)); frame.render_stateful_widget(table, area, &mut state); } /// A promo discount: a percentage, or an amount off in the creator's own /// currency. A fixed discount is denominated the same as the price it comes /// off, so it takes the same currency rather than a bare dollar sign. fn format_discount(dtype: Option<&str>, value: Option, currency: Currency) -> String { match (dtype, value) { (Some("percentage"), Some(v)) => format!("{v}% off"), (Some("fixed"), Some(v)) => format!("{} off", format::format_cents(i64::from(v), currency)), _ => "Free".to_string(), } }