feat: add dispensary marketing portal (Phase 5)
Add white-labeled marketing portal for dispensary partners:
- Business branding settings table and model for white-labeling
- Portal middleware (EnsureMarketingPortalAccess) with contact_type check
- Portal route group at /portal/{business}/*
- DashboardController with stats and CannaiQ recommendations
- PromoController for viewing recommended/existing promos
- CampaignController with create/send/schedule/cancel
- ListController for managing contact lists
- Policies for MarketingCampaign and MarketingPromo
- White-labeled portal layout with custom branding CSS
- Marketing Portal link in Filament admin for buyer businesses
- MarketingPortalUserSeeder for development testing
- PORTAL_ACCESS.md documentation
This commit is contained in:
@@ -28,6 +28,14 @@ class EditBusiness extends EditRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('view_marketing_portal')
|
||||
->label('Marketing Portal')
|
||||
->icon('heroicon-o-megaphone')
|
||||
->color('info')
|
||||
->url(fn () => route('portal.dashboard', $this->record->slug))
|
||||
->openUrlInNewTab()
|
||||
->visible(fn () => $this->record->status === 'approved' && $this->record->business_type === 'buyer'),
|
||||
|
||||
Actions\Action::make('approve_application')
|
||||
->label('Approve Application')
|
||||
->icon('heroicon-o-check-circle')
|
||||
|
||||
249
app/Http/Controllers/Portal/CampaignController.php
Normal file
249
app/Http/Controllers/Portal/CampaignController.php
Normal file
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\SendMarketingCampaignJob;
|
||||
use App\Models\Branding\BusinessBrandingSetting;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use App\Models\Marketing\MarketingList;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class CampaignController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
$campaigns = MarketingCampaign::where('business_id', $business->id)
|
||||
->with('list')
|
||||
->when($request->status, fn ($q, $status) => $q->where('status', $status))
|
||||
->when($request->channel, fn ($q, $channel) => $q->where('channel', $channel))
|
||||
->latest()
|
||||
->paginate(15);
|
||||
|
||||
$statuses = [
|
||||
'draft' => 'Draft',
|
||||
'scheduled' => 'Scheduled',
|
||||
'sending' => 'Sending',
|
||||
'sent' => 'Sent',
|
||||
'completed' => 'Completed',
|
||||
'cancelled' => 'Cancelled',
|
||||
'failed' => 'Failed',
|
||||
];
|
||||
|
||||
$channels = MarketingCampaign::CHANNELS;
|
||||
|
||||
return view('portal.campaigns.index', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'campaigns',
|
||||
'statuses',
|
||||
'channels'
|
||||
));
|
||||
}
|
||||
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
// Get lists for this business
|
||||
$lists = MarketingList::where('business_id', $business->id)
|
||||
->withCount('contacts')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Pre-populate from promo if provided
|
||||
$promo = null;
|
||||
if ($request->query('promo_id')) {
|
||||
$promo = MarketingPromo::where('business_id', $business->id)
|
||||
->find($request->query('promo_id'));
|
||||
}
|
||||
|
||||
// Pre-select channel if provided
|
||||
$preselectedChannel = $request->query('channel', 'email');
|
||||
|
||||
$channels = MarketingCampaign::CHANNELS;
|
||||
|
||||
return view('portal.campaigns.create', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'lists',
|
||||
'promo',
|
||||
'preselectedChannel',
|
||||
'channels'
|
||||
));
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'channel' => 'required|in:email,sms',
|
||||
'list_id' => 'required|exists:marketing_lists,id',
|
||||
'subject' => 'required_if:channel,email|nullable|string|max:255',
|
||||
'body' => 'required|string',
|
||||
'send_at' => 'nullable|date|after:now',
|
||||
'promo_id' => 'nullable|exists:marketing_promos,id',
|
||||
]);
|
||||
|
||||
// Verify list belongs to this business
|
||||
$list = MarketingList::where('business_id', $business->id)
|
||||
->findOrFail($validated['list_id']);
|
||||
|
||||
// Build campaign data
|
||||
$campaignData = [
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'channel' => $validated['channel'],
|
||||
'list_id' => $list->id,
|
||||
'subject' => $validated['subject'] ?? null,
|
||||
'body' => $validated['body'],
|
||||
'status' => 'draft',
|
||||
'created_by' => Auth::id(),
|
||||
// Use branding defaults for from fields
|
||||
'from_name' => $branding->effective_from_name,
|
||||
'from_email' => $branding->effective_from_email,
|
||||
];
|
||||
|
||||
// Link to promo if provided
|
||||
if (! empty($validated['promo_id'])) {
|
||||
$promo = MarketingPromo::where('business_id', $business->id)
|
||||
->find($validated['promo_id']);
|
||||
|
||||
if ($promo) {
|
||||
$campaignData['source_type'] = 'promo';
|
||||
$campaignData['source_id'] = $promo->id;
|
||||
}
|
||||
}
|
||||
|
||||
// Set schedule if provided
|
||||
if (! empty($validated['send_at'])) {
|
||||
$campaignData['send_at'] = $validated['send_at'];
|
||||
$campaignData['status'] = 'scheduled';
|
||||
}
|
||||
|
||||
$campaign = MarketingCampaign::create($campaignData);
|
||||
|
||||
if ($campaign->status === 'scheduled') {
|
||||
return redirect()
|
||||
->route('portal.campaigns.show', [$business->slug, $campaign])
|
||||
->with('success', 'Campaign scheduled successfully.');
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('portal.campaigns.show', [$business->slug, $campaign])
|
||||
->with('success', 'Campaign created as draft. Review and send when ready.');
|
||||
}
|
||||
|
||||
public function show(Request $request, Business $business, MarketingCampaign $campaign)
|
||||
{
|
||||
// Ensure campaign belongs to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
$campaign->load(['list', 'logs']);
|
||||
|
||||
// Get stats
|
||||
$stats = [
|
||||
'total_recipients' => $campaign->total_recipients,
|
||||
'sent' => $campaign->total_sent,
|
||||
'delivered' => $campaign->total_delivered,
|
||||
'opened' => $campaign->total_opened,
|
||||
'clicked' => $campaign->total_clicked,
|
||||
'failed' => $campaign->total_failed,
|
||||
];
|
||||
|
||||
return view('portal.campaigns.show', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'campaign',
|
||||
'stats'
|
||||
));
|
||||
}
|
||||
|
||||
public function sendNow(Request $request, Business $business, MarketingCampaign $campaign)
|
||||
{
|
||||
// Ensure campaign belongs to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
|
||||
return back()->with('error', 'This campaign cannot be sent.');
|
||||
}
|
||||
|
||||
// Count recipients
|
||||
$recipientCount = $campaign->list?->contacts()->count() ?? 0;
|
||||
|
||||
if ($recipientCount === 0) {
|
||||
return back()->with('error', 'No recipients in the selected list.');
|
||||
}
|
||||
|
||||
// Update campaign
|
||||
$campaign->update([
|
||||
'status' => 'sending',
|
||||
'total_recipients' => $recipientCount,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
// Dispatch job
|
||||
SendMarketingCampaignJob::dispatch($campaign);
|
||||
|
||||
return redirect()
|
||||
->route('portal.campaigns.show', [$business->slug, $campaign])
|
||||
->with('success', "Campaign is now sending to {$recipientCount} recipients.");
|
||||
}
|
||||
|
||||
public function schedule(Request $request, Business $business, MarketingCampaign $campaign)
|
||||
{
|
||||
// Ensure campaign belongs to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($campaign->status !== 'draft') {
|
||||
return back()->with('error', 'Only draft campaigns can be scheduled.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'send_at' => 'required|date|after:now',
|
||||
]);
|
||||
|
||||
$campaign->update([
|
||||
'status' => 'scheduled',
|
||||
'send_at' => $validated['send_at'],
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('portal.campaigns.show', [$business->slug, $campaign])
|
||||
->with('success', 'Campaign scheduled for '.$campaign->send_at->format('M j, Y g:i A'));
|
||||
}
|
||||
|
||||
public function cancel(Request $request, Business $business, MarketingCampaign $campaign)
|
||||
{
|
||||
// Ensure campaign belongs to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
|
||||
return back()->with('error', 'This campaign cannot be cancelled.');
|
||||
}
|
||||
|
||||
$campaign->update([
|
||||
'status' => 'cancelled',
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('portal.campaigns.index', $business->slug)
|
||||
->with('success', 'Campaign cancelled.');
|
||||
}
|
||||
}
|
||||
80
app/Http/Controllers/Portal/DashboardController.php
Normal file
80
app/Http/Controllers/Portal/DashboardController.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Branding\BusinessBrandingSetting;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use App\Services\Marketing\PromoRecommendationService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PromoRecommendationService $promoService
|
||||
) {}
|
||||
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
// Get recommended promos for this business
|
||||
$recommendedPromos = collect();
|
||||
try {
|
||||
// Get store external IDs for this business if available
|
||||
$storeExternalIds = $business->cannaiqStores()
|
||||
->pluck('external_id')
|
||||
->toArray();
|
||||
|
||||
if (! empty($storeExternalIds)) {
|
||||
$recommendations = $this->promoService->getRecommendations(
|
||||
$business,
|
||||
$storeExternalIds[0] ?? null,
|
||||
limit: 5
|
||||
);
|
||||
$recommendedPromos = collect($recommendations['recommendations'] ?? []);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// CannaiQ not configured or error - that's fine, show empty
|
||||
}
|
||||
|
||||
// Get recent campaigns for this business
|
||||
$recentCampaigns = MarketingCampaign::where('business_id', $business->id)
|
||||
->with('list')
|
||||
->latest()
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// Get active promos
|
||||
$activePromos = MarketingPromo::forBusiness($business->id)
|
||||
->currentlyActive()
|
||||
->with('brand')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// Get campaign stats
|
||||
$campaignStats = [
|
||||
'total' => MarketingCampaign::where('business_id', $business->id)->count(),
|
||||
'sent' => MarketingCampaign::where('business_id', $business->id)
|
||||
->whereIn('status', ['sent', 'completed'])
|
||||
->count(),
|
||||
'draft' => MarketingCampaign::where('business_id', $business->id)
|
||||
->where('status', 'draft')
|
||||
->count(),
|
||||
'scheduled' => MarketingCampaign::where('business_id', $business->id)
|
||||
->where('status', 'scheduled')
|
||||
->count(),
|
||||
];
|
||||
|
||||
return view('portal.dashboard', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'recommendedPromos',
|
||||
'recentCampaigns',
|
||||
'activePromos',
|
||||
'campaignStats'
|
||||
));
|
||||
}
|
||||
}
|
||||
83
app/Http/Controllers/Portal/ListController.php
Normal file
83
app/Http/Controllers/Portal/ListController.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Branding\BusinessBrandingSetting;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingList;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ListController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
$lists = MarketingList::where('business_id', $business->id)
|
||||
->withCount('contacts')
|
||||
->orderBy('name')
|
||||
->paginate(15);
|
||||
|
||||
return view('portal.lists.index', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'lists'
|
||||
));
|
||||
}
|
||||
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
$types = MarketingList::getTypes();
|
||||
|
||||
return view('portal.lists.create', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'types'
|
||||
));
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'type' => 'required|in:static,smart',
|
||||
]);
|
||||
|
||||
$list = MarketingList::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'],
|
||||
'type' => $validated['type'],
|
||||
'created_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('portal.lists.show', [$business->slug, $list])
|
||||
->with('success', 'List created successfully.');
|
||||
}
|
||||
|
||||
public function show(Request $request, Business $business, MarketingList $list)
|
||||
{
|
||||
// Ensure list belongs to this business
|
||||
if ($list->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
$contacts = $list->contacts()
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(25);
|
||||
|
||||
return view('portal.lists.show', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'list',
|
||||
'contacts'
|
||||
));
|
||||
}
|
||||
}
|
||||
75
app/Http/Controllers/Portal/PromoController.php
Normal file
75
app/Http/Controllers/Portal/PromoController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Branding\BusinessBrandingSetting;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use App\Services\Marketing\PromoRecommendationService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PromoController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PromoRecommendationService $promoService
|
||||
) {}
|
||||
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
// Get recommended promos from CannaiQ
|
||||
$recommendedPromos = collect();
|
||||
try {
|
||||
$storeExternalIds = $business->cannaiqStores()
|
||||
->pluck('external_id')
|
||||
->toArray();
|
||||
|
||||
if (! empty($storeExternalIds)) {
|
||||
$recommendations = $this->promoService->getRecommendations(
|
||||
$business,
|
||||
$storeExternalIds[0] ?? null,
|
||||
limit: 20
|
||||
);
|
||||
$recommendedPromos = collect($recommendations['recommendations'] ?? []);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// CannaiQ not available
|
||||
}
|
||||
|
||||
// Get existing promos for this business
|
||||
$existingPromos = MarketingPromo::forBusiness($business->id)
|
||||
->with('brand')
|
||||
->when($request->status, fn ($q, $status) => $q->where('status', $status))
|
||||
->latest()
|
||||
->paginate(12);
|
||||
|
||||
$statuses = MarketingPromo::getStatuses();
|
||||
|
||||
return view('portal.promos.index', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'recommendedPromos',
|
||||
'existingPromos',
|
||||
'statuses'
|
||||
));
|
||||
}
|
||||
|
||||
public function show(Request $request, Business $business, MarketingPromo $promo)
|
||||
{
|
||||
// Ensure promo belongs to this business
|
||||
if ($promo->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
$promo->load('brand');
|
||||
|
||||
return view('portal.promos.show', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'promo'
|
||||
));
|
||||
}
|
||||
}
|
||||
79
app/Http/Middleware/EnsureMarketingPortalAccess.php
Normal file
79
app/Http/Middleware/EnsureMarketingPortalAccess.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Business;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Middleware to ensure the user has Marketing Portal access.
|
||||
*
|
||||
* Marketing Portal access is granted to users with:
|
||||
* - contact_type = 'marketing_portal' on business_user pivot
|
||||
* - OR users who are super admins (for testing/support)
|
||||
*
|
||||
* This middleware is applied to /portal/{business}/* routes.
|
||||
*/
|
||||
class EnsureMarketingPortalAccess
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Must be authenticated
|
||||
if (! $user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
// Get business from route (could be slug or id)
|
||||
$businessParam = $request->route('business');
|
||||
$business = $this->resolveBusiness($businessParam);
|
||||
|
||||
if (! $business) {
|
||||
abort(404, 'Business not found.');
|
||||
}
|
||||
|
||||
// Store resolved business for later use
|
||||
$request->route()->setParameter('business', $business);
|
||||
|
||||
// Super admins can access any portal
|
||||
if ($user->isSuperAdmin()) {
|
||||
$request->attributes->set('is_portal_admin', true);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Check if user has marketing portal access for this business
|
||||
if (! $user->isMarketingPortalUser($business)) {
|
||||
abort(403, 'You do not have Marketing Portal access for this business.');
|
||||
}
|
||||
|
||||
$request->attributes->set('is_portal_admin', false);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve business from slug or ID
|
||||
*/
|
||||
protected function resolveBusiness($param): ?Business
|
||||
{
|
||||
if (! $param) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Already a Business model
|
||||
if ($param instanceof Business) {
|
||||
return $param;
|
||||
}
|
||||
|
||||
// Try by slug first, then by ID
|
||||
return Business::where('slug', $param)->first()
|
||||
?? Business::find($param);
|
||||
}
|
||||
}
|
||||
169
app/Models/Branding/BusinessBrandingSetting.php
Normal file
169
app/Models/Branding/BusinessBrandingSetting.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Branding;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Business Branding Settings
|
||||
*
|
||||
* White-label branding configuration for the Marketing Portal.
|
||||
* Controls logo, colors, and messaging defaults for portal users.
|
||||
*/
|
||||
class BusinessBrandingSetting extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'logo_path',
|
||||
'favicon_path',
|
||||
'primary_color',
|
||||
'secondary_color',
|
||||
'accent_color',
|
||||
'email_from_name',
|
||||
'email_from_email',
|
||||
'sms_from_label',
|
||||
'portal_title',
|
||||
'portal_welcome_message',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'meta' => 'array',
|
||||
];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Relationships
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Accessors
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the logo URL for display
|
||||
*/
|
||||
public function getLogoUrlAttribute(): ?string
|
||||
{
|
||||
if (! $this->logo_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Storage::url($this->logo_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the favicon URL for display
|
||||
*/
|
||||
public function getFaviconUrlAttribute(): ?string
|
||||
{
|
||||
if (! $this->favicon_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Storage::url($this->favicon_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective email from name (falls back to business name)
|
||||
*/
|
||||
public function getEffectiveFromNameAttribute(): string
|
||||
{
|
||||
return $this->email_from_name ?? $this->business->name ?? 'Marketing Portal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective email from address
|
||||
*/
|
||||
public function getEffectiveFromEmailAttribute(): ?string
|
||||
{
|
||||
return $this->email_from_email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective SMS sender label
|
||||
*/
|
||||
public function getEffectiveSmsLabelAttribute(): string
|
||||
{
|
||||
// SMS sender IDs have strict requirements - max 11 alphanumeric chars
|
||||
$label = $this->sms_from_label ?? substr(preg_replace('/[^A-Za-z0-9]/', '', $this->business->name ?? 'Portal'), 0, 11);
|
||||
|
||||
return $label ?: 'Portal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get portal title (falls back to business name + " Portal")
|
||||
*/
|
||||
public function getEffectivePortalTitleAttribute(): string
|
||||
{
|
||||
return $this->portal_title ?? ($this->business->name ?? 'Marketing').' Portal';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CSS Helper Methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get CSS custom properties for theming
|
||||
*/
|
||||
public function getCssVariables(): array
|
||||
{
|
||||
$vars = [];
|
||||
|
||||
if ($this->primary_color) {
|
||||
$vars['--portal-primary'] = $this->primary_color;
|
||||
}
|
||||
|
||||
if ($this->secondary_color) {
|
||||
$vars['--portal-secondary'] = $this->secondary_color;
|
||||
}
|
||||
|
||||
if ($this->accent_color) {
|
||||
$vars['--portal-accent'] = $this->accent_color;
|
||||
}
|
||||
|
||||
return $vars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inline style string for CSS variables
|
||||
*/
|
||||
public function getCssVariableStyle(): string
|
||||
{
|
||||
$vars = $this->getCssVariables();
|
||||
|
||||
if (empty($vars)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return implode('; ', array_map(
|
||||
fn ($key, $value) => "{$key}: {$value}",
|
||||
array_keys($vars),
|
||||
array_values($vars)
|
||||
));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Static Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get or create branding settings for a business
|
||||
*/
|
||||
public static function forBusiness(Business $business): self
|
||||
{
|
||||
return self::firstOrCreate(
|
||||
['business_id' => $business->id],
|
||||
[
|
||||
'portal_title' => $business->name.' Portal',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1084,4 +1084,39 @@ class User extends Authenticatable implements FilamentUser
|
||||
{
|
||||
return $this->hasMany(Business::class, 'owner_user_id');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Marketing Portal Methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if user is a Marketing Portal user for a given business.
|
||||
*
|
||||
* Marketing Portal access is granted when:
|
||||
* - User belongs to the business with contact_type = 'marketing_portal'
|
||||
*/
|
||||
public function isMarketingPortalUser(Business $business): bool
|
||||
{
|
||||
// Super admins have access to all portals
|
||||
if ($this->isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check contact_type on business_user pivot
|
||||
$pivot = $this->businesses()
|
||||
->where('business_id', $business->id)
|
||||
->first()?->pivot;
|
||||
|
||||
return $pivot && $pivot->contact_type === 'marketing_portal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get businesses where user has marketing portal access.
|
||||
*/
|
||||
public function getMarketingPortalBusinesses(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return $this->businesses()
|
||||
->wherePivot('contact_type', 'marketing_portal')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
||||
149
app/Policies/MarketingCampaignPolicy.php
Normal file
149
app/Policies/MarketingCampaignPolicy.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use App\Models\User;
|
||||
|
||||
class MarketingCampaignPolicy
|
||||
{
|
||||
/**
|
||||
* Determine if the user can view any campaigns for the business
|
||||
*/
|
||||
public function viewAny(User $user, ?Business $business = null): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $business) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id)
|
||||
|| $user->isMarketingPortalUser($business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can view a specific campaign
|
||||
*/
|
||||
public function view(User $user, MarketingCampaign $campaign, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Campaign must belong to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id)
|
||||
|| $user->isMarketingPortalUser($business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can create campaigns
|
||||
*/
|
||||
public function create(User $user, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id)
|
||||
|| $user->isMarketingPortalUser($business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can update the campaign
|
||||
*/
|
||||
public function update(User $user, MarketingCampaign $campaign, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Campaign must belong to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Can only update drafts and scheduled campaigns
|
||||
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id)
|
||||
|| $user->isMarketingPortalUser($business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can delete the campaign
|
||||
*/
|
||||
public function delete(User $user, MarketingCampaign $campaign, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Campaign must belong to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Can only delete drafts
|
||||
if ($campaign->status !== 'draft') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can send the campaign
|
||||
*/
|
||||
public function send(User $user, MarketingCampaign $campaign, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Campaign must belong to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Can only send drafts and scheduled campaigns
|
||||
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id)
|
||||
|| $user->isMarketingPortalUser($business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can cancel the campaign
|
||||
*/
|
||||
public function cancel(User $user, MarketingCampaign $campaign, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Campaign must belong to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Can only cancel drafts and scheduled campaigns
|
||||
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id)
|
||||
|| $user->isMarketingPortalUser($business);
|
||||
}
|
||||
}
|
||||
127
app/Policies/MarketingPromoPolicy.php
Normal file
127
app/Policies/MarketingPromoPolicy.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use App\Models\User;
|
||||
|
||||
class MarketingPromoPolicy
|
||||
{
|
||||
/**
|
||||
* Determine if the user can view any promos for the business
|
||||
*/
|
||||
public function viewAny(User $user, ?Business $business = null): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $business) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id)
|
||||
|| $user->isMarketingPortalUser($business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can view a specific promo
|
||||
*/
|
||||
public function view(User $user, MarketingPromo $promo, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Promo must belong to this business
|
||||
if ($promo->business_id !== $business->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id)
|
||||
|| $user->isMarketingPortalUser($business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can create promos
|
||||
*
|
||||
* Portal users can only view - they can't create promos
|
||||
*/
|
||||
public function create(User $user, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Portal users are view-only for promos
|
||||
if ($user->isMarketingPortalUser($business) && ! $user->businesses->contains($business->id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can update the promo
|
||||
*
|
||||
* Portal users can only view - they can't update promos
|
||||
*/
|
||||
public function update(User $user, MarketingPromo $promo, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Promo must belong to this business
|
||||
if ($promo->business_id !== $business->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Portal users are view-only for promos
|
||||
if ($user->isMarketingPortalUser($business) && ! $user->businesses->contains($business->id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can delete the promo
|
||||
*
|
||||
* Portal users can only view - they can't delete promos
|
||||
*/
|
||||
public function delete(User $user, MarketingPromo $promo, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Promo must belong to this business
|
||||
if ($promo->business_id !== $business->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user can launch a campaign from this promo
|
||||
*
|
||||
* Portal users CAN launch campaigns from promos
|
||||
*/
|
||||
public function launchCampaign(User $user, MarketingPromo $promo, Business $business): bool
|
||||
{
|
||||
if ($user->hasRole('Super Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Promo must belong to this business
|
||||
if ($promo->business_id !== $business->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->businesses->contains($business->id)
|
||||
|| $user->isMarketingPortalUser($business);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use App\Models\Product;
|
||||
use App\Policies\MarketingCampaignPolicy;
|
||||
use App\Policies\MarketingPromoPolicy;
|
||||
use App\Policies\ProductPolicy;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
|
||||
@@ -15,6 +19,8 @@ class AuthServiceProvider extends ServiceProvider
|
||||
*/
|
||||
protected $policies = [
|
||||
Product::class => ProductPolicy::class,
|
||||
MarketingCampaign::class => MarketingCampaignPolicy::class,
|
||||
MarketingPromo::class => MarketingPromoPolicy::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
then: function () {
|
||||
Route::middleware('web')
|
||||
->group(base_path('routes/health.php'));
|
||||
|
||||
// Marketing Portal routes (dispensary partners)
|
||||
require base_path('routes/portal.php');
|
||||
},
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
@@ -54,6 +57,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
|
||||
// Brand Portal access control
|
||||
'brand-portal' => \App\Http\Middleware\EnsureBrandPortalAccess::class,
|
||||
|
||||
// Marketing Portal access control (dispensary partners)
|
||||
'marketing-portal' => \App\Http\Middleware\EnsureMarketingPortalAccess::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('business_branding_settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('business_id')->unique()->constrained()->cascadeOnDelete();
|
||||
$table->string('logo_path')->nullable();
|
||||
$table->string('favicon_path')->nullable();
|
||||
$table->string('primary_color', 7)->nullable(); // hex color
|
||||
$table->string('secondary_color', 7)->nullable();
|
||||
$table->string('accent_color', 7)->nullable();
|
||||
$table->string('email_from_name')->nullable();
|
||||
$table->string('email_from_email')->nullable();
|
||||
$table->string('sms_from_label', 11)->nullable(); // SMS sender ID max 11 chars
|
||||
$table->string('portal_title')->nullable();
|
||||
$table->text('portal_welcome_message')->nullable();
|
||||
$table->json('meta')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('business_branding_settings');
|
||||
}
|
||||
};
|
||||
108
database/seeders/MarketingPortalUserSeeder.php
Normal file
108
database/seeders/MarketingPortalUserSeeder.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Branding\BusinessBrandingSetting;
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
/**
|
||||
* Creates test users for Marketing Portal scenarios.
|
||||
*
|
||||
* Test users created:
|
||||
* - marketing-portal@example.com / password - Marketing Portal user for a dispensary
|
||||
*
|
||||
* Also creates branding settings for the assigned business.
|
||||
*/
|
||||
class MarketingPortalUserSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// Find a buyer (dispensary) business to assign the portal user to
|
||||
$dispensary = Business::where('type', 'buyer')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if (! $dispensary) {
|
||||
// Create a test dispensary if none exists
|
||||
$dispensary = Business::create([
|
||||
'name' => 'Green Leaf Dispensary',
|
||||
'slug' => 'green-leaf-dispensary',
|
||||
'type' => 'buyer',
|
||||
'status' => 'approved',
|
||||
'email' => 'contact@greenleaf.example.com',
|
||||
'phone' => '555-123-4567',
|
||||
]);
|
||||
$this->command->info('Created test dispensary: Green Leaf Dispensary');
|
||||
}
|
||||
|
||||
// Create or update the marketing portal user
|
||||
$user = User::updateOrCreate(
|
||||
['email' => 'marketing-portal@example.com'],
|
||||
[
|
||||
'first_name' => 'Marketing',
|
||||
'last_name' => 'Portal',
|
||||
'password' => Hash::make('password'),
|
||||
'user_type' => 'buyer', // Portal users are buyer-type
|
||||
'status' => 'active',
|
||||
'email_verified_at' => now(),
|
||||
'business_onboarding_completed' => true,
|
||||
]
|
||||
);
|
||||
|
||||
// Attach user to business with marketing_portal contact type
|
||||
if (! $user->businesses()->where('business_id', $dispensary->id)->exists()) {
|
||||
$user->businesses()->attach($dispensary->id, [
|
||||
'contact_type' => 'marketing_portal',
|
||||
'is_primary' => true,
|
||||
'permissions' => json_encode([
|
||||
'view_promos',
|
||||
'create_campaigns',
|
||||
'view_campaigns',
|
||||
'send_campaigns',
|
||||
'view_contacts',
|
||||
'manage_lists',
|
||||
]),
|
||||
]);
|
||||
} else {
|
||||
// Update existing pivot to ensure marketing_portal is set
|
||||
$user->businesses()->updateExistingPivot($dispensary->id, [
|
||||
'contact_type' => 'marketing_portal',
|
||||
'is_primary' => true,
|
||||
'permissions' => json_encode([
|
||||
'view_promos',
|
||||
'create_campaigns',
|
||||
'view_campaigns',
|
||||
'send_campaigns',
|
||||
'view_contacts',
|
||||
'manage_lists',
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// Create branding settings for this dispensary
|
||||
BusinessBrandingSetting::updateOrCreate(
|
||||
['business_id' => $dispensary->id],
|
||||
[
|
||||
'portal_title' => $dispensary->name.' Marketing Portal',
|
||||
'primary_color' => '#059669', // Emerald green
|
||||
'secondary_color' => '#064e3b',
|
||||
'accent_color' => '#10b981',
|
||||
'email_from_name' => $dispensary->name,
|
||||
'email_from_email' => $dispensary->email,
|
||||
'sms_from_label' => substr(preg_replace('/[^A-Za-z0-9]/', '', $dispensary->name), 0, 11),
|
||||
'portal_welcome_message' => 'Welcome to your Marketing Portal! Launch campaigns and see recommended promos to drive sales.',
|
||||
]
|
||||
);
|
||||
|
||||
$this->command->info('Created marketing-portal@example.com user:');
|
||||
$this->command->info(" - Business: {$dispensary->name} (id: {$dispensary->id}, slug: {$dispensary->slug})");
|
||||
$this->command->info(' - Password: password');
|
||||
$this->command->info(' - Contact Type: marketing_portal');
|
||||
$this->command->info(" - Portal URL: /portal/{$dispensary->slug}/dashboard");
|
||||
$this->command->newLine();
|
||||
$this->command->info('Branding settings created for portal.');
|
||||
}
|
||||
}
|
||||
195
docs/marketing/PORTAL_ACCESS.md
Normal file
195
docs/marketing/PORTAL_ACCESS.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Marketing Portal Access
|
||||
|
||||
The Marketing Portal is a white-labeled, restricted UI for dispensary partners to view recommended promotions and launch email/SMS campaigns to their customer lists.
|
||||
|
||||
## Overview
|
||||
|
||||
The portal provides:
|
||||
- Dashboard with campaign stats and recommended deals
|
||||
- Promo listings (from CannaiQ recommendations + existing promos)
|
||||
- Campaign creation and management (email/SMS)
|
||||
- Contact list management
|
||||
|
||||
## Access Control
|
||||
|
||||
### Portal User Role
|
||||
|
||||
Portal access is controlled via the `contact_type` field on the `business_user` pivot table:
|
||||
|
||||
```php
|
||||
// User is a marketing portal user for a specific business
|
||||
$user->isMarketingPortalUser($business)
|
||||
```
|
||||
|
||||
The `contact_type` value of `marketing_portal` grants access to the portal for that business only.
|
||||
|
||||
### Middleware
|
||||
|
||||
The `EnsureMarketingPortalAccess` middleware (`marketing-portal` alias) protects all portal routes:
|
||||
|
||||
```php
|
||||
// In routes/portal.php
|
||||
Route::middleware(['web', 'auth', 'verified', 'marketing-portal'])
|
||||
->prefix('portal/{business}')
|
||||
->name('portal.')
|
||||
->group(function () {
|
||||
// Portal routes
|
||||
});
|
||||
```
|
||||
|
||||
### Super Admin Access
|
||||
|
||||
Super admins can access any business's portal directly. They also have access via a "Marketing Portal" button in the Filament admin panel on the business edit page.
|
||||
|
||||
## URL Structure
|
||||
|
||||
All portal routes are prefixed with `/portal/{business}/`:
|
||||
|
||||
| Route | Description |
|
||||
|-------|-------------|
|
||||
| `/portal/{slug}/` | Dashboard |
|
||||
| `/portal/{slug}/promos` | Promo listing |
|
||||
| `/portal/{slug}/promos/{promo}` | Promo detail |
|
||||
| `/portal/{slug}/campaigns` | Campaign listing |
|
||||
| `/portal/{slug}/campaigns/create` | Create campaign |
|
||||
| `/portal/{slug}/campaigns/{campaign}` | Campaign detail |
|
||||
| `/portal/{slug}/lists` | Contact lists |
|
||||
| `/portal/{slug}/lists/create` | Create list |
|
||||
| `/portal/{slug}/lists/{list}` | List detail |
|
||||
|
||||
## White-Label Branding
|
||||
|
||||
Each business can customize their portal appearance via the `business_branding_settings` table:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `logo_path` | Custom logo (displayed in navbar) |
|
||||
| `favicon_path` | Custom favicon |
|
||||
| `primary_color` | Primary brand color (hex) |
|
||||
| `secondary_color` | Secondary color (hex) |
|
||||
| `accent_color` | Accent color (hex) |
|
||||
| `portal_title` | Custom portal title |
|
||||
| `portal_welcome_message` | Dashboard welcome text |
|
||||
| `email_from_name` | Default sender name for campaigns |
|
||||
| `email_from_email` | Default sender email for campaigns |
|
||||
| `sms_from_label` | SMS sender label (max 11 chars) |
|
||||
|
||||
Branding settings are auto-created when first accessed via `BusinessBrandingSetting::forBusiness($business)`.
|
||||
|
||||
### CSS Variables
|
||||
|
||||
The portal layout injects custom CSS variables based on branding:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--portal-primary: #hexcolor;
|
||||
--portal-secondary: #hexcolor;
|
||||
--portal-accent: #hexcolor;
|
||||
}
|
||||
```
|
||||
|
||||
Use utility classes like `portal-primary` and `portal-primary-text` in views.
|
||||
|
||||
## Permissions
|
||||
|
||||
Portal users have different permissions than business staff:
|
||||
|
||||
| Action | Portal User | Business Staff | Super Admin |
|
||||
|--------|-------------|----------------|-------------|
|
||||
| View promos | Yes | Yes | Yes |
|
||||
| Create promos | No | Yes | Yes |
|
||||
| View campaigns | Yes | Yes | Yes |
|
||||
| Create campaigns | Yes | Yes | Yes |
|
||||
| Send campaigns | Yes | Yes | Yes |
|
||||
| Delete campaigns | No | Yes | Yes |
|
||||
| View lists | Yes | Yes | Yes |
|
||||
| Create lists | Yes | Yes | Yes |
|
||||
|
||||
Policies are defined in:
|
||||
- `App\Policies\MarketingCampaignPolicy`
|
||||
- `App\Policies\MarketingPromoPolicy`
|
||||
|
||||
## Creating Portal Users
|
||||
|
||||
### Via Seeder (Development)
|
||||
|
||||
```bash
|
||||
php artisan db:seed --class=MarketingPortalUserSeeder
|
||||
```
|
||||
|
||||
Creates test user: `marketing-portal@example.com` / `password`
|
||||
|
||||
### Programmatically
|
||||
|
||||
```php
|
||||
use App\Models\User;
|
||||
use App\Models\Business;
|
||||
|
||||
$user = User::create([
|
||||
'first_name' => 'Portal',
|
||||
'last_name' => 'User',
|
||||
'email' => 'portal@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
'user_type' => 'buyer',
|
||||
]);
|
||||
|
||||
$business = Business::where('slug', 'example-dispensary')->first();
|
||||
|
||||
$user->businesses()->attach($business->id, [
|
||||
'contact_type' => 'marketing_portal',
|
||||
'role' => 'user',
|
||||
'is_primary' => false,
|
||||
]);
|
||||
```
|
||||
|
||||
## CannaiQ Integration
|
||||
|
||||
The dashboard and promos index use `PromoRecommendationService` to fetch AI-driven promo recommendations:
|
||||
|
||||
```php
|
||||
$recommendations = app(PromoRecommendationService::class)
|
||||
->getRecommendations($business->id, [
|
||||
'limit' => 5,
|
||||
]);
|
||||
```
|
||||
|
||||
Recommendations are displayed as actionable cards that link directly to campaign creation.
|
||||
|
||||
## Controllers
|
||||
|
||||
| Controller | Purpose |
|
||||
|------------|---------|
|
||||
| `Portal\DashboardController` | Dashboard stats, recent campaigns, active promos |
|
||||
| `Portal\PromoController` | Promo listing and detail |
|
||||
| `Portal\CampaignController` | Campaign CRUD, send/schedule/cancel |
|
||||
| `Portal\ListController` | Contact list management |
|
||||
|
||||
## Views
|
||||
|
||||
All portal views extend `layouts.portal` and are located in `resources/views/portal/`:
|
||||
|
||||
```
|
||||
portal/
|
||||
├── dashboard.blade.php
|
||||
├── promos/
|
||||
│ ├── index.blade.php
|
||||
│ └── show.blade.php
|
||||
├── campaigns/
|
||||
│ ├── index.blade.php
|
||||
│ ├── create.blade.php
|
||||
│ └── show.blade.php
|
||||
└── lists/
|
||||
├── index.blade.php
|
||||
├── create.blade.php
|
||||
└── show.blade.php
|
||||
```
|
||||
|
||||
## Admin Panel Integration
|
||||
|
||||
Super admins can access any business's portal from the Filament admin panel:
|
||||
|
||||
1. Navigate to Admin > Accounts > Businesses
|
||||
2. Edit a buyer business
|
||||
3. Click "Marketing Portal" button in header
|
||||
|
||||
This opens the portal in a new tab, authenticated as the admin user.
|
||||
165
resources/views/layouts/portal.blade.php
Normal file
165
resources/views/layouts/portal.blade.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<title>{{ $branding->effective_portal_title ?? 'Marketing Portal' }} - @yield('title', 'Dashboard')</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
@if($branding->favicon_url)
|
||||
<link rel="icon" href="{{ $branding->favicon_url }}" />
|
||||
@elseif(\App\Models\SiteSetting::get('favicon_path'))
|
||||
<link rel="icon" href="{{ \App\Models\SiteSetting::getFaviconUrl() }}" />
|
||||
@else
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
@endif
|
||||
|
||||
<!-- Scripts -->
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
|
||||
<!-- Portal Branding Colors -->
|
||||
@if($branding->primary_color || $branding->secondary_color || $branding->accent_color)
|
||||
<style>
|
||||
:root {
|
||||
@if($branding->primary_color)
|
||||
--portal-primary: {{ $branding->primary_color }};
|
||||
@endif
|
||||
@if($branding->secondary_color)
|
||||
--portal-secondary: {{ $branding->secondary_color }};
|
||||
@endif
|
||||
@if($branding->accent_color)
|
||||
--portal-accent: {{ $branding->accent_color }};
|
||||
@endif
|
||||
}
|
||||
.portal-primary { background-color: var(--portal-primary, theme('colors.primary')); }
|
||||
.portal-primary-text { color: var(--portal-primary, theme('colors.primary')); }
|
||||
.portal-accent { background-color: var(--portal-accent, theme('colors.accent')); }
|
||||
.portal-accent-text { color: var(--portal-accent, theme('colors.accent')); }
|
||||
</style>
|
||||
@endif
|
||||
</head>
|
||||
<body class="font-sans antialiased bg-base-200 min-h-screen">
|
||||
<script>
|
||||
// DaisyUI theme system
|
||||
(function() {
|
||||
const theme = localStorage.getItem("theme") || "light";
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<!-- Portal Navigation -->
|
||||
<nav class="navbar bg-base-100 shadow-sm border-b border-base-200 sticky top-0 z-50">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex-1">
|
||||
<!-- Logo -->
|
||||
<a href="{{ route('portal.dashboard', $business->slug) }}" class="flex items-center gap-3">
|
||||
@if($branding->logo_url)
|
||||
<img src="{{ $branding->logo_url }}" alt="{{ $business->name }}" class="h-8 w-auto">
|
||||
@else
|
||||
<div class="w-8 h-8 rounded-lg portal-primary flex items-center justify-center">
|
||||
<span class="text-white font-bold text-sm">{{ substr($business->name, 0, 1) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
<span class="font-semibold text-lg hidden sm:inline">{{ $branding->effective_portal_title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<div class="flex-none">
|
||||
<ul class="menu menu-horizontal px-1 gap-1">
|
||||
<li>
|
||||
<a href="{{ route('portal.dashboard', $business->slug) }}"
|
||||
class="{{ request()->routeIs('portal.dashboard') ? 'active' : '' }}">
|
||||
<span class="icon-[lucide--layout-dashboard] size-4"></span>
|
||||
<span class="hidden md:inline">Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route('portal.promos.index', $business->slug) }}"
|
||||
class="{{ request()->routeIs('portal.promos.*') ? 'active' : '' }}">
|
||||
<span class="icon-[lucide--tag] size-4"></span>
|
||||
<span class="hidden md:inline">Promos</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route('portal.campaigns.index', $business->slug) }}"
|
||||
class="{{ request()->routeIs('portal.campaigns.*') ? 'active' : '' }}">
|
||||
<span class="icon-[lucide--megaphone] size-4"></span>
|
||||
<span class="hidden md:inline">Campaigns</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route('portal.lists.index', $business->slug) }}"
|
||||
class="{{ request()->routeIs('portal.lists.*') ? 'active' : '' }}">
|
||||
<span class="icon-[lucide--users] size-4"></span>
|
||||
<span class="hidden md:inline">Lists</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- User Menu -->
|
||||
<div class="dropdown dropdown-end ml-2">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar placeholder">
|
||||
<div class="bg-neutral text-neutral-content w-10 rounded-full">
|
||||
<span class="text-sm">{{ substr(auth()->user()->first_name ?? 'U', 0, 1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul tabindex="0" class="menu menu-sm dropdown-content z-[1] mt-3 w-52 p-2 shadow bg-base-100 rounded-box">
|
||||
<li class="menu-title">
|
||||
<span>{{ auth()->user()->name }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="w-full text-left">
|
||||
<span class="icon-[lucide--log-out] size-4"></span>
|
||||
Log Out
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
@if(session('success'))
|
||||
<div class="container mx-auto px-4 mt-4">
|
||||
<div class="alert alert-success">
|
||||
<span class="icon-[lucide--check-circle] size-5"></span>
|
||||
<span>{{ session('success') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="container mx-auto px-4 mt-4">
|
||||
<div class="alert alert-error">
|
||||
<span class="icon-[lucide--alert-circle] size-5"></span>
|
||||
<span>{{ session('error') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Page Content -->
|
||||
<main class="flex-1 container mx-auto px-4 py-6">
|
||||
@yield('content')
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-base-100 border-t border-base-200 py-4">
|
||||
<div class="container mx-auto px-4 text-center text-sm text-base-content/60">
|
||||
<p>
|
||||
© {{ date('Y') }} {{ $business->name }} Marketing Portal
|
||||
<span class="mx-2">|</span>
|
||||
Powered by <a href="https://cannabrands.app" target="_blank" class="link link-hover">Cannabrands</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
282
resources/views/portal/campaigns/create.blade.php
Normal file
282
resources/views/portal/campaigns/create.blade.php
Normal file
@@ -0,0 +1,282 @@
|
||||
@extends('layouts.portal')
|
||||
|
||||
@section('title', 'Create Campaign')
|
||||
|
||||
@section('content')
|
||||
<div class="space-y-6">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="{{ route('portal.campaigns.index', $business->slug) }}">Campaigns</a></li>
|
||||
<li>Create</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Create Campaign</h1>
|
||||
<p class="text-base-content/70 mt-1">
|
||||
@if($promo)
|
||||
Creating campaign from "{{ $promo->name }}" promo
|
||||
@else
|
||||
Set up your email or SMS campaign
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form method="POST" action="{{ route('portal.campaigns.store', $business->slug) }}" class="space-y-6">
|
||||
@csrf
|
||||
|
||||
@if($promo)
|
||||
<input type="hidden" name="promo_id" value="{{ $promo->id }}">
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Main Form -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Campaign Details -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">Campaign Details</h2>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Campaign Name</span>
|
||||
</label>
|
||||
<input type="text" name="name"
|
||||
value="{{ old('name', $promo?->name . ' Campaign' ?? '') }}"
|
||||
class="input input-bordered @error('name') input-error @enderror"
|
||||
placeholder="e.g., Holiday Sale Announcement"
|
||||
required>
|
||||
@error('name')
|
||||
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Channel</span>
|
||||
</label>
|
||||
<div class="flex gap-4">
|
||||
@foreach($channels as $channel)
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="channel" value="{{ $channel }}"
|
||||
class="radio radio-primary"
|
||||
{{ old('channel', $preselectedChannel) === $channel ? 'checked' : '' }}
|
||||
required>
|
||||
<span class="badge badge-{{ $channel === 'email' ? 'info' : 'success' }} badge-lg gap-1">
|
||||
<span class="icon-[lucide--{{ $channel === 'email' ? 'mail' : 'smartphone' }}] size-3"></span>
|
||||
{{ strtoupper($channel) }}
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('channel')
|
||||
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Recipient List</span>
|
||||
</label>
|
||||
<select name="list_id" class="select select-bordered @error('list_id') select-error @enderror" required>
|
||||
<option value="">Select a list...</option>
|
||||
@foreach($lists as $list)
|
||||
<option value="{{ $list->id }}" {{ old('list_id') == $list->id ? 'selected' : '' }}>
|
||||
{{ $list->name }} ({{ $list->contacts_count }} contacts)
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('list_id')
|
||||
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
|
||||
@enderror
|
||||
@if($lists->isEmpty())
|
||||
<label class="label">
|
||||
<span class="label-text-alt">
|
||||
<a href="{{ route('portal.lists.create', $business->slug) }}" class="link link-primary">
|
||||
Create a list first
|
||||
</a>
|
||||
</span>
|
||||
</label>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">Content</h2>
|
||||
|
||||
<div class="form-control mb-4" id="subject-field">
|
||||
<label class="label">
|
||||
<span class="label-text">Subject Line</span>
|
||||
<span class="label-text-alt text-base-content/60">Email only</span>
|
||||
</label>
|
||||
<input type="text" name="subject"
|
||||
value="{{ old('subject', $promo?->name ?? '') }}"
|
||||
class="input input-bordered @error('subject') input-error @enderror"
|
||||
placeholder="Catchy subject line...">
|
||||
@error('subject')
|
||||
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Message Body</span>
|
||||
</label>
|
||||
<textarea name="body" rows="8"
|
||||
class="textarea textarea-bordered @error('body') textarea-error @enderror"
|
||||
placeholder="Write your message..."
|
||||
required>{{ old('body', $preselectedChannel === 'sms' ? $promo?->sms_copy : $promo?->email_copy) }}</textarea>
|
||||
@error('body')
|
||||
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
|
||||
@enderror
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-base-content/60" id="char-count">0 characters</span>
|
||||
<span class="label-text-alt text-base-content/60" id="sms-segments" style="display: none;">1 SMS segment</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scheduling -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">Scheduling</h2>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Send Time</span>
|
||||
<span class="label-text-alt text-base-content/60">Optional - leave blank to save as draft</span>
|
||||
</label>
|
||||
<input type="datetime-local" name="send_at"
|
||||
value="{{ old('send_at') }}"
|
||||
class="input input-bordered @error('send_at') input-error @enderror"
|
||||
min="{{ now()->addMinutes(5)->format('Y-m-d\TH:i') }}">
|
||||
@error('send_at')
|
||||
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<!-- Promo Info -->
|
||||
@if($promo)
|
||||
<div class="card bg-gradient-to-r from-warning/10 to-warning/5 border border-warning/20">
|
||||
<div class="card-body">
|
||||
<h3 class="font-medium flex items-center gap-2">
|
||||
<span class="icon-[lucide--sparkles] size-4 text-warning"></span>
|
||||
Linked Promo
|
||||
</h3>
|
||||
<p class="text-sm font-semibold mt-2">{{ $promo->name }}</p>
|
||||
<p class="text-sm text-base-content/60">{{ $promo->type_display }}</p>
|
||||
@if($promo->expected_lift)
|
||||
<p class="text-sm text-success mt-1">
|
||||
+{{ number_format($promo->expected_lift, 1) }}% expected lift
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h3 class="font-medium mb-4">Actions</h3>
|
||||
<div class="space-y-2">
|
||||
<button type="submit" class="btn btn-primary btn-block gap-2">
|
||||
<span class="icon-[lucide--save] size-4"></span>
|
||||
Save Campaign
|
||||
</button>
|
||||
<a href="{{ route('portal.campaigns.index', $business->slug) }}" class="btn btn-ghost btn-block">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50 mt-3">
|
||||
You can review and send the campaign after saving.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tips -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h3 class="font-medium mb-4">Tips</h3>
|
||||
<ul class="text-sm text-base-content/70 space-y-2">
|
||||
<li class="flex gap-2">
|
||||
<span class="icon-[lucide--check] size-4 text-success flex-shrink-0 mt-0.5"></span>
|
||||
Keep SMS under 160 characters for best deliverability
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="icon-[lucide--check] size-4 text-success flex-shrink-0 mt-0.5"></span>
|
||||
Personalize messages with customer's name when possible
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="icon-[lucide--check] size-4 text-success flex-shrink-0 mt-0.5"></span>
|
||||
Include a clear call-to-action
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const channelRadios = document.querySelectorAll('input[name="channel"]');
|
||||
const subjectField = document.getElementById('subject-field');
|
||||
const bodyTextarea = document.querySelector('textarea[name="body"]');
|
||||
const charCount = document.getElementById('char-count');
|
||||
const smsSegments = document.getElementById('sms-segments');
|
||||
|
||||
function updateCharCount() {
|
||||
const length = bodyTextarea.value.length;
|
||||
charCount.textContent = length + ' characters';
|
||||
|
||||
const isEmail = document.querySelector('input[name="channel"]:checked')?.value === 'email';
|
||||
if (!isEmail) {
|
||||
const segments = Math.ceil(length / 160) || 1;
|
||||
smsSegments.textContent = segments + ' SMS segment' + (segments > 1 ? 's' : '');
|
||||
smsSegments.style.display = 'inline';
|
||||
if (length > 160) {
|
||||
charCount.classList.add('text-warning');
|
||||
} else {
|
||||
charCount.classList.remove('text-warning');
|
||||
}
|
||||
} else {
|
||||
smsSegments.style.display = 'none';
|
||||
charCount.classList.remove('text-warning');
|
||||
}
|
||||
}
|
||||
|
||||
function handleChannelChange() {
|
||||
const selectedChannel = document.querySelector('input[name="channel"]:checked')?.value;
|
||||
if (selectedChannel === 'sms') {
|
||||
subjectField.style.display = 'none';
|
||||
} else {
|
||||
subjectField.style.display = 'block';
|
||||
}
|
||||
updateCharCount();
|
||||
}
|
||||
|
||||
channelRadios.forEach(radio => {
|
||||
radio.addEventListener('change', handleChannelChange);
|
||||
});
|
||||
|
||||
bodyTextarea.addEventListener('input', updateCharCount);
|
||||
|
||||
// Initialize
|
||||
handleChannelChange();
|
||||
updateCharCount();
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
151
resources/views/portal/campaigns/index.blade.php
Normal file
151
resources/views/portal/campaigns/index.blade.php
Normal file
@@ -0,0 +1,151 @@
|
||||
@extends('layouts.portal')
|
||||
|
||||
@section('title', 'Campaigns')
|
||||
|
||||
@section('content')
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Campaigns</h1>
|
||||
<p class="text-base-content/70 mt-1">Manage your email and SMS marketing campaigns</p>
|
||||
</div>
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}" class="btn btn-primary gap-2">
|
||||
<span class="icon-[lucide--plus] size-4"></span>
|
||||
New Campaign
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body py-3">
|
||||
<form method="GET" class="flex flex-wrap items-center gap-3">
|
||||
<select name="status" class="select select-bordered select-sm">
|
||||
<option value="">All Statuses</option>
|
||||
@foreach($statuses as $key => $label)
|
||||
<option value="{{ $key }}" {{ request('status') === $key ? 'selected' : '' }}>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<select name="channel" class="select select-bordered select-sm">
|
||||
<option value="">All Channels</option>
|
||||
@foreach($channels as $channel)
|
||||
<option value="{{ $channel }}" {{ request('channel') === $channel ? 'selected' : '' }}>{{ strtoupper($channel) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Filter</button>
|
||||
@if(request()->hasAny(['status', 'channel']))
|
||||
<a href="{{ route('portal.campaigns.index', $business->slug) }}" class="btn btn-sm btn-ghost">Clear</a>
|
||||
@endif
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Campaigns Table -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
@if($campaigns->isEmpty())
|
||||
<div class="text-center py-12 text-base-content/60">
|
||||
<span class="icon-[lucide--megaphone] size-16 opacity-30 mx-auto block mb-4"></span>
|
||||
<h3 class="font-semibold text-lg mb-2">No campaigns yet</h3>
|
||||
<p class="mb-4">Create your first campaign to start reaching your customers.</p>
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}" class="btn btn-primary">
|
||||
Create Campaign
|
||||
</a>
|
||||
</div>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Campaign</th>
|
||||
<th>Channel</th>
|
||||
<th>List</th>
|
||||
<th>Status</th>
|
||||
<th>Sent</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($campaigns as $campaign)
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ route('portal.campaigns.show', [$business->slug, $campaign]) }}"
|
||||
class="font-medium hover:text-primary">
|
||||
{{ $campaign->name }}
|
||||
</a>
|
||||
@if($campaign->subject)
|
||||
<p class="text-sm text-base-content/60 truncate max-w-xs">{{ $campaign->subject }}</p>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-{{ $campaign->channel === 'email' ? 'info' : 'success' }} badge-sm">
|
||||
{{ strtoupper($campaign->channel) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@if($campaign->list)
|
||||
<span class="text-sm">{{ $campaign->list->name }}</span>
|
||||
@else
|
||||
<span class="text-sm text-base-content/50">No list</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@php
|
||||
$statusColors = [
|
||||
'draft' => 'warning',
|
||||
'scheduled' => 'info',
|
||||
'sending' => 'primary',
|
||||
'sent' => 'success',
|
||||
'completed' => 'success',
|
||||
'cancelled' => 'ghost',
|
||||
'failed' => 'error',
|
||||
];
|
||||
@endphp
|
||||
<span class="badge badge-{{ $statusColors[$campaign->status] ?? 'ghost' }}">
|
||||
{{ ucfirst($campaign->status) }}
|
||||
</span>
|
||||
@if($campaign->status === 'scheduled' && $campaign->send_at)
|
||||
<span class="text-xs text-base-content/60 block">
|
||||
{{ $campaign->send_at->format('M j, g:i A') }}
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@if($campaign->sent_at)
|
||||
<span class="text-sm">{{ $campaign->sent_at->format('M j, Y') }}</span>
|
||||
<span class="text-xs text-base-content/60 block">
|
||||
{{ $campaign->sent_at->format('g:i A') }}
|
||||
</span>
|
||||
@else
|
||||
<span class="text-sm text-base-content/50">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-1">
|
||||
<a href="{{ route('portal.campaigns.show', [$business->slug, $campaign]) }}"
|
||||
class="btn btn-ghost btn-xs">
|
||||
View
|
||||
</a>
|
||||
@if(in_array($campaign->status, ['draft', 'scheduled']))
|
||||
<form method="POST" action="{{ route('portal.campaigns.send-now', [$business->slug, $campaign]) }}"
|
||||
onsubmit="return confirm('Send this campaign now?')">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-primary btn-xs">Send</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
{{ $campaigns->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
309
resources/views/portal/campaigns/show.blade.php
Normal file
309
resources/views/portal/campaigns/show.blade.php
Normal file
@@ -0,0 +1,309 @@
|
||||
@extends('layouts.portal')
|
||||
|
||||
@section('title', $campaign->name)
|
||||
|
||||
@section('content')
|
||||
<div class="space-y-6">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="{{ route('portal.campaigns.index', $business->slug) }}">Campaigns</a></li>
|
||||
<li>{{ $campaign->name }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold">{{ $campaign->name }}</h1>
|
||||
@php
|
||||
$statusColors = [
|
||||
'draft' => 'warning',
|
||||
'scheduled' => 'info',
|
||||
'sending' => 'primary',
|
||||
'sent' => 'success',
|
||||
'completed' => 'success',
|
||||
'cancelled' => 'ghost',
|
||||
'failed' => 'error',
|
||||
];
|
||||
@endphp
|
||||
<span class="badge badge-{{ $statusColors[$campaign->status] ?? 'ghost' }} badge-lg">
|
||||
{{ ucfirst($campaign->status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<span class="badge badge-{{ $campaign->channel === 'email' ? 'info' : 'success' }} badge-sm gap-1">
|
||||
<span class="icon-[lucide--{{ $campaign->channel === 'email' ? 'mail' : 'smartphone' }}] size-3"></span>
|
||||
{{ strtoupper($campaign->channel) }}
|
||||
</span>
|
||||
@if($campaign->list)
|
||||
<span class="text-sm text-base-content/60">
|
||||
To: {{ $campaign->list->name }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(in_array($campaign->status, ['draft', 'scheduled']))
|
||||
<div class="flex gap-2">
|
||||
<form method="POST" action="{{ route('portal.campaigns.send-now', [$business->slug, $campaign]) }}"
|
||||
onsubmit="return confirm('Send this campaign immediately?')">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-primary gap-2">
|
||||
<span class="icon-[lucide--send] size-4"></span>
|
||||
Send Now
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('portal.campaigns.cancel', [$business->slug, $campaign]) }}"
|
||||
onsubmit="return confirm('Cancel this campaign?')">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-ghost gap-2">
|
||||
<span class="icon-[lucide--x] size-4"></span>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Main Content -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Stats (if sent) -->
|
||||
@if(in_array($campaign->status, ['sending', 'sent', 'completed']))
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body py-4">
|
||||
<div class="text-sm text-base-content/60">Recipients</div>
|
||||
<div class="text-2xl font-bold">{{ number_format($stats['total_recipients']) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body py-4">
|
||||
<div class="text-sm text-base-content/60">Delivered</div>
|
||||
<div class="text-2xl font-bold text-success">{{ number_format($stats['delivered']) }}</div>
|
||||
@if($stats['total_recipients'] > 0)
|
||||
<div class="text-xs text-base-content/50">
|
||||
{{ number_format(($stats['delivered'] / $stats['total_recipients']) * 100, 1) }}%
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@if($campaign->channel === 'email')
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body py-4">
|
||||
<div class="text-sm text-base-content/60">Opened</div>
|
||||
<div class="text-2xl font-bold text-info">{{ number_format($stats['opened']) }}</div>
|
||||
@if($stats['delivered'] > 0)
|
||||
<div class="text-xs text-base-content/50">
|
||||
{{ number_format(($stats['opened'] / $stats['delivered']) * 100, 1) }}% open rate
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body py-4">
|
||||
<div class="text-sm text-base-content/60">Clicked</div>
|
||||
<div class="text-2xl font-bold text-primary">{{ number_format($stats['clicked']) }}</div>
|
||||
@if($stats['opened'] > 0)
|
||||
<div class="text-xs text-base-content/50">
|
||||
{{ number_format(($stats['clicked'] / $stats['opened']) * 100, 1) }}% CTR
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body py-4">
|
||||
<div class="text-sm text-base-content/60">Failed</div>
|
||||
<div class="text-2xl font-bold text-error">{{ number_format($stats['failed']) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Content Preview -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">Content Preview</h2>
|
||||
|
||||
@if($campaign->channel === 'email' && $campaign->subject)
|
||||
<div class="mb-4">
|
||||
<label class="text-sm text-base-content/60">Subject</label>
|
||||
<p class="font-medium text-lg">{{ $campaign->subject }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<label class="text-sm text-base-content/60 mb-2 block">Message Body</label>
|
||||
<div class="bg-base-200 rounded-lg p-4 {{ $campaign->channel === 'email' ? 'prose prose-sm max-w-none' : 'font-mono text-sm' }}">
|
||||
@if($campaign->channel === 'email')
|
||||
{!! $campaign->body !!}
|
||||
@else
|
||||
{{ $campaign->body }}
|
||||
@endif
|
||||
</div>
|
||||
@if($campaign->channel === 'sms')
|
||||
<div class="text-xs text-base-content/50 mt-2">
|
||||
{{ strlen($campaign->body) }} characters
|
||||
({{ ceil(strlen($campaign->body) / 160) }} SMS segment{{ ceil(strlen($campaign->body) / 160) > 1 ? 's' : '' }})
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delivery Log (if has logs) -->
|
||||
@if($campaign->logs && $campaign->logs->count() > 0)
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">Recent Activity</h2>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Recipient</th>
|
||||
<th>Status</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($campaign->logs->take(10) as $log)
|
||||
<tr>
|
||||
<td class="text-sm">
|
||||
{{ $log->recipient ?? 'Unknown' }}
|
||||
</td>
|
||||
<td>
|
||||
@php
|
||||
$logStatusColors = [
|
||||
'sent' => 'success',
|
||||
'delivered' => 'success',
|
||||
'opened' => 'info',
|
||||
'clicked' => 'primary',
|
||||
'bounced' => 'error',
|
||||
'failed' => 'error',
|
||||
];
|
||||
@endphp
|
||||
<span class="badge badge-{{ $logStatusColors[$log->status] ?? 'ghost' }} badge-sm">
|
||||
{{ ucfirst($log->status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-sm text-base-content/60">
|
||||
{{ $log->created_at->diffForHumans() }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<!-- Schedule (for draft) -->
|
||||
@if($campaign->status === 'draft')
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">Schedule</h2>
|
||||
<form method="POST" action="{{ route('portal.campaigns.schedule', [$business->slug, $campaign]) }}">
|
||||
@csrf
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Send At</span>
|
||||
</label>
|
||||
<input type="datetime-local" name="send_at"
|
||||
class="input input-bordered"
|
||||
min="{{ now()->addMinutes(5)->format('Y-m-d\TH:i') }}"
|
||||
required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline btn-block gap-2">
|
||||
<span class="icon-[lucide--calendar] size-4"></span>
|
||||
Schedule
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Scheduled Info -->
|
||||
@if($campaign->status === 'scheduled' && $campaign->send_at)
|
||||
<div class="card bg-info/10 border border-info/20">
|
||||
<div class="card-body">
|
||||
<h3 class="font-medium flex items-center gap-2">
|
||||
<span class="icon-[lucide--calendar] size-4 text-info"></span>
|
||||
Scheduled
|
||||
</h3>
|
||||
<p class="text-2xl font-bold mt-2">{{ $campaign->send_at->format('M j, Y') }}</p>
|
||||
<p class="text-lg text-base-content/70">{{ $campaign->send_at->format('g:i A') }}</p>
|
||||
<p class="text-sm text-base-content/60 mt-2">{{ $campaign->send_at->diffForHumans() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Campaign Details -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">Details</h2>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/60">Channel</span>
|
||||
<span class="font-medium">{{ strtoupper($campaign->channel) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/60">Recipient List</span>
|
||||
<span class="font-medium">{{ $campaign->list?->name ?? 'None' }}</span>
|
||||
</div>
|
||||
@if($campaign->from_name)
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/60">From Name</span>
|
||||
<span class="font-medium">{{ $campaign->from_name }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if($campaign->from_email && $campaign->channel === 'email')
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/60">From Email</span>
|
||||
<span class="font-medium text-xs">{{ $campaign->from_email }}</span>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/60">Created</span>
|
||||
<span>{{ $campaign->created_at->format('M j, Y') }}</span>
|
||||
</div>
|
||||
@if($campaign->sent_at)
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/60">Sent</span>
|
||||
<span>{{ $campaign->sent_at->format('M j, Y g:i A') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">Quick Actions</h2>
|
||||
<div class="space-y-2">
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}"
|
||||
class="btn btn-outline btn-block btn-sm gap-2">
|
||||
<span class="icon-[lucide--copy] size-4"></span>
|
||||
Create Similar
|
||||
</a>
|
||||
<a href="{{ route('portal.campaigns.index', $business->slug) }}"
|
||||
class="btn btn-ghost btn-block btn-sm gap-2">
|
||||
<span class="icon-[lucide--arrow-left] size-4"></span>
|
||||
Back to Campaigns
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
182
resources/views/portal/dashboard.blade.php
Normal file
182
resources/views/portal/dashboard.blade.php
Normal file
@@ -0,0 +1,182 @@
|
||||
@extends('layouts.portal')
|
||||
|
||||
@section('title', 'Dashboard')
|
||||
|
||||
@section('content')
|
||||
<div class="space-y-6">
|
||||
<!-- Welcome Header -->
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Welcome back!</h1>
|
||||
@if($branding->portal_welcome_message)
|
||||
<p class="text-base-content/70 mt-1">{{ $branding->portal_welcome_message }}</p>
|
||||
@else
|
||||
<p class="text-base-content/70 mt-1">Manage your marketing campaigns and see recommended promos.</p>
|
||||
@endif
|
||||
</div>
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}" class="btn btn-primary gap-2">
|
||||
<span class="icon-[lucide--plus] size-4"></span>
|
||||
New Campaign
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body py-4">
|
||||
<div class="text-sm text-base-content/60">Total Campaigns</div>
|
||||
<div class="text-2xl font-bold">{{ $campaignStats['total'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body py-4">
|
||||
<div class="text-sm text-base-content/60">Sent</div>
|
||||
<div class="text-2xl font-bold text-success">{{ $campaignStats['sent'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body py-4">
|
||||
<div class="text-sm text-base-content/60">Drafts</div>
|
||||
<div class="text-2xl font-bold text-warning">{{ $campaignStats['draft'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body py-4">
|
||||
<div class="text-sm text-base-content/60">Scheduled</div>
|
||||
<div class="text-2xl font-bold text-info">{{ $campaignStats['scheduled'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Recommended Promos -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="card-title text-lg">
|
||||
<span class="icon-[lucide--sparkles] size-5 text-warning"></span>
|
||||
Recommended Deals
|
||||
</h2>
|
||||
<a href="{{ route('portal.promos.index', $business->slug) }}" class="btn btn-ghost btn-sm">View All</a>
|
||||
</div>
|
||||
|
||||
@if($recommendedPromos->isEmpty())
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<span class="icon-[lucide--tag] size-12 opacity-30 mx-auto block mb-2"></span>
|
||||
<p>No recommendations available yet.</p>
|
||||
<p class="text-sm">Check back soon for personalized promo ideas.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach($recommendedPromos->take(3) as $rec)
|
||||
<div class="flex items-center justify-between p-3 bg-base-200 rounded-lg">
|
||||
<div>
|
||||
<div class="font-medium">{{ $rec['promo_type'] ?? 'Promo' }}</div>
|
||||
<div class="text-sm text-base-content/60">
|
||||
@if(isset($rec['expected_lift']))
|
||||
Expected lift: +{{ number_format($rec['expected_lift'], 1) }}%
|
||||
@else
|
||||
{{ $rec['reason'] ?? '' }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}?channel=email" class="btn btn-primary btn-sm">
|
||||
Launch
|
||||
</a>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Campaigns -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="card-title text-lg">
|
||||
<span class="icon-[lucide--megaphone] size-5"></span>
|
||||
Recent Campaigns
|
||||
</h2>
|
||||
<a href="{{ route('portal.campaigns.index', $business->slug) }}" class="btn btn-ghost btn-sm">View All</a>
|
||||
</div>
|
||||
|
||||
@if($recentCampaigns->isEmpty())
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<span class="icon-[lucide--megaphone] size-12 opacity-30 mx-auto block mb-2"></span>
|
||||
<p>No campaigns yet.</p>
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}" class="btn btn-primary btn-sm mt-3">Create Your First Campaign</a>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach($recentCampaigns as $campaign)
|
||||
<a href="{{ route('portal.campaigns.show', [$business->slug, $campaign]) }}"
|
||||
class="flex items-center justify-between p-3 bg-base-200 rounded-lg hover:bg-base-300 transition-colors">
|
||||
<div>
|
||||
<div class="font-medium">{{ $campaign->name }}</div>
|
||||
<div class="flex items-center gap-2 text-sm text-base-content/60">
|
||||
<span class="badge badge-xs badge-{{ $campaign->channel === 'email' ? 'info' : 'success' }}">
|
||||
{{ strtoupper($campaign->channel) }}
|
||||
</span>
|
||||
<span>{{ $campaign->list?->name ?? 'No list' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge badge-{{ match($campaign->status) {
|
||||
'draft' => 'warning',
|
||||
'scheduled' => 'info',
|
||||
'sending' => 'primary',
|
||||
'sent', 'completed' => 'success',
|
||||
'cancelled', 'failed' => 'error',
|
||||
default => 'ghost'
|
||||
} }}">
|
||||
{{ ucfirst($campaign->status) }}
|
||||
</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Promos -->
|
||||
@if($activePromos->isNotEmpty())
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="card-title text-lg">
|
||||
<span class="icon-[lucide--zap] size-5 text-success"></span>
|
||||
Active Promos
|
||||
</h2>
|
||||
<a href="{{ route('portal.promos.index', $business->slug) }}" class="btn btn-ghost btn-sm">View All</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@foreach($activePromos as $promo)
|
||||
<div class="border border-base-200 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium">{{ $promo->name }}</h3>
|
||||
<p class="text-sm text-base-content/60">{{ $promo->type_display }}</p>
|
||||
</div>
|
||||
<span class="badge badge-success badge-sm">Live</span>
|
||||
</div>
|
||||
@if($promo->ends_at)
|
||||
<div class="text-xs text-base-content/60 mt-2">
|
||||
Ends {{ $promo->ends_at->diffForHumans() }}
|
||||
</div>
|
||||
@endif
|
||||
<div class="mt-3 flex gap-2">
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}?promo_id={{ $promo->id }}&channel=email"
|
||||
class="btn btn-outline btn-xs">Email</a>
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}?promo_id={{ $promo->id }}&channel=sms"
|
||||
class="btn btn-outline btn-xs">SMS</a>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
140
resources/views/portal/lists/create.blade.php
Normal file
140
resources/views/portal/lists/create.blade.php
Normal file
@@ -0,0 +1,140 @@
|
||||
@extends('layouts.portal')
|
||||
|
||||
@section('title', 'Create List')
|
||||
|
||||
@section('content')
|
||||
<div class="space-y-6">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="{{ route('portal.lists.index', $business->slug) }}">Lists</a></li>
|
||||
<li>Create</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Create List</h1>
|
||||
<p class="text-base-content/70 mt-1">Set up a new contact list for your campaigns</p>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form method="POST" action="{{ route('portal.lists.store', $business->slug) }}">
|
||||
@csrf
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Main Form -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">List Details</h2>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">List Name</span>
|
||||
</label>
|
||||
<input type="text" name="name"
|
||||
value="{{ old('name') }}"
|
||||
class="input input-bordered @error('name') input-error @enderror"
|
||||
placeholder="e.g., VIP Customers, Newsletter Subscribers"
|
||||
required>
|
||||
@error('name')
|
||||
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Description</span>
|
||||
<span class="label-text-alt text-base-content/60">Optional</span>
|
||||
</label>
|
||||
<textarea name="description" rows="3"
|
||||
class="textarea textarea-bordered @error('description') textarea-error @enderror"
|
||||
placeholder="Brief description of this list...">{{ old('description') }}</textarea>
|
||||
@error('description')
|
||||
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">List Type</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@foreach($types as $key => $label)
|
||||
<label class="cursor-pointer">
|
||||
<input type="radio" name="type" value="{{ $key }}"
|
||||
class="hidden peer"
|
||||
{{ old('type', 'static') === $key ? 'checked' : '' }}
|
||||
required>
|
||||
<div class="card border-2 border-base-200 peer-checked:border-primary peer-checked:bg-primary/5 transition-colors">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="icon-[lucide--{{ $key === 'smart' ? 'sparkles' : 'users' }}] size-6 text-primary"></span>
|
||||
<div>
|
||||
<h4 class="font-medium">{{ $label }}</h4>
|
||||
<p class="text-sm text-base-content/60">
|
||||
@if($key === 'static')
|
||||
Manually add and manage contacts
|
||||
@else
|
||||
Auto-updates based on criteria
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('type')
|
||||
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<!-- Actions -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h3 class="font-medium mb-4">Actions</h3>
|
||||
<div class="space-y-2">
|
||||
<button type="submit" class="btn btn-primary btn-block gap-2">
|
||||
<span class="icon-[lucide--save] size-4"></span>
|
||||
Create List
|
||||
</button>
|
||||
<a href="{{ route('portal.lists.index', $business->slug) }}" class="btn btn-ghost btn-block">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h3 class="font-medium mb-4">About Lists</h3>
|
||||
<ul class="text-sm text-base-content/70 space-y-2">
|
||||
<li class="flex gap-2">
|
||||
<span class="icon-[lucide--info] size-4 text-info flex-shrink-0 mt-0.5"></span>
|
||||
Static lists let you manually add specific contacts
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="icon-[lucide--info] size-4 text-info flex-shrink-0 mt-0.5"></span>
|
||||
Smart lists auto-update based on customer criteria
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="icon-[lucide--info] size-4 text-info flex-shrink-0 mt-0.5"></span>
|
||||
You can add contacts after creating the list
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endsection
|
||||
68
resources/views/portal/lists/index.blade.php
Normal file
68
resources/views/portal/lists/index.blade.php
Normal file
@@ -0,0 +1,68 @@
|
||||
@extends('layouts.portal')
|
||||
|
||||
@section('title', 'Lists')
|
||||
|
||||
@section('content')
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Lists</h1>
|
||||
<p class="text-base-content/70 mt-1">Manage your customer contact lists for campaigns</p>
|
||||
</div>
|
||||
<a href="{{ route('portal.lists.create', $business->slug) }}" class="btn btn-primary gap-2">
|
||||
<span class="icon-[lucide--plus] size-4"></span>
|
||||
New List
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Lists Grid -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
@if($lists->isEmpty())
|
||||
<div class="text-center py-12 text-base-content/60">
|
||||
<span class="icon-[lucide--users] size-16 opacity-30 mx-auto block mb-4"></span>
|
||||
<h3 class="font-semibold text-lg mb-2">No lists yet</h3>
|
||||
<p class="mb-4">Create your first contact list to start sending campaigns.</p>
|
||||
<a href="{{ route('portal.lists.create', $business->slug) }}" class="btn btn-primary">
|
||||
Create List
|
||||
</a>
|
||||
</div>
|
||||
@else
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@foreach($lists as $list)
|
||||
<a href="{{ route('portal.lists.show', [$business->slug, $list]) }}"
|
||||
class="card border border-base-200 hover:shadow-md transition-shadow">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium">{{ $list->name }}</h3>
|
||||
@if($list->description)
|
||||
<p class="text-sm text-base-content/60 mt-1 line-clamp-2">{{ $list->description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<span class="badge badge-{{ $list->type === 'smart' ? 'info' : 'ghost' }} badge-sm">
|
||||
{{ ucfirst($list->type) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-base-content/60">
|
||||
<span class="icon-[lucide--users] size-4"></span>
|
||||
<span class="text-sm font-medium">{{ number_format($list->contacts_count) }} contacts</span>
|
||||
</div>
|
||||
<span class="icon-[lucide--chevron-right] size-4 text-base-content/40"></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
{{ $lists->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
192
resources/views/portal/lists/show.blade.php
Normal file
192
resources/views/portal/lists/show.blade.php
Normal file
@@ -0,0 +1,192 @@
|
||||
@extends('layouts.portal')
|
||||
|
||||
@section('title', $list->name)
|
||||
|
||||
@section('content')
|
||||
<div class="space-y-6">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="{{ route('portal.lists.index', $business->slug) }}">Lists</a></li>
|
||||
<li>{{ $list->name }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold">{{ $list->name }}</h1>
|
||||
<span class="badge badge-{{ $list->type === 'smart' ? 'info' : 'ghost' }} badge-lg">
|
||||
{{ ucfirst($list->type) }}
|
||||
</span>
|
||||
</div>
|
||||
@if($list->description)
|
||||
<p class="text-base-content/70 mt-1">{{ $list->description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}?channel=email"
|
||||
class="btn btn-primary gap-2">
|
||||
<span class="icon-[lucide--mail] size-4"></span>
|
||||
Email Campaign
|
||||
</a>
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}?channel=sms"
|
||||
class="btn btn-outline gap-2">
|
||||
<span class="icon-[lucide--smartphone] size-4"></span>
|
||||
SMS Campaign
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Main Content -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body py-4">
|
||||
<div class="text-sm text-base-content/60">Total Contacts</div>
|
||||
<div class="text-2xl font-bold">{{ number_format($contacts->total()) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body py-4">
|
||||
<div class="text-sm text-base-content/60">With Email</div>
|
||||
<div class="text-2xl font-bold text-info">{{ number_format($list->contacts()->whereNotNull('email')->count()) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body py-4">
|
||||
<div class="text-sm text-base-content/60">With Phone</div>
|
||||
<div class="text-2xl font-bold text-success">{{ number_format($list->contacts()->whereNotNull('phone')->count()) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contacts Table -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="card-title text-lg">Contacts</h2>
|
||||
</div>
|
||||
|
||||
@if($contacts->isEmpty())
|
||||
<div class="text-center py-12 text-base-content/60">
|
||||
<span class="icon-[lucide--users] size-16 opacity-30 mx-auto block mb-4"></span>
|
||||
<h3 class="font-semibold text-lg mb-2">No contacts yet</h3>
|
||||
<p>Contacts will appear here once added to this list.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th>Added</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($contacts as $contact)
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-neutral text-neutral-content w-8 rounded-full">
|
||||
<span class="text-xs">{{ substr($contact->first_name ?? $contact->email ?? '?', 0, 1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="font-medium">
|
||||
{{ $contact->first_name }} {{ $contact->last_name }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if($contact->email)
|
||||
<span class="text-sm">{{ $contact->email }}</span>
|
||||
@else
|
||||
<span class="text-sm text-base-content/50">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@if($contact->phone)
|
||||
<span class="text-sm">{{ $contact->phone }}</span>
|
||||
@else
|
||||
<span class="text-sm text-base-content/50">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-sm text-base-content/60">
|
||||
{{ $contact->created_at->diffForHumans() }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
{{ $contacts->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<!-- Quick Actions -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">Launch Campaign</h2>
|
||||
<p class="text-sm text-base-content/60 mb-4">
|
||||
Send a campaign to all {{ number_format($contacts->total()) }} contacts in this list.
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}?channel=email"
|
||||
class="btn btn-primary btn-block gap-2">
|
||||
<span class="icon-[lucide--mail] size-4"></span>
|
||||
Email Campaign
|
||||
</a>
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}?channel=sms"
|
||||
class="btn btn-outline btn-block gap-2">
|
||||
<span class="icon-[lucide--smartphone] size-4"></span>
|
||||
SMS Campaign
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List Details -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">Details</h2>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/60">Type</span>
|
||||
<span class="font-medium">{{ ucfirst($list->type) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/60">Created</span>
|
||||
<span>{{ $list->created_at->format('M j, Y') }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/60">Updated</span>
|
||||
<span>{{ $list->updated_at->diffForHumans() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Back -->
|
||||
<a href="{{ route('portal.lists.index', $business->slug) }}" class="btn btn-ghost btn-block gap-2">
|
||||
<span class="icon-[lucide--arrow-left] size-4"></span>
|
||||
Back to Lists
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
155
resources/views/portal/promos/index.blade.php
Normal file
155
resources/views/portal/promos/index.blade.php
Normal file
@@ -0,0 +1,155 @@
|
||||
@extends('layouts.portal')
|
||||
|
||||
@section('title', 'Promos')
|
||||
|
||||
@section('content')
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Promos</h1>
|
||||
<p class="text-base-content/70 mt-1">View recommended deals and active promotions</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recommended Promos from CannaiQ -->
|
||||
@if($recommendedPromos->isNotEmpty())
|
||||
<div class="card bg-gradient-to-r from-warning/10 to-warning/5 border border-warning/20">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">
|
||||
<span class="icon-[lucide--sparkles] size-5 text-warning"></span>
|
||||
Recommended Deals for You
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@foreach($recommendedPromos as $rec)
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="font-medium">{{ $rec['promo_type'] ?? 'Promotional Deal' }}</h3>
|
||||
@if(isset($rec['products']) && is_array($rec['products']))
|
||||
<p class="text-sm text-base-content/60">
|
||||
{{ implode(', ', array_slice($rec['products'], 0, 3)) }}
|
||||
@if(count($rec['products']) > 3)
|
||||
+{{ count($rec['products']) - 3 }} more
|
||||
@endif
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if(isset($rec['expected_lift']))
|
||||
<div class="mt-2">
|
||||
<span class="text-success font-semibold">+{{ number_format($rec['expected_lift'], 1) }}%</span>
|
||||
<span class="text-sm text-base-content/60">expected lift</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(isset($rec['reason']))
|
||||
<p class="text-xs text-base-content/50 mt-1">{{ $rec['reason'] }}</p>
|
||||
@endif
|
||||
|
||||
<div class="card-actions justify-end mt-3">
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}?channel=email"
|
||||
class="btn btn-primary btn-sm gap-1">
|
||||
<span class="icon-[lucide--mail] size-3"></span>
|
||||
Email
|
||||
</a>
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}?channel=sms"
|
||||
class="btn btn-outline btn-sm gap-1">
|
||||
<span class="icon-[lucide--smartphone] size-3"></span>
|
||||
SMS
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body py-3">
|
||||
<form method="GET" class="flex flex-wrap items-center gap-3">
|
||||
<select name="status" class="select select-bordered select-sm">
|
||||
<option value="">All Statuses</option>
|
||||
@foreach($statuses as $key => $label)
|
||||
<option value="{{ $key }}" {{ request('status') === $key ? 'selected' : '' }}>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Filter</button>
|
||||
@if(request()->hasAny(['status']))
|
||||
<a href="{{ route('portal.promos.index', $business->slug) }}" class="btn btn-sm btn-ghost">Clear</a>
|
||||
@endif
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing Promos -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">Your Promos</h2>
|
||||
|
||||
@if($existingPromos->isEmpty())
|
||||
<div class="text-center py-12 text-base-content/60">
|
||||
<span class="icon-[lucide--tag] size-16 opacity-30 mx-auto block mb-4"></span>
|
||||
<h3 class="font-semibold text-lg mb-2">No promos yet</h3>
|
||||
<p>Promos will appear here once they're created.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@foreach($existingPromos as $promo)
|
||||
<div class="card border border-base-200 hover:shadow-md transition-shadow">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium">{{ $promo->name }}</h3>
|
||||
<p class="text-sm text-base-content/60">{{ $promo->type_display }}</p>
|
||||
</div>
|
||||
<span class="badge badge-{{ $promo->status_color }}">{{ ucfirst($promo->status) }}</span>
|
||||
</div>
|
||||
|
||||
@if($promo->brand)
|
||||
<p class="text-xs text-base-content/50 mt-1">{{ $promo->brand->name }}</p>
|
||||
@endif
|
||||
|
||||
@if($promo->expected_lift)
|
||||
<div class="mt-2">
|
||||
<span class="text-success font-semibold">+{{ number_format($promo->expected_lift, 1) }}%</span>
|
||||
<span class="text-sm text-base-content/60">expected lift</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="text-xs text-base-content/50 mt-2">
|
||||
@if($promo->starts_at)
|
||||
{{ $promo->starts_at->format('M j') }}
|
||||
@endif
|
||||
@if($promo->starts_at && $promo->ends_at)
|
||||
-
|
||||
@endif
|
||||
@if($promo->ends_at)
|
||||
{{ $promo->ends_at->format('M j, Y') }}
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-between mt-3">
|
||||
<a href="{{ route('portal.promos.show', [$business->slug, $promo]) }}"
|
||||
class="btn btn-ghost btn-sm">Details</a>
|
||||
<div class="flex gap-1">
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}?promo_id={{ $promo->id }}&channel=email"
|
||||
class="btn btn-primary btn-xs">Email</a>
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}?promo_id={{ $promo->id }}&channel=sms"
|
||||
class="btn btn-outline btn-xs">SMS</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
{{ $existingPromos->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
190
resources/views/portal/promos/show.blade.php
Normal file
190
resources/views/portal/promos/show.blade.php
Normal file
@@ -0,0 +1,190 @@
|
||||
@extends('layouts.portal')
|
||||
|
||||
@section('title', $promo->name)
|
||||
|
||||
@section('content')
|
||||
<div class="space-y-6">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="{{ route('portal.promos.index', $business->slug) }}">Promos</a></li>
|
||||
<li>{{ $promo->name }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold">{{ $promo->name }}</h1>
|
||||
<span class="badge badge-{{ $promo->status_color }} badge-lg">{{ ucfirst($promo->status) }}</span>
|
||||
@if($promo->is_running)
|
||||
<span class="badge badge-success badge-sm">Live</span>
|
||||
@endif
|
||||
</div>
|
||||
<p class="text-base-content/70 mt-1">{{ $promo->type_display }} promo</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}?promo_id={{ $promo->id }}&channel=email"
|
||||
class="btn btn-primary gap-2">
|
||||
<span class="icon-[lucide--mail] size-4"></span>
|
||||
Email Campaign
|
||||
</a>
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}?promo_id={{ $promo->id }}&channel=sms"
|
||||
class="btn btn-outline gap-2">
|
||||
<span class="icon-[lucide--smartphone] size-4"></span>
|
||||
SMS Campaign
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Main Details -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Promo Details -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">Promo Details</h2>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-sm text-base-content/60">Type</label>
|
||||
<p class="font-medium">{{ $promo->type_display }}</p>
|
||||
</div>
|
||||
@if($promo->brand)
|
||||
<div>
|
||||
<label class="text-sm text-base-content/60">Brand</label>
|
||||
<p class="font-medium">{{ $promo->brand->name }}</p>
|
||||
</div>
|
||||
@endif
|
||||
<div>
|
||||
<label class="text-sm text-base-content/60">Start Date</label>
|
||||
<p class="font-medium">{{ $promo->starts_at?->format('M j, Y') ?? 'Not set' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-base-content/60">End Date</label>
|
||||
<p class="font-medium">{{ $promo->ends_at?->format('M j, Y') ?? 'Not set' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($promo->description)
|
||||
<div class="mt-4">
|
||||
<label class="text-sm text-base-content/60">Description</label>
|
||||
<p class="mt-1">{{ $promo->description }}</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Marketing Copy -->
|
||||
@if($promo->sms_copy || $promo->email_copy)
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">Marketing Copy</h2>
|
||||
|
||||
@if($promo->sms_copy)
|
||||
<div class="mb-4">
|
||||
<label class="text-sm text-base-content/60 flex items-center gap-2 mb-2">
|
||||
<span class="icon-[lucide--smartphone] size-4"></span>
|
||||
SMS Copy
|
||||
</label>
|
||||
<div class="bg-base-200 rounded-lg p-4 font-mono text-sm">
|
||||
{{ $promo->sms_copy }}
|
||||
</div>
|
||||
<div class="text-xs text-base-content/50 mt-1">
|
||||
{{ strlen($promo->sms_copy) }} characters
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($promo->email_copy)
|
||||
<div>
|
||||
<label class="text-sm text-base-content/60 flex items-center gap-2 mb-2">
|
||||
<span class="icon-[lucide--mail] size-4"></span>
|
||||
Email Copy
|
||||
</label>
|
||||
<div class="bg-base-200 rounded-lg p-4 prose prose-sm max-w-none">
|
||||
{!! $promo->email_copy !!}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<!-- Expected Impact -->
|
||||
@if($promo->expected_lift || $promo->expected_margin_brand || $promo->expected_margin_store)
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">Expected Impact</h2>
|
||||
|
||||
@if($promo->expected_lift)
|
||||
<div class="mb-3">
|
||||
<label class="text-sm text-base-content/60">Expected Lift</label>
|
||||
<p class="text-2xl font-bold text-success">+{{ number_format($promo->expected_lift, 1) }}%</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
@if($promo->expected_margin_brand !== null)
|
||||
<div>
|
||||
<label class="text-sm text-base-content/60">Brand Margin</label>
|
||||
<p class="font-medium">{{ number_format($promo->expected_margin_brand, 1) }}%</p>
|
||||
</div>
|
||||
@endif
|
||||
@if($promo->expected_margin_store !== null)
|
||||
<div>
|
||||
<label class="text-sm text-base-content/60">Store Margin</label>
|
||||
<p class="font-medium">{{ number_format($promo->expected_margin_store, 1) }}%</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">Launch Campaign</h2>
|
||||
<p class="text-sm text-base-content/60 mb-4">
|
||||
Create a campaign using this promo's marketing copy.
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}?promo_id={{ $promo->id }}&channel=email"
|
||||
class="btn btn-primary btn-block gap-2">
|
||||
<span class="icon-[lucide--mail] size-4"></span>
|
||||
Email Campaign
|
||||
</a>
|
||||
<a href="{{ route('portal.campaigns.create', $business->slug) }}?promo_id={{ $promo->id }}&channel=sms"
|
||||
class="btn btn-outline btn-block gap-2">
|
||||
<span class="icon-[lucide--smartphone] size-4"></span>
|
||||
SMS Campaign
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">Details</h2>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/60">Created</span>
|
||||
<span>{{ $promo->created_at->diffForHumans() }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/60">Updated</span>
|
||||
<span>{{ $promo->updated_at->diffForHumans() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
55
routes/portal.php
Normal file
55
routes/portal.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Portal\CampaignController;
|
||||
use App\Http\Controllers\Portal\DashboardController;
|
||||
use App\Http\Controllers\Portal\PromoController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Marketing Portal Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These routes are for the Dispensary Marketing Portal - a white-labeled
|
||||
| interface for dispensary partners to:
|
||||
| - View recommended promos from CannaiQ
|
||||
| - Launch email/SMS campaigns to their customer lists
|
||||
| - Track campaign performance
|
||||
|
|
||||
| All routes are scoped by business and require marketing-portal access.
|
||||
|
|
||||
*/
|
||||
|
||||
Route::prefix('portal/{business}')
|
||||
->name('portal.')
|
||||
->middleware(['web', 'auth', 'verified', 'marketing-portal'])
|
||||
->group(function () {
|
||||
|
||||
// Dashboard
|
||||
Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
|
||||
|
||||
// Promos - View recommended promos and existing promos
|
||||
Route::prefix('promos')->name('promos.')->group(function () {
|
||||
Route::get('/', [PromoController::class, 'index'])->name('index');
|
||||
Route::get('/{promo}', [PromoController::class, 'show'])->name('show');
|
||||
});
|
||||
|
||||
// Campaigns - Create and manage campaigns
|
||||
Route::prefix('campaigns')->name('campaigns.')->group(function () {
|
||||
Route::get('/', [CampaignController::class, 'index'])->name('index');
|
||||
Route::get('/create', [CampaignController::class, 'create'])->name('create');
|
||||
Route::post('/', [CampaignController::class, 'store'])->name('store');
|
||||
Route::get('/{campaign}', [CampaignController::class, 'show'])->name('show');
|
||||
Route::post('/{campaign}/send-now', [CampaignController::class, 'sendNow'])->name('send-now');
|
||||
Route::post('/{campaign}/schedule', [CampaignController::class, 'schedule'])->name('schedule');
|
||||
Route::post('/{campaign}/cancel', [CampaignController::class, 'cancel'])->name('cancel');
|
||||
});
|
||||
|
||||
// Lists - View and manage marketing lists
|
||||
Route::prefix('lists')->name('lists.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Portal\ListController::class, 'index'])->name('index');
|
||||
Route::get('/create', [\App\Http\Controllers\Portal\ListController::class, 'create'])->name('create');
|
||||
Route::post('/', [\App\Http\Controllers\Portal\ListController::class, 'store'])->name('store');
|
||||
Route::get('/{list}', [\App\Http\Controllers\Portal\ListController::class, 'show'])->name('show');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user