docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Application\Campaign;
use App\Application\Campaign\ValueObjects\Campaign;
/**
* Campaign Repository Interface
*
* Data access for campaigns
*/
interface CampaignRepository
{
public function findBySlug(string $slug): ?Campaign;
public function findById(string $id): ?Campaign;
public function hasUserSaved(string $campaignId, string $sessionId): bool;
public function recordPreSave(
string $campaignId,
string $platform,
string $sessionId,
?string $email = null
): void;
public function incrementSaveCount(string $campaignId): void;
public function recordEmailSubscription(
string $campaignId,
string $email,
?string $name = null,
bool $newsletter = false
): void;
/**
* @return array<Campaign>
*/
public function findAll(): array;
public function create(Campaign $campaign): Campaign;
public function update(Campaign $campaign): void;
public function delete(string $id): void;
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Application\Campaign;
use App\Application\Campaign\ValueObjects\Campaign;
use App\Framework\Attributes\Singleton;
use App\Framework\Http\Session\SessionId;
/**
* Campaign Service
*
* Business logic for campaign operations
*/
#[Singleton]
final readonly class CampaignService
{
public function __construct(
private CampaignRepository $repository
) {}
public function findBySlug(string $slug): ?Campaign
{
return $this->repository->findBySlug($slug);
}
public function hasUserSaved(string $campaignId, SessionId $sessionId): bool
{
return $this->repository->hasUserSaved($campaignId, $sessionId->toString());
}
public function recordPreSave(
string $campaignId,
string $platform,
SessionId $sessionId,
?string $email = null
): void {
$this->repository->recordPreSave($campaignId, $platform, $sessionId->toString(), $email);
$this->repository->incrementSaveCount($campaignId);
}
public function recordEmailSubscription(
string $campaignId,
string $email,
?string $name = null,
bool $newsletter = false
): void {
$this->repository->recordEmailSubscription(
$campaignId,
$email,
$name,
$newsletter
);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Application\Campaign;
use App\Framework\DI\Initializer;
/**
* Campaign Service Initializer
*
* Registers campaign repository in DI container
*/
final readonly class CampaignServiceInitializer
{
#[Initializer]
public function initializeCampaignRepository(): CampaignRepository
{
return new InMemoryCampaignRepository();
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Application\Campaign;
use App\Application\Campaign\ValueObjects\Campaign;
/**
* In-Memory Campaign Repository (for testing)
*
* Temporary implementation until real database repository is created
*/
final readonly class InMemoryCampaignRepository implements CampaignRepository
{
private array $campaigns;
public function __construct()
{
// Sample campaign data
$this->campaigns = [
Campaign::fromArray([
'id' => '1',
'slug' => 'test-campaign',
'artist_name' => 'Test Artist',
'album_title' => 'Amazing Album',
'description' => 'This is a test campaign for pre-save functionality',
'artwork_url' => 'https://via.placeholder.com/400',
'release_date' => '2024-12-15',
'total_saves' => 1250,
'track_count' => 3,
'spotify_enabled' => true,
'apple_music_enabled' => true,
'spotify_uri' => 'spotify:album:test123',
'apple_music_id' => 'apple456',
'tracks' => [
[
'id' => 'track_1',
'position' => 1,
'title' => 'Opening Track',
'duration' => 240,
'preview_url' => null,
'spotify_id' => 'spotify_track_1',
'apple_music_id' => 'apple_track_1',
],
[
'id' => 'track_2',
'position' => 2,
'title' => 'Middle Track',
'duration' => 210,
'preview_url' => null,
'spotify_id' => 'spotify_track_2',
'apple_music_id' => 'apple_track_2',
],
[
'id' => 'track_3',
'position' => 3,
'title' => 'Closing Track',
'duration' => 180,
'preview_url' => null,
'spotify_id' => 'spotify_track_3',
'apple_music_id' => 'apple_track_3',
]
],
'status' => 'active',
])
];
}
public function findBySlug(string $slug): ?Campaign
{
foreach ($this->campaigns as $campaign) {
if ($campaign->slug === $slug) {
return $campaign;
}
}
return null;
}
public function findById(string $id): ?Campaign
{
foreach ($this->campaigns as $campaign) {
if ($campaign->id === $id) {
return $campaign;
}
}
return null;
}
public function hasUserSaved(string $campaignId, string $sessionId): bool
{
// Mock: always return false for testing
return false;
}
public function recordPreSave(
string $campaignId,
string $platform,
string $sessionId,
?string $email = null
): void {
// Mock: do nothing for now
}
public function incrementSaveCount(string $campaignId): void
{
// Mock: do nothing for now
}
public function recordEmailSubscription(
string $campaignId,
string $email,
?string $name = null,
bool $newsletter = false
): void {
// Mock: do nothing for now
}
public function findAll(): array
{
return $this->campaigns;
}
public function create(Campaign $campaign): Campaign
{
// Mock: return same campaign
return $campaign;
}
public function update(Campaign $campaign): void
{
// Mock: do nothing
}
public function delete(string $id): void
{
// Mock: do nothing
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Application\Campaign;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Session\Session;
use App\Application\Campaign\Services\SpotifyCampaignService;
use App\Framework\Router\Result\Redirect;
/**
* Pre-Save Campaign
*
* Handles platform-specific pre-save redirects
*/
final readonly class PreSaveCampaign
{
public function __construct(
private CampaignService $campaignService,
private SpotifyCampaignService $spotifyCampaignService
) {}
#[Route(path: '/campaign/{slug}/presave/{platform}', method: Method::GET)]
public function __invoke(string $slug, string $platform, Session $session): Redirect
{
// Fetch campaign
$campaign = $this->campaignService->findBySlug($slug);
if (!$campaign) {
return Redirect::to('/404')
->withFlash('error', 'Campaign not found');
}
// Record pre-save
$this->campaignService->recordPreSave(
$campaign->id,
$platform,
$session->getId()
);
// Handle platform-specific flow
if ($platform === 'spotify') {
return $this->handleSpotifyPreSave($session, $campaign, $slug);
}
if ($platform === 'apple-music') {
return $this->handleAppleMusicPreSave($campaign, $slug);
}
return Redirect::to("/campaign/{$slug}")
->withFlash('error', 'Invalid platform');
}
/**
* Handle Spotify pre-save with OAuth flow
*/
private function handleSpotifyPreSave(Session $session, object $campaign, string $slug): Redirect
{
if (!$campaign->spotify_enabled || !$campaign->spotify_uri) {
return Redirect::to("/campaign/{$slug}")
->withFlash('error', 'Spotify is not enabled for this campaign');
}
// Generate OAuth state for CSRF protection
$state = bin2hex(random_bytes(16));
// Store state and campaign slug in session
$session->set('spotify_oauth_state', $state);
$session->set('spotify_campaign_slug', $slug);
// Get Spotify authorization URL
$authUrl = $this->spotifyCampaignService->getAuthorizationUrl($campaign, $state);
// Redirect to Spotify for authorization
return Redirect::to($authUrl);
}
/**
* Handle Apple Music pre-save (direct link, no OAuth)
*/
private function handleAppleMusicPreSave(object $campaign, string $slug): Redirect
{
if (!$campaign->apple_music_enabled || !$campaign->apple_music_id) {
return Redirect::to("/campaign/{$slug}")
->withFlash('error', 'Apple Music is not enabled for this campaign');
}
$appleUrl = "https://music.apple.com/us/album/{$campaign->apple_music_id}";
// Redirect to success page with Apple Music URL
return Redirect::to("/campaign/{$slug}/success")
->withFlash('success', 'Redirecting to Apple Music...')
->withSession([
'presave_platform' => 'Apple Music',
'presave_url' => $appleUrl
]);
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Application\Campaign\Services;
use App\Framework\OAuth\Providers\SpotifyProvider;
use App\Framework\OAuth\ValueObjects\OAuthToken;
use App\Application\Campaign\ValueObjects\Campaign;
use App\Framework\Attributes\Singleton;
/**
* Spotify Campaign Integration Service
*
* Handles Spotify pre-save functionality for campaigns
*/
#[Singleton]
final readonly class SpotifyCampaignService
{
public function __construct(
private SpotifyProvider $spotifyProvider
) {}
/**
* Get Spotify authorization URL for campaign pre-save
*
* @param Campaign $campaign
* @param string $state CSRF state token
* @return string Authorization URL
*/
public function getAuthorizationUrl(Campaign $campaign, string $state): string
{
return $this->spotifyProvider->getAuthorizationUrl([
'state' => $state,
'scope' => [
'user-library-read',
'user-library-modify',
'user-read-email',
],
'show_dialog' => false, // Don't force re-auth if already authorized
]);
}
/**
* Add campaign tracks to user's Spotify library
*
* @param OAuthToken $token User's OAuth token
* @param Campaign $campaign Campaign with track data
* @return bool Success status
*/
public function addCampaignToLibrary(OAuthToken $token, Campaign $campaign): bool
{
if (!$campaign->spotify_enabled || empty($campaign->tracks)) {
return false;
}
// Extract Spotify track IDs from campaign tracks
$trackIds = array_filter(
array_map(
fn($track) => $this->extractSpotifyId($track['spotify_uri'] ?? null),
$campaign->tracks
)
);
if (empty($trackIds)) {
return false;
}
return $this->spotifyProvider->addTracksToLibrary($token, $trackIds);
}
/**
* Check if campaign tracks are already in user's library
*
* @param OAuthToken $token User's OAuth token
* @param Campaign $campaign Campaign with track data
* @return array<string, bool> Track ID => in library status
*/
public function checkCampaignInLibrary(OAuthToken $token, Campaign $campaign): array
{
if (!$campaign->spotify_enabled || empty($campaign->tracks)) {
return [];
}
$trackIds = array_filter(
array_map(
fn($track) => $this->extractSpotifyId($track['spotify_uri'] ?? null),
$campaign->tracks
)
);
if (empty($trackIds)) {
return [];
}
// Spotify API returns boolean array matching input order
$results = $this->spotifyProvider->checkTracksInLibrary($token, $trackIds);
return array_combine($trackIds, $results);
}
/**
* Get user's Spotify profile
*
* @param OAuthToken $token User's OAuth token
* @return array<string, mixed> User profile data
*/
public function getUserProfile(OAuthToken $token): array
{
return $this->spotifyProvider->getUserProfile($token);
}
/**
* Extract Spotify ID from URI
*
* Converts spotify:track:xxxx or spotify:album:xxxx to just the ID
*/
private function extractSpotifyId(?string $uri): ?string
{
if ($uri === null) {
return null;
}
// Handle spotify:track:xxxx format
if (str_starts_with($uri, 'spotify:')) {
$parts = explode(':', $uri);
return $parts[2] ?? null;
}
// Handle URL format: https://open.spotify.com/track/xxxx
if (str_contains($uri, 'open.spotify.com')) {
$parts = explode('/', $uri);
return end($parts);
}
// Already just an ID
return $uri;
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Application\Campaign;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Http\Session\Session;
use App\Framework\Meta\MetaData;
/**
* Show Campaign Landing Page
*
* Displays public campaign landing page with pre-save functionality
*/
final readonly class ShowCampaign
{
public function __construct(
private CampaignService $campaignService
) {}
#[Route(path: '/campaign/{slug}', method: Method::GET)]
public function __invoke(string $slug, Session $session): ViewResult
{
// Fetch campaign data
$campaign = $this->campaignService->findBySlug($slug);
if (!$campaign) {
throw new \RuntimeException("Campaign not found: {$slug}");
}
// Check if user has already saved
$userHasSaved = $this->campaignService->hasUserSaved(
$campaign->id,
$session->getId()
);
// Prepare campaign data for view
$campaignData = [
'slug' => $campaign->slug,
'artist_name' => $campaign->artist_name,
'album_title' => $campaign->album_title,
'description' => $campaign->description,
'artwork_url' => $campaign->artwork_url,
'release_date' => $campaign->release_date?->format('F j, Y'),
'total_saves' => $campaign->total_saves,
'track_count' => $campaign->track_count,
'spotify_enabled' => (bool) $campaign->spotify_enabled,
'apple_music_enabled' => (bool) $campaign->apple_music_enabled,
'tracks' => $campaign->tracks ? array_map(
fn($track) => [
'position' => $track->position,
'title' => $track->title,
'duration' => $track->duration ? $this->formatDuration($track->duration) : null,
'preview_url' => $track->preview_url,
],
$campaign->tracks
) : null,
];
return new ViewResult(
'campaign-landing',
new MetaData(
title: $campaign->artist_name . ' - ' . $campaign->album_title,
description: $campaign->description ?? 'Pre-save this album on Spotify',
),
data: [
'campaign' => $campaignData,
'user_has_saved' => $userHasSaved,
'csrf_token' => $session->csrf->generateToken('campaign-landing')->toString(),
]
);
}
private function formatDuration(int $seconds): string
{
$minutes = floor($seconds / 60);
$remainingSeconds = $seconds % 60;
return sprintf('%d:%02d', $minutes, $remainingSeconds);
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Application\Campaign;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Responses\ViewResult;
/**
* Show Campaign Success Page
*
* Displays thank you page after pre-save or email subscription
*/
final readonly class ShowSuccess
{
public function __construct(
private CampaignService $campaignService
) {}
#[Route(path: '/campaign/{slug}/success', method: Method::GET)]
public function __invoke(string $slug, Request $request): ViewResult
{
// Fetch campaign
$campaign = $this->campaignService->findBySlug($slug);
if (!$campaign) {
throw new \RuntimeException("Campaign not found: {$slug}");
}
// Get pre-save info from session
$presavePlatform = $request->session->get('presave_platform');
$presaveUrl = $request->session->get('presave_url');
$emailSubscribed = $request->session->get('email_subscribed', false);
// Clear session data
$request->session->forget('presave_platform');
$request->session->forget('presave_url');
$request->session->forget('email_subscribed');
return new ViewResult('campaign-success', [
'campaign' => [
'slug' => $campaign->slug,
'artist_name' => $campaign->artist_name,
'album_title' => $campaign->album_title,
'release_date' => $campaign->release_date?->format('F j, Y'),
],
'presave_platform' => $presavePlatform ? ucfirst(str_replace('-', ' ', $presavePlatform)) : null,
'presave_url' => $presaveUrl,
'email_subscribed' => $emailSubscribed,
]);
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Application\Campaign;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Responses\Redirect;
use App\Framework\OAuth\Providers\SpotifyProvider;
use App\Application\Campaign\Services\SpotifyCampaignService;
use App\Application\Campaign\CampaignService;
/**
* Spotify OAuth Callback Handler
*
* Handles OAuth callback from Spotify and adds tracks to user's library
*/
final readonly class SpotifyCallbackHandler
{
public function __construct(
private SpotifyProvider $spotifyProvider,
private SpotifyCampaignService $spotifyCampaignService,
private CampaignService $campaignService
) {}
#[Route(path: '/campaign/spotify/callback', method: Method::GET)]
public function __invoke(Request $request): Redirect
{
// Get OAuth code and state from callback
$code = $request->query->get('code');
$state = $request->query->get('state');
$error = $request->query->get('error');
// Handle OAuth errors
if ($error) {
return Redirect::to('/')
->withFlash('error', "Spotify authorization failed: {$error}");
}
// Validate state (CSRF protection)
$expectedState = $request->session->get('spotify_oauth_state');
if (!$state || $state !== $expectedState) {
return Redirect::to('/')
->withFlash('error', 'Invalid OAuth state. Please try again.');
}
// Get campaign slug from session
$campaignSlug = $request->session->get('spotify_campaign_slug');
if (!$campaignSlug) {
return Redirect::to('/')
->withFlash('error', 'Campaign session expired. Please try again.');
}
try {
// Exchange code for access token
$token = $this->spotifyProvider->getAccessToken($code, $state);
// Get campaign data
$campaign = $this->campaignService->findBySlug($campaignSlug);
if (!$campaign) {
return Redirect::to('/')
->withFlash('error', 'Campaign not found');
}
// Add campaign tracks to user's Spotify library
$success = $this->spotifyCampaignService->addCampaignToLibrary($token, $campaign);
if (!$success) {
return Redirect::to("/campaign/{$campaignSlug}")
->withFlash('error', 'Failed to add tracks to your library');
}
// Get user profile for personalization
$profile = $this->spotifyCampaignService->getUserProfile($token);
// Record pre-save with email if available
$this->campaignService->recordPreSave(
campaignId: $campaign->id,
platform: 'spotify',
sessionId: $request->session->getId(),
email: $profile['email'] ?? null
);
// Clean up session
$request->session->forget('spotify_oauth_state');
$request->session->forget('spotify_campaign_slug');
// Redirect to success page
return Redirect::to("/campaign/{$campaignSlug}/success")
->withFlash('success', "Successfully added {$campaign->album_title} to your Spotify library!")
->withSession([
'presave_platform' => 'Spotify',
'presave_url' => "https://open.spotify.com/album/" . $this->extractSpotifyId($campaign->spotify_uri),
'user_name' => $profile['display_name'] ?? null,
]);
} catch (\Exception $e) {
return Redirect::to("/campaign/{$campaignSlug}")
->withFlash('error', 'An error occurred during Spotify authorization');
}
}
/**
* Extract Spotify ID from URI
*/
private function extractSpotifyId(?string $uri): ?string
{
if ($uri === null) {
return null;
}
if (str_starts_with($uri, 'spotify:')) {
$parts = explode(':', $uri);
return $parts[2] ?? null;
}
return $uri;
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Application\Campaign;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Responses\Redirect;
/**
* Subscribe to Campaign Email Notifications
*
* Handles email collection for release notifications
*/
final readonly class SubscribeCampaign
{
public function __construct(
private CampaignService $campaignService
) {}
#[Route(path: '/campaign/{slug}/subscribe', method: Method::POST)]
public function __invoke(string $slug, Request $request): Redirect
{
// Fetch campaign
$campaign = $this->campaignService->findBySlug($slug);
if (!$campaign) {
return Redirect::to('/404')
->withFlash('error', 'Campaign not found');
}
// Validate email
$email = $request->parsedBody->get('email');
$name = $request->parsedBody->get('name');
$newsletter = $request->parsedBody->get('newsletter') === '1';
if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return Redirect::back()
->withFlash('error', 'Please provide a valid email address')
->withInput($request->parsedBody->toArray());
}
// Record subscription
try {
$this->campaignService->recordEmailSubscription(
$campaign->id,
$email,
$name,
$newsletter
);
return Redirect::to("/campaign/{$slug}/success")
->withFlash('success', "You'll receive an email when {$campaign->album_title} is released!");
} catch (\Exception $e) {
return Redirect::back()
->withFlash('error', 'Failed to subscribe. Please try again.')
->withInput($request->parsedBody->toArray());
}
}
}

View File

@@ -0,0 +1,584 @@
<layout name="layouts/main" />
<div class="campaign-landing">
<!-- Hero Section with Album Art -->
<section class="campaign-hero">
<div class="container">
<div class="row align-items-center">
<div class="col-lg-6">
<div class="campaign-artwork">
<if condition="campaign.artwork_url">
<img src="{{ campaign.artwork_url }}"
alt="{{ campaign.artist_name }} - {{ campaign.album_title }}"
class="img-fluid rounded shadow-lg">
</if>
<if condition="!campaign.artwork_url">
<div class="artwork-placeholder">
<i class="bi bi-music-note-beamed"></i>
</div>
</if>
</div>
</div>
<div class="col-lg-6">
<div class="campaign-info">
<h1 class="campaign-title">{{ campaign.album_title }}</h1>
<h2 class="campaign-artist">{{ campaign.artist_name }}</h2>
<if condition="campaign.release_date">
<div class="release-info">
<i class="bi bi-calendar-event"></i>
<span>Releases {{ campaign.release_date }}</span>
</div>
</if>
<if condition="campaign.description">
<p class="campaign-description">{{ campaign.description }}</p>
</if>
<div class="campaign-stats">
<div class="stat-item">
<span class="stat-value">{{ campaign.total_saves ?? 0 }}</span>
<span class="stat-label">Pre-Saves</span>
</div>
<if condition="campaign.track_count">
<div class="stat-item">
<span class="stat-value">{{ campaign.track_count }}</span>
<span class="stat-label">Tracks</span>
</div>
</if>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Pre-Save Actions Section -->
<section class="campaign-actions">
<div class="container">
<div class="actions-wrapper">
<h3 class="actions-title">Pre-Save Now</h3>
<p class="actions-subtitle">Be the first to hear it when it drops</p>
<div class="platform-buttons">
<if condition="campaign.spotify_enabled">
<button class="platform-btn spotify-btn" data-platform="spotify">
<i class="bi bi-spotify"></i>
<span>Pre-Save on Spotify</span>
</button>
</if>
<if condition="campaign.apple_music_enabled">
<button class="platform-btn apple-btn" data-platform="apple-music">
<i class="bi bi-apple"></i>
<span>Pre-Add on Apple Music</span>
</button>
</if>
</div>
<if condition="!user_has_saved">
<div class="email-capture-prompt">
<p class="small text-muted">
Or enter your email to get notified on release day
</p>
<button class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#emailModal">
<i class="bi bi-envelope"></i> Get Email Reminder
</button>
</div>
</if>
<if condition="user_has_saved">
<div class="saved-confirmation">
<i class="bi bi-check-circle-fill text-success"></i>
<span>You've pre-saved this release!</span>
</div>
</if>
</div>
</div>
</section>
<!-- Track List (if available) -->
<if condition="campaign.track_count">
<section class="campaign-tracklist">
<div class="container">
<h3 class="section-title">Track List</h3>
<div class="tracks">
<for var="track" in="campaign.tracks">
<div class="track-item">
<span class="track-number">{{ track.position }}</span>
<div class="track-info">
<div class="track-title">{{ track.title }}</div>
<if condition="track.duration">
<div class="track-duration">{{ track.duration }}</div>
</if>
</div>
<if condition="track.preview_url">
<button class="btn-preview" data-preview="{{ track.preview_url }}">
<i class="bi bi-play-circle"></i>
</button>
</if>
</div>
</for>
</div>
</div>
</section>
</if>
<!-- Social Share Section -->
<section class="campaign-share">
<div class="container">
<div class="share-wrapper">
<h3 class="share-title">Spread the Word</h3>
<div class="share-buttons">
<button class="share-btn twitter" data-share="twitter">
<i class="bi bi-twitter"></i>
<span>Tweet</span>
</button>
<button class="share-btn facebook" data-share="facebook">
<i class="bi bi-facebook"></i>
<span>Share</span>
</button>
<button class="share-btn whatsapp" data-share="whatsapp">
<i class="bi bi-whatsapp"></i>
<span>WhatsApp</span>
</button>
<button class="share-btn copy-link" data-share="copy">
<i class="bi bi-link-45deg"></i>
<span>Copy Link</span>
</button>
</div>
</div>
</div>
</section>
</div>
<!-- Email Collection Modal -->
<div class="modal fade" id="emailModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Get Notified on Release Day</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="emailCaptureForm" action="/campaign/{{ campaign.slug }}/subscribe" method="POST">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="mb-3">
<label for="email" class="form-label">Email Address</label>
<input type="email"
class="form-control"
id="email"
name="email"
required
placeholder="your@email.com">
<div class="invalid-feedback">
Please enter a valid email address
</div>
</div>
<div class="mb-3">
<label for="name" class="form-label">Name (optional)</label>
<input type="text"
class="form-control"
id="name"
name="name"
placeholder="Your name">
</div>
<div class="form-check mb-3">
<input type="checkbox"
class="form-check-input"
id="newsletter"
name="newsletter"
value="1">
<label class="form-check-label" for="newsletter">
Also send me news and updates from {{ campaign.artist_name }}
</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="bi bi-bell"></i> Notify Me
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<style>
.campaign-landing {
padding: 2rem 0;
}
.campaign-hero {
padding: 3rem 0;
}
.campaign-artwork img {
width: 100%;
max-width: 500px;
display: block;
margin: 0 auto;
}
.artwork-placeholder {
width: 100%;
max-width: 500px;
aspect-ratio: 1;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
}
.artwork-placeholder i {
font-size: 8rem;
color: rgba(255, 255, 255, 0.3);
}
.campaign-info {
padding: 2rem 0;
}
.campaign-title {
font-size: 3rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.campaign-artist {
font-size: 1.5rem;
color: #6c757d;
margin-bottom: 1.5rem;
}
.release-info {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
color: #6c757d;
}
.campaign-description {
font-size: 1.1rem;
line-height: 1.6;
margin-bottom: 2rem;
}
.campaign-stats {
display: flex;
gap: 2rem;
margin-top: 2rem;
}
.stat-item {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: #667eea;
}
.stat-label {
font-size: 0.875rem;
color: #6c757d;
text-transform: uppercase;
}
.campaign-actions {
background: #f8f9fa;
padding: 3rem 0;
}
.actions-wrapper {
text-align: center;
max-width: 600px;
margin: 0 auto;
}
.actions-title {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.actions-subtitle {
color: #6c757d;
margin-bottom: 2rem;
}
.platform-buttons {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 2rem;
}
.platform-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 1rem 2rem;
border: none;
border-radius: 50px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.platform-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.platform-btn i {
font-size: 1.5rem;
}
.spotify-btn {
background: #1DB954;
color: white;
}
.apple-btn {
background: #FA243C;
color: white;
}
.email-capture-prompt {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid #dee2e6;
}
.saved-confirmation {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 2rem;
padding: 1rem;
background: #d1e7dd;
border-radius: 0.5rem;
color: #0f5132;
font-weight: 500;
}
.saved-confirmation i {
font-size: 1.5rem;
}
.campaign-tracklist {
padding: 3rem 0;
}
.section-title {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 1.5rem;
}
.tracks {
background: white;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
overflow: hidden;
}
.track-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border-bottom: 1px solid #dee2e6;
transition: background 0.2s;
}
.track-item:last-child {
border-bottom: none;
}
.track-item:hover {
background: #f8f9fa;
}
.track-number {
width: 2rem;
text-align: center;
color: #6c757d;
font-weight: 500;
}
.track-info {
flex: 1;
}
.track-title {
font-weight: 500;
}
.track-duration {
font-size: 0.875rem;
color: #6c757d;
}
.btn-preview {
background: none;
border: none;
color: #667eea;
font-size: 1.5rem;
cursor: pointer;
padding: 0.5rem;
transition: color 0.2s;
}
.btn-preview:hover {
color: #764ba2;
}
.campaign-share {
padding: 3rem 0;
background: #f8f9fa;
}
.share-wrapper {
text-align: center;
max-width: 600px;
margin: 0 auto;
}
.share-title {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 1.5rem;
}
.share-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.share-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: 2px solid #dee2e6;
background: white;
border-radius: 50px;
cursor: pointer;
transition: all 0.2s;
}
.share-btn:hover {
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.share-btn.twitter:hover {
border-color: #1DA1F2;
color: #1DA1F2;
}
.share-btn.facebook:hover {
border-color: #1877F2;
color: #1877F2;
}
.share-btn.whatsapp:hover {
border-color: #25D366;
color: #25D366;
}
.share-btn.copy-link:hover {
border-color: #667eea;
color: #667eea;
}
@media (max-width: 991px) {
.campaign-title {
font-size: 2rem;
}
.campaign-artist {
font-size: 1.25rem;
}
.campaign-artwork img,
.artwork-placeholder {
max-width: 100%;
}
}
</style>
<script>
// Platform Pre-Save Buttons
document.querySelectorAll('.platform-btn').forEach(btn => {
btn.addEventListener('click', function() {
const platform = this.dataset.platform;
const campaignSlug = '{{ campaign.slug }}';
if (platform === 'spotify') {
window.location.href = `/campaign/${campaignSlug}/presave/spotify`;
} else if (platform === 'apple-music') {
window.location.href = `/campaign/${campaignSlug}/presave/apple-music`;
}
});
});
// Share Functionality
document.querySelectorAll('.share-btn').forEach(btn => {
btn.addEventListener('click', function() {
const shareType = this.dataset.share;
const url = window.location.href;
const text = `Check out {{ campaign.album_title }} by {{ campaign.artist_name }}!`;
switch(shareType) {
case 'twitter':
window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`, '_blank');
break;
case 'facebook':
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`, '_blank');
break;
case 'whatsapp':
window.open(`https://wa.me/?text=${encodeURIComponent(text + ' ' + url)}`, '_blank');
break;
case 'copy':
navigator.clipboard.writeText(url).then(() => {
this.innerHTML = '<i class="bi bi-check"></i><span>Copied!</span>';
setTimeout(() => {
this.innerHTML = '<i class="bi bi-link-45deg"></i><span>Copy Link</span>';
}, 2000);
});
break;
}
});
});
// Email Form Validation
const emailForm = document.getElementById('emailCaptureForm');
if (emailForm) {
emailForm.addEventListener('submit', function(e) {
const emailInput = this.querySelector('#email');
if (!emailInput.value || !emailInput.value.includes('@')) {
e.preventDefault();
emailInput.classList.add('is-invalid');
return false;
}
});
}
</script>

View File

@@ -0,0 +1,296 @@
<layout name="layouts/main" />
<div class="campaign-success">
<div class="container">
<div class="success-card">
<div class="success-icon">
<i class="bi bi-check-circle-fill"></i>
</div>
<h1 class="success-title">You're All Set!</h1>
<if condition="{{ presave_platform }}">
<p class="success-message">
Thank you for pre-saving <strong>{{ campaign.album_title }}</strong> by <strong>{{ campaign.artist_name }}</strong>!
</p>
<p class="platform-message">
We're redirecting you to {{ presave_platform }} to complete your pre-save...
</p>
</if>
<if condition="{{ !presave_platform && email_subscribed }}">
<p class="success-message">
Thank you! We'll send you an email when <strong>{{ campaign.album_title }}</strong> is released.
</p>
</if>
<div class="success-actions">
<if condition="{{ presave_url }}">
<a href="{{ presave_url }}" class="btn btn-primary btn-lg" target="_blank">
<i class="bi bi-box-arrow-up-right"></i>
Continue to {{ presave_platform }}
</a>
</if>
<a href="/campaign/{{ campaign.slug }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i>
Back to Campaign
</a>
</div>
<div class="share-section">
<h3>Share with Friends</h3>
<p class="text-muted">Help spread the word about this release!</p>
<div class="share-buttons">
<button class="share-btn twitter" data-share="twitter">
<i class="bi bi-twitter"></i>
<span>Tweet</span>
</button>
<button class="share-btn facebook" data-share="facebook">
<i class="bi bi-facebook"></i>
<span>Share</span>
</button>
<button class="share-btn whatsapp" data-share="whatsapp">
<i class="bi bi-whatsapp"></i>
<span>WhatsApp</span>
</button>
<button class="share-btn copy-link" data-share="copy">
<i class="bi bi-link-45deg"></i>
<span>Copy Link</span>
</button>
</div>
</div>
<if condition="{{ campaign.release_date }}">
<div class="release-reminder">
<i class="bi bi-calendar-event"></i>
<span>Mark your calendar: <strong>{{ campaign.release_date }}</strong></span>
</div>
</if>
</div>
</div>
</div>
<style>
.campaign-success {
min-height: 100vh;
display: flex;
align-items: center;
padding: 2rem 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.success-card {
background: white;
border-radius: 1rem;
padding: 3rem;
max-width: 600px;
margin: 0 auto;
text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.success-icon {
font-size: 5rem;
color: #28a745;
margin-bottom: 1.5rem;
animation: successPulse 0.6s ease-out;
}
@keyframes successPulse {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.success-title {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 1rem;
color: #212529;
}
.success-message {
font-size: 1.25rem;
color: #6c757d;
margin-bottom: 1rem;
}
.platform-message {
color: #6c757d;
margin-bottom: 2rem;
}
.success-actions {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 3rem;
}
.success-actions .btn {
border-radius: 50px;
padding: 0.75rem 2rem;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.share-section {
padding-top: 2rem;
border-top: 1px solid #dee2e6;
margin-top: 2rem;
}
.share-section h3 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.share-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-top: 1.5rem;
}
.share-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: 2px solid #dee2e6;
background: white;
border-radius: 50px;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.share-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.share-btn.twitter:hover {
border-color: #1DA1F2;
color: #1DA1F2;
background: #f0f9ff;
}
.share-btn.facebook:hover {
border-color: #1877F2;
color: #1877F2;
background: #f0f5ff;
}
.share-btn.whatsapp:hover {
border-color: #25D366;
color: #25D366;
background: #f0fff4;
}
.share-btn.copy-link:hover {
border-color: #667eea;
color: #667eea;
background: #f5f3ff;
}
.share-btn i {
font-size: 1.25rem;
}
.release-reminder {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-top: 2rem;
padding: 1rem 1.5rem;
background: #f8f9fa;
border-radius: 50px;
color: #495057;
}
.release-reminder i {
font-size: 1.25rem;
color: #667eea;
}
@media (max-width: 767px) {
.success-card {
padding: 2rem 1.5rem;
}
.success-title {
font-size: 2rem;
}
.success-message {
font-size: 1.1rem;
}
.share-buttons {
flex-direction: column;
}
.share-btn {
width: 100%;
justify-content: center;
}
}
</style>
<script>
// Auto-redirect to platform if URL provided
const presaveUrl = '{{ presave_url }}';
if (presaveUrl) {
setTimeout(() => {
window.open(presaveUrl, '_blank');
}, 2000);
}
// Share Functionality
document.querySelectorAll('.share-btn').forEach(btn => {
btn.addEventListener('click', function() {
const shareType = this.dataset.share;
const url = window.location.origin + '/campaign/{{ campaign.slug }}';
const text = `Check out {{ campaign.album_title }} by {{ campaign.artist_name }}! Pre-save it now:`;
switch(shareType) {
case 'twitter':
window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`, '_blank');
break;
case 'facebook':
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`, '_blank');
break;
case 'whatsapp':
window.open(`https://wa.me/?text=${encodeURIComponent(text + ' ' + url)}`, '_blank');
break;
case 'copy':
navigator.clipboard.writeText(url).then(() => {
this.innerHTML = '<i class="bi bi-check"></i><span>Copied!</span>';
this.style.borderColor = '#28a745';
this.style.color = '#28a745';
setTimeout(() => {
this.innerHTML = '<i class="bi bi-link-45deg"></i><span>Copy Link</span>';
this.style.borderColor = '';
this.style.color = '';
}, 2000);
});
break;
}
});
});
</script>

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Application\Campaign\ValueObjects;
/**
* Campaign Value Object
*
* Represents a pre-save campaign with all metadata
*/
final readonly class Campaign
{
/**
* @param array<CampaignTrack>|null $tracks
*/
public function __construct(
public string $id,
public string $slug,
public string $artist_name,
public string $album_title,
public ?string $description,
public ?string $artwork_url,
public ?\DateTimeImmutable $release_date,
public int $total_saves,
public ?int $track_count,
public bool $spotify_enabled,
public bool $apple_music_enabled,
public ?string $spotify_uri,
public ?string $apple_music_id,
public ?array $tracks = null,
public string $status = 'active',
public ?\DateTimeImmutable $created_at = null,
public ?\DateTimeImmutable $updated_at = null,
) {}
public static function fromArray(array $data): self
{
return new self(
id: $data['id'],
slug: $data['slug'],
artist_name: $data['artist_name'],
album_title: $data['album_title'],
description: $data['description'] ?? null,
artwork_url: $data['artwork_url'] ?? null,
release_date: isset($data['release_date'])
? new \DateTimeImmutable($data['release_date'])
: null,
total_saves: (int) ($data['total_saves'] ?? 0),
track_count: isset($data['track_count']) ? (int) $data['track_count'] : null,
spotify_enabled: (bool) ($data['spotify_enabled'] ?? false),
apple_music_enabled: (bool) ($data['apple_music_enabled'] ?? false),
spotify_uri: $data['spotify_uri'] ?? null,
apple_music_id: $data['apple_music_id'] ?? null,
tracks: isset($data['tracks']) ? array_map(
fn($track) => CampaignTrack::fromArray($track),
$data['tracks']
) : null,
status: $data['status'] ?? 'active',
created_at: isset($data['created_at'])
? new \DateTimeImmutable($data['created_at'])
: null,
updated_at: isset($data['updated_at'])
? new \DateTimeImmutable($data['updated_at'])
: null,
);
}
public function toArray(): array
{
return [
'id' => $this->id,
'slug' => $this->slug,
'artist_name' => $this->artist_name,
'album_title' => $this->album_title,
'description' => $this->description,
'artwork_url' => $this->artwork_url,
'release_date' => $this->release_date?->format('Y-m-d'),
'total_saves' => $this->total_saves,
'track_count' => $this->track_count,
'spotify_enabled' => $this->spotify_enabled,
'apple_music_enabled' => $this->apple_music_enabled,
'spotify_uri' => $this->spotify_uri,
'apple_music_id' => $this->apple_music_id,
'status' => $this->status,
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
'updated_at' => $this->updated_at?->format('Y-m-d H:i:s'),
];
}
public function isActive(): bool
{
return $this->status === 'active';
}
public function hasReleased(): bool
{
if (!$this->release_date) {
return false;
}
return $this->release_date <= new \DateTimeImmutable();
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Application\Campaign\ValueObjects;
/**
* Campaign Track Value Object
*
* Represents a track within a campaign
*/
final readonly class CampaignTrack
{
public function __construct(
public string $id,
public int $position,
public string $title,
public ?int $duration,
public ?string $preview_url,
public ?string $spotify_id = null,
public ?string $apple_music_id = null,
) {}
public static function fromArray(array $data): self
{
return new self(
id: $data['id'],
position: (int) $data['position'],
title: $data['title'],
duration: isset($data['duration']) ? (int) $data['duration'] : null,
preview_url: $data['preview_url'] ?? null,
spotify_id: $data['spotify_id'] ?? null,
apple_music_id: $data['apple_music_id'] ?? null,
);
}
public function toArray(): array
{
return [
'id' => $this->id,
'position' => $this->position,
'title' => $this->title,
'duration' => $this->duration,
'preview_url' => $this->preview_url,
'spotify_id' => $this->spotify_id,
'apple_music_id' => $this->apple_music_id,
];
}
public function hasPreview(): bool
{
return $this->preview_url !== null;
}
}