[ 'label' => 'ID', 'sortable' => true, 'class' => 'text-center', ], 'title' => [ 'label' => 'Title', 'sortable' => true, 'searchable' => true, ], 'artist_name' => [ 'label' => 'Artist', 'sortable' => true, 'searchable' => true, ], 'release_date' => [ 'label' => 'Release Date', 'sortable' => true, 'formatter' => 'date', ], 'status' => [ 'label' => 'Status', 'formatter' => 'status', ], 'created_at' => [ 'label' => 'Created', 'sortable' => true, 'formatter' => 'date', ], ], sortable: true, searchable: true ); $campaigns = $this->repository->findAll(); $campaignData = array_map( fn ($campaign) => [ ...$campaign->toArray(), 'release_date' => $campaign->releaseDate->format('Y-m-d H:i'), 'created_at' => $campaign->createdAt->format('Y-m-d H:i'), ], $campaigns ); $table = $this->tableFactory->create($tableConfig, $campaignData); return $this->pageRenderer->renderIndex( resource: 'presave-campaigns', table: $table, title: 'Pre-Save Campaigns', actions: [ [ 'url' => '/admin/presave-campaigns/create', 'label' => 'Create Campaign', 'icon' => 'plus', ], ] ); } /** * Show campaign statistics */ #[Route('/admin/presave-campaigns/{id}', Method::GET)] public function show(int $id): ViewResult { try { $campaign = $this->repository->findById($id); if ($campaign === null) { throw FrameworkException::create( ErrorCode::ENTITY_NOT_FOUND, 'Campaign not found' )->withData(['campaign_id' => $id]); } $stats = $this->service->getCampaignStats($id); return $this->pageRenderer->renderShow( resource: 'presave-campaigns', title: $campaign->title, data: [ 'campaign' => $campaign->toArray(), 'stats' => $stats, ] ); } catch (FrameworkException $e) { throw $e; } catch (\Throwable $e) { throw FrameworkException::create( ErrorCode::DB_QUERY_FAILED, 'Failed to load campaign' )->withContext($e->getMessage()); } } /** * API endpoint for campaign list */ #[Route('/admin/api/presave-campaigns', Method::GET)] public function apiList(HttpRequest $request): JsonResult { $campaigns = $this->repository->findAll(); $items = array_map( fn ($campaign) => [ ...$campaign->toArray(), 'release_date' => $campaign->releaseDate->format('Y-m-d H:i'), 'created_at' => $campaign->createdAt->format('Y-m-d H:i'), ], $campaigns ); return new JsonResult([ 'success' => true, 'data' => $items, 'pagination' => [ 'page' => 1, 'per_page' => count($items), 'total' => count($items), 'pages' => 1, ], ]); } /** * API endpoint for campaign statistics */ #[Route('/admin/api/presave-campaigns/{id}/stats', Method::GET)] public function apiStats(int $id): JsonResult { try { $stats = $this->service->getCampaignStats($id); return new JsonResult([ 'success' => true, 'data' => $stats, ]); } catch (\Exception $e) { return new JsonResult([ 'success' => false, 'error' => $e->getMessage(), ], 404); } } /** * Show create campaign form */ #[Route('/admin/presave-campaigns/create', Method::GET)] public function create(): ViewResult { $formConfig = AdminFormConfig::create( resource: 'presave-campaigns', action: '/admin/presave-campaigns', method: Method::POST, fields: [ 'title' => [ 'type' => 'text', 'label' => 'Campaign Title', 'required' => true, 'placeholder' => 'e.g., New Album Release', 'help' => 'Name of the album or single', ], 'artist_name' => [ 'type' => 'text', 'label' => 'Artist Name', 'required' => true, 'placeholder' => 'e.g., The Artist', ], 'cover_image_url' => [ 'type' => 'text', 'label' => 'Cover Image URL', 'required' => true, 'placeholder' => 'https://...', 'help' => 'URL to album/single artwork', ], 'description' => [ 'type' => 'textarea', 'label' => 'Description', 'placeholder' => 'Tell fans about this release...', 'help' => 'Optional description shown on the pre-save page', ], 'release_date' => [ 'type' => 'text', 'label' => 'Release Date', 'required' => true, 'placeholder' => 'YYYY-MM-DD HH:MM', 'help' => 'When the track/album will be released', ], 'start_date' => [ 'type' => 'text', 'label' => 'Campaign Start Date', 'placeholder' => 'YYYY-MM-DD HH:MM (optional)', 'help' => 'When to start accepting pre-saves (optional, defaults to now)', ], 'track_url_spotify' => [ 'type' => 'text', 'label' => 'Spotify Track URL', 'placeholder' => 'https://open.spotify.com/track/...', 'help' => 'Spotify track link for pre-save', ], 'track_url_tidal' => [ 'type' => 'text', 'label' => 'Tidal Track URL (optional)', 'placeholder' => 'https://tidal.com/browse/track/...', ], 'track_url_apple_music' => [ 'type' => 'text', 'label' => 'Apple Music Track URL (optional)', 'placeholder' => 'https://music.apple.com/...', ], ] ); $form = $this->formFactory->create($formConfig); return $this->pageRenderer->renderForm( resource: 'presave-campaigns', form: $form, title: 'Create Pre-Save Campaign', backUrl: '/admin/presave-campaigns' ); } /** * Store new campaign */ #[Route('/admin/presave-campaigns', Method::POST)] public function store(HttpRequest $request): Redirect { $data = $request->parsedBody->toArray(); // Parse track URLs $trackUrls = []; if (! empty($data['track_url_spotify'])) { $trackUrls[] = TrackUrl::fromUrl($data['track_url_spotify']); } if (! empty($data['track_url_tidal'])) { $trackUrls[] = TrackUrl::fromUrl($data['track_url_tidal']); } if (! empty($data['track_url_apple_music'])) { $trackUrls[] = TrackUrl::fromUrl($data['track_url_apple_music']); } try { // Validate at least one track URL if (empty($trackUrls)) { throw FrameworkException::create( ErrorCode::VAL_REQUIRED_FIELD_MISSING, 'At least one track URL is required' ); } // Create campaign entity $campaign = PreSaveCampaign::create( title: $data['title'], artistName: $data['artist_name'], coverImageUrl: $data['cover_image_url'], releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable($data['release_date'])), trackUrls: $trackUrls, description: ! empty($data['description']) ? $data['description'] : null, startDate: ! empty($data['start_date']) ? Timestamp::fromDateTime(new \DateTimeImmutable($data['start_date'])) : null ); // Save campaign $savedCampaign = $this->repository->save($campaign); return new Redirect('/admin/presave-campaigns/' . $savedCampaign->id); } catch (FrameworkException $e) { throw $e; } catch (\Throwable $e) { throw FrameworkException::create( ErrorCode::DB_QUERY_FAILED, 'Failed to create campaign' )->withContext($e->getMessage()); } } /** * Show edit campaign form */ #[Route('/admin/presave-campaigns/{id}/edit', Method::GET)] public function edit(int $id): ViewResult { $campaign = $this->repository->findById($id); if ($campaign === null) { throw FrameworkException::create( ErrorCode::ENTITY_NOT_FOUND, 'Campaign not found' )->withData(['campaign_id' => $id]); } // Extract track URLs by platform $trackUrlData = []; foreach ($campaign->trackUrls as $trackUrl) { $trackUrlData['track_url_' . $trackUrl->platform->value] = $trackUrl->url; } $formConfig = AdminFormConfig::create( resource: 'presave-campaigns', action: '/admin/presave-campaigns/' . $id, method: Method::POST, fields: [ '_method' => [ 'type' => 'hidden', ], 'title' => [ 'type' => 'text', 'label' => 'Campaign Title', 'required' => true, ], 'artist_name' => [ 'type' => 'text', 'label' => 'Artist Name', 'required' => true, ], 'cover_image_url' => [ 'type' => 'text', 'label' => 'Cover Image URL', 'required' => true, ], 'description' => [ 'type' => 'textarea', 'label' => 'Description', ], 'release_date' => [ 'type' => 'text', 'label' => 'Release Date', 'required' => true, ], 'start_date' => [ 'type' => 'text', 'label' => 'Campaign Start Date', ], 'track_url_spotify' => [ 'type' => 'text', 'label' => 'Spotify Track URL', ], 'track_url_tidal' => [ 'type' => 'text', 'label' => 'Tidal Track URL', ], 'track_url_apple_music' => [ 'type' => 'text', 'label' => 'Apple Music Track URL', ], 'status' => [ 'type' => 'select', 'label' => 'Status', 'required' => true, 'options' => [ CampaignStatus::DRAFT->value => 'Draft', CampaignStatus::SCHEDULED->value => 'Scheduled', CampaignStatus::ACTIVE->value => 'Active', CampaignStatus::CANCELLED->value => 'Cancelled', ], 'help' => 'Campaign lifecycle status', ], ] )->withData([ '_method' => 'PUT', 'title' => $campaign->title, 'artist_name' => $campaign->artistName, 'cover_image_url' => $campaign->coverImageUrl, 'description' => $campaign->description ?? '', 'release_date' => $campaign->releaseDate->format('Y-m-d H:i'), 'start_date' => $campaign->startDate?->format('Y-m-d H:i') ?? '', 'status' => $campaign->status->value, ...$trackUrlData, ]); $form = $this->formFactory->create($formConfig); return $this->pageRenderer->renderForm( resource: 'presave-campaigns', form: $form, title: 'Edit Campaign: ' . $campaign->title, backUrl: '/admin/presave-campaigns' ); } /** * Update existing campaign */ #[Route('/admin/presave-campaigns/{id}', Method::POST)] public function update(int $id, HttpRequest $request): Redirect { try { $campaign = $this->repository->findById($id); if ($campaign === null) { throw FrameworkException::create( ErrorCode::ENTITY_NOT_FOUND, 'Campaign not found' )->withData(['campaign_id' => $id]); } $data = $request->parsedBody->toArray(); // Parse track URLs $trackUrls = []; if (! empty($data['track_url_spotify'])) { $trackUrls[] = TrackUrl::fromUrl($data['track_url_spotify']); } if (! empty($data['track_url_tidal'])) { $trackUrls[] = TrackUrl::fromUrl($data['track_url_tidal']); } if (! empty($data['track_url_apple_music'])) { $trackUrls[] = TrackUrl::fromUrl($data['track_url_apple_music']); } // Validate at least one track URL if (empty($trackUrls)) { throw FrameworkException::create( ErrorCode::VAL_REQUIRED_FIELD_MISSING, 'At least one track URL is required' ); } // Update campaign - create new instance with updated values $updatedCampaign = new PreSaveCampaign( id: $campaign->id, title: $data['title'], artistName: $data['artist_name'], coverImageUrl: $data['cover_image_url'], releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable($data['release_date'])), trackUrls: $trackUrls, status: CampaignStatus::from($data['status']), createdAt: $campaign->createdAt, updatedAt: Timestamp::now(), description: ! empty($data['description']) ? $data['description'] : null, startDate: ! empty($data['start_date']) ? Timestamp::fromDateTime(new \DateTimeImmutable($data['start_date'])) : null ); $this->repository->save($updatedCampaign); return new Redirect('/admin/presave-campaigns/' . $id); } catch (FrameworkException $e) { throw $e; } catch (\Throwable $e) { throw FrameworkException::create( ErrorCode::DB_QUERY_FAILED, 'Failed to update campaign' )->withContext($e->getMessage()); } } /** * Delete campaign */ #[Route('/admin/presave-campaigns/{id}/delete', Method::POST)] public function destroy(int $id): Redirect { $this->repository->delete($id); return new Redirect('/admin/presave-campaigns'); } /** * Publish campaign (custom action) */ #[Route('/admin/presave-campaigns/{id}/publish', Method::POST)] public function publish(int $id): Redirect { try { $campaign = $this->repository->findById($id); if ($campaign === null) { throw FrameworkException::create( ErrorCode::ENTITY_NOT_FOUND, 'Campaign not found' )->withData(['campaign_id' => $id]); } if ($campaign->status !== CampaignStatus::DRAFT) { throw FrameworkException::create( ErrorCode::VAL_BUSINESS_RULE_VIOLATION, 'Only draft campaigns can be published' )->withData([ 'campaign_id' => $id, 'current_status' => $campaign->status->value, ]); } $publishedCampaign = $campaign->publish(); $this->repository->save($publishedCampaign); return new Redirect('/admin/presave-campaigns/' . $id); } catch (FrameworkException $e) { throw $e; } catch (\Throwable $e) { throw FrameworkException::create( ErrorCode::DB_QUERY_FAILED, 'Failed to publish campaign' )->withContext($e->getMessage()); } } /** * Cancel campaign (custom action) */ #[Route('/admin/presave-campaigns/{id}/cancel', Method::POST)] public function cancel(int $id): Redirect { try { $campaign = $this->repository->findById($id); if ($campaign === null) { throw FrameworkException::create( ErrorCode::ENTITY_NOT_FOUND, 'Campaign not found' )->withData(['campaign_id' => $id]); } if (! in_array($campaign->status, [CampaignStatus::DRAFT, CampaignStatus::SCHEDULED, CampaignStatus::ACTIVE], true)) { throw FrameworkException::create( ErrorCode::VAL_BUSINESS_RULE_VIOLATION, 'Campaign cannot be cancelled in current status' )->withData([ 'campaign_id' => $id, 'current_status' => $campaign->status->value, ]); } $cancelledCampaign = $campaign->cancel(); $this->repository->save($cancelledCampaign); return new Redirect('/admin/presave-campaigns/' . $id); } catch (FrameworkException $e) { throw $e; } catch (\Throwable $e) { throw FrameworkException::create( ErrorCode::DB_QUERY_FAILED, 'Failed to cancel campaign' )->withContext($e->getMessage()); } } }