diff --git a/app/Classes/General/Eloquent/Filters/AffiliateSearch.php b/app/Classes/General/Eloquent/Filters/AffiliateSearch.php
new file mode 100644
index 00000000..7e1f5b51
--- /dev/null
+++ b/app/Classes/General/Eloquent/Filters/AffiliateSearch.php
@@ -0,0 +1,37 @@
+where(function ($query) use ($sanitized) {
+ $query->where('campaign_name', 'LIKE', '%' . $sanitized . '%')
+ ->orWhere('code', 'LIKE', '%' . $sanitized . '%')
+ ->orWhere('campaign_description', 'LIKE', '%' . $sanitized . '%');
+ });
+ }
+
+}
+
diff --git a/app/Classes/Modules/Accounts/ControllersLogic/CreateCustomerLogic.php b/app/Classes/Modules/Accounts/ControllersLogic/CreateCustomerLogic.php
index d4da4d34..d681d182 100644
--- a/app/Classes/Modules/Accounts/ControllersLogic/CreateCustomerLogic.php
+++ b/app/Classes/Modules/Accounts/ControllersLogic/CreateCustomerLogic.php
@@ -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, $request->get('tracking'));
+
return $this->response($this->authenticationProcessor->execute($request, false));
}
diff --git a/app/Classes/Modules/Affiliate/ControllersLogic/CreateAffiliateLogic.php b/app/Classes/Modules/Affiliate/ControllersLogic/CreateAffiliateLogic.php
new file mode 100644
index 00000000..b6470ffe
--- /dev/null
+++ b/app/Classes/Modules/Affiliate/ControllersLogic/CreateAffiliateLogic.php
@@ -0,0 +1,80 @@
+ 'Created Affiliate',
+ 'message' => 'You have successfully created a new Affiliate Code'
+ ];
+ }
+
+ /** @var CanCreateAffiliate */
+ private $canCreateAffiliate;
+
+ /** @var CreatesAffiliate */
+ private $createsAffiliate;
+
+ /**
+ * CreateAffiliateLogic constructor.
+ * @param CanCreateAffiliate $canCreateAffiliate
+ * @param CreatesAffiliate $createsAffiliate
+ */
+ public function __construct(
+ CanCreateAffiliate $canCreateAffiliate,
+ CreatesAffiliate $createsAffiliate
+ )
+ {
+ $this->canCreateAffiliate = $canCreateAffiliate;
+ $this->createsAffiliate = $createsAffiliate;
+ }
+
+ /**
+ * @param Request $request
+ * @return JsonResponse
+ * @throws \App\Classes\Exceptions\AccessForbiddenException
+ * @throws \App\Classes\Exceptions\RequestValidationException
+ */
+ public function logic(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
+ );
+
+ // Validate the affiliate object
+ $this->canCreateAffiliate->passes($object);
+
+ // Create the affiliate
+ $affiliate = $this->createsAffiliate->execute($object);
+
+ return $this->resourceResponse(new AffiliateResource($affiliate));
+ }
+}
+
diff --git a/app/Classes/Modules/Affiliate/ControllersLogic/DeleteAffiliateLogic.php b/app/Classes/Modules/Affiliate/ControllersLogic/DeleteAffiliateLogic.php
new file mode 100644
index 00000000..74581ace
--- /dev/null
+++ b/app/Classes/Modules/Affiliate/ControllersLogic/DeleteAffiliateLogic.php
@@ -0,0 +1,36 @@
+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'
+ ]);
+ }
+}
+
diff --git a/app/Classes/Modules/Affiliate/ControllersLogic/GetAffiliateSettingsLogic.php b/app/Classes/Modules/Affiliate/ControllersLogic/GetAffiliateSettingsLogic.php
new file mode 100644
index 00000000..8bc6f2bc
--- /dev/null
+++ b/app/Classes/Modules/Affiliate/ControllersLogic/GetAffiliateSettingsLogic.php
@@ -0,0 +1,33 @@
+getsAffiliateSettings = $getsAffiliateSettings;
+ }
+
+ /**
+ * @param Request $request
+ * @return JsonResponse
+ */
+ public function execute(Request $request): JsonResponse
+ {
+ $settings = $this->getsAffiliateSettings->execute();
+
+ return response()->json($settings);
+ }
+}
+
diff --git a/app/Classes/Modules/Affiliate/ControllersLogic/ListAffiliatesLogic.php b/app/Classes/Modules/Affiliate/ControllersLogic/ListAffiliatesLogic.php
new file mode 100644
index 00000000..b03f99ca
--- /dev/null
+++ b/app/Classes/Modules/Affiliate/ControllersLogic/ListAffiliatesLogic.php
@@ -0,0 +1,45 @@
+ '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));
+ }
+}
+
diff --git a/app/Classes/Modules/Affiliate/ControllersLogic/UpdateAffiliateLogic.php b/app/Classes/Modules/Affiliate/ControllersLogic/UpdateAffiliateLogic.php
new file mode 100644
index 00000000..faf9f2fb
--- /dev/null
+++ b/app/Classes/Modules/Affiliate/ControllersLogic/UpdateAffiliateLogic.php
@@ -0,0 +1,55 @@
+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
+ ]);
+ }
+}
+
diff --git a/app/Classes/Modules/Affiliate/ControllersLogic/UpdateAffiliateSettingsLogic.php b/app/Classes/Modules/Affiliate/ControllersLogic/UpdateAffiliateSettingsLogic.php
new file mode 100644
index 00000000..9fd49a4d
--- /dev/null
+++ b/app/Classes/Modules/Affiliate/ControllersLogic/UpdateAffiliateSettingsLogic.php
@@ -0,0 +1,41 @@
+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
+ ]);
+ }
+}
+
diff --git a/app/Classes/Modules/Affiliate/DataTransferObjects/AffiliateObject.php b/app/Classes/Modules/Affiliate/DataTransferObjects/AffiliateObject.php
new file mode 100644
index 00000000..160c9c41
--- /dev/null
+++ b/app/Classes/Modules/Affiliate/DataTransferObjects/AffiliateObject.php
@@ -0,0 +1,68 @@
+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;
+ }
+}
+
diff --git a/app/Classes/Modules/Affiliate/DataTransferObjects/AffiliateSettingsObject.php b/app/Classes/Modules/Affiliate/DataTransferObjects/AffiliateSettingsObject.php
new file mode 100644
index 00000000..c8042d0b
--- /dev/null
+++ b/app/Classes/Modules/Affiliate/DataTransferObjects/AffiliateSettingsObject.php
@@ -0,0 +1,29 @@
+codeBindingDays = $codeBindingDays;
+ }
+
+ /**
+ * @return int
+ */
+ public function getCodeBindingDays(): int
+ {
+ return $this->codeBindingDays;
+ }
+}
+
diff --git a/app/Classes/Modules/Affiliate/Services/CreatesAffiliate.php b/app/Classes/Modules/Affiliate/Services/CreatesAffiliate.php
new file mode 100644
index 00000000..6a565f4b
--- /dev/null
+++ b/app/Classes/Modules/Affiliate/Services/CreatesAffiliate.php
@@ -0,0 +1,68 @@
+getCode();
+
+ // Auto-generate code if not provided
+ if (empty($code)) {
+ $code = $this->generateUniqueCode();
+ } else {
+ // Uppercase the code for consistency
+ $code = strtoupper($code);
+ }
+
+ $model = new Affiliate();
+ $model->code = strtoupper($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;
+ }
+}
+
diff --git a/app/Classes/Modules/Affiliate/Services/DeletesAffiliate.php b/app/Classes/Modules/Affiliate/Services/DeletesAffiliate.php
new file mode 100644
index 00000000..de882c3a
--- /dev/null
+++ b/app/Classes/Modules/Affiliate/Services/DeletesAffiliate.php
@@ -0,0 +1,18 @@
+delete();
+ }
+}
+
diff --git a/app/Classes/Modules/Affiliate/Services/GetsAffiliateSettings.php b/app/Classes/Modules/Affiliate/Services/GetsAffiliateSettings.php
new file mode 100644
index 00000000..9f616229
--- /dev/null
+++ b/app/Classes/Modules/Affiliate/Services/GetsAffiliateSettings.php
@@ -0,0 +1,29 @@
+whereNull('owner_type')
+ ->whereNull('owner_id')
+ ->first();
+
+ $codeBindingDays = $keyValuePair ? (int) $keyValuePair->value : self::DEFAULT_VALUE;
+
+ return [
+ 'code_binding_days' => $codeBindingDays
+ ];
+ }
+}
+
diff --git a/app/Classes/Modules/Affiliate/Services/ListsAffiliates.php b/app/Classes/Modules/Affiliate/Services/ListsAffiliates.php
new file mode 100644
index 00000000..b5890bec
--- /dev/null
+++ b/app/Classes/Modules/Affiliate/Services/ListsAffiliates.php
@@ -0,0 +1,31 @@
+repository = $repository;
+ }
+
+ /**
+ * @return Builder
+ */
+ function getRepository(): Builder
+ {
+ return $this->repository->newQuery()->with('creator')->orderBy('created_at', 'desc');
+ }
+}
+
diff --git a/app/Classes/Modules/Affiliate/Services/TracksAffiliateClick.php b/app/Classes/Modules/Affiliate/Services/TracksAffiliateClick.php
new file mode 100644
index 00000000..c1cac4d3
--- /dev/null
+++ b/app/Classes/Modules/Affiliate/Services/TracksAffiliateClick.php
@@ -0,0 +1,88 @@
+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;
+ }
+}
+
diff --git a/app/Classes/Modules/Affiliate/Services/TracksAffiliateOrder.php b/app/Classes/Modules/Affiliate/Services/TracksAffiliateOrder.php
new file mode 100644
index 00000000..b6f81dc7
--- /dev/null
+++ b/app/Classes/Modules/Affiliate/Services/TracksAffiliateOrder.php
@@ -0,0 +1,83 @@
+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;
+
+ $existingKvp = $booking->attributesKVP()
+ ->where('key', $kvpKey)
+ ->exists();
+
+ if ($existingKvp) {
+ 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;
+ }
+ }
+}
+
diff --git a/app/Classes/Modules/Affiliate/Services/TracksAffiliateRegistration.php b/app/Classes/Modules/Affiliate/Services/TracksAffiliateRegistration.php
new file mode 100644
index 00000000..ef1b049b
--- /dev/null
+++ b/app/Classes/Modules/Affiliate/Services/TracksAffiliateRegistration.php
@@ -0,0 +1,85 @@
+ $user, 'code' => $code]);
+
+ if (!$code || !$this->isValidCode($code)) {
+ Log::info('Code is not valid', ['code' => $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);
+ }
+}
+
diff --git a/app/Classes/Modules/Affiliate/Services/UpdatesAffiliate.php b/app/Classes/Modules/Affiliate/Services/UpdatesAffiliate.php
new file mode 100644
index 00000000..f828d2fd
--- /dev/null
+++ b/app/Classes/Modules/Affiliate/Services/UpdatesAffiliate.php
@@ -0,0 +1,27 @@
+campaign_name = $object->getCampaignName();
+ $model->campaign_description = $object->getCampaignDescription();
+ $model->is_active = $object->getIsActive();
+
+ return $this->handler($model);
+ }
+}
+
diff --git a/app/Classes/Modules/Affiliate/Services/UpdatesAffiliateSettings.php b/app/Classes/Modules/Affiliate/Services/UpdatesAffiliateSettings.php
new file mode 100644
index 00000000..b7159f5e
--- /dev/null
+++ b/app/Classes/Modules/Affiliate/Services/UpdatesAffiliateSettings.php
@@ -0,0 +1,40 @@
+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()
+ ];
+ }
+}
+
diff --git a/app/Classes/Modules/Affiliate/Standards/Rules/CanCreateAffiliate.php b/app/Classes/Modules/Affiliate/Standards/Rules/CanCreateAffiliate.php
new file mode 100644
index 00000000..03e7cee1
--- /dev/null
+++ b/app/Classes/Modules/Affiliate/Standards/Rules/CanCreateAffiliate.php
@@ -0,0 +1,52 @@
+affiliateCreateValidation = $affiliateCreateValidation;
+ }
+
+ /**
+ * @param AffiliateObject $object
+ * @return bool
+ */
+ protected function authorized($object): bool
+ {
+ // Authorization is handled by policy in controller
+ return true;
+ }
+
+ /**
+ * @param AffiliateObject $object
+ * @return bool
+ * @throws \App\Classes\Exceptions\RequestValidationException
+ */
+ protected function validators($object): bool
+ {
+ return $this->affiliateCreateValidation->validate($object);
+ }
+
+ /**
+ * @param AffiliateObject $object
+ * @return bool
+ */
+ protected function criteria($object): bool
+ {
+ // No additional criteria needed for affiliate creation
+ return true;
+ }
+}
diff --git a/app/Classes/Modules/Affiliate/Standards/Validators/AffiliateCreateValidation.php b/app/Classes/Modules/Affiliate/Standards/Validators/AffiliateCreateValidation.php
new file mode 100644
index 00000000..85fa5079
--- /dev/null
+++ b/app/Classes/Modules/Affiliate/Standards/Validators/AffiliateCreateValidation.php
@@ -0,0 +1,69 @@
+ $object->getCode(),
+ 'campaign_name' => $object->getCampaignName(),
+ 'campaign_description' => $object->getCampaignDescription(),
+ 'is_active' => $object->getIsActive(),
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ protected function rules(): array
+ {
+ return [
+ 'code' => [
+ 'nullable',
+ 'string',
+ 'min:3',
+ 'max:255',
+ 'regex:/^[a-zA-Z0-9_-]+$/',
+ function ($attribute, $value, $fail) {
+ if (!empty($value)) {
+ $uppercaseCode = strtoupper($value);
+ $exists = Affiliate::whereRaw('UPPER(code) = ?', [$uppercaseCode])
+ ->whereNull('deleted_at')
+ ->exists();
+
+ if ($exists) {
+ $fail('Affiliate code already exists. Please choose a different code.');
+ }
+ }
+ },
+ ],
+ 'campaign_name' => 'required|string|max:255',
+ 'campaign_description' => 'nullable|string|max:65535',
+ 'is_active' => 'required|boolean',
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ protected function messages(): array
+ {
+ return [
+ 'code.regex' => 'The affiliate code may only contain letters, numbers, hyphens, and underscores.',
+ '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.',
+ ];
+ }
+}
diff --git a/app/Classes/Modules/Bookings/ControllersLogic/CreateBookingLogic.php b/app/Classes/Modules/Bookings/ControllersLogic/CreateBookingLogic.php
index f9c79326..540d94f3 100644
--- a/app/Classes/Modules/Bookings/ControllersLogic/CreateBookingLogic.php
+++ b/app/Classes/Modules/Bookings/ControllersLogic/CreateBookingLogic.php
@@ -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]);
diff --git a/app/Http/Controllers/Affiliate/CreateAffiliateController.php b/app/Http/Controllers/Affiliate/CreateAffiliateController.php
new file mode 100644
index 00000000..9a0679c7
--- /dev/null
+++ b/app/Http/Controllers/Affiliate/CreateAffiliateController.php
@@ -0,0 +1,23 @@
+execute($request);
+ }
+}
+
diff --git a/app/Http/Controllers/Affiliate/DeleteAffiliateController.php b/app/Http/Controllers/Affiliate/DeleteAffiliateController.php
new file mode 100644
index 00000000..5cb79882
--- /dev/null
+++ b/app/Http/Controllers/Affiliate/DeleteAffiliateController.php
@@ -0,0 +1,37 @@
+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);
+ }
+}
+
diff --git a/app/Http/Controllers/Affiliate/GetAffiliateSettingsController.php b/app/Http/Controllers/Affiliate/GetAffiliateSettingsController.php
new file mode 100644
index 00000000..c3426ceb
--- /dev/null
+++ b/app/Http/Controllers/Affiliate/GetAffiliateSettingsController.php
@@ -0,0 +1,23 @@
+execute($request);
+ }
+}
+
diff --git a/app/Http/Controllers/Affiliate/ListAffiliatesController.php b/app/Http/Controllers/Affiliate/ListAffiliatesController.php
new file mode 100644
index 00000000..f4487782
--- /dev/null
+++ b/app/Http/Controllers/Affiliate/ListAffiliatesController.php
@@ -0,0 +1,23 @@
+execute($request);
+ }
+}
+
diff --git a/app/Http/Controllers/Affiliate/UpdateAffiliateController.php b/app/Http/Controllers/Affiliate/UpdateAffiliateController.php
new file mode 100644
index 00000000..9ec26c0c
--- /dev/null
+++ b/app/Http/Controllers/Affiliate/UpdateAffiliateController.php
@@ -0,0 +1,39 @@
+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);
+ }
+}
+
diff --git a/app/Http/Controllers/Affiliate/UpdateAffiliateSettingsController.php b/app/Http/Controllers/Affiliate/UpdateAffiliateSettingsController.php
new file mode 100644
index 00000000..e8e8a00f
--- /dev/null
+++ b/app/Http/Controllers/Affiliate/UpdateAffiliateSettingsController.php
@@ -0,0 +1,24 @@
+execute($request);
+ }
+}
+
diff --git a/app/Http/Middleware/RedirectIfAuthenticated.php b/app/Http/Middleware/RedirectIfAuthenticated.php
index 2395ddcc..bd955e53 100644
--- a/app/Http/Middleware/RedirectIfAuthenticated.php
+++ b/app/Http/Middleware/RedirectIfAuthenticated.php
@@ -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;
+ }
}
diff --git a/app/Http/Middleware/TrackAffiliateClick.php b/app/Http/Middleware/TrackAffiliateClick.php
new file mode 100644
index 00000000..205177f1
--- /dev/null
+++ b/app/Http/Middleware/TrackAffiliateClick.php
@@ -0,0 +1,146 @@
+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);
+ }
+}
diff --git a/app/Http/Requests/CreateAffiliateRequest.php b/app/Http/Requests/CreateAffiliateRequest.php
new file mode 100644
index 00000000..babdadf6
--- /dev/null
+++ b/app/Http/Requests/CreateAffiliateRequest.php
@@ -0,0 +1,58 @@
+ [
+ 'nullable',
+ 'string',
+ 'min:3',
+ 'max:255',
+ 'regex:/^[a-zA-Z0-9_-]+$/',
+ ],
+ '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.',
+ ];
+ }
+}
+
+
diff --git a/app/Http/Requests/UpdateAffiliateRequest.php b/app/Http/Requests/UpdateAffiliateRequest.php
new file mode 100644
index 00000000..d4dda93e
--- /dev/null
+++ b/app/Http/Requests/UpdateAffiliateRequest.php
@@ -0,0 +1,47 @@
+ '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.',
+ ];
+ }
+}
+
+
diff --git a/app/Http/Requests/UpdateAffiliateSettingsRequest.php b/app/Http/Requests/UpdateAffiliateSettingsRequest.php
new file mode 100644
index 00000000..b05f7040
--- /dev/null
+++ b/app/Http/Requests/UpdateAffiliateSettingsRequest.php
@@ -0,0 +1,47 @@
+ '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.',
+ ];
+ }
+}
+
+
diff --git a/app/Http/Resources/AffiliateResource.php b/app/Http/Resources/AffiliateResource.php
new file mode 100644
index 00000000..404e0e5d
--- /dev/null
+++ b/app/Http/Resources/AffiliateResource.php
@@ -0,0 +1,32 @@
+ $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,
+ ];
+ }
+}
+
diff --git a/app/Models/Affiliate.php b/app/Models/Affiliate.php
new file mode 100644
index 00000000..71d0115a
--- /dev/null
+++ b/app/Models/Affiliate.php
@@ -0,0 +1,63 @@
+ '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;
+ }
+}
+
diff --git a/app/Models/User.php b/app/Models/User.php
index 46e8635f..d396fc20 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -120,4 +120,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();
+ }
}
diff --git a/app/Models/UserAffiliate.php b/app/Models/UserAffiliate.php
new file mode 100644
index 00000000..bf8792e5
--- /dev/null
+++ b/app/Models/UserAffiliate.php
@@ -0,0 +1,39 @@
+belongsTo(User::class);
+ }
+
+ /**
+ * @return BelongsTo
+ */
+ public function affiliate(): BelongsTo
+ {
+ return $this->belongsTo(Affiliate::class);
+ }
+}
+
diff --git a/app/Policies/AffiliatePolicy.php b/app/Policies/AffiliatePolicy.php
new file mode 100644
index 00000000..8a5b36a0
--- /dev/null
+++ b/app/Policies/AffiliatePolicy.php
@@ -0,0 +1,96 @@
+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);
+ }
+}
+
+
diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php
index b14e5898..31a1ad6f 100644
--- a/app/Providers/AuthServiceProvider.php
+++ b/app/Providers/AuthServiceProvider.php
@@ -13,6 +13,7 @@ class AuthServiceProvider extends ServiceProvider
*/
protected $policies = [
// 'App\Model' => 'App\Policies\ModelPolicy',
+ \App\Models\Affiliate::class => \App\Policies\AffiliatePolicy::class,
];
/**
diff --git a/database/migrations/2025_11_16_190751_create_affiliates_table.php b/database/migrations/2025_11_16_190751_create_affiliates_table.php
new file mode 100644
index 00000000..48e2cf1e
--- /dev/null
+++ b/database/migrations/2025_11_16_190751_create_affiliates_table.php
@@ -0,0 +1,43 @@
+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');
+ }
+}
diff --git a/database/migrations/2025_11_16_191326_create_user_affiliates_table.php b/database/migrations/2025_11_16_191326_create_user_affiliates_table.php
new file mode 100644
index 00000000..60a7349d
--- /dev/null
+++ b/database/migrations/2025_11_16_191326_create_user_affiliates_table.php
@@ -0,0 +1,42 @@
+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');
+ }
+}
diff --git a/database/migrations/2025_11_28_002359_add_foreign_key_to_affiliates_created_by.php b/database/migrations/2025_11_28_002359_add_foreign_key_to_affiliates_created_by.php
new file mode 100644
index 00000000..0ad17e29
--- /dev/null
+++ b/database/migrations/2025_11_28_002359_add_foreign_key_to_affiliates_created_by.php
@@ -0,0 +1,37 @@
+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']);
+ });
+ }
+}
diff --git a/resources/assets/vue/components/accounts/forms/RegistrationFormComponent.vue b/resources/assets/vue/components/accounts/forms/RegistrationFormComponent.vue
index a046a7eb..d7a84c0b 100644
--- a/resources/assets/vue/components/accounts/forms/RegistrationFormComponent.vue
+++ b/resources/assets/vue/components/accounts/forms/RegistrationFormComponent.vue
@@ -205,7 +205,9 @@
},
submitForm(){
- this.step === 2 ? this.submit(this.route('api.account.registration.register'), 'post', 'registrationSection', false, false) : this.changeStep('next');
+ const urlParams = new URLSearchParams(window.location.search);
+ const tracking = urlParams.get('tracking');
+ this.step === 2 ? this.submit(this.route('api.account.registration.register') + (tracking ? '?tracking=' + tracking : ''), 'post', 'registrationSection', false, false) : this.changeStep('next');
},
successHandler(response){
// fire to gtag manager for fb pixel tracking: CompleteRegistration
diff --git a/resources/assets/vue/components/settings/elements/AffiliateSectionComponent.vue b/resources/assets/vue/components/settings/elements/AffiliateSectionComponent.vue
new file mode 100644
index 00000000..8da2cdbf
--- /dev/null
+++ b/resources/assets/vue/components/settings/elements/AffiliateSectionComponent.vue
@@ -0,0 +1,137 @@
+
+ Are you sure you want to delete the affiliate code {{data.campaign_name}} ({{data.code}})?{{ parameters.id ? 'Update Affiliate Code' : 'Create Affiliate Code' }}
+ Delete Affiliate Code
+