From e85db0d143913c8725ab10c5881d47e0e1c288db Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 2 Dec 2024 19:44:25 -0300 Subject: [PATCH 1/8] feat: added link targeting for ios and android --- app/Enums/Link/Os.php | 15 + app/Http/Controllers/Api/LinkController.php | 4 + app/Http/Controllers/LinkController.php | 4 + app/Http/Controllers/RedirectController.php | 23 +- app/Http/Requests/Link/CreateRequest.php | 3 +- app/Http/Requests/Link/UpdateRequest.php | 3 +- app/Http/Resources/Api/LinkResource.php | 2 + app/Jobs/ProcessLinkStat.php | 2 +- app/Models/Link.php | 5 + app/Services/UserAgentService.php | 14 +- database/factories/LinkFactory.php | 2 + ...230_add_ios_and_android_to_links_table.php | 30 ++ lang/php_en.json | 1 + resources/js/Components/Dropdown.vue | 275 ++++++++++-------- resources/js/Pages/Link/Create.vue | 177 +++++++---- resources/js/Pages/Link/Edit.vue | 176 +++++++---- tests/Feature/Api/LinkTest.php | 18 ++ tests/Feature/App/LinkTest.php | 1 - tests/Feature/App/RedirectTest.php | 88 ++++++ 19 files changed, 590 insertions(+), 253 deletions(-) create mode 100644 app/Enums/Link/Os.php create mode 100644 database/migrations/2024_12_02_215230_add_ios_and_android_to_links_table.php create mode 100644 lang/php_en.json diff --git a/app/Enums/Link/Os.php b/app/Enums/Link/Os.php new file mode 100644 index 0000000..b93cbd8 --- /dev/null +++ b/app/Enums/Link/Os.php @@ -0,0 +1,15 @@ + $request->key, 'url' => $request->url, 'link' => "https://{$request->domain}/{$request->key}", + 'ios' => $request->ios, + 'android' => $request->android, 'utm_source' => $request->utm_source, 'utm_medium' => $request->utm_medium, 'utm_campaign' => $request->utm_campaign, @@ -75,6 +77,8 @@ public function update($id, UpdateRequest $request) 'key' => $request->key, 'url' => $request->url, 'link' => "https://{$request->domain}/{$request->key}", + 'ios' => $request->ios, + 'android' => $request->android, 'utm_source' => $request->utm_source, 'utm_medium' => $request->utm_medium, 'utm_campaign' => $request->utm_campaign, diff --git a/app/Http/Controllers/LinkController.php b/app/Http/Controllers/LinkController.php index 623e144..9f2fe9b 100644 --- a/app/Http/Controllers/LinkController.php +++ b/app/Http/Controllers/LinkController.php @@ -78,6 +78,8 @@ public function store(CreateRequest $request) 'domain' => $request->domain, 'key' => $key, 'url' => $request->url, + 'ios' => $request->ios, + 'android' => $request->android, 'link' => "https://{$request->domain}/{$key}", 'utm_source' => $request->utm_source, 'utm_medium' => $request->utm_medium, @@ -108,6 +110,8 @@ public function update($id, UpdateRequest $request) 'key' => $key, 'url' => $request->url, 'link' => "https://{$request->domain}/{$key}", + 'ios' => $request->ios, + 'android' => $request->android, 'utm_source' => $request->utm_source, 'utm_medium' => $request->utm_medium, 'utm_campaign' => $request->utm_campaign, diff --git a/app/Http/Controllers/RedirectController.php b/app/Http/Controllers/RedirectController.php index 62edcd9..06fcdda 100644 --- a/app/Http/Controllers/RedirectController.php +++ b/app/Http/Controllers/RedirectController.php @@ -4,10 +4,14 @@ namespace App\Http\Controllers; +use App\Services\UserAgentService; + use Illuminate\Support\Facades\Gate; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use App\Enums\Link\Os; + use App\Jobs\ProcessLinkStat; use App\Models\Link; @@ -20,7 +24,6 @@ public function __invoke($key, Request $request): RedirectResponse ->with('workspace') ->firstOrFail(); - $reachEventLimit = Gate::inspect('reached-event-limit', $link->workspace); ProcessLinkStat::dispatchIf( @@ -32,6 +35,24 @@ public function __invoke($key, Request $request): RedirectResponse $request->input('qr') ? true : false, $request->header('Referer') ); + + /** + * If the link has an iOS or Android redirect URL, we need to check the user's OS + * and redirect to the appropriate URL. + */ + if ($link->ios || $link->android) { + $service = new UserAgentService(); + $os = $service->getOS($request->userAgent()); + + if ($os === Os::IOS->value && $link->ios) { + return redirect($link->ios, 302); + } + + if ($os === Os::ANDROID->value && $link->android) { + return redirect($link->android, 302); + } + } + return redirect($link->url, 302); } } diff --git a/app/Http/Requests/Link/CreateRequest.php b/app/Http/Requests/Link/CreateRequest.php index ab53853..5d0859e 100644 --- a/app/Http/Requests/Link/CreateRequest.php +++ b/app/Http/Requests/Link/CreateRequest.php @@ -40,7 +40,8 @@ public function rules(): array Rule::unique('links')->where('key', $this->key)->ignore($this->route('id')), ], 'url' => ['required', 'url', 'max:255', 'min:2'], - + 'ios' => ['nullable', 'url', 'max:255', 'min:2'], + 'android' => ['nullable', 'url', 'max:255', 'min:2'], 'utm_source' => Rule::when( fn() => $this->utm_source, ['required', 'string', 'max:255', 'min:2'] diff --git a/app/Http/Requests/Link/UpdateRequest.php b/app/Http/Requests/Link/UpdateRequest.php index 0984e39..7f0f84b 100644 --- a/app/Http/Requests/Link/UpdateRequest.php +++ b/app/Http/Requests/Link/UpdateRequest.php @@ -40,7 +40,8 @@ public function rules(): array Rule::unique('links')->where('key', $this->key)->ignore($this->route('id')), ], 'url' => ['required', 'url', 'max:255', 'min:2'], - + 'ios' => ['nullable', 'url', 'max:255', 'min:2'], + 'android' => ['nullable', 'url', 'max:255', 'min:2'], 'utm_source' => Rule::when( fn() => $this->utm_source, ['required', 'string', 'max:255', 'min:2'] diff --git a/app/Http/Resources/Api/LinkResource.php b/app/Http/Resources/Api/LinkResource.php index 28e2a9d..b699f1f 100644 --- a/app/Http/Resources/Api/LinkResource.php +++ b/app/Http/Resources/Api/LinkResource.php @@ -20,6 +20,8 @@ public function toArray(Request $request): array 'domain' => $this->domain, 'key' => $this->key, 'url' => $this->url, + 'ios' => $this->ios, + 'android' => $this->android, 'link' => $this->link, 'utm_source' => $this->utm_source, 'utm_medium' => $this->utm_medium, diff --git a/app/Jobs/ProcessLinkStat.php b/app/Jobs/ProcessLinkStat.php index c1aa566..6be10ee 100644 --- a/app/Jobs/ProcessLinkStat.php +++ b/app/Jobs/ProcessLinkStat.php @@ -40,7 +40,7 @@ public function handle(): void // user geo $geo = geoip($this->ip); - $linkStat = LinkStat::create([ + LinkStat::create([ 'workspace_id' => $this->link->workspace_id, 'link_id' => $this->link->id, 'event' => $this->qr ? Event::QR_SCAN : Event::CLICK, diff --git a/app/Models/Link.php b/app/Models/Link.php index e71cb7b..c9664a3 100644 --- a/app/Models/Link.php +++ b/app/Models/Link.php @@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use App\Enums\Link\Os; + class Link extends Model { use HasFactory; @@ -27,6 +29,8 @@ class Link extends Model 'key', 'url', 'link', + 'ios', + 'android', 'utm_source', 'utm_medium', 'utm_campaign', @@ -54,6 +58,7 @@ protected function casts(): array { return [ 'last_click' => 'datetime', + 'os' => Os::class, ]; } diff --git a/app/Services/UserAgentService.php b/app/Services/UserAgentService.php index 43d447d..c49ed3d 100644 --- a/app/Services/UserAgentService.php +++ b/app/Services/UserAgentService.php @@ -4,6 +4,8 @@ namespace App\Services; +use App\Enums\Link\Os; + class UserAgentService { /** @@ -41,11 +43,11 @@ public function getBrowser(string $userAgent): string public function getOS(string $userAgent): string { $osArray = [ - 'Windows' => 'Windows', - 'MacOS' => '(Mac_PowerPC)|(Macintosh)', - 'Linux' => 'Linux', - 'iOS' => 'iPhone|iPad', - 'Android' => 'Android', + Os::ANDROID->value => 'Android', + Os::IOS->value => 'iPhone|iPad', + Os::WINDOWS->value => 'Windows', + Os::MACOS->value => '(Mac_PowerPC)|(Macintosh)', + Os::LINUX->value => 'Linux', ]; foreach ($osArray as $key => $pattern) { @@ -54,7 +56,7 @@ public function getOS(string $userAgent): string } } - return 'Unknown OS'; + return Os::UNKNOWN->value; } /** diff --git a/database/factories/LinkFactory.php b/database/factories/LinkFactory.php index 9b34715..746226d 100644 --- a/database/factories/LinkFactory.php +++ b/database/factories/LinkFactory.php @@ -31,6 +31,8 @@ public function definition(): array 'key' => $slug, 'url' => $this->faker->url, 'link' => "https://{$domain}/{$slug}", + 'ios' => $this->faker->url, + 'android' => $this->faker->url, 'utm_source' => $this->faker->word, 'utm_medium' => $this->faker->word, 'utm_campaign' => $this->faker->word, diff --git a/database/migrations/2024_12_02_215230_add_ios_and_android_to_links_table.php b/database/migrations/2024_12_02_215230_add_ios_and_android_to_links_table.php new file mode 100644 index 0000000..df7b9d9 --- /dev/null +++ b/database/migrations/2024_12_02_215230_add_ios_and_android_to_links_table.php @@ -0,0 +1,30 @@ +string('ios')->after('link')->nullable(); + $table->string('android')->after('ios')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('links', function (Blueprint $table) { + $table->dropColumn('ios'); + $table->dropColumn('android'); + }); + } +}; diff --git a/lang/php_en.json b/lang/php_en.json new file mode 100644 index 0000000..ea3477b --- /dev/null +++ b/lang/php_en.json @@ -0,0 +1 @@ +{"auth.failed":"These credentials do not match our records.","auth.password":"The provided password is incorrect.","auth.throttle":"Too many login attempts. Please try again in :seconds seconds.","pagination.previous":"Previous","pagination.next":"Next","passwords.reset":"Your password has been reset.","passwords.sent":"We have emailed your password reset link.","passwords.throttled":"Please wait before retrying.","passwords.token":"This password reset token is invalid.","passwords.user":"We can't find a user with that email address.","validation.accepted":"The :attribute field must be accepted.","validation.accepted_if":"The :attribute field must be accepted when :other is :value.","validation.active_url":"The :attribute field must be a valid URL.","validation.after":"The :attribute field must be a date after :date.","validation.after_or_equal":"The :attribute field must be a date after or equal to :date.","validation.alpha":"The :attribute field must only contain letters.","validation.alpha_dash":"The :attribute field must only contain letters, numbers, dashes, and underscores.","validation.alpha_num":"The :attribute field must only contain letters and numbers.","validation.array":"The :attribute field must be an array.","validation.ascii":"The :attribute field must only contain single-byte alphanumeric characters and symbols.","validation.before":"The :attribute field must be a date before :date.","validation.before_or_equal":"The :attribute field must be a date before or equal to :date.","validation.between.array":"The :attribute field must have between :min and :max items.","validation.between.file":"The :attribute field must be between :min and :max kilobytes.","validation.between.numeric":"The :attribute field must be between :min and :max.","validation.between.string":"The :attribute field must be between :min and :max characters.","validation.boolean":"The :attribute field must be true or false.","validation.can":"The :attribute field contains an unauthorized value.","validation.confirmed":"The :attribute field confirmation does not match.","validation.contains":"The :attribute field is missing a required value.","validation.current_password":"The password is incorrect.","validation.date":"The :attribute field must be a valid date.","validation.date_equals":"The :attribute field must be a date equal to :date.","validation.date_format":"The :attribute field must match the format :format.","validation.decimal":"The :attribute field must have :decimal decimal places.","validation.declined":"The :attribute field must be declined.","validation.declined_if":"The :attribute field must be declined when :other is :value.","validation.different":"The :attribute field and :other must be different.","validation.digits":"The :attribute field must be :digits digits.","validation.digits_between":"The :attribute field must be between :min and :max digits.","validation.dimensions":"The :attribute field has invalid image dimensions.","validation.distinct":"The :attribute field has a duplicate value.","validation.doesnt_end_with":"The :attribute field must not end with one of the following: :values.","validation.doesnt_start_with":"The :attribute field must not start with one of the following: :values.","validation.email":"The :attribute field must be a valid email address.","validation.ends_with":"The :attribute field must end with one of the following: :values.","validation.enum":"The selected :attribute is invalid.","validation.exists":"The selected :attribute is invalid.","validation.extensions":"The :attribute field must have one of the following extensions: :values.","validation.file":"The :attribute field must be a file.","validation.filled":"The :attribute field must have a value.","validation.gt.array":"The :attribute field must have more than :value items.","validation.gt.file":"The :attribute field must be greater than :value kilobytes.","validation.gt.numeric":"The :attribute field must be greater than :value.","validation.gt.string":"The :attribute field must be greater than :value characters.","validation.gte.array":"The :attribute field must have :value items or more.","validation.gte.file":"The :attribute field must be greater than or equal to :value kilobytes.","validation.gte.numeric":"The :attribute field must be greater than or equal to :value.","validation.gte.string":"The :attribute field must be greater than or equal to :value characters.","validation.hex_color":"The :attribute field must be a valid hexadecimal color.","validation.image":"The :attribute field must be an image.","validation.in":"The selected :attribute is invalid.","validation.in_array":"The :attribute field must exist in :other.","validation.integer":"The :attribute field must be an integer.","validation.ip":"The :attribute field must be a valid IP address.","validation.ipv4":"The :attribute field must be a valid IPv4 address.","validation.ipv6":"The :attribute field must be a valid IPv6 address.","validation.json":"The :attribute field must be a valid JSON string.","validation.list":"The :attribute field must be a list.","validation.lowercase":"The :attribute field must be lowercase.","validation.lt.array":"The :attribute field must have less than :value items.","validation.lt.file":"The :attribute field must be less than :value kilobytes.","validation.lt.numeric":"The :attribute field must be less than :value.","validation.lt.string":"The :attribute field must be less than :value characters.","validation.lte.array":"The :attribute field must not have more than :value items.","validation.lte.file":"The :attribute field must be less than or equal to :value kilobytes.","validation.lte.numeric":"The :attribute field must be less than or equal to :value.","validation.lte.string":"The :attribute field must be less than or equal to :value characters.","validation.mac_address":"The :attribute field must be a valid MAC address.","validation.max.array":"The :attribute field must not have more than :max items.","validation.max.file":"The :attribute field must not be greater than :max kilobytes.","validation.max.numeric":"The :attribute field must not be greater than :max.","validation.max.string":"The :attribute field must not be greater than :max characters.","validation.max_digits":"The :attribute field must not have more than :max digits.","validation.mimes":"The :attribute field must be a file of type: :values.","validation.mimetypes":"The :attribute field must be a file of type: :values.","validation.min.array":"The :attribute field must have at least :min items.","validation.min.file":"The :attribute field must be at least :min kilobytes.","validation.min.numeric":"The :attribute field must be at least :min.","validation.min.string":"The :attribute field must be at least :min characters.","validation.min_digits":"The :attribute field must have at least :min digits.","validation.missing":"The :attribute field must be missing.","validation.missing_if":"The :attribute field must be missing when :other is :value.","validation.missing_unless":"The :attribute field must be missing unless :other is :value.","validation.missing_with":"The :attribute field must be missing when :values is present.","validation.missing_with_all":"The :attribute field must be missing when :values are present.","validation.multiple_of":"The :attribute field must be a multiple of :value.","validation.not_in":"The selected :attribute is invalid.","validation.not_regex":"The :attribute field format is invalid.","validation.numeric":"The :attribute field must be a number.","validation.password.letters":"The :attribute field must contain at least one letter.","validation.password.mixed":"The :attribute field must contain at least one uppercase and one lowercase letter.","validation.password.numbers":"The :attribute field must contain at least one number.","validation.password.symbols":"The :attribute field must contain at least one symbol.","validation.password.uncompromised":"The given :attribute has appeared in a data leak. Please choose a different :attribute.","validation.present":"The :attribute field must be present.","validation.present_if":"The :attribute field must be present when :other is :value.","validation.present_unless":"The :attribute field must be present unless :other is :value.","validation.present_with":"The :attribute field must be present when :values is present.","validation.present_with_all":"The :attribute field must be present when :values are present.","validation.prohibited":"The :attribute field is prohibited.","validation.prohibited_if":"The :attribute field is prohibited when :other is :value.","validation.prohibited_unless":"The :attribute field is prohibited unless :other is in :values.","validation.prohibits":"The :attribute field prohibits :other from being present.","validation.regex":"The :attribute field format is invalid.","validation.required":"The :attribute field is required.","validation.required_array_keys":"The :attribute field must contain entries for: :values.","validation.required_if":"The :attribute field is required when :other is :value.","validation.required_if_accepted":"The :attribute field is required when :other is accepted.","validation.required_if_declined":"The :attribute field is required when :other is declined.","validation.required_unless":"The :attribute field is required unless :other is in :values.","validation.required_with":"The :attribute field is required when :values is present.","validation.required_with_all":"The :attribute field is required when :values are present.","validation.required_without":"The :attribute field is required when :values is not present.","validation.required_without_all":"The :attribute field is required when none of :values are present.","validation.same":"The :attribute field must match :other.","validation.size.array":"The :attribute field must contain :size items.","validation.size.file":"The :attribute field must be :size kilobytes.","validation.size.numeric":"The :attribute field must be :size.","validation.size.string":"The :attribute field must be :size characters.","validation.starts_with":"The :attribute field must start with one of the following: :values.","validation.string":"The :attribute field must be a string.","validation.timezone":"The :attribute field must be a valid timezone.","validation.unique":"The :attribute has already been taken.","validation.uploaded":"The :attribute failed to upload.","validation.uppercase":"The :attribute field must be uppercase.","validation.url":"The :attribute field must be a valid URL.","validation.ulid":"The :attribute field must be a valid ULID.","validation.uuid":"The :attribute field must be a valid UUID.","validation.custom.attribute-name.rule-name":"custom-message","pagination.showing":"Showing","pagination.to":"to","pagination.of":"of","pagination.results":"results"} \ No newline at end of file diff --git a/resources/js/Components/Dropdown.vue b/resources/js/Components/Dropdown.vue index 8ccc50d..622c430 100644 --- a/resources/js/Components/Dropdown.vue +++ b/resources/js/Components/Dropdown.vue @@ -48,6 +48,9 @@ const { modelValue, options, placeholder, multiple } = defineProps({ }, }); +const listboxButton = ref(null); +const dropdownPosition = ref({ top: "0px", left: "0px", width: "0px" }); + const input = ref(null); const query = ref(""); const selected = ref( @@ -74,9 +77,21 @@ const setFocus = (open) => { } }; +const updateDropdownPosition = () => { + if (listboxButton.value) { + const buttonRect = listboxButton.value.getBoundingClientRect(); + dropdownPosition.value = { + top: `${buttonRect.bottom + window.scrollY}px`, + left: `${buttonRect.left + window.scrollX}px`, + width: `${buttonRect.width}px`, + }; + } +}; + watch( () => selected.value, (value) => { + updateDropdownPosition(); if (multiple) { emit( "update:modelValue", @@ -89,142 +104,172 @@ watch( query.value = ""; } ); + +watch( + () => query.value, + () => { + updateDropdownPosition(); + } +); diff --git a/resources/js/Pages/Link/Create.vue b/resources/js/Pages/Link/Create.vue index aeff1b1..63491ee 100644 --- a/resources/js/Pages/Link/Create.vue +++ b/resources/js/Pages/Link/Create.vue @@ -2,6 +2,7 @@ import { useForm, usePage } from "@inertiajs/vue3"; import { ref } from "vue"; +import Accordion from "@/Components/Accordion.vue"; import SlideOver from "@/Components/SlideOver.vue"; import Button from "@/Components/Button.vue"; import Input from "@/Components/Input.vue"; @@ -17,6 +18,8 @@ const form = useForm({ domain: domains[0], key: "", tags: [], + ios: "", + android: "", }); const show = ref(false); @@ -53,69 +56,117 @@ const store = () => {