|null */ public ?array $data = []; public ?int $activeCampaignId = null; public bool $campaignNotificationSent = false; public function mount(): void { $this->form->fill([ 'message' => '', 'send_to_all' => true, 'coupon_ids' => [], 'excluded_coupon_ids' => [], ]); } public function defaultForm(Schema $schema): Schema { return $schema ->statePath('data') ->columns(1); } public function form(Schema $schema): Schema { return $schema ->components([ Section::make('Message') ->description('Write the SMS exactly as recipients should see it.') ->schema([ Textarea::make('message') ->label('SMS message') ->required() ->rows(8) ->maxLength(1000) ->live(debounce: 300) ->helperText(fn (Get $get): string => app(SmsMessageAnalyzer::class)->summary((string) ($get('message') ?? ''))), ]), Section::make('Recipients') ->description('Send to everyone, pick specific coupon holders, or exclude numbers from a broadcast.') ->schema([ Checkbox::make('send_to_all') ->label('Send to all coupon holders') ->helperText('When enabled, every coupon holder is included. Manual selection is disabled.') ->live(), Select::make('coupon_ids') ->label('Select recipients') ->multiple() ->searchable() ->disabled(fn (Get $get): bool => (bool) $get('send_to_all')) ->dehydrated(fn (Get $get): bool => ! (bool) $get('send_to_all')) ->helperText('Search by phone, coupon code, or ID.') ->getSearchResultsUsing(fn (string $search): array => $this->searchCoupons($search)) ->getOptionLabelsUsing(fn (array $values): array => $this->couponLabels($values)), Select::make('excluded_coupon_ids') ->label('Exclude recipients') ->multiple() ->searchable() ->helperText('Excluded numbers will not receive this message, even when sending to all.') ->getSearchResultsUsing(fn (string $search): array => $this->searchCoupons($search)) ->getOptionLabelsUsing(fn (array $values): array => $this->couponLabels($values)), ]), ]); } public function content(Schema $schema): Schema { return $schema ->components([ Form::make([EmbeddedSchema::make('form')]) ->id('send-sms-form') ->livewireSubmitHandler('sendCampaign') ->footer([ Actions::make([ Action::make('send') ->label('Send SMS') ->icon(Heroicon::OutlinedPaperAirplane) ->requiresConfirmation() ->modalHeading('Send SMS now?') ->modalDescription(fn (): string => $this->confirmationDescription()) ->modalSubmitActionLabel('Send') ->action('sendCampaign') ->disabled(fn (): bool => $this->activeCampaignId !== null && ! $this->isCampaignComplete()), ]), ]), ]); } public function sendCampaign(): void { if ($this->activeCampaignId !== null && ! $this->isCampaignComplete()) { Notification::make() ->title('A campaign is still sending') ->warning() ->send(); return; } $data = $this->form->getState(); $validator = Validator::make($data, [ 'message' => ['required', 'string', 'max:1000'], 'send_to_all' => ['required', 'boolean'], 'coupon_ids' => [ Rule::requiredIf(fn (): bool => ! ($data['send_to_all'] ?? false)), 'array', ], 'coupon_ids.*' => ['integer', 'exists:coupons,id'], 'excluded_coupon_ids' => ['nullable', 'array'], 'excluded_coupon_ids.*' => ['integer', 'exists:coupons,id'], ]); $validator->validate(); $sendToAll = (bool) $data['send_to_all']; $selectedIds = array_map('intval', $data['coupon_ids'] ?? []); $excludedIds = array_map('intval', $data['excluded_coupon_ids'] ?? []); $service = app(SmsBroadcastService::class); $willReceive = $service->resolveRecipients($sendToAll, $selectedIds, $excludedIds) ->filter(fn (Coupon $coupon): bool => is_valid_coupon_phone($coupon->phone)) ->count(); if ($willReceive === 0) { Notification::make() ->title('No valid recipients') ->body('Adjust your selection or exclusions — no coupon holders with valid phone numbers will receive this message.') ->danger() ->send(); return; } $campaign = $service->startCampaign( admin: auth()->user(), message: $data['message'], sendToAll: $sendToAll, selectedIds: $selectedIds, excludedIds: $excludedIds, ); $this->activeCampaignId = $campaign->id; $this->campaignNotificationSent = false; if ($campaign->isComplete()) { $this->notifyCampaignFinished($campaign); } else { Notification::make() ->title('SMS campaign queued') ->body("Sending to {$campaign->recipient_count} recipient(s) in the background.") ->success() ->send(); } } public function refreshCampaignProgress(): void { if ($this->activeCampaignId === null) { return; } $campaign = SmsCampaign::query()->find($this->activeCampaignId); if ($campaign === null) { $this->activeCampaignId = null; return; } if ($campaign->isComplete()) { $this->notifyCampaignFinished($campaign); } } protected function notifyCampaignFinished(SmsCampaign $campaign): void { if ($this->campaignNotificationSent) { return; } $this->campaignNotificationSent = true; $campaign->refresh(); if ($campaign->failed_count > 0 && $campaign->sent_count === 0) { Notification::make() ->title('SMS campaign failed') ->body("{$campaign->failed_count} message(s) failed to send.") ->danger() ->send(); } elseif ($campaign->failed_count > 0) { Notification::make() ->title('SMS campaign finished with errors') ->body("Sent {$campaign->sent_count}, failed {$campaign->failed_count}, skipped {$campaign->skipped_count}.") ->warning() ->send(); } else { Notification::make() ->title('SMS campaign completed') ->body("Successfully sent {$campaign->sent_count} message(s).") ->success() ->send(); } } protected function isCampaignComplete(): bool { if ($this->activeCampaignId === null) { return true; } return SmsCampaign::query() ->whereKey($this->activeCampaignId) ->whereNotNull('completed_at') ->exists(); } protected function confirmationDescription(): string { $message = (string) ($this->data['message'] ?? ''); $analyzer = app(SmsMessageAnalyzer::class); $willReceive = $this->getWillReceiveCount(); return "Send to {$willReceive} recipient(s)? {$analyzer->summary($message)}"; } public function getWillReceiveCount(): int { $data = $this->data ?? []; return app(SmsBroadcastService::class) ->resolveRecipients( (bool) ($data['send_to_all'] ?? false), array_map('intval', $data['coupon_ids'] ?? []), array_map('intval', $data['excluded_coupon_ids'] ?? []), ) ->filter(fn (Coupon $coupon): bool => is_valid_coupon_phone($coupon->phone)) ->count(); } public function getCampaignProgressPercent(): int { if ($this->activeCampaignId === null) { return 0; } $campaign = SmsCampaign::query()->find($this->activeCampaignId); return $campaign?->progressPercent() ?? 0; } public function getCampaignProgressLabel(): string { if ($this->activeCampaignId === null) { return ''; } $campaign = SmsCampaign::query()->find($this->activeCampaignId); if ($campaign === null) { return ''; } return "{$campaign->processedCount()} / {$campaign->recipient_count} processed · {$campaign->sent_count} sent"; } /** * @return array */ protected function getViewData(): array { return [ 'campaignProgressPercent' => $this->getCampaignProgressPercent(), 'campaignProgressLabel' => $this->getCampaignProgressLabel(), ]; } /** * @return array */ protected function searchCoupons(string $search): array { $search = trim($search); if ($search === '') { return []; } return Coupon::query() ->where(function ($query) use ($search): void { $query->where('phone', 'like', "%{$search}%") ->orWhere('code', 'like', "%{$search}%"); if (ctype_digit($search)) { $query->orWhere('id', (int) $search); } }) ->orderBy('id') ->limit(50) ->get() ->mapWithKeys(fn (Coupon $coupon): array => [ $coupon->id => $this->couponLabel($coupon), ]) ->all(); } /** * @param array $values * @return array */ protected function couponLabels(array $values): array { if ($values === []) { return []; } return Coupon::query() ->whereIn('id', $values) ->get() ->mapWithKeys(fn (Coupon $coupon): array => [ $coupon->id => $this->couponLabel($coupon), ]) ->all(); } protected function couponLabel(Coupon $coupon): string { return format_phone($coupon->phone).' · #'.$coupon->id.' · '.$coupon->code; } }