Merge branch 'affiliate-program' of https://gitlab.com/CIEFWorldwideSdnBhd/exchange-2.0 into vapor/development

# Conflicts:
#	routes/api.php
This commit is contained in:
Edmond Lang
2025-11-30 23:34:31 +08:00
50 changed files with 2427 additions and 6 deletions
@@ -0,0 +1,37 @@
<?php
namespace App\Classes\General\Eloquent\Filters;
use Illuminate\Database\Eloquent\Builder;
class AffiliateSearch implements Filter
{
/**
* @param Builder $builder
* @param $value
* @return Builder|mixed
*/
public static function apply(Builder $builder, $value)
{
// Sanitize input to prevent SQL injection
// Remove any characters that could be used for SQL injection
$sanitized = preg_replace('/[^a-zA-Z0-9\s\-_]/', '', $value);
// Trim whitespace
$sanitized = trim($sanitized);
// If sanitized value is empty, return builder without filtering
if (empty($sanitized)) {
return $builder;
}
return $builder->where(function ($query) use ($sanitized) {
$query->where('campaign_name', 'LIKE', '%' . $sanitized . '%')
->orWhere('code', 'LIKE', '%' . $sanitized . '%')
->orWhere('campaign_description', 'LIKE', '%' . $sanitized . '%');
});
}
}
@@ -17,6 +17,7 @@ use App\Classes\Modules\Vouchers\Processors\CreateVoucherProcessor;
use App\Classes\Modules\Companies\DataTransferObjects\EmploymentObject;
use App\Classes\Modules\PerfexCRM\DataTransferObjects\CreateLeadPerfexCRMObject;
use App\Classes\Modules\Rewards\Services\CreatesUserReward;
use App\Classes\Modules\Affiliate\Services\TracksAffiliateRegistration;
use App\Classes\ValueObjects\Constants\ApprovalStatus;
use App\Classes\ValueObjects\Constants\BusinessType;
use App\Classes\ValueObjects\Constants\CompanyType;
@@ -82,6 +83,9 @@ class CreateCustomerLogic extends AbstractControllerLogic
/** @var CreatesUserReward */
private $createsUserReward;
/** @var TracksAffiliateRegistration */
private $tracksAffiliateRegistration;
/**
* CreateCustomerLogic constructor.
* @param CreateUserProcessor $createUserProcessor
@@ -96,9 +100,10 @@ class CreateCustomerLogic extends AbstractControllerLogic
* @param NewCustomerToVoucherifyProcessor $newCustomerToVoucherifyProcessor
* @param CreateVoucherProcessor $createVoucherProcessor
* @param CreatesUserReward $createsUserReward
* @param TracksAffiliateRegistration $tracksAffiliateRegistration
*/
public function __construct(CreateUserProcessor $createUserProcessor, CreateCompanyProcessor $createCompanyProcessor, CreateContactProcessor $createContactProcessor, AssignEmployeeProcessor $assignEmployeeProcessor, AssignSegmentProcessor $assignSegmentProcessor, AuthenticationProcessor $authenticationProcessor, GenerateEmailVerificationAttemptProcessor $generateEmailVerificationAttemptProcessor,
CreatesSeasonalSegment $createsSeasonalSegment, CheckMilestonesForRewardProcessor $checkMilestonesForRewardProcessor, NewCustomerToVoucherifyProcessor $newCustomerToVoucherifyProcessor, CreateVoucherProcessor $createVoucherProcessor, CreatesUserReward $createsUserReward)
CreatesSeasonalSegment $createsSeasonalSegment, CheckMilestonesForRewardProcessor $checkMilestonesForRewardProcessor, NewCustomerToVoucherifyProcessor $newCustomerToVoucherifyProcessor, CreateVoucherProcessor $createVoucherProcessor, CreatesUserReward $createsUserReward, TracksAffiliateRegistration $tracksAffiliateRegistration)
{
$this->createUserProcessor = $createUserProcessor;
$this->createCompanyProcessor = $createCompanyProcessor;
@@ -112,6 +117,7 @@ class CreateCustomerLogic extends AbstractControllerLogic
$this->newCustomerToVoucherifyProcessor = $newCustomerToVoucherifyProcessor;
$this->createVoucherProcessor = $createVoucherProcessor;
$this->createsUserReward = $createsUserReward;
$this->tracksAffiliateRegistration = $tracksAffiliateRegistration;
}
/**
@@ -177,6 +183,9 @@ class CreateCustomerLogic extends AbstractControllerLogic
}
}
// Track affiliate registration if user came from affiliate link
$this->tracksAffiliateRegistration->execute($user);
return $this->response($this->authenticationProcessor->execute($request, false));
}
@@ -0,0 +1,54 @@
<?php
namespace App\Classes\Modules\Affiliate\ControllersLogic;
use App\Classes\Modules\Affiliate\Services\CreatesAffiliate;
use App\Classes\Modules\Affiliate\DataTransferObjects\AffiliateObject;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CreateAffiliateLogic
{
/** @var CreatesAffiliate */
private $createsAffiliate;
/**
* @param CreatesAffiliate $createsAffiliate
*/
public function __construct(CreatesAffiliate $createsAffiliate)
{
$this->createsAffiliate = $createsAffiliate;
}
/**
* @param Request $request
* @return JsonResponse
*/
public function execute(Request $request): JsonResponse
{
$code = $request->input('code');
$code = $code !== null ? (string) $code : '';
$isActive = $request->input('is_active', true);
// Convert string 'true'/'false' to boolean if needed
if (is_string($isActive)) {
$isActive = filter_var($isActive, FILTER_VALIDATE_BOOLEAN);
}
$isActive = (bool) $isActive;
$object = new AffiliateObject(
$code,
$request->input('campaign_name'),
$request->input('campaign_description'),
$isActive
);
$affiliate = $this->createsAffiliate->execute($object);
return response()->json([
'message' => 'Affiliate code created successfully',
'data' => $affiliate
]);
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Classes\Modules\Affiliate\ControllersLogic;
use App\Classes\Modules\Affiliate\Services\DeletesAffiliate;
use App\Models\Affiliate;
use Illuminate\Http\JsonResponse;
class DeleteAffiliateLogic
{
/** @var DeletesAffiliate */
private $deletesAffiliate;
/**
* @param DeletesAffiliate $deletesAffiliate
*/
public function __construct(DeletesAffiliate $deletesAffiliate)
{
$this->deletesAffiliate = $deletesAffiliate;
}
/**
* @param int $id
* @return JsonResponse
*/
public function execute(int $id): JsonResponse
{
$affiliate = Affiliate::findOrFail($id);
$this->deletesAffiliate->execute($affiliate);
return response()->json([
'message' => 'Affiliate code deleted successfully'
]);
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Classes\Modules\Affiliate\ControllersLogic;
use App\Classes\Modules\Affiliate\Services\GetsAffiliateSettings;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class GetAffiliateSettingsLogic
{
/** @var GetsAffiliateSettings */
private $getsAffiliateSettings;
/**
* @param GetsAffiliateSettings $getsAffiliateSettings
*/
public function __construct(GetsAffiliateSettings $getsAffiliateSettings)
{
$this->getsAffiliateSettings = $getsAffiliateSettings;
}
/**
* @param Request $request
* @return JsonResponse
*/
public function execute(Request $request): JsonResponse
{
$settings = $this->getsAffiliateSettings->execute();
return response()->json($settings);
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Classes\Modules\Affiliate\ControllersLogic;
use App\Classes\General\Abstracts\AbstractControllerLogic;
use App\Classes\Modules\Affiliate\Services\ListsAffiliates;
use App\Http\Resources\AffiliateResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ListAffiliatesLogic extends AbstractControllerLogic
{
/**
* @return array
*/
protected function notification(): array {
return [
'title' => 'Retrieved Affiliates',
'message' => 'You have successfully retrieved a list of Affiliates'
];
}
/** @var ListsAffiliates */
private $listsAffiliates;
/**
* @param ListsAffiliates $listsAffiliates
*/
public function __construct(ListsAffiliates $listsAffiliates)
{
$this->listsAffiliates = $listsAffiliates;
}
/**
* @param Request $request
* @return JsonResponse
*/
public function logic(Request $request): JsonResponse
{
$query = $this->listsAffiliates->execute($this->listsAffiliates->deserializeFilters($request->input('filters')));
return $this->collectionResponse(AffiliateResource::collection($query));
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Classes\Modules\Affiliate\ControllersLogic;
use App\Classes\Modules\Affiliate\Services\UpdatesAffiliate;
use App\Classes\Modules\Affiliate\DataTransferObjects\AffiliateObject;
use App\Models\Affiliate;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UpdateAffiliateLogic
{
/** @var UpdatesAffiliate */
private $updatesAffiliate;
/**
* @param UpdatesAffiliate $updatesAffiliate
*/
public function __construct(UpdatesAffiliate $updatesAffiliate)
{
$this->updatesAffiliate = $updatesAffiliate;
}
/**
* @param Request $request
* @param int $id
* @return JsonResponse
*/
public function execute(Request $request, int $id): JsonResponse
{
$affiliate = Affiliate::findOrFail($id);
$isActive = $request->input('is_active', $affiliate->is_active);
// Convert string 'true'/'false' to boolean if needed
if (is_string($isActive)) {
$isActive = filter_var($isActive, FILTER_VALIDATE_BOOLEAN);
}
$isActive = (bool) $isActive;
$object = new AffiliateObject(
$affiliate->code, // Keep existing code
$request->input('campaign_name'),
$request->input('campaign_description'),
$isActive
);
$affiliate = $this->updatesAffiliate->execute($affiliate, $object);
return response()->json([
'message' => 'Affiliate code updated successfully',
'data' => $affiliate
]);
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Classes\Modules\Affiliate\ControllersLogic;
use App\Classes\Modules\Affiliate\Services\UpdatesAffiliateSettings;
use App\Classes\Modules\Affiliate\DataTransferObjects\AffiliateSettingsObject;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UpdateAffiliateSettingsLogic
{
/** @var UpdatesAffiliateSettings */
private $updatesAffiliateSettings;
/**
* @param UpdatesAffiliateSettings $updatesAffiliateSettings
*/
public function __construct(UpdatesAffiliateSettings $updatesAffiliateSettings)
{
$this->updatesAffiliateSettings = $updatesAffiliateSettings;
}
/**
* @param Request $request
* @return JsonResponse
*/
public function execute(Request $request): JsonResponse
{
$object = new AffiliateSettingsObject(
$request->input('code_binding_days', 30)
);
$settings = $this->updatesAffiliateSettings->execute($object);
return response()->json([
'message' => 'Affiliate settings updated successfully',
'data' => $settings
]);
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Classes\Modules\Affiliate\DataTransferObjects;
use App\Classes\General\Interfaces\DataTransferObject;
class AffiliateObject implements DataTransferObject
{
/** @var string | null */
private ?string $code;
/** @var string */
private $campaignName;
/** @var string|null */
private $campaignDescription;
/** @var bool */
private $isActive;
/**
* AffiliateObject constructor.
* @param string $code
* @param string $campaignName
* @param string|null $campaignDescription
* @param bool $isActive
*/
public function __construct(string $code = '', string $campaignName, ?string $campaignDescription = null, bool $isActive = true)
{
$this->code = $code;
$this->campaignName = $campaignName;
$this->campaignDescription = $campaignDescription;
$this->isActive = $isActive;
}
/**
* @return string
*/
public function getCode(): string
{
return $this->code;
}
/**
* @return string
*/
public function getCampaignName(): string
{
return $this->campaignName;
}
/**
* @return string|null
*/
public function getCampaignDescription(): ?string
{
return $this->campaignDescription;
}
/**
* @return bool
*/
public function getIsActive(): bool
{
return $this->isActive;
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Classes\Modules\Affiliate\DataTransferObjects;
use App\Classes\General\Interfaces\DataTransferObject;
class AffiliateSettingsObject implements DataTransferObject
{
/** @var int */
private $codeBindingDays;
/**
* AffiliateSettingsObject constructor.
* @param int $codeBindingDays
*/
public function __construct(int $codeBindingDays)
{
$this->codeBindingDays = $codeBindingDays;
}
/**
* @return int
*/
public function getCodeBindingDays(): int
{
return $this->codeBindingDays;
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Classes\Modules\Affiliate\Services;
use App\Classes\General\Eloquent\AbstractUpdateRecord;
use App\Classes\Modules\Affiliate\DataTransferObjects\AffiliateObject;
use App\Models\Affiliate;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
class CreatesAffiliate extends AbstractUpdateRecord
{
/**
* @param AffiliateObject $object
* @return \Illuminate\Database\Eloquent\Model
* @throws \App\Classes\Exceptions\MalformedRequestException
*/
public function execute(AffiliateObject $object)
{
$code = $object->getCode();
// Auto-generate code if not provided
if (empty($code)) {
$code = $this->generateUniqueCode();
} else {
// Validate uniqueness of user-provided code (including soft-deleted records)
$existingAffiliate = Affiliate::where('code', $code)
->whereNull('deleted_at')
->first();
if ($existingAffiliate) {
throw new \App\Classes\Exceptions\MalformedRequestException('Affiliate code already exists. Please choose a different code.');
}
}
$model = new Affiliate();
$model->code = $code;
$model->campaign_name = $object->getCampaignName();
$model->campaign_description = $object->getCampaignDescription();
$model->is_active = $object->getIsActive();
$model->created_by = Auth::id();
$model->clicks_count = 0;
$model->registrations_count = 0;
$model->orders_count = 0;
return $this->handler($model);
}
/**
* Generate a unique affiliate code
*
* @return string
*/
private function generateUniqueCode(): string
{
$maxAttempts = 10;
$attempts = 0;
do {
// Increased from 12 to 16 characters for better uniqueness
$code = Str::random(16);
$attempts++;
if ($attempts >= $maxAttempts) {
// Fallback to UUID if we can't generate unique code
$code = strtoupper(Str::substr(str_replace('-', '', Str::uuid()), 0, 16));
break;
}
} while (Affiliate::where('code', $code)->exists());
return $code;
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Classes\Modules\Affiliate\Services;
use App\Models\Affiliate;
class DeletesAffiliate
{
/**
* @param Affiliate $affiliate
* @return bool
*/
public function execute(Affiliate $affiliate): bool
{
return $affiliate->delete();
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Classes\Modules\Affiliate\Services;
use App\Models\KeyValuePair;
class GetsAffiliateSettings
{
const SETTING_KEY = 'affiliate_code_binding_days';
const DEFAULT_VALUE = 30;
/**
* @return array
*/
public function execute(): array
{
$keyValuePair = KeyValuePair::where('key', self::SETTING_KEY)
->whereNull('owner_type')
->whereNull('owner_id')
->first();
$codeBindingDays = $keyValuePair ? (int) $keyValuePair->value : self::DEFAULT_VALUE;
return [
'code_binding_days' => $codeBindingDays
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Classes\Modules\Affiliate\Services;
use App\Classes\General\Eloquent\AbstractListRecord;
use App\Models\Affiliate;
use Illuminate\Database\Eloquent\Builder;
class ListsAffiliates extends AbstractListRecord
{
/** @var Affiliate */
private $repository;
/**
* ListsAffiliates constructor.
* @param Affiliate $repository
*/
public function __construct(Affiliate $repository)
{
$this->repository = $repository;
}
/**
* @return Builder
*/
function getRepository(): Builder
{
return $this->repository->newQuery()->with('creator')->orderBy('created_at', 'desc');
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Classes\Modules\Affiliate\Services;
use App\Models\Affiliate;
use Illuminate\Support\Facades\Cookie;
class TracksAffiliateClick
{
const COOKIE_NAME = 'affiliate_tracking_code';
const COOKIE_EXPIRY_DAYS = 30; // Default, can be overridden by settings
/**
* Track a click on an affiliate link
*
* @param string $code
* @return Affiliate|null
*/
public function execute(string $code): ?Affiliate
{
// Validate code format and length
if (!$this->isValidCode($code)) {
return null;
}
$affiliate = Affiliate::where('code', $code)
->where('is_active', true)
->first();
if (!$affiliate) {
return null;
}
// Increment clicks count
$affiliate->increment('clicks_count');
// Get binding days from settings
$bindingDays = $this->getBindingDays();
// Store in cookie for later use during registration
// Set secure cookie flags for security
Cookie::queue(
cookie(
self::COOKIE_NAME,
$code,
$bindingDays * 24 * 60, // Convert days to minutes
'/', // path
null, // domain (null = current domain)
config('session.secure', false), // secure (HTTPS only)
true, // httpOnly (prevent JavaScript access)
false, // raw
config('session.same_site', 'lax') // sameSite
)
);
return $affiliate;
}
/**
* Validate affiliate code format
*
* @param string $code
* @return bool
*/
private function isValidCode(string $code): bool
{
// Check length (max 255 chars based on database schema)
if (strlen($code) > 255 || strlen($code) < 1) {
return false;
}
// Only allow alphanumeric characters, hyphens, and underscores
return (bool) preg_match('/^[a-zA-Z0-9_-]+$/', $code);
}
/**
* Get code binding days from settings
*
* @return int
*/
private function getBindingDays(): int
{
$settingsService = new GetsAffiliateSettings();
$settings = $settingsService->execute();
return $settings['code_binding_days'] ?? self::COOKIE_EXPIRY_DAYS;
}
}
@@ -0,0 +1,79 @@
<?php
namespace App\Classes\Modules\Affiliate\Services;
use App\Models\Affiliate;
use App\Models\Booking;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TracksAffiliateOrder
{
/**
* Track when a user with an affiliate code creates an order
*
* @param Booking $booking
* @return Affiliate|null
*/
public function execute(Booking $booking): ?Affiliate
{
try {
return DB::transaction(function () use ($booking) {
$user = $booking->company->employees()->first();
if (!$user) {
return null;
}
// Get user's affiliate
$userAffiliate = \App\Models\UserAffiliate::where('user_id', $user->id)
->whereNotNull('registered_at')
->first();
if (!$userAffiliate) {
return null;
}
// Lock the affiliate record to prevent race conditions
$affiliate = Affiliate::where('id', $userAffiliate->affiliate_id)
->where('is_active', true)
->lockForUpdate()
->first();
if (!$affiliate) {
return null;
}
// Check if this booking has already been counted
// We'll use a KeyValuePair to track which bookings have been counted
$kvpKey = 'affiliate_order_tracked_' . $affiliate->id;
if ($booking->hasAttribute($kvpKey)) {
return $affiliate; // Already counted
}
// Mark this booking as tracked
$keyValuePairObject = new \App\Classes\Modules\Accounts\DataTransferObjects\KeyValuePairObject(
$kvpKey,
'1'
);
$createsKeyValuePair = app()->make(\App\Classes\Modules\Accounts\Services\CreatesKeyValuePair::class);
$createsKeyValuePair->execute($booking, $keyValuePairObject);
// Increment orders count atomically
$affiliate->increment('orders_count');
return $affiliate;
});
} catch (\Exception $e) {
Log::error('Failed to track affiliate order', [
'booking_id' => $booking->id,
'error' => $e->getMessage()
]);
return null;
}
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Classes\Modules\Affiliate\Services;
use App\Models\Affiliate;
use App\Models\User;
use App\Models\UserAffiliate;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Http\Request;
class TracksAffiliateRegistration
{
/**
* Track when a user registers with an affiliate code
*
* @param User $user
* @return Affiliate|null
*/
public function execute(User $user): ?Affiliate
{
// Get tracking code from cookie
$request = request();
$code = $request->cookie(TracksAffiliateClick::COOKIE_NAME);
if (!$code || !$this->isValidCode($code)) {
return null;
}
$affiliate = Affiliate::where('code', $code)
->where('is_active', true)
->first();
if (!$affiliate) {
return null;
}
// Check if user is already linked to this affiliate
$userAffiliate = UserAffiliate::where('user_id', $user->id)
->where('affiliate_id', $affiliate->id)
->first();
if (!$userAffiliate) {
// Create new link
$userAffiliate = new UserAffiliate();
$userAffiliate->user_id = $user->id;
$userAffiliate->affiliate_id = $affiliate->id;
$userAffiliate->clicked_at = now(); // Approximate, we don't track exact click time
$userAffiliate->registered_at = now();
$userAffiliate->save();
// Increment registrations count
$affiliate->increment('registrations_count');
} else {
// Update registration time if not set
if (!$userAffiliate->registered_at) {
$userAffiliate->registered_at = now();
$userAffiliate->save();
$affiliate->increment('registrations_count');
}
}
// Clear the cookie after registration
Cookie::queue(Cookie::forget(TracksAffiliateClick::COOKIE_NAME));
return $affiliate;
}
/**
* Validate affiliate code format
*
* @param string $code
* @return bool
*/
private function isValidCode(string $code): bool
{
// Check length (max 255 chars based on database schema)
if (strlen($code) > 255 || strlen($code) < 1) {
return false;
}
// Only allow alphanumeric characters, hyphens, and underscores
return (bool) preg_match('/^[a-zA-Z0-9_-]+$/', $code);
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Classes\Modules\Affiliate\Services;
use App\Classes\General\Eloquent\AbstractUpdateRecord;
use App\Classes\Modules\Affiliate\DataTransferObjects\AffiliateObject;
use App\Models\Affiliate;
class UpdatesAffiliate extends AbstractUpdateRecord
{
/**
* @param Affiliate $model
* @param AffiliateObject $object
* @return \Illuminate\Database\Eloquent\Model
* @throws \App\Classes\Exceptions\MalformedRequestException
*/
public function execute(Affiliate $model, AffiliateObject $object)
{
// Don't update code if it already exists
$model->campaign_name = $object->getCampaignName();
$model->campaign_description = $object->getCampaignDescription();
$model->is_active = $object->getIsActive();
return $this->handler($model);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Classes\Modules\Affiliate\Services;
use App\Classes\Modules\Affiliate\DataTransferObjects\AffiliateSettingsObject;
use App\Models\KeyValuePair;
class UpdatesAffiliateSettings
{
const SETTING_KEY = 'affiliate_code_binding_days';
/**
* @param AffiliateSettingsObject $object
* @return array
*/
public function execute(AffiliateSettingsObject $object): array
{
$keyValuePair = KeyValuePair::where('key', self::SETTING_KEY)
->whereNull('owner_type')
->whereNull('owner_id')
->first();
if ($keyValuePair) {
$keyValuePair->value = (string) $object->getCodeBindingDays();
$keyValuePair->save();
} else {
$keyValuePair = new KeyValuePair();
$keyValuePair->key = self::SETTING_KEY;
$keyValuePair->value = (string) $object->getCodeBindingDays();
$keyValuePair->owner_type = null;
$keyValuePair->owner_id = null;
$keyValuePair->save();
}
return [
'code_binding_days' => $object->getCodeBindingDays()
];
}
}
@@ -15,6 +15,7 @@ use App\Classes\Modules\Vouchers\DataTransferObjects\CreateVoucherifyOrderObject
use App\Classes\Modules\Vouchers\Services\Voucherify\CreatesVoucherifyOrder;
use App\Classes\Modules\Vouchers\Services\CreatesVoucherEntityMapping;
use App\Classes\Modules\Vouchers\DataTransferObjects\VoucherEntityObject;
use App\Classes\Modules\Affiliate\Services\TracksAffiliateOrder;
use App\Classes\ValueObjects\Constants\BookingAttributeNames;
use App\Classes\ValueObjects\Constants\VoucherifyEntityType;
use App\Http\Resources\BookingResource;
@@ -60,6 +61,9 @@ class CreateBookingLogic extends AbstractControllerLogic
/** @var CreatesVoucherEntityMapping */
private $createsVoucherEntityMapping;
/** @var TracksAffiliateOrder */
private $tracksAffiliateOrder;
/**
* CreateBookingLogic constructor.
* @param CanCreateBooking $canCreateBooking
@@ -70,8 +74,9 @@ class CreateBookingLogic extends AbstractControllerLogic
* @param CheckMilestoneForRewardProcessor $checkMilestonesForRewardProcessor
* @param CreatesVoucherifyOrder $createsVoucherifyOrder
* @param CreatesVoucherEntityMapping $createsVoucherEntityMapping
* @param TracksAffiliateOrder $tracksAffiliateOrder
*/
public function __construct(CanCreateBooking $canCreateBooking, CreatesBooking $createsBooking, GeneratesBookingMarking $generatesBookingMarking, FetchesCompany $fetchesCompany, BookingToPerfexCRMProcessor $bookingToPerfexCRMProcessor, CheckMilestonesForRewardProcessor $checkMilestonesForRewardProcessor, CreatesVoucherifyOrder $createsVoucherifyOrder, CreatesVoucherEntityMapping $createsVoucherEntityMapping)
public function __construct(CanCreateBooking $canCreateBooking, CreatesBooking $createsBooking, GeneratesBookingMarking $generatesBookingMarking, FetchesCompany $fetchesCompany, BookingToPerfexCRMProcessor $bookingToPerfexCRMProcessor, CheckMilestonesForRewardProcessor $checkMilestonesForRewardProcessor, CreatesVoucherifyOrder $createsVoucherifyOrder, CreatesVoucherEntityMapping $createsVoucherEntityMapping, TracksAffiliateOrder $tracksAffiliateOrder)
{
$this->canCreateBooking = $canCreateBooking;
$this->createsBooking = $createsBooking;
@@ -81,6 +86,7 @@ class CreateBookingLogic extends AbstractControllerLogic
$this->checkMilestonesForRewardProcessor = $checkMilestonesForRewardProcessor;
$this->createsVoucherifyOrder = $createsVoucherifyOrder;
$this->createsVoucherEntityMapping = $createsVoucherEntityMapping;
$this->tracksAffiliateOrder = $tracksAffiliateOrder;
}
@@ -147,6 +153,9 @@ class CreateBookingLogic extends AbstractControllerLogic
$this->recordVoucherifyOrderInfo($voucherify_order_id, $booking);
$this->recordVoucherifyCustomerInfo($voucherify_customer_id, $user);
// Track affiliate order if user has an affiliate code
$this->tracksAffiliateOrder->execute($booking);
//cief todo: case study 5 voucherify
// $user = $company->employees()->first();
// $this->checkMilestonesForRewardProcessor->execute($user, [Milestones::MILESTONE_5]);
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Affiliate;
use App\Classes\Modules\Affiliate\ControllersLogic\CreateAffiliateLogic;
use App\Http\Requests\CreateAffiliateRequest;
use App\Models\Affiliate;
use Illuminate\Http\JsonResponse;
class CreateAffiliateController
{
/**
* @param CreateAffiliateRequest $request
* @param CreateAffiliateLogic $logic
* @return JsonResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function create(CreateAffiliateRequest $request, CreateAffiliateLogic $logic): JsonResponse {
$this->authorize('create', Affiliate::class);
return $logic->execute($request);
}
/**
* Authorize a given action for the current user.
*
* @param mixed $ability
* @param mixed|array $arguments
* @return \Illuminate\Auth\Access\Response
*
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function authorize($ability, $arguments = [])
{
return app(\Illuminate\Contracts\Auth\Access\Gate::class)->authorize($ability, $arguments);
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Affiliate;
use App\Classes\Modules\Affiliate\ControllersLogic\DeleteAffiliateLogic;
use App\Models\Affiliate;
use Illuminate\Http\JsonResponse;
class DeleteAffiliateController
{
/**
* @param int $id
* @param DeleteAffiliateLogic $logic
* @return JsonResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function delete(int $id, DeleteAffiliateLogic $logic): JsonResponse {
$affiliate = Affiliate::findOrFail($id);
$this->authorize('delete', $affiliate);
return $logic->execute($id);
}
/**
* Authorize a given action for the current user.
*
* @param mixed $ability
* @param mixed|array $arguments
* @return \Illuminate\Auth\Access\Response
*
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function authorize($ability, $arguments = [])
{
return app(\Illuminate\Contracts\Auth\Access\Gate::class)->authorize($ability, $arguments);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Affiliate;
use App\Classes\Modules\Affiliate\ControllersLogic\GetAffiliateSettingsLogic;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class GetAffiliateSettingsController
{
/**
* @param Request $request
* @param GetAffiliateSettingsLogic $logic
* @return JsonResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function get(Request $request, GetAffiliateSettingsLogic $logic): JsonResponse {
Gate::authorize('manageSettings', \App\Models\Affiliate::class);
return $logic->execute($request);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Affiliate;
use App\Classes\Modules\Affiliate\ControllersLogic\ListAffiliatesLogic;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class ListAffiliatesController
{
/**
* @param Request $request
* @param ListAffiliatesLogic $logic
* @return JsonResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function list(Request $request, ListAffiliatesLogic $logic): JsonResponse {
Gate::authorize('viewAny', \App\Models\Affiliate::class);
return $logic->execute($request);
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Affiliate;
use App\Classes\Modules\Affiliate\ControllersLogic\UpdateAffiliateLogic;
use App\Http\Requests\UpdateAffiliateRequest;
use App\Models\Affiliate;
use Illuminate\Http\JsonResponse;
class UpdateAffiliateController
{
/**
* @param UpdateAffiliateRequest $request
* @param int $id
* @param UpdateAffiliateLogic $logic
* @return JsonResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function update(UpdateAffiliateRequest $request, int $id, UpdateAffiliateLogic $logic): JsonResponse {
$affiliate = Affiliate::findOrFail($id);
$this->authorize('update', $affiliate);
return $logic->execute($request, $id);
}
/**
* Authorize a given action for the current user.
*
* @param mixed $ability
* @param mixed|array $arguments
* @return \Illuminate\Auth\Access\Response
*
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function authorize($ability, $arguments = [])
{
return app(\Illuminate\Contracts\Auth\Access\Gate::class)->authorize($ability, $arguments);
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Affiliate;
use App\Classes\Modules\Affiliate\ControllersLogic\UpdateAffiliateSettingsLogic;
use App\Http\Requests\UpdateAffiliateSettingsRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
class UpdateAffiliateSettingsController
{
/**
* @param UpdateAffiliateSettingsRequest $request
* @param UpdateAffiliateSettingsLogic $logic
* @return JsonResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function update(UpdateAffiliateSettingsRequest $request, UpdateAffiliateSettingsLogic $logic): JsonResponse {
// Check if user has permission to manage affiliate settings
Gate::authorize('manageSettings', \App\Models\Affiliate::class);
return $logic->execute($request);
}
}
@@ -4,7 +4,9 @@ namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Tymon\JWTAuth\Facades\JWTAuth;
class RedirectIfAuthenticated
{
@@ -18,10 +20,48 @@ class RedirectIfAuthenticated
*/
public function handle($request, Closure $next, $guard = null)
{
// Check session-based authentication
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
return redirect()->route('dashboard');
}
// Check JWT authentication (since tokens are in localStorage, check cookies/headers)
try {
$token =
$this->getBearerToken($request) ??
$request->get('token') ??
$request->cookie('access_token') ??
$request->cookie('user-token');
if ($token) {
try {
JWTAuth::setToken($token);
$user = JWTAuth::authenticate();
if ($user) {
return redirect()->route('dashboard');
}
} catch (\Exception $e) {
// Token invalid, continue
}
}
} catch (\Exception $e) {
// JWT check failed, continue
}
return $next($request);
}
/**
* Extract Bearer token from Authorization header
*/
private function getBearerToken(Request $request): ?string
{
if (!$request->hasHeader('Authorization')) {
return null;
}
return preg_match('/Bearer\s+(.*)$/i', $request->header('Authorization'), $matches)
? $matches[1]
: null;
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Log, Auth, Session};
use Tymon\JWTAuth\Facades\JWTAuth;
use App\Classes\Modules\Affiliate\Services\TracksAffiliateClick;
class TrackAffiliateClick
{
public function handle(Request $request, Closure $next)
{
$isAuthenticated = $this->checkWebAuth() || $this->checkJwtAuth($request);
if (!$isAuthenticated && $request->filled('tracking')) {
$trackingCode = $this->validateTrackingCode($request->get('tracking'));
if ($trackingCode) {
$this->trackClick($trackingCode);
}
}
return $next($request);
}
/**
* Validate and sanitize tracking code
*
* @param mixed $code
* @return string|null
*/
private function validateTrackingCode($code): ?string
{
if (!is_string($code)) {
return null;
}
// Trim whitespace
$code = trim($code);
// Check length (max 255 chars based on database schema)
if (strlen($code) > 255 || strlen($code) < 1) {
return null;
}
// Only allow alphanumeric characters, hyphens, and underscores
// This matches typical affiliate code formats
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $code)) {
return null;
}
return $code;
}
/**
* Check if user is authenticated via WEB session
*/
private function checkWebAuth(): bool
{
try {
if (Auth::guard('web')->check()) {
return true;
}
// fallback session check
foreach (Session::all() as $key => $value) {
if (strpos($key, 'login_web_') === 0 && !empty($value)) {
return true;
}
}
} catch (\Exception $e) {
Log::warning('AffiliateClickMiddleware: Web auth error', ['error' => $e->getMessage()]);
}
return false;
}
/**
* Check if user is authenticated via JWT token
*/
private function checkJwtAuth(Request $request): bool
{
try {
$token =
$this->getBearerToken($request) ??
$request->get('token') ??
$request->cookie('access_token') ??
$request->cookie('user-token');
if (!$token) {
return false;
}
JWTAuth::setToken($token);
$user = JWTAuth::authenticate();
if ($user) {
return true;
}
} catch (\Exception $e) {
Log::debug('AffiliateClickMiddleware: JWT invalid', ['error' => $e->getMessage()]);
}
return false;
}
/**
* Extract Bearer token
*/
private function getBearerToken(Request $request): ?string
{
if (!$request->hasHeader('Authorization')) return null;
return preg_match('/Bearer\s+(.*)$/i', $request->header('Authorization'), $m)
? $m[1]
: null;
}
/**
* Track affiliate click once per session
*/
private function trackClick(string $trackingCode): void
{
// Use a single session key with an array to prevent session bloat
$trackedClicks = Session::get('affiliate_clicks_tracked', []);
// Check if this code has already been tracked in this session
if (in_array($trackingCode, $trackedClicks)) {
return; // Already tracked
}
// Execute tracking
app(TracksAffiliateClick::class)->execute($trackingCode);
// Add to tracked list
$trackedClicks[] = $trackingCode;
// Limit array size to prevent DoS (keep only last 10 clicks)
if (count($trackedClicks) > 10) {
$trackedClicks = array_slice($trackedClicks, -10);
}
Session::put('affiliate_clicks_tracked', $trackedClicks);
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CreateAffiliateRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
// Authorization will be handled by middleware and policy
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'code' => [
'nullable',
'string',
'min:3',
'max:255',
'regex:/^[a-zA-Z0-9_-]+$/',
'unique:affiliates,code,NULL,id,deleted_at,NULL'
],
'campaign_name' => 'required|string|max:255',
'campaign_description' => 'nullable|string|max:65535',
'is_active' => 'nullable|boolean',
];
}
/**
* Get custom error messages for validation rules.
*
* @return array
*/
public function messages()
{
return [
'code.regex' => 'The affiliate code may only contain letters, numbers, hyphens, and underscores.',
'code.unique' => 'This affiliate code is already in use.',
'code.min' => 'The affiliate code must be at least 3 characters.',
'campaign_name.required' => 'A campaign name is required.',
'campaign_name.max' => 'The campaign name cannot exceed 255 characters.',
];
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateAffiliateRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'campaign_name' => 'required|string|max:255',
'campaign_description' => 'nullable|string|max:65535',
'is_active' => 'nullable|boolean',
];
}
/**
* Get custom error messages for validation rules.
*
* @return array
*/
public function messages()
{
return [
'campaign_name.required' => 'A campaign name is required.',
'campaign_name.max' => 'The campaign name cannot exceed 255 characters.',
];
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateAffiliateSettingsRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'code_binding_days' => 'required|integer|min:1|max:365',
];
}
/**
* Get custom error messages for validation rules.
*
* @return array
*/
public function messages()
{
return [
'code_binding_days.required' => 'The code binding days setting is required.',
'code_binding_days.integer' => 'The code binding days must be a number.',
'code_binding_days.min' => 'The code binding days must be at least 1 day.',
'code_binding_days.max' => 'The code binding days cannot exceed 365 days.',
];
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class AffiliateResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
public function toArray($request)
{
return [
'id' => $this->id,
'code' => $this->code,
'campaign_name' => $this->campaign_name,
'campaign_description' => $this->campaign_description,
'is_active' => (bool) $this->is_active,
'created_by' => $this->created_by,
'clicks_count' => $this->clicks_count,
'registrations_count' => $this->registrations_count,
'orders_count' => $this->orders_count,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Affiliate extends AbstractModel
{
use SoftDeletes;
protected $table = 'affiliates';
protected $dates = ['deleted_at'];
protected $fillable = [
'code',
'campaign_name',
'campaign_description',
'is_active',
'created_by',
'clicks_count',
'registrations_count',
'orders_count'
];
protected $casts = [
'is_active' => 'boolean',
'clicks_count' => 'integer',
'registrations_count' => 'integer',
'orders_count' => 'integer'
];
/**
* @return BelongsTo
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* @return BelongsToMany
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'user_affiliates', 'affiliate_id', 'user_id')
->withPivot('clicked_at', 'registered_at')
->withTimestamps();
}
/**
* Generate the affiliate tracking link
*
* @return string
*/
public function getTrackingLinkAttribute(): string
{
$baseUrl = config('app.url');
return $baseUrl . '/signup?tracking=' . $this->code;
}
}
+10
View File
@@ -121,4 +121,14 @@ class User extends AbstractModel implements
{
return $this->morphMany(KeyValuePair::class, 'owner');
}
/**
* @return BelongsToMany
*/
public function affiliates(): BelongsToMany
{
return $this->belongsToMany(Affiliate::class, 'user_affiliates', 'user_id', 'affiliate_id')
->withPivot('clicked_at', 'registered_at')
->withTimestamps();
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserAffiliate extends AbstractModel
{
protected $table = 'user_affiliates';
protected $fillable = [
'user_id',
'affiliate_id',
'clicked_at',
'registered_at'
];
protected $dates = [
'clicked_at',
'registered_at'
];
/**
* @return BelongsTo
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @return BelongsTo
*/
public function affiliate(): BelongsTo
{
return $this->belongsTo(Affiliate::class);
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\Affiliate;
use App\Classes\ValueObjects\Constants\RoleTypes;
use Illuminate\Auth\Access\HandlesAuthorization;
class AffiliatePolicy
{
use HandlesAuthorization;
/**
* Check if user is admin
*
* @param User $user
* @return bool
*/
private function isAdmin(User $user): bool
{
return in_array($user->type, RoleTypes::ADMIN_ROLES);
}
/**
* Determine whether the user can view any affiliates.
*
* @param User $user
* @return bool
*/
public function viewAny(User $user)
{
// Only admins can view affiliates list
return $this->isAdmin($user);
}
/**
* Determine whether the user can view the affiliate.
*
* @param User $user
* @param Affiliate $affiliate
* @return bool
*/
public function view(User $user, Affiliate $affiliate)
{
return $this->isAdmin($user);
}
/**
* Determine whether the user can create affiliates.
*
* @param User $user
* @return bool
*/
public function create(User $user)
{
return $this->isAdmin($user);
}
/**
* Determine whether the user can update the affiliate.
*
* @param User $user
* @param Affiliate $affiliate
* @return bool
*/
public function update(User $user, Affiliate $affiliate)
{
return $this->isAdmin($user);
}
/**
* Determine whether the user can delete the affiliate.
*
* @param User $user
* @param Affiliate $affiliate
* @return bool
*/
public function delete(User $user, Affiliate $affiliate)
{
return $this->isAdmin($user);
}
/**
* Determine whether the user can manage affiliate settings.
*
* @param User $user
* @return bool
*/
public function manageSettings(User $user)
{
return $this->isAdmin($user);
}
}
+1
View File
@@ -13,6 +13,7 @@ class AuthServiceProvider extends ServiceProvider
*/
protected $policies = [
// 'App\Model' => 'App\Policies\ModelPolicy',
\App\Models\Affiliate::class => \App\Policies\AffiliatePolicy::class,
];
/**
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateAffiliatesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('affiliates', function (Blueprint $table) {
$table->id();
$table->string('code')->unique();
$table->string('campaign_name');
$table->text('campaign_description')->nullable();
$table->boolean('is_active')->default(true);
$table->unsignedBigInteger('created_by')->nullable();
$table->unsignedInteger('clicks_count')->default(0);
$table->unsignedInteger('registrations_count')->default(0);
$table->unsignedInteger('orders_count')->default(0);
$table->timestamps();
$table->softDeletes();
$table->index('code');
$table->index('is_active');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('affiliates');
}
}
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateUserAffiliatesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('user_affiliates', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id');
$table->unsignedBigInteger('affiliate_id');
$table->timestamp('clicked_at')->nullable();
$table->timestamp('registered_at')->nullable();
$table->timestamps();
$table->unique(['user_id', 'affiliate_id']);
$table->index('user_id');
$table->index('affiliate_id');
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->foreign('affiliate_id')->references('id')->on('affiliates')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_affiliates');
}
}
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddForeignKeyToAffiliatesCreatedBy extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('affiliates', function (Blueprint $table) {
// Add foreign key constraint to created_by column
$table->foreign('created_by')
->references('id')
->on('users')
->onDelete('set null'); // Set to null if user is deleted, preserving affiliate record
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('affiliates', function (Blueprint $table) {
// Drop the foreign key constraint
$table->dropForeign(['created_by']);
});
}
}
@@ -0,0 +1,126 @@
<template>
<div class="row">
<div class="col">
<loading-component style="height: 50px; top: 0;" key="1" color="success" v-show="$store.getters.isLoading(section)"></loading-component>
<div class="row" v-show="!$store.getters.isLoading(section)">
<div class="col">
<div class="row m-b-20 parentContainer align-items-center p-b-10 b-b" style="border-color: #e0e0e0 !important;">
<div class="col">
<validation-wrapper-component :validator="$v.parameters.code_binding_days">
<label>Code Binding Days</label>
<input
type="number"
class="form-control"
v-model.number="parameters.code_binding_days"
min="1"
max="365"
placeholder="e.g., 30">
</validation-wrapper-component>
</div>
<div class="col-auto">
<div class="btn btn-sm btn-success b-rad-none" @click="submitForm()">
<i class="fa fa-save m-r-5"></i>Save
</div>
</div>
</div>
<div class="row m-b-5 animate__animated animate__fadeInUpBig animate__fast" v-if="error">
<div class="col">
<small class="bold fs-10 text-danger">{{error}}</small>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { required, minValue, maxValue, integer } from "vuelidate/lib/validators";
import request from '../../../general/mixins/request';
export default {
props: {
section: {
type: String,
default: "affiliateSettingsSection"
}
},
data() {
return {
error: '',
parameters: {
code_binding_days: 30
}
}
},
validations: {
parameters: {
code_binding_days: {
required,
integer,
minValue: minValue(1),
maxValue: maxValue(365)
}
}
},
created() {
this.loadSettings();
},
methods: {
loadSettings() {
this.$store.dispatch('toggleLoading', {name: this.section, status: true});
this.$store.dispatch('crudRequest', {
endpoint: this.route('api.affiliate.settings.get'),
method: 'get'
}).then(response => {
this.$store.dispatch('toggleLoading', {name: this.section, status: false});
if (response.ok) {
response.json().then(data => {
if (data && data.code_binding_days !== undefined) {
this.parameters.code_binding_days = parseInt(data.code_binding_days);
}
});
} else {
response.json().then(data => {
this.error = data.message || 'Failed to load settings';
});
}
}).catch(error => {
this.$store.dispatch('toggleLoading', {name: this.section, status: false});
this.error = 'Failed to load settings';
});
},
submitForm() {
if (!this.validate()) {
return;
}
this.error = '';
this.submit(
this.route('api.affiliate.settings.update'),
'post',
this.section,
true,
true
);
},
validate() {
this.$v.$touch();
if (this.$v.$invalid) {
return false;
}
return true;
},
successHandler(response, section) {
this.$store.dispatch('createNotification', {
title: 'Success',
message: 'Affiliate settings updated successfully',
type: 'success'
});
},
errorHandler(response, statusCode, section) {
this.error = response.message || 'Failed to update settings';
}
},
mixins: [request]
}
</script>
@@ -0,0 +1,85 @@
<template>
<div class="row m-b-10 parentContainer align-items-center b-b p-b-10 p-t-5" style="border-color: #e0e0e0 !important;">
<div class="col-2">
<div class="font-heading fs-10 bold">{{item.campaign_name}}</div>
<div class="font-heading fs-9 text-info">{{item.code}}</div>
</div>
<div class="col-4">
<div class="input-group input-group-sm">
<span class="copyLink font-heading fs-10 text-primary pointer" @click="copyLink">Copy link</span>
</div>
<div class="font-heading fs-9 hint-text" v-if="item.campaign_description" :title="item.campaign_description">
{{ item.campaign_description.length > 30 ? item.campaign_description.substring(0, 30) + '...' : item.campaign_description }}
</div>
</div>
<div class="col-3">
<div class="row">
<div class="col-4">
<div class="font-heading fs-10 bold">{{item.clicks_count || 0}}</div>
</div>
<div class="col-4">
<div class="font-heading fs-10 bold">{{item.registrations_count || 0}}</div>
</div>
<div class="col-4">
<div class="font-heading fs-10 bold">{{item.orders_count || 0}}</div>
</div>
</div>
</div>
<div class="col-1">
<span class="badge" :class="(item.is_active === true || item.is_active === 1 || item.is_active === '1' || item.is_active === 'true') ? 'badge-success' : 'badge-secondary'">
{{ (item.is_active === true || item.is_active === 1 || item.is_active === '1' || item.is_active === 'true') ? 'Active' : 'Inactive' }}
</span>
</div>
<div class="col-auto">
<button class="btn btn-xs btn-primary b-rad-none requestModal" data-type="createModal">
<i class="fa fa-pencil"></i>
</button>
<button class="btn btn-xs btn-outline-danger b-rad-none m-l-5 requestModal" data-type="deleteAffiliate">
<i class="fa fa-times"></i>
</button>
<modal-component class="animate__animated animate__fast animate__fadeIn" styleType="fill-in" type="deleteAffiliate">
<delete-affiliate-form-component :data="item" :section="section" class="text-center"></delete-affiliate-form-component>
</modal-component>
<modal-form-component size="large" section="affiliatesSection">
<template slot="form" slot-scope="{section}">
<affiliate-form-component :data="data" :section="section"></affiliate-form-component>
</template>
</modal-form-component>
</div>
</div>
</template>
<script>
import componentHandler from '../../../general/mixins/componentHandler';
export default {
props: {
section: {
default: 'affiliatesSection'
}
},
computed: {
trackingLink() {
if (this.item && this.item.code) {
const baseUrl = window.location.origin;
return `${baseUrl}/signup?tracking=${this.item.code}`;
}
return '';
}
},
methods: {
copyLink() {
if (this.trackingLink) {
navigator.clipboard.writeText(this.trackingLink).then(() => {
this.$store.dispatch('createNotification', {
title: 'Success',
message: 'Tracking link copied to clipboard',
type: 'success'
});
});
}
}
},
mixins: [componentHandler]
}
</script>
@@ -0,0 +1,239 @@
<template>
<div class="row">
<div class="col">
<loading-component style="height: 50px; top: 0;" key="1" color="success" v-show="$store.getters.isLoading(section)"></loading-component>
<div class="row" v-show="!$store.getters.isLoading(section)">
<div class="col">
<div class="row m-b-20">
<div class="col">
<h6 class="all-caps m-b-5 bold no-margin">{{ parameters.id ? 'Update Affiliate Code' : 'Create Affiliate Code' }}</h6>
</div>
</div>
<div class="row m-b-5 animate__animated animate__fadeInUpBig animate__fast" v-if="error">
<div class="col">
<small class="bold fs-10 text-danger">{{error}}</small>
</div>
</div>
<div class="row m-b-10">
<div class="col">
<input type="hidden" class="form-control" v-if="parameters.id" v-model="parameters.id">
<validation-wrapper-component :validator="$v.parameters.campaign_name">
<label>Campaign Name</label>
<input type="text" class="form-control" v-model="parameters.campaign_name" placeholder="e.g., Summer Campaign 2024">
</validation-wrapper-component>
</div>
</div>
<div class="row m-b-10">
<div class="col">
<validation-wrapper-component :validator="$v.parameters.code">
<label>Code</label>
<input type="text" class="form-control" v-model="parameters.code" placeholder="e.g., SUMMER2024" :disabled="!!parameters.id">
<small class="hint-text" v-if="!parameters.id">Leave empty to auto-generate a unique code</small>
<small class="hint-text" v-if="parameters.id">Code cannot be changed after creation</small>
</validation-wrapper-component>
</div>
</div>
<div class="row m-b-10">
<div class="col">
<validation-wrapper-component :validator="$v.parameters.campaign_description">
<label>Campaign Description</label>
<textarea class="form-control" v-model="parameters.campaign_description" rows="3" placeholder="Optional description for this affiliate campaign"></textarea>
</validation-wrapper-component>
</div>
</div>
<div class="row m-b-10">
<div class="col">
<label>Is Active</label>
<div class="row m-t-5">
<div class="col-auto">
<div class="pointer" @click="parameters.is_active = !parameters.is_active">
<div class="row align-items-center">
<div class="col-auto p-r-10">
<div class="icon-thumbnail fs-12 icon-30 btn-rounded" :class="[{'bg-success': parameters.is_active}, {'bg-master-light': !parameters.is_active}]">
<i class="fa fa-check fs-12 text-white" v-if="parameters.is_active"></i>
</div>
</div>
<div class="col p-l-0">
<div class="font-heading fs-10" :class="[{'text-success': parameters.is_active}, {'text-muted': !parameters.is_active}]">
{{ parameters.is_active ? 'Active' : 'Inactive' }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row m-b-20" v-if="parameters.id && trackingLink">
<div class="col">
<label>Tracking Link</label>
<div class="input-group">
<input type="text" class="form-control" :value="trackingLink" readonly>
<div class="input-group-append">
<button class="btn btn-sm btn-default" @click="copyLink" type="button">
<i class="fa fa-copy"></i> Copy
</button>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col p-r-5">
<div class="btn btn-sm btn-default bg-master-lightest btn-block b-rad-none" data-dismiss="modal">Cancel</div>
</div>
<div class="col p-l-5">
<div class="btn btn-sm btn-success btn-block b-rad-none" @click="submitForm()">{{ parameters.id ? 'Update' : 'Create' }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import ModalFormHandler from '../../../general/mixins/modalFormHandler';
import { required } from "vuelidate/lib/validators";
export default {
props: {
section: {
type: String,
required: true
},
data: {
type: Object,
default: null
},
},
data() {
return {
error: '',
parameters: {
id: null,
campaign_name: '',
code: '',
campaign_description: '',
is_active: true
}
}
},
computed: {
trackingLink() {
if (this.parameters.id && this.parameters.code) {
const baseUrl = window.location.origin;
return `${baseUrl}/signup?tracking=${this.parameters.code}`;
}
return null;
}
},
created() {
// Override formHandler mixin's created() to create a copy instead of reference
if (this.data) {
// Create a deep copy to avoid modifying the original data object
this.parameters = JSON.parse(JSON.stringify(this.data));
// Normalize is_active to boolean
if (this.parameters.is_active !== undefined) {
if (this.parameters.is_active === false || this.parameters.is_active === 'false' || this.parameters.is_active === 0 || this.parameters.is_active === '0') {
this.parameters.is_active = false;
} else if (this.parameters.is_active === true || this.parameters.is_active === 'true' || this.parameters.is_active === 1 || this.parameters.is_active === '1') {
this.parameters.is_active = true;
}
} else {
this.parameters.is_active = true;
}
}
},
watch: {
data: {
handler(newData) {
// Create a deep copy to avoid modifying the original data object
if (newData) {
this.parameters = JSON.parse(JSON.stringify(newData));
// Normalize is_active to boolean
if (this.parameters.is_active !== undefined) {
if (this.parameters.is_active === false || this.parameters.is_active === 'false' || this.parameters.is_active === 0 || this.parameters.is_active === '0') {
this.parameters.is_active = false;
} else if (this.parameters.is_active === true || this.parameters.is_active === 'true' || this.parameters.is_active === 1 || this.parameters.is_active === '1') {
this.parameters.is_active = true;
}
} else {
this.parameters.is_active = true;
}
} else {
// For new records, reset to defaults
this.parameters = {
id: null,
campaign_name: '',
code: '',
campaign_description: '',
is_active: true
};
}
},
immediate: true,
deep: true
}
},
validations: {
parameters: {
campaign_name: { required },
code: {},
campaign_description: {},
is_active: {}
}
},
methods: {
successHandler() {
// Only refresh the list on successful create/update
this.closeModal();
// Set loading to false
this.isLoading = false;
// Reset form
this.resetForm();
// Reload the affiliates list only on success
this.$store.dispatch('reloadList', {'name': 'affiliatesSection'});
},
errorHandler(error) {
// Don't reload the list on error - just handle the error message
this.isLoading = false;
this.errorMessageHandler(error);
},
resetForm() {
if (this.$v) {
this.$v.$reset();
}
if (!this.data) {
this.parameters = {
id: null,
campaign_name: '',
code: '',
campaign_description: '',
is_active: true
};
}
},
submitForm() {
if (!this.validate()) {
return;
}
const url = this.parameters.id
? this.route('api.affiliate.update', this.parameters.id)
: this.route('api.affiliate.create');
const method = this.parameters.id ? 'put' : 'post';
this.submit(url, method, this.section, true, false);
},
copyLink() {
if (this.trackingLink) {
navigator.clipboard.writeText(this.trackingLink).then(() => {
this.$store.dispatch('createNotification', {
title: 'Success',
message: 'Tracking link copied to clipboard',
type: 'success'
});
});
}
}
},
mixins: [ModalFormHandler]
}
</script>
@@ -0,0 +1,46 @@
<template>
<div class="row">
<div class="col">
<div class="row m-b-20">
<div class="col">
<h6 class="all-caps m-b-5 bold no-margin">Delete Affiliate Code</h6>
</div>
</div>
<div class="row m-b-20">
<div class="col">
<p>Are you sure you want to delete the affiliate code <strong>{{data.campaign_name}}</strong> ({{data.code}})?</p>
</div>
</div>
<div class="row">
<div class="col p-r-5">
<div class="btn btn-sm btn-default bg-master-lightest btn-block b-rad-none" data-dismiss="modal">Cancel</div>
</div>
<div class="col p-l-5">
<div class="btn btn-sm btn-danger btn-block b-rad-none" @click="submitForm()">Delete</div>
</div>
</div>
</div>
</div>
</template>
<script>
import ModalFormHandler from '../../../general/mixins/modalFormHandler';
export default {
props: {
section: {
type: String,
required: true
},
data: {
type: Object,
required: true
}
},
methods: {
submitForm() {
this.submit(this.route('api.affiliate.delete', this.data.id), 'delete', this.section, true, false);
}
},
mixins: [ModalFormHandler]
}
</script>
+19 -2
View File
@@ -5,8 +5,25 @@ export default {
(this.$store.getters.isAuthenticated && !this.isProtectedRoute()&& !this.isWithTokenRoute()) ? window.location.href = this.route('dashboard') : '';
},
isProtectedRoute(){
const unprotectedRoutes = [this.route('login'), this.route('signup'), this.route('account.email.verification')];
return !unprotectedRoutes.includes(window.location.href);
// Compare pathname only (without query parameters) to handle affiliate tracking links
const currentPath = window.location.pathname;
// Define unprotected routes as pathnames directly
// This ensures affiliate tracking links like /signup?tracking=xxx work correctly
const unprotectedPaths = [
'/', // login route
'/signup', // signup route
'/account/email/verification' // email verification route (may have token param)
];
// Check if current path matches any unprotected path
// For email verification, check if path starts with the base path
if (currentPath.startsWith('/account/email/verification')) {
return false; // It's an email verification route (unprotected)
}
// Check exact matches for other routes
return !unprotectedPaths.includes(currentPath);
},
isWithTokenRoute(){
return window.location.href.includes(this.route('account.password.reset'));
@@ -1,6 +1,17 @@
@extends('layouts.base_login')
@section('inner_content')
<script>
// Immediate redirect for logged-in users (before Vue loads)
// Since JWT tokens are in localStorage, server can't detect them
(function() {
const userToken = localStorage.getItem('user-token');
if (userToken && userToken.trim()) {
// User is logged in, redirect to dashboard
window.location.href = '{{ route("dashboard") }}';
}
})();
</script>
<transition-component group enter-class="animate__animated animate__fadeInRightBig animate__faster" leave-class="animate__animated animate__fadeOutRightBig">
<div class="row" key="1">
<div class="col-12 col-md p-l-0 p-r-0 p-t-15 p-b-15">
+106
View File
@@ -264,6 +264,31 @@
</div>
</div>
</div>
<template v-if="$store.getters.isAdmin">
<div class="row m-b-5">
<div class="col">
<div class="row">
<div class="col">
<div class="row">
<div class="col bg-master-light tabButton" tab-name="affiliateSettings">
<div class="row align-items-center">
<div class="col-auto p-t-10 p-b-10 b-r b-grey">
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
width="35" height="35"
viewBox="0 0 172 172"
style=" fill:#000000;"><defs><linearGradient x1="86" y1="28.55469" x2="86" y2="143.10938" gradientUnits="userSpaceOnUse" id="color-4_affiliate_gr4"><stop offset="0" stop-color="#009add"></stop><stop offset="1" stop-color="#00baa4"></stop></linearGradient></defs><g fill="none" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><path d="M0,172v-172h172v172z" fill="none"></path><g><path d="M86,21.5c-35.56369,0 -64.5,28.93631 -64.5,64.5c0,35.56369 28.93631,64.5 64.5,64.5c35.56369,0 64.5,-28.93631 64.5,-64.5c0,-35.56369 -28.93631,-64.5 -64.5,-64.5zM86,32.25c29.63631,0 53.75,24.11369 53.75,53.75c0,29.63631 -24.11369,53.75 -53.75,53.75c-29.63631,0 -53.75,-24.11369 -53.75,-53.75c0,-29.63631 24.11369,-53.75 53.75,-53.75zM86,48.375c-20.81887,0 -37.625,16.80613 -37.625,37.625c0,20.81887 16.80613,37.625 37.625,37.625c20.81887,0 37.625,-16.80613 37.625,-37.625c0,-20.81887 -16.80613,-37.625 -37.625,-37.625zM86,59.125c14.81887,0 26.875,12.05613 26.875,26.875c0,14.81887 -12.05613,26.875 -26.875,26.875c-14.81887,0 -26.875,-12.05613 -26.875,-26.875c0,-14.81887 12.05613,-26.875 26.875,-26.875z" fill="url(#color-4_affiliate_gr4)"></path></g></g></svg>
</div>
<div class="col">
<div class="fs-12 m-t-5 all-caps m-b-5">Affiliate</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<div class="row m-b-5" v-if="$store.getters.isAdmin">
<div class="col">
<div class="row">
@@ -707,6 +732,87 @@
</div>
</div>
</div>
<template v-if="$store.getters.isSuperAdmin">
<div class="row tabsContainer hide tabContent" tab-name="affiliateSettings">
<div class="col">
<loading-component style="height: 200px; top: 0;" key="1" color="success" v-show="$store.getters.isLoading('affiliateSettingsSection')"></loading-component>
<div class="row" v-show="!$store.getters.isLoading('affiliateSettingsSection')">
<div class="col">
<div class="row p-b-5 m-b-20 b-b b-grey align-items-center parentContainer">
<div class="col">
<div class="font-heading all-caps fs-10 hint-text">
Settings
</div>
</div>
</div>
<div class="row m-b-20">
<div class="col">
<affiliate-settings-component section="affiliateSettingsSection"></affiliate-settings-component>
</div>
</div>
</div>
</div>
<div class="row m-t-30" v-show="!$store.getters.isLoading('affiliateSettingsSection')">
<div class="col">
<div class="row p-b-5 m-b-20 b-b b-grey align-items-center parentContainer">
<div class="col">
<div class="font-heading all-caps fs-10 hint-text">
Affiliate Codes
</div>
</div>
<div class="col-auto">
<button class="btn btn-xs btn-primary b-rad-none requestModal" data-type="createModal">
<i class="fa fa-plus m-r-5"></i>
New Code
</button>
<modal-form-component section="affiliatesSection">
<template slot="form" slot-scope="{section}">
<affiliate-form-component :section="section"></affiliate-form-component>
</template>
</modal-form-component>
</div>
</div>
<div class="row m-b-10 b-b p-b-5" style="border-color: #e0e0e0 !important;">
<div class="col-2">
<div class="font-heading all-caps fs-9 muted">Campaign</div>
</div>
<div class="col-4">
<div class="font-heading all-caps fs-9 muted">Tracking Link</div>
</div>
<div class="col-3">
<div class="row">
<div class="col-4">
<div class="font-heading all-caps fs-9 muted">Clicks</div>
</div>
<div class="col-4">
<div class="font-heading all-caps fs-9 muted">Registers</div>
</div>
<div class="col-4">
<div class="font-heading all-caps fs-9 muted">Orders</div>
</div>
</div>
</div>
<div class="col-1">
<div class="font-heading all-caps fs-9 muted">Status</div>
</div>
<div class="col-auto">
<div class="font-heading all-caps fs-9 muted">Actions</div>
</div>
</div>
<div class="row m-b-20">
<div class="col">
<list-component section="affiliatesSection" :endpoint="route('api.affiliate.list')">
<template slot="list" slot-scope="{data}">
<affiliate-single-item-component section="affiliatesSection" :data="data"></affiliate-single-item-component>
</template>
</list-component>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<div class="row tabsContainer hide tabContent" tab-name="rewardsSegment" v-if="$store.getters.isAdmin">
<div class="col">
<loading-component style="height: 200px; top: 0;" key="1" color="success" v-show="$store.getters.isLoading('rewardsSection')"></loading-component>
+14
View File
@@ -0,0 +1,14 @@
<?php
use Illuminate\Support\Facades\Route;
Route::group(['prefix' => 'affiliate', 'as' => 'affiliate.'], function () {
Route::get('/settings', 'Affiliate\GetAffiliateSettingsController@get')->name('settings.get');
Route::post('/settings', 'Affiliate\UpdateAffiliateSettingsController@update')->name('settings.update');
Route::get('/list', 'Affiliate\ListAffiliatesController@list')->name('list');
Route::post('/create', 'Affiliate\CreateAffiliateController@create')->name('create');
Route::put('/update/{id}', 'Affiliate\UpdateAffiliateController@update')->name('update');
Route::delete('/delete/{id}', 'Affiliate\DeleteAffiliateController@delete')->name('delete');
});
+2
View File
@@ -77,6 +77,8 @@ Route::group(['middleware' => 'api', 'prefix' => 'v1', 'as' => 'api.'], function
require __DIR__ . '/export.php';
require __DIR__ . '/setting.php';
require __DIR__ . '/affiliate.php';
// require __DIR__ . '/rate.php';
// require __DIR__ . '/receipt.php';
+4 -1
View File
@@ -59,7 +59,10 @@ Route::get('', function () {
Route::get('/signup', function () {
return view('pages.accounts.sign_up');
})->name('signup');
})->middleware([
\App\Http\Middleware\TrackAffiliateClick::class,
'guest:web'
])->name('signup');
Route::get('/account/email/verification/{token}', function ($token) {
return view('pages.accounts.email_verified', ['token' => $token]);