feat: add marketing video mode and Remotion marketing video project
Add debug-only Marketing Video Mode toggle that enables hands-free screen recording across the app: auto-scrolling Featured Trips carousel, auto-filling trip wizard, smooth trip detail scrolling via CADisplayLink, and trip options auto-sort with scroll. Add Remotion marketing video project with 6 scene compositions using image sequences extracted from screen recordings, varied phone entrance animations, and deduped frames for smooth playback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ struct AdaptiveHomeContent: View {
|
||||
@Binding var showNewTrip: Bool
|
||||
@Binding var selectedTab: Int
|
||||
@Binding var selectedSuggestedTrip: SuggestedTrip?
|
||||
@Binding var marketingAutoScroll: Bool
|
||||
|
||||
let savedTrips: [SavedTrip]
|
||||
let suggestedTripsGenerator: SuggestedTripsGenerator
|
||||
@@ -24,6 +25,7 @@ struct AdaptiveHomeContent: View {
|
||||
showNewTrip: $showNewTrip,
|
||||
selectedTab: $selectedTab,
|
||||
selectedSuggestedTrip: $selectedSuggestedTrip,
|
||||
marketingAutoScroll: $marketingAutoScroll,
|
||||
savedTrips: savedTrips,
|
||||
suggestedTripsGenerator: suggestedTripsGenerator,
|
||||
displayedTips: displayedTips
|
||||
@@ -34,6 +36,7 @@ struct AdaptiveHomeContent: View {
|
||||
showNewTrip: $showNewTrip,
|
||||
selectedTab: $selectedTab,
|
||||
selectedSuggestedTrip: $selectedSuggestedTrip,
|
||||
marketingAutoScroll: $marketingAutoScroll,
|
||||
savedTrips: savedTrips,
|
||||
suggestedTripsGenerator: suggestedTripsGenerator,
|
||||
displayedTips: displayedTips
|
||||
|
||||
@@ -18,6 +18,9 @@ struct HomeView: View {
|
||||
@State private var selectedSuggestedTrip: SuggestedTrip?
|
||||
@State private var displayedTips: [PlanningTip] = []
|
||||
@State private var showProPaywall = false
|
||||
#if DEBUG
|
||||
@State private var marketingAutoScroll = false
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
@@ -27,6 +30,7 @@ struct HomeView: View {
|
||||
showNewTrip: $showNewTrip,
|
||||
selectedTab: $selectedTab,
|
||||
selectedSuggestedTrip: $selectedSuggestedTrip,
|
||||
marketingAutoScroll: marketingAutoScrollBinding,
|
||||
savedTrips: savedTrips,
|
||||
suggestedTripsGenerator: suggestedTripsGenerator,
|
||||
displayedTips: displayedTips
|
||||
@@ -98,6 +102,13 @@ struct HomeView: View {
|
||||
let oldName = oldTab < tabNames.count ? tabNames[oldTab] : nil
|
||||
AnalyticsManager.shared.track(.tabSwitched(tab: newName, previousTab: oldName))
|
||||
AnalyticsManager.shared.trackScreen(newName)
|
||||
#if DEBUG
|
||||
if newTab == 0 && UserDefaults.standard.bool(forKey: "marketingVideoMode") {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||
marketingAutoScroll = true
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: $showNewTrip) {
|
||||
TripWizardView()
|
||||
@@ -131,6 +142,14 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var marketingAutoScrollBinding: Binding<Bool> {
|
||||
#if DEBUG
|
||||
return $marketingAutoScroll
|
||||
#else
|
||||
return .constant(false)
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Hero Card
|
||||
|
||||
private var heroCard: some View {
|
||||
|
||||
@@ -15,6 +15,7 @@ struct HomeContent_Classic: View {
|
||||
@Binding var showNewTrip: Bool
|
||||
@Binding var selectedTab: Int
|
||||
@Binding var selectedSuggestedTrip: SuggestedTrip?
|
||||
@Binding var marketingAutoScroll: Bool
|
||||
|
||||
let savedTrips: [SavedTrip]
|
||||
let suggestedTripsGenerator: SuggestedTripsGenerator
|
||||
@@ -124,36 +125,50 @@ struct HomeContent_Classic: View {
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
|
||||
// Horizontal carousel grouped by region
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Theme.Spacing.lg) {
|
||||
ForEach(suggestedTripsGenerator.tripsByRegion, id: \.region) { regionGroup in
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
// Region header
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
Image(systemName: regionGroup.region.iconName)
|
||||
.font(.caption)
|
||||
.accessibilityHidden(true)
|
||||
Text(regionGroup.region.shortName)
|
||||
.font(.subheadline)
|
||||
}
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Theme.Spacing.lg) {
|
||||
ForEach(suggestedTripsGenerator.tripsByRegion, id: \.region) { regionGroup in
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
// Region header
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
Image(systemName: regionGroup.region.iconName)
|
||||
.font(.caption)
|
||||
.accessibilityHidden(true)
|
||||
Text(regionGroup.region.shortName)
|
||||
.font(.subheadline)
|
||||
}
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
// Trip cards for this region
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
ForEach(regionGroup.trips) { suggestedTrip in
|
||||
Button {
|
||||
selectedSuggestedTrip = suggestedTrip
|
||||
} label: {
|
||||
SuggestedTripCard(suggestedTrip: suggestedTrip)
|
||||
// Trip cards for this region
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
ForEach(regionGroup.trips) { suggestedTrip in
|
||||
Button {
|
||||
selectedSuggestedTrip = suggestedTrip
|
||||
} label: {
|
||||
SuggestedTripCard(suggestedTrip: suggestedTrip)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.id(regionGroup.region)
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentMargins(.horizontal, Theme.Spacing.md, for: .scrollContent)
|
||||
.onChange(of: marketingAutoScroll) { _, shouldScroll in
|
||||
if shouldScroll,
|
||||
let lastRegion = suggestedTripsGenerator.tripsByRegion.last?.region {
|
||||
withAnimation(.easeInOut(duration: 3.0)) {
|
||||
proxy.scrollTo(lastRegion, anchor: .trailing)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) {
|
||||
marketingAutoScroll = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentMargins(.horizontal, Theme.Spacing.md, for: .scrollContent)
|
||||
}
|
||||
} else if let error = suggestedTripsGenerator.error {
|
||||
// Error state
|
||||
|
||||
@@ -15,6 +15,7 @@ struct HomeContent_ClassicAnimated: View {
|
||||
@Binding var showNewTrip: Bool
|
||||
@Binding var selectedTab: Int
|
||||
@Binding var selectedSuggestedTrip: SuggestedTrip?
|
||||
@Binding var marketingAutoScroll: Bool
|
||||
|
||||
let savedTrips: [SavedTrip]
|
||||
let suggestedTripsGenerator: SuggestedTripsGenerator
|
||||
@@ -123,36 +124,50 @@ struct HomeContent_ClassicAnimated: View {
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
|
||||
// Horizontal carousel grouped by region
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Theme.Spacing.lg) {
|
||||
ForEach(suggestedTripsGenerator.tripsByRegion, id: \.region) { regionGroup in
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
// Region header
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
Image(systemName: regionGroup.region.iconName)
|
||||
.font(.caption)
|
||||
.accessibilityHidden(true)
|
||||
Text(regionGroup.region.shortName)
|
||||
.font(.subheadline)
|
||||
}
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Theme.Spacing.lg) {
|
||||
ForEach(suggestedTripsGenerator.tripsByRegion, id: \.region) { regionGroup in
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
// Region header
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
Image(systemName: regionGroup.region.iconName)
|
||||
.font(.caption)
|
||||
.accessibilityHidden(true)
|
||||
Text(regionGroup.region.shortName)
|
||||
.font(.subheadline)
|
||||
}
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
// Trip cards for this region
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
ForEach(regionGroup.trips) { suggestedTrip in
|
||||
Button {
|
||||
selectedSuggestedTrip = suggestedTrip
|
||||
} label: {
|
||||
SuggestedTripCard(suggestedTrip: suggestedTrip)
|
||||
// Trip cards for this region
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
ForEach(regionGroup.trips) { suggestedTrip in
|
||||
Button {
|
||||
selectedSuggestedTrip = suggestedTrip
|
||||
} label: {
|
||||
SuggestedTripCard(suggestedTrip: suggestedTrip)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.id(regionGroup.region)
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentMargins(.horizontal, Theme.Spacing.md, for: .scrollContent)
|
||||
.onChange(of: marketingAutoScroll) { _, shouldScroll in
|
||||
if shouldScroll,
|
||||
let lastRegion = suggestedTripsGenerator.tripsByRegion.last?.region {
|
||||
withAnimation(.easeInOut(duration: 3.0)) {
|
||||
proxy.scrollTo(lastRegion, anchor: .trailing)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) {
|
||||
marketingAutoScroll = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentMargins(.horizontal, Theme.Spacing.md, for: .scrollContent)
|
||||
}
|
||||
} else if let error = suggestedTripsGenerator.error {
|
||||
// Error state
|
||||
|
||||
@@ -519,6 +519,13 @@ struct SettingsView: View {
|
||||
Label("Override Pro Status", systemImage: "star.fill")
|
||||
}
|
||||
|
||||
Toggle(isOn: Binding(
|
||||
get: { UserDefaults.standard.bool(forKey: "marketingVideoMode") },
|
||||
set: { UserDefaults.standard.set($0, forKey: "marketingVideoMode") }
|
||||
)) {
|
||||
Label("Marketing Video Mode", systemImage: "video.fill")
|
||||
}
|
||||
|
||||
Button {
|
||||
showOnboardingPaywall = true
|
||||
} label: {
|
||||
|
||||
@@ -511,6 +511,45 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
ItineraryReorderingLogic.travelRow(in: flatItems, forDay: day)
|
||||
}
|
||||
|
||||
// MARK: - Marketing Video Auto-Scroll
|
||||
|
||||
#if DEBUG
|
||||
private var displayLink: CADisplayLink?
|
||||
private var scrollStartTime: CFTimeInterval = 0
|
||||
private var scrollDuration: CFTimeInterval = 6.0
|
||||
private var scrollStartOffset: CGFloat = 0
|
||||
private var scrollEndOffset: CGFloat = 0
|
||||
|
||||
func scrollToBottomAnimated(delay: TimeInterval = 1.5, duration: TimeInterval = 6.0) {
|
||||
guard UserDefaults.standard.bool(forKey: "marketingVideoMode") else { return }
|
||||
self.scrollDuration = duration
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self else { return }
|
||||
let maxOffset = self.tableView.contentSize.height - self.tableView.bounds.height + self.tableView.contentInset.bottom
|
||||
guard maxOffset > 0 else { return }
|
||||
self.scrollStartOffset = self.tableView.contentOffset.y
|
||||
self.scrollEndOffset = maxOffset
|
||||
self.scrollStartTime = CACurrentMediaTime()
|
||||
let link = CADisplayLink(target: self, selector: #selector(self.marketingScrollTick))
|
||||
link.add(to: .main, forMode: .common)
|
||||
self.displayLink = link
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func marketingScrollTick() {
|
||||
let elapsed = CACurrentMediaTime() - scrollStartTime
|
||||
let t = min(elapsed / scrollDuration, 1.0)
|
||||
// Ease-in-out curve
|
||||
let eased = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
|
||||
let newOffset = scrollStartOffset + (scrollEndOffset - scrollStartOffset) * eased
|
||||
tableView.contentOffset = CGPoint(x: 0, y: newOffset)
|
||||
if t >= 1.0 {
|
||||
displayLink?.invalidate()
|
||||
displayLink = nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Drag State Management
|
||||
//
|
||||
// These methods handle the start, update, and end of drag operations,
|
||||
|
||||
@@ -90,6 +90,10 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
||||
travelSegmentIndices: travelSegmentIndices
|
||||
)
|
||||
|
||||
#if DEBUG
|
||||
controller.scrollToBottomAnimated(delay: 5.0)
|
||||
#endif
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
|
||||
@@ -254,32 +254,49 @@ struct TripDetailView: View {
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
} else {
|
||||
// Non-editable scroll view for unsaved trips
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
heroMapSection
|
||||
.frame(height: 280)
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
heroMapSection
|
||||
.frame(height: 280)
|
||||
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
tripHeader
|
||||
.padding(.top, Theme.Spacing.lg)
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
tripHeader
|
||||
.padding(.top, Theme.Spacing.lg)
|
||||
|
||||
statsRow
|
||||
statsRow
|
||||
|
||||
if let score = trip.score {
|
||||
scoreCard(score)
|
||||
if let score = trip.score {
|
||||
scoreCard(score)
|
||||
}
|
||||
|
||||
itinerarySection
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.lg)
|
||||
.padding(.bottom, Theme.Spacing.xxl)
|
||||
|
||||
itinerarySection
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.id("tripDetailBottom")
|
||||
}
|
||||
.onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: .constant(false)) { _ in
|
||||
draggedTravelId = nil
|
||||
draggedItem = nil
|
||||
dropTargetId = nil
|
||||
return true
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.lg)
|
||||
.padding(.bottom, Theme.Spacing.xxl)
|
||||
}
|
||||
.onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: .constant(false)) { _ in
|
||||
draggedTravelId = nil
|
||||
draggedItem = nil
|
||||
dropTargetId = nil
|
||||
return true
|
||||
#if DEBUG
|
||||
.onAppear {
|
||||
if UserDefaults.standard.bool(forKey: "marketingVideoMode") {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
|
||||
withAnimation(.easeInOut(duration: 6.0)) {
|
||||
proxy.scrollTo("tripDetailBottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,8 +231,9 @@ struct TripOptionsView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 16) {
|
||||
VStack(spacing: 16) {
|
||||
// Hero header
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "point.topright.arrow.triangle.backward.to.point.bottomleft.scurvepath.fill")
|
||||
@@ -291,8 +292,27 @@ struct TripOptionsView: View {
|
||||
}
|
||||
}
|
||||
.padding(.bottom, Theme.Spacing.xxl)
|
||||
Color.clear.frame(height: 1).id("tripOptionsBottom")
|
||||
}
|
||||
.themedBackground()
|
||||
#if DEBUG
|
||||
.onAppear {
|
||||
if UserDefaults.standard.bool(forKey: "marketingVideoMode") && !hasAppliedDemoSelection {
|
||||
hasAppliedDemoSelection = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
Theme.Animation.withMotion(.easeInOut(duration: 0.3)) {
|
||||
sortOption = .mostGames
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
||||
withAnimation(.easeInOut(duration: 20.0)) {
|
||||
proxy.scrollTo("tripOptionsBottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} // ScrollViewReader
|
||||
.navigationDestination(isPresented: $showTripDetail) {
|
||||
if let trip = selectedTrip {
|
||||
TripDetailView(trip: trip)
|
||||
|
||||
@@ -28,6 +28,7 @@ struct TripWizardView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
GeometryReader { geometry in
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.vertical) {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// Step 1: Planning Mode (always visible)
|
||||
@@ -130,11 +131,23 @@ struct TripWizardView: View {
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.id("wizardBottom")
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.frame(width: geometry.size.width)
|
||||
.animation(Theme.Animation.prefersReducedMotion ? .none : .easeInOut(duration: 0.2), value: viewModel.areStepsVisible)
|
||||
}
|
||||
#if DEBUG
|
||||
.onChange(of: viewModel.planningMode) { _, newMode in
|
||||
if newMode == .gameFirst && UserDefaults.standard.bool(forKey: "marketingVideoMode") {
|
||||
marketingAutoFill(proxy: proxy)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.themedBackground()
|
||||
.navigationTitle("Plan a Trip")
|
||||
@@ -335,6 +348,72 @@ struct TripWizardView: View {
|
||||
}
|
||||
return cities.joined(separator: " → ")
|
||||
}
|
||||
|
||||
// MARK: - Marketing Video Auto-Fill
|
||||
|
||||
#if DEBUG
|
||||
private func marketingAutoFill(proxy: ScrollViewProxy) {
|
||||
// Pre-fetch data off the main thread, then run a clean sequential fill
|
||||
Task {
|
||||
let astros = AppDataProvider.shared.teams.first { $0.fullName.contains("Astros") }
|
||||
let allGames = try? await AppDataProvider.shared.allGames(for: [.mlb])
|
||||
let astrosGames = (allGames ?? []).filter {
|
||||
$0.homeTeamId == astros?.id || $0.awayTeamId == astros?.id
|
||||
}
|
||||
let pickedGames = Array(astrosGames.shuffled().prefix(3))
|
||||
let pickedIds = Set(pickedGames.map { $0.id })
|
||||
|
||||
// Sequential fill with generous pauses — no competing animations
|
||||
await MainActor.run {
|
||||
// Step 1: Select MLB
|
||||
viewModel.gamePickerSports = [.mlb]
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(1.5))
|
||||
|
||||
await MainActor.run {
|
||||
// Step 2: Select Astros
|
||||
if let astros {
|
||||
viewModel.gamePickerTeamIds = [astros.id]
|
||||
}
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(1.5))
|
||||
|
||||
await MainActor.run {
|
||||
// Step 3: Pick games + set date range
|
||||
if !pickedIds.isEmpty {
|
||||
viewModel.selectedGameIds = pickedIds
|
||||
if let earliest = pickedGames.map({ $0.dateTime }).min(),
|
||||
let latest = pickedGames.map({ $0.dateTime }).max() {
|
||||
viewModel.startDate = Calendar.current.date(byAdding: .day, value: -1, to: earliest) ?? earliest
|
||||
viewModel.endDate = Calendar.current.date(byAdding: .day, value: 1, to: latest) ?? latest
|
||||
}
|
||||
}
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(1.5))
|
||||
|
||||
await MainActor.run {
|
||||
// Step 4: Balanced route
|
||||
viewModel.routePreference = .balanced
|
||||
viewModel.hasSetRoutePreference = true
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(1.5))
|
||||
|
||||
await MainActor.run {
|
||||
// Step 5: Allow repeat cities
|
||||
viewModel.allowRepeatCities = true
|
||||
viewModel.hasSetRepeatCities = true
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(0.5))
|
||||
|
||||
// Single smooth scroll to bottom after everything is laid out
|
||||
await MainActor.run {
|
||||
withAnimation(.easeInOut(duration: 5.0)) {
|
||||
proxy.scrollTo("wizardBottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
Reference in New Issue
Block a user