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:
kelly
2025-12-09 15:59:40 -07:00
parent c7e2b0e4ac
commit c7fdc67060
26 changed files with 3297 additions and 0 deletions

View File

@@ -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')

View 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.');
}
}

View 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'
));
}
}

View 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'
));
}
}

View 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'
));
}
}

View 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);
}
}

View 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',
]
);
}
}

View File

@@ -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();
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -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,
];
/**

View File

@@ -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) {

View File

@@ -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');
}
};

View 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.');
}
}

View 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.

View 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>
&copy; {{ 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>

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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');
});
});