Skip to content

Commit ef0b891

Browse files
authored
Merge pull request #310 from ploi/widget
Widget support
2 parents bc151b3 + 0e8b08f commit ef0b891

File tree

18 files changed

+1776
-1
lines changed

18 files changed

+1776
-1
lines changed

.junie/guidelines.md

Lines changed: 494 additions & 0 deletions
Large diffs are not rendered by default.

.junie/mcp/mcp.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"mcpServers": {
3+
"laravel-boost": {
4+
"command": "/opt/homebrew/Cellar/php/8.4.10/bin/php",
5+
"args": [
6+
"/Users/dennissmink/Workspace/roadmap/artisan",
7+
"boost:mcp"
8+
]
9+
}
10+
}
11+
}

.mcp.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"mcpServers": {
3+
"laravel-boost": {
4+
"command": "php",
5+
"args": [
6+
"artisan",
7+
"boost:mcp"
8+
]
9+
}
10+
}
11+
}

app/Filament/Pages/Widget.php

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
<?php
2+
3+
namespace App\Filament\Pages;
4+
5+
use Filament\Schemas\Schema;
6+
use Filament\Schemas\Components\Section;
7+
use App\Enums\UserRole;
8+
use Filament\Forms\Components\Toggle;
9+
use Filament\Forms\Components\Select;
10+
use Filament\Forms\Components\ColorPicker;
11+
use Filament\Forms\Components\TextInput;
12+
use Filament\Forms\Components\TagsInput;
13+
use Filament\Forms\Components\Placeholder;
14+
use Filament\Forms\Components\Actions\Action;
15+
use App\Settings\WidgetSettings;
16+
use Filament\Pages\SettingsPage;
17+
use Illuminate\Contracts\Support\Htmlable;
18+
use Illuminate\Support\HtmlString;
19+
20+
class Widget extends SettingsPage
21+
{
22+
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-code-bracket';
23+
24+
protected static string $settings = WidgetSettings::class;
25+
26+
protected static ?int $navigationSort = 1500;
27+
28+
public static function getNavigationGroup(): ?string
29+
{
30+
return trans('nav.manage');
31+
}
32+
33+
public static function getNavigationLabel(): string
34+
{
35+
return 'Widget';
36+
}
37+
38+
public function getHeading(): string|Htmlable
39+
{
40+
return 'Feedback Widget';
41+
}
42+
43+
public static function shouldRegisterNavigation(): bool
44+
{
45+
return auth()->user()->hasRole(UserRole::Admin);
46+
}
47+
48+
public function mount(): void
49+
{
50+
parent::mount();
51+
52+
abort_unless(auth()->user()->hasRole(UserRole::Admin), 403);
53+
}
54+
55+
public function form(Schema $schema): Schema
56+
{
57+
return $schema->components(
58+
[
59+
Section::make('Widget Configuration')
60+
->description('Configure the feedback widget that can be embedded on external websites.')
61+
->columnSpanFull()
62+
->schema(
63+
[
64+
Toggle::make('enabled')
65+
->label('Enable Widget')
66+
->helperText('Enable or disable the feedback widget'),
67+
68+
Select::make('position')
69+
->label('Widget Position')
70+
->options([
71+
'bottom-right' => 'Bottom Right',
72+
'bottom-left' => 'Bottom Left',
73+
'top-right' => 'Top Right',
74+
'top-left' => 'Top Left',
75+
])
76+
->default('bottom-right')
77+
->required(),
78+
79+
ColorPicker::make('primary_color')
80+
->label('Primary Color')
81+
->default('#2563EB'),
82+
83+
TextInput::make('button_text')
84+
->label('Button Text')
85+
->default('Feedback')
86+
->required(),
87+
88+
Toggle::make('hide_button')
89+
->label('Hide Button')
90+
->helperText('Hide the default floating button. Use $roadmap.open() to open the modal programmatically.'),
91+
92+
TagsInput::make('allowed_domains')
93+
->label('Allowed Domains')
94+
->helperText('Restrict widget usage to specific domains (e.g., example.com). Leave empty to allow all domains.')
95+
->placeholder('example.com'),
96+
]
97+
)->columns(),
98+
99+
Section::make('Embed Code')
100+
->description('Copy and paste this code into your website to enable the feedback widget.')
101+
->columnSpanFull()
102+
->schema(
103+
[
104+
Placeholder::make('embed_code')
105+
->label('JavaScript Embed Code')
106+
->content(fn () => new HtmlString(
107+
'<div class="relative">
108+
<pre id="widget-embed-code" class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 p-4 rounded-md overflow-x-auto text-sm leading-relaxed font-mono">' .
109+
htmlspecialchars($this->getEmbedCode()) .
110+
'</pre>
111+
<button
112+
type="button"
113+
onclick="
114+
const code = document.getElementById(\'widget-embed-code\').textContent;
115+
navigator.clipboard.writeText(code).then(() => {
116+
this.textContent = \'Copied!\';
117+
setTimeout(() => { this.innerHTML = \'<svg class=\\\'w-4 h-4 mr-1\\\' fill=\\\'none\\\' stroke=\\\'currentColor\\\' viewBox=\\\'0 0 24 24\\\'><path stroke-linecap=\\\'round\\\' stroke-linejoin=\\\'round\\\' stroke-width=\\\'2\\\' d=\\\'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\\\'></path></svg> Copy\'; }, 2000);
118+
});
119+
"
120+
class="absolute top-4 right-2 px-3 py-1.5 bg-transparent border-0 rounded-md text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline transition-all flex items-center gap-1 cursor-pointer"
121+
>
122+
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
123+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
124+
</svg>
125+
Copy
126+
</button>
127+
</div>'
128+
))
129+
->helperText('Click the copy button to copy this code and paste it before the closing </body> tag on your website.'),
130+
]
131+
),
132+
]
133+
);
134+
}
135+
136+
protected function getEmbedCode(): string
137+
{
138+
$url = url('/widget.js');
139+
return <<<HTML
140+
<script>
141+
(function() {
142+
const script = document.createElement('script');
143+
script.src = '{$url}';
144+
script.async = true;
145+
document.body.appendChild(script);
146+
147+
// Optional: Configure widget
148+
script.onload = function() {
149+
// Pre-fill user data:
150+
// \$roadmap.setName('John Doe');
151+
// \$roadmap.setEmail('john@example.com');
152+
153+
// If you hid the default button, use your own to trigger:
154+
// document.getElementById('my-feedback-btn').onclick = function() {
155+
// \$roadmap.open();
156+
// };
157+
};
158+
})();
159+
</script>
160+
HTML;
161+
}
162+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Models\Item;
6+
use App\Models\User;
7+
use Illuminate\Http\Request;
8+
use Illuminate\Http\Response;
9+
use App\Settings\WidgetSettings;
10+
use Illuminate\Http\JsonResponse;
11+
use Illuminate\Support\Facades\Validator;
12+
13+
class WidgetController extends Controller
14+
{
15+
public function javascript(): Response
16+
{
17+
return response()
18+
->view('widget.index')
19+
->header('Content-Type', 'application/javascript');
20+
}
21+
22+
public function config(Request $request): JsonResponse
23+
{
24+
$settings = app(WidgetSettings::class);
25+
26+
if (!$settings->enabled) {
27+
return response()->json(['enabled' => false], 200);
28+
}
29+
30+
// Validate origin domain if restrictions are set
31+
if (!empty($settings->allowed_domains)) {
32+
$origin = $request->header('Origin') ?? $request->header('Referer');
33+
34+
if ($origin) {
35+
$domain = parse_url($origin, PHP_URL_HOST);
36+
$allowed = false;
37+
38+
foreach ($settings->allowed_domains as $allowedDomain) {
39+
if ($domain === $allowedDomain || str_ends_with($domain, '.' . $allowedDomain)) {
40+
$allowed = true;
41+
break;
42+
}
43+
}
44+
45+
if (!$allowed) {
46+
return response()->json(['enabled' => false], 200);
47+
}
48+
}
49+
}
50+
51+
return response()->json([
52+
'enabled' => $settings->enabled,
53+
'position' => $settings->position,
54+
'primary_color' => $settings->primary_color,
55+
'button_text' => $settings->button_text,
56+
'hide_button' => $settings->hide_button,
57+
]);
58+
}
59+
60+
public function submit(Request $request): JsonResponse
61+
{
62+
$settings = app(WidgetSettings::class);
63+
64+
// Check if widget is enabled
65+
if (!$settings->enabled) {
66+
return response()->json(['error' => 'Widget is not enabled'], 403);
67+
}
68+
69+
// Validate origin domain if restrictions are set
70+
if (!empty($settings->allowed_domains)) {
71+
$origin = $request->header('Origin') ?? $request->header('Referer');
72+
73+
if ($origin) {
74+
$domain = parse_url($origin, PHP_URL_HOST);
75+
$allowed = false;
76+
77+
foreach ($settings->allowed_domains as $allowedDomain) {
78+
if ($domain === $allowedDomain || str_ends_with($domain, '.' . $allowedDomain)) {
79+
$allowed = true;
80+
break;
81+
}
82+
}
83+
84+
if (!$allowed) {
85+
return response()->json(['error' => 'Domain not allowed'], 403);
86+
}
87+
}
88+
}
89+
90+
// Validate request
91+
$validator = Validator::make($request->all(), [
92+
'title' => 'required|string|max:255',
93+
'content' => 'required|string',
94+
'email' => 'nullable|email',
95+
'name' => 'nullable|string|max:255',
96+
]);
97+
98+
if ($validator->fails()) {
99+
return response()->json(['errors' => $validator->errors()], 422);
100+
}
101+
102+
// Only create/find user if email is provided
103+
$userId = null;
104+
if ($request->filled('email')) {
105+
$user = User::firstOrCreate(
106+
['email' => $request->input('email')],
107+
[
108+
'name' => $request->input('name', 'Widget User'),
109+
'password' => bcrypt(str()->random(32)),
110+
]
111+
);
112+
$userId = $user->id;
113+
}
114+
115+
// Create item
116+
$item = Item::create([
117+
'title' => $request->input('title'),
118+
'content' => $request->input('content'),
119+
'user_id' => $userId,
120+
'private' => false,
121+
]);
122+
123+
return response()->json([
124+
'success' => true,
125+
'message' => 'Feedback submitted successfully',
126+
'item_id' => $item->id,
127+
'item_url' => route('items.show', $item),
128+
], 201);
129+
}
130+
}

app/Settings/WidgetSettings.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
namespace App\Settings;
4+
5+
use Spatie\LaravelSettings\Settings;
6+
7+
class WidgetSettings extends Settings
8+
{
9+
public bool $enabled;
10+
public string $position;
11+
public array $allowed_domains;
12+
public string $primary_color;
13+
public string $button_text;
14+
public bool $hide_button;
15+
16+
public static function group(): string
17+
{
18+
return 'widget';
19+
}
20+
}

boost.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"agents": [
3+
"claude_code",
4+
"phpstorm"
5+
],
6+
"editors": [
7+
"claude_code",
8+
"phpstorm"
9+
],
10+
"guidelines": []
11+
}

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"barryvdh/laravel-debugbar": "^3.6",
4040
"fakerphp/faker": "^1.9.1",
4141
"friendsofphp/php-cs-fixer": "^3.13.1",
42+
"laravel/boost": "^1.3",
4243
"mockery/mockery": "^1.4.4",
4344
"nunomaduro/collision": "^8.1",
4445
"pestphp/pest": "^3.8",

0 commit comments

Comments
 (0)