Версия 0.1.0 #1

Merged
kor-elf merged 20 commits from develop into main 2024-04-24 21:56:47 +05:00
44 changed files with 1050 additions and 0 deletions
Showing only changes of commit 8c353a49b7 - Show all commits

View File

@ -0,0 +1,10 @@
<?php declare(strict_types=1);
namespace App\Dto\Builder;
final readonly class ProjectFeedback
{
public function __construct(
) { }
}

View File

@ -0,0 +1,21 @@
<?php declare(strict_types=1);
namespace App\Dto;
final readonly class HttpUserData
{
public function __construct(
private ?string $clientIp = null,
private ?string $userAgent = null,
) { }
public function getClientIp(): ?string
{
return $this->clientIp;
}
public function getUserAgent(): ?string
{
return $this->userAgent;
}
}

View File

@ -0,0 +1,21 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Admin\Project\Feedback;
use App\Dto\Builder\ProjectFeedback;
use App\Dto\Service\Pages;
final readonly class Index extends Pages
{
public function __construct(
private ProjectFeedback $projectFeedbackBuilderDto,
int $page
) {
parent::__construct($page);
}
public function getProjectFeedbackBuilderDto(): ProjectFeedback
{
return $this->projectFeedbackBuilderDto;
}
}

View File

@ -30,6 +30,14 @@ public static function getTranslationCodes(): array
'site.Choose language', 'site.Choose language',
'site.Page without translation', 'site.Page without translation',
'site.Project', 'site.Project',
'site.Feedback',
'site.Feedback-send',
'site.required field',
'site.attributes.name',
'site.attributes.email',
'site.attributes.message',
'site.Message sent successfully',
'Server Error',
]; ];
} }
} }

View File

@ -0,0 +1,36 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Site\Feedback;
use App\Dto\HttpUserData;
use App\Dto\Service\Dto;
final readonly class Send extends Dto
{
public function __construct(
private string $message,
private HttpUserData $httpUserData,
private ?string $name,
private ?string $email,
) { }
public function getName(): ?string
{
return $this->name;
}
public function getEmail(): ?string
{
return $this->email;
}
public function getMessage(): string
{
return $this->message;
}
public function getHttpUserData(): HttpUserData
{
return $this->httpUserData;
}
}

View File

@ -11,6 +11,7 @@ enum Permission: string
case ProjectContent = 'project-content'; case ProjectContent = 'project-content';
case ProjectLink = 'project-link'; case ProjectLink = 'project-link';
case ProjectTranslation = 'project-translation'; case ProjectTranslation = 'project-translation';
case ProjectFeedback = 'project-feedback';
public function getPermissions(): array public function getPermissions(): array
{ {
@ -27,6 +28,9 @@ public function getPermissions(): array
'view' => __('permissions.Allowed to watch'), 'view' => __('permissions.Allowed to watch'),
'update' => __('permissions.Allowed to edit'), 'update' => __('permissions.Allowed to edit'),
], ],
self::ProjectFeedback => [
'view' => __('permissions.Allowed to watch'),
],
default => $this->getBasePermissions() default => $this->getBasePermissions()
}; };

View File

@ -8,6 +8,8 @@
enum ProjectSection enum ProjectSection
{ {
case Home; case Home;
case Feedback;
case FeedbackSend;
public function url(Project $project, ?ProjectLanguage $language = null): string public function url(Project $project, ?ProjectLanguage $language = null): string
{ {
@ -26,6 +28,9 @@ public function url(Project $project, ?ProjectLanguage $language = null): string
$route = match ($this) { $route = match ($this) {
self::Home => \route($prefixProject . 'home' . $prefixLanguage, $parameters, false), self::Home => \route($prefixProject . 'home' . $prefixLanguage, $parameters, false),
self::Feedback => \route($prefixProject . 'feedback' . $prefixLanguage, $parameters, false),
self::FeedbackSend => \route($prefixProject . 'feedback.send' . $prefixLanguage, $parameters, false),
}; };
return $project->http_host . $route; return $project->http_host . $route;

View File

@ -0,0 +1,52 @@
<?php declare(strict_types=1);
namespace App\Http\Controllers\Admin\Projects;
use App\Dto\QuerySettingsDto;
use App\Http\Controllers\Admin\Controller;
use App\Http\Requests\Admin\Projects\Feedbacks\IndexRequest;
use App\Services\Admin\Project\FeedbackService;
use Illuminate\View\View;
final class FeedbacksController extends Controller
{
public function __construct(
private readonly FeedbackService $feedbackService,
) { }
public function index(IndexRequest $request): View
{
$user = $request->user();
$data = $request->getDto();
$querySettingsDto = new QuerySettingsDto(
limit: 20,
page: $data->getPage(),
queryWith: ['project']
);
$result = $this->feedbackService->index($data->getProjectFeedbackBuilderDto(), $querySettingsDto, $user);
if ($result->isError()) {
$this->errors($result);
}
return view('admin.projects.feedbacks.index', $result->getData());
}
public function project(int $projectId, IndexRequest $request): View
{
$user = $request->user();
$data = $request->getDto();
$querySettingsDto = new QuerySettingsDto(
limit: 20,
page: $data->getPage(),
queryWith: []
);
$result = $this->feedbackService->project($projectId, $data->getProjectFeedbackBuilderDto(), $querySettingsDto, $user);
if ($result->isError()) {
$this->errors($result);
}
return view('admin.projects.feedbacks.project', $result->getData());
}
}

View File

@ -0,0 +1,41 @@
<?php declare(strict_types=1);
namespace App\Http\Controllers\Site;
use App\Http\Requests\Site\Feedback\SendRequest;
use App\Models\ProjectFeedback;
use App\Services\Site\FeedbackService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
final class FeedbackController extends Controller
{
public function __construct(
private readonly FeedbackService $feedbackService,
) { }
public function index(Request $request): View
{
return view('site.feedback.index', [
'project' => $request->get('project'),
'websiteTranslations' => $request->get('websiteTranslations'),
]);
}
public function send(SendRequest $request): RedirectResponse
{
$project = $request->get('project');
$websiteTranslations = $request->get('websiteTranslations');
$user = $request->user();
$data = $request->getDto();
$result = $this->feedbackService->send($data, $project, $websiteTranslations, $user);
if ($result->isError()) {
return redirect()->back()->withInput()->withErrors($result->getErrorsOrMessage());
}
$url = \App\Enums\Site\ProjectSection::Feedback->url($project, $websiteTranslations->getLanguage());
return redirect($url)->withSuccess($result->getMessage());
}
}

View File

@ -0,0 +1,29 @@
<?php declare(strict_types=1);
namespace App\Http\Requests\Admin\Projects\Feedbacks;
use App\Contracts\FormRequestDto;
use App\Dto\Builder\ProjectFeedback;
use App\Dto\Service\Admin\Project\Feedback\Index;
use Illuminate\Foundation\Http\FormRequest;
final class IndexRequest extends FormRequest implements FormRequestDto
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'page' => ['nullable', 'numeric', 'min:1']
];
}
public function getDto(): Index
{
return new Index(
projectFeedbackBuilderDto: new ProjectFeedback(),
page: (int) $this->input('page', 1),
);
}
}

View File

@ -0,0 +1,57 @@
<?php declare(strict_types=1);
namespace App\Http\Requests\Site\Feedback;
use App\Contracts\FormRequestDto;
use App\Dto\HttpUserData;
use App\Dto\Service\Site\Feedback\Send;
use App\Services\WebsiteTranslations;
use Illuminate\Foundation\Http\FormRequest;
final class SendRequest extends FormRequest implements FormRequestDto
{
public function attributes(): array
{
$attributes = [];
$websiteTranslations = $this->request->get('websiteTranslations', null);
if ($websiteTranslations) {
/** @var WebsiteTranslations $websiteTranslations */
$attributes = [
'name' => $websiteTranslations->translate('site.attributes.name'),
'email' => $websiteTranslations->translate('site.attributes.email'),
'message' => $websiteTranslations->translate('site.attributes.message'),
];
}
return $attributes;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'name' => ['nullable', 'string', 'max:255'],
'email' => ['nullable', 'string', 'max:255', 'email'],
'message' => ['required', 'string', 'max:5000'],
'captcha-verified' => ['captcha'],
];
}
public function getDto(): Send
{
$httpUserData = new HttpUserData(
clientIp: $this->getClientIp(),
userAgent: $this->userAgent(),
);
return new Send(
message: $this->input('message'),
httpUserData: $httpUserData,
name: $this->input('name', null),
email: $this->input('email', null),
);
}
}

View File

@ -57,4 +57,9 @@ public function links(): HasMany
{ {
return $this->hasMany(ProjectLink::class); return $this->hasMany(ProjectLink::class);
} }
public function feedbacks(): HasMany
{
return $this->hasMany(ProjectFeedback::class);
}
} }

View File

@ -0,0 +1,52 @@
<?php declare(strict_types=1);
namespace App\Models;
use App\Models\Scopes\CreatedScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[ScopedBy([CreatedScope::class])]
final class ProjectFeedback extends Model
{
use HasUuids, HasFactory, SoftDeletes;
protected $table = 'project_feedback';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name',
'email',
'message',
'ip',
'user_agent',
'user_id',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'name' => 'encrypted',
'email' => 'encrypted',
'message' => 'encrypted',
];
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
}

View File

@ -0,0 +1,18 @@
<?php declare(strict_types=1);
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class CreatedScope implements Scope
{
/**
* Apply the scope to a given Eloquent query builder.
*/
public function apply(Builder $builder, Model $model): void
{
$builder->orderBy('created_at', 'desc');
}
}

View File

@ -0,0 +1,19 @@
<?php declare(strict_types=1);
namespace App\Policies;
use App\Models\ProjectFeedback;
use App\Models\User;
final readonly class ProjectFeedbackPolicy extends Policy
{
public function viewAny(User $user): bool
{
return $user->hasPermission('project-feedback.view');
}
public function view(User $user, ProjectFeedback $projectFeedback): bool
{
return $user->hasPermission('project-feedback.view');
}
}

View File

@ -0,0 +1,38 @@
<?php declare(strict_types=1);
namespace App\Repositories;
use App\Contracts\Search;
use App\Dto\Builder\ProjectFeedback as ProjectFeedbackBuilderDto;
use App\Models\Project;
use App\Models\ProjectFeedback;
use App\Services\ProjectFeedback\BuilderCommand;
use App\Services\Search\CreateSearchInstanceCommand;
final readonly class ProjectFeedbackRepository
{
public function __construct(
private CreateSearchInstanceCommand $createSearchInstanceCommand,
private BuilderCommand $builderCommand
) { }
public function getFeedbacks(ProjectFeedbackBuilderDto $projectFeedbackBuilderDto, array $with = []): Search
{
$query = $this->builderCommand->execute(
query: ProjectFeedback::query()->with($with),
projectFeedbackBuilderDto: $projectFeedbackBuilderDto
);
return $this->createSearchInstanceCommand->execute($query);
}
public function getFeedbacksByProject(Project $project, ProjectFeedbackBuilderDto $projectFeedbackBuilderDto, array $with = []): Search
{
$query = $this->builderCommand->execute(
query: $project->feedbacks()->with($with),
projectFeedbackBuilderDto: $projectFeedbackBuilderDto
);
return $this->createSearchInstanceCommand->execute($query);
}
}

View File

@ -0,0 +1,66 @@
<?php declare(strict_types=1);
namespace App\Services\Admin\Project;
use App\Dto\Builder\ProjectFeedback as ProjectFeedbackBuilderDto;
use App\Dto\QuerySettingsDto;
use App\Models\ProjectFeedback;
use App\Models\User;
use App\Repositories\ProjectFeedbackRepository;
use App\Repositories\ProjectRepository;
use App\ServiceResults\ServiceResultArray;
use App\ServiceResults\ServiceResultError;
use App\Services\Service;
final class FeedbackService extends Service
{
public function __construct(
private readonly ProjectFeedbackRepository $projectFeedbackRepository,
private readonly ProjectRepository $projectRepository,
) { }
public function index(ProjectFeedbackBuilderDto $projectFeedbackBuilderDto, QuerySettingsDto $querySettingsDto, User $user): ServiceResultError | ServiceResultArray
{
if ($user->cannot('viewAny', ProjectFeedback::class)) {
return $this->errFobidden(__('Access is denied'));
}
$feedbacks = $this->projectFeedbackRepository->getFeedbacks(
$projectFeedbackBuilderDto,
$querySettingsDto->getQueryWith()
)->pagination(
$querySettingsDto->getLimit(),
$querySettingsDto->getPage()
);
return $this->result([
'feedbacks' => $feedbacks,
]);
}
public function project(int $projectId, ProjectFeedbackBuilderDto $projectFeedbackBuilderDto, QuerySettingsDto $querySettingsDto, User $user): ServiceResultError | ServiceResultArray
{
$project = $this->projectRepository->getProjectById($projectId);
if (\is_null($project)) {
return $this->errNotFound(__('Not Found'));
}
if ($user->cannot('viewAny', ProjectFeedback::class)) {
return $this->errFobidden(__('Access is denied'));
}
$feedbacks = $this->projectFeedbackRepository->getFeedbacksByProject(
$project,
$projectFeedbackBuilderDto,
$querySettingsDto->getQueryWith()
)->pagination(
$querySettingsDto->getLimit(),
$querySettingsDto->getPage()
);
return $this->result([
'project' => $project,
'feedbacks' => $feedbacks,
]);
}
}

View File

@ -0,0 +1,15 @@
<?php declare(strict_types=1);
namespace App\Services\ProjectFeedback;
use App\Dto\Builder\ProjectFeedback as ProjectFeedbackBuilderDto;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
final readonly class BuilderCommand
{
public function execute(Relation | Builder $query, ProjectFeedbackBuilderDto $projectFeedbackBuilderDto): Relation | Builder
{
return $query;
}
}

View File

@ -0,0 +1,15 @@
<?php declare(strict_types=1);
namespace App\Services\ProjectFeedback;
use App\Models\Project;
use App\Models\ProjectFeedback;
final readonly class ProjectFeedbackCommandHandler
{
public function handleStore(Project $project, array $data): ProjectFeedback
{
$feedback = $project->feedbacks()->create($data);
return $feedback;
}
}

View File

@ -0,0 +1,53 @@
<?php declare(strict_types=1);
namespace App\Services\Site;
use App\Dto\Service\Site\Feedback\Send;
use App\Models\Project;
use App\Models\User;
use App\ServiceResults\ServiceResultError;
use App\ServiceResults\ServiceResultSuccess;
use App\Services\ProjectFeedback\ProjectFeedbackCommandHandler;
use App\Services\Service;
use App\Services\WebsiteTranslations;
use Illuminate\Support\Facades\DB;
final class FeedbackService extends Service
{
public function __construct(
private readonly ProjectFeedbackCommandHandler $feedbackCommandHandler,
) { }
public function send(Send $send, Project $project, WebsiteTranslations $websiteTranslations, ?User $user = null): ServiceResultError | ServiceResultSuccess
{
try {
DB::transaction(function () use ($send, $project, $user) {
$data = $this->getDataFeedback($send);
$data['user_id'] = $user?->id;
$this->feedbackCommandHandler->handleStore($project, $data);
});
} catch (\Throwable $e) {
report($e);
return $this->errService($websiteTranslations->translate('Server Error'));
}
return $this->ok($websiteTranslations->translate('site.Message sent successfully'));
}
private function getDataFeedback(Send $send): array
{
$name = $send->getName();
if ($name !== null) {
$name = strip_tags($name);
}
$message = $send->getMessage();
$message = strip_tags($message, ['<b>', '<strong>', '<p>', '<br>', '<ul>', '<li>', '<ol>', '<table>', '<tr>', '<td>', '<th>', '<i>',]);
return [
'name' => $name,
'email' => $send->getEmail(),
'message' => $message,
'ip' => $send->getHttpUserData()->getClientIp(),
'user_agent' => $send->getHttpUserData()->getUserAgent(),
];
}
}

View File

@ -0,0 +1,26 @@
<?php declare(strict_types=1);
namespace App\View\Components\Site\Forms;
use App\Helpers\Helpers;
use Illuminate\View\Component;
use Illuminate\View\View;
abstract class Form extends Component
{
private ?string $requestName = null;
abstract protected function getName(): string;
abstract public function render(): View;
protected function getRequestName(): string
{
if (!is_null($this->requestName)) {
return $this->requestName;
}
$this->requestName = Helpers::formatAttributeNameToRequestName($this->getName());
return $this->requestName;
}
}

View File

@ -0,0 +1,58 @@
<?php declare(strict_types=1);
namespace App\View\Components\Site\Forms;
use App\Services\WebsiteTranslations;
use Illuminate\Support\Str;
use Illuminate\View\View;
final class Input extends Form
{
public function __construct(
private readonly WebsiteTranslations $websiteTranslations,
private readonly string $title,
private readonly string $name,
private readonly string $type = 'text',
private readonly ?string $value = '',
) { }
protected function getName(): string
{
return $this->name;
}
private function getTitle(): string
{
return Str::ucfirst($this->title);
}
private function getType(): string
{
return $this->type;
}
private function getValue(): string
{
return (string) old($this->getRequestName(), $this->value);
}
private function getWebsiteTranslations(): WebsiteTranslations
{
return $this->websiteTranslations;
}
/**
* @inheritDoc
*/
public function render(): View
{
return view('components.site.forms.input', [
'websiteTranslations' => $this->getWebsiteTranslations(),
'title' => $this->getTitle(),
'name' => $this->getName(),
'requestName' => $this->getRequestName(),
'type' => $this->getType(),
'value' => $this->getValue(),
]);
}
}

View File

@ -0,0 +1,54 @@
<?php declare(strict_types=1);
namespace App\View\Components\Site\Forms;
use App\Services\WebsiteTranslations;
use Illuminate\Support\Str;
use Illuminate\View\View;
final class TextareaWysiwyg extends Form
{
public function __construct(
private readonly WebsiteTranslations $websiteTranslations,
private readonly string $title,
private readonly string $name,
private readonly ?string $value = '',
) { }
protected function getName(): string
{
return $this->name;
}
private function getTitle(): string
{
return Str::ucfirst($this->title);
}
private function getValue(): string
{
return (string) old($this->getRequestName(), $this->value);
}
private function getWebsiteTranslations(): WebsiteTranslations
{
return $this->websiteTranslations;
}
/**
* @inheritDoc
*/
public function render(): View
{
$tinymceLicenseKey = config('tinymce.license_key');
return view('components.site.forms.textarea-wysiwyg', [
'websiteTranslations' => $this->getWebsiteTranslations(),
'tinymceLicenseKey' => $tinymceLicenseKey,
'title' => $this->getTitle(),
'name' => $this->getName(),
'requestName' => $this->getRequestName(),
'value' => $this->getValue(),
]);
}
}

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('project_feedback', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->unsignedBigInteger('project_id')->index();
$table->foreign('project_id')->references('id')->on('projects');
$table->unsignedBigInteger('user_id')->nullable()->index();
$table->foreign('user_id')->references('id')->on('users');
$table->text('name')->nullable();
$table->text('email')->nullable();
$table->text('message');
$table->ipAddress('ip')->nullable();
$table->string('user_agent')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['created_at']);
$table->index(['project_id', 'deleted_at']);
$table->index(['user_id', 'deleted_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('project_feedback');
}
};

View File

@ -12,4 +12,5 @@
'Last update' => 'Last update', 'Last update' => 'Last update',
'Links project' => 'Links from the project', 'Links project' => 'Links from the project',
'Translations' => 'Translations', 'Translations' => 'Translations',
'Feedback' => 'Feedback',
]; ];

View File

@ -16,4 +16,5 @@
'ProjectContent' => 'About the project', 'ProjectContent' => 'About the project',
'ProjectLink' => 'Links from the project', 'ProjectLink' => 'Links from the project',
'ProjectTranslation' => 'Translations', 'ProjectTranslation' => 'Translations',
'ProjectFeedback' => 'Feedback',
]; ];

View File

@ -7,4 +7,13 @@
'Page without translation' => 'Page without translation', 'Page without translation' => 'Page without translation',
'Powered by service' => 'Powered by the My Projects website engine', 'Powered by service' => 'Powered by the My Projects website engine',
'Choose language' => 'Choose language', 'Choose language' => 'Choose language',
'Feedback' => 'Feedback',
'Feedback-send' => 'Send review',
'required field' => 'required field',
'Message sent successfully' => 'Message sent successfully',
'attributes' => [
'name' => 'name',
'email' => 'email',
'message' => 'message',
],
]; ];

View File

@ -12,4 +12,5 @@
'Last update' => 'Последнее обновление', 'Last update' => 'Последнее обновление',
'Links project' => 'Ссылки от проекта', 'Links project' => 'Ссылки от проекта',
'Translations' => 'Переводы', 'Translations' => 'Переводы',
'Feedback' => 'Обратная связь',
]; ];

View File

@ -16,4 +16,5 @@
'ProjectContent' => 'О проекте', 'ProjectContent' => 'О проекте',
'ProjectLink' => 'Ссылки от проекта', 'ProjectLink' => 'Ссылки от проекта',
'ProjectTranslation' => 'Переводы', 'ProjectTranslation' => 'Переводы',
'ProjectFeedback' => 'Обратная связь',
]; ];

View File

@ -7,4 +7,13 @@
'Page without translation' => 'Страница без перевода', 'Page without translation' => 'Страница без перевода',
'Powered by service' => 'Работает на движке сайта «Мои проекты»', 'Powered by service' => 'Работает на движке сайта «Мои проекты»',
'Choose language' => 'Выберите язык', 'Choose language' => 'Выберите язык',
'Feedback' => 'Обратная связь',
'Feedback-send' => 'Отправить отзыв',
'required field' => 'обязательное поле',
'Message sent successfully' => 'Сообщение успешно отправлено',
'attributes' => [
'name' => 'имя',
'email' => 'email',
'message' => 'сообщение',
],
]; ];

View File

@ -1,5 +1,6 @@
@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,400;0,700;1,400;1,700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,400;0,700;1,400;1,700&display=swap');
@import "reset"; @import "reset";
@import "forms";
html, html,
body { body {

View File

@ -0,0 +1,86 @@
.form-block {
padding: 10px 0;
label {
display: block;
font-size: 16px;
font-weight: 600;
padding-bottom: 7px;
color: #000;
span {
font-size: 14px;
font-weight: 400;
color: #555;
}
}
.form-control {
display: block;
width: 100%;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid #ddd;
}
.form-control.is-invalid {
border-color: #dc3545;
}
.form-control:focus,
.form-control:focus-visible {
outline: 2px solid #006ce7;
}
.invalid-feedback {
display: block;
padding: 7px 10px;
color: #dc3545;
font-size: 14px;
border: 1px solid #dc3545;
border-radius: 6px;
margin-top: 10px;
margin-bottom: 10px;
}
}
.button {
background: #006ce7;
border: 1px solid #002452;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
font-size: 16px;
padding: 14px 24px;
color: #fff;
}
.button:hover {
opacity: 0.8;
border-color: #006ce7;
color: #ccc;
}
.captcha-service-kor-elf {
position: relative;
z-index: 10;
}
.alert {
border-radius: 6px;
font-size: 16px;
padding: 14px 24px;
ul {
padding: 0;
margin: 0;
list-style: none;
}
}
.alert.alert-success {
border: 1px solid #0a6f4d;
color: #0a6f4d;
}
.alert.alert-danger {
border: 1px solid #dc3545;
color: #dc3545;
}

View File

@ -26,6 +26,22 @@
</li> </li>
@endcan @endcan
@can('viewAny', \App\Models\ProjectFeedback::class)
<li @class([
'nav-item',
'active' => request()->route()->named('admin.feedbacks.*'),
])>
<a href="{{ route('admin.feedbacks.index') }}" class="nav-link">
<span class="sidebar-icon">
<svg class="icon icon-xs me-2" data-slot="icon" fill="none" stroke-width="1.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"></path>
</svg>
</span>
<span class="sidebar-text">{{ __('admin-sections.Feedback') }}</span>
</a>
</li>
@endcan
@can('viewAny', \App\Models\User::class) @can('viewAny', \App\Models\User::class)
<li @class([ <li @class([
'nav-item', 'nav-item',

View File

@ -0,0 +1,37 @@
@section('meta_title', __('admin-sections.Feedback'))
@section('h1', __('admin-sections.Feedback'))
<x-admin.layout>
<div class="card border-0 shadow mb-4">
<div class="card-body">
<div class="table-responsive">
<table class="table table-centered table-nowrap mb-0 rounded">
<thead class="thead-light">
<tr>
<th class="border-0">{{ __('admin-sections.Project') }}</th>
<th class="border-0" style="width: 100%;">{{ __('site.attributes.message') }}</th>
<th class="border-0">{{ __('site.attributes.name') }}</th>
<th class="border-0">{{ __('site.attributes.email') }}</th>
</tr>
</thead>
<tbody>
@foreach($feedbacks as $feedback)
<tr>
<td>{{ $feedback->project->name }}</td>
<td>{!! $feedback->message !!}</td>
<td>
<p>{{ $feedback->name }}</p>
<p><strong>IP:</strong> {{ $feedback->ip }}</p>
<p><strong>UserAgent:</strong> {{ $feedback->user_agent }}</p>
</td>
<td>{{ $feedback->email }}</td>
</tr>
@endforeach
</tbody>
</table>
<div class="card-footer border-0">
{{ $feedbacks->links() }}
</div>
</div>
</div>
</div>
</x-admin.layout>

View File

@ -0,0 +1,36 @@
@section('meta_title', __('admin-sections.Project') . ': ' . $project->name . ' - ' . __('admin-sections.Feedback'))
@section('h1', __('admin-sections.Project') . ': ' . $project->name)
<x-admin.layout>
<div class="card border-0 shadow mb-4">
<div class="card-body">
<h3 id="category" class="mb-4">{{ __('admin-sections.Feedback') }}</h3>
<div class="table-responsive">
<table class="table table-centered table-nowrap mb-0 rounded">
<thead class="thead-light">
<tr>
<th class="border-0" style="width: 100%;">{{ __('site.attributes.message') }}</th>
<th class="border-0">{{ __('site.attributes.name') }}</th>
<th class="border-0">{{ __('site.attributes.email') }}</th>
</tr>
</thead>
<tbody>
@foreach($feedbacks as $feedback)
<tr>
<td>{!! $feedback->message !!}</td>
<td>
<p>{{ $feedback->name }}</p>
<p><strong>IP:</strong> {{ $feedback->ip }}</p>
<p><strong>UserAgent:</strong> {{ $feedback->user_agent }}</p>
</td>
<td>{{ $feedback->email }}</td>
</tr>
@endforeach
</tbody>
</table>
<div class="card-footer border-0">
{{ $feedbacks->links() }}
</div>
</div>
</div>
</div>
</x-admin.layout>

View File

@ -48,6 +48,18 @@
</td> </td>
</tr> </tr>
@endcan @endcan
@can('viewAny', \App\Models\ProjectFeedback::class)
<tr>
<td>
<a href="{{ route('admin.projects.feedbacks.index', ['project' => $project->id]) }}" class="fw-bold">
<svg width="16" height="16" class="align-text-top" data-slot="icon" fill="none" stroke-width="1.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"></path>
</svg>
{{ __('admin-sections.Feedback') }}
</a>
</td>
</tr>
@endcan
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -0,0 +1,12 @@
<div class="form-block">
<label for="form-input-{{ $requestName }}">
{{ $title }}
@if($attributes->get('required'))
<span>{{ $websiteTranslations->translate('site.required field') }}</span>
@endif
</label>
<input id="form-input-{{ $requestName }}" class="form-control @error($requestName) is-invalid @enderror" name="{{ $name }}" type="{{ $type }}" @if($type !== 'password') value="{{ $value }}" @endif {{ $attributes }}>
@error($requestName)
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>

View File

@ -0,0 +1,36 @@
<div class="form-block">
<label for="form-textarea-wysiwyg-{{ $requestName }}">
{{ $title }}
@if($attributes->get('required'))
<span>{{ $websiteTranslations->translate('site.required field') }}</span>
@endif
</label>
<textarea class="form-control textarea-tinymce @error($requestName) is-invalid @enderror" name="{{ $name }}" id="form-textarea-wysiwyg-{{ $requestName }}" rows="3">{{ $value }}</textarea>
@error($requestName)
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
@pushOnce('scripts')
<script src="{{ asset('/build/tinymce/tinymce.min.js') }}" referrerpolicy="origin"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
tinymce.init({
selector: '.textarea-tinymce',
@if(in_array(app()->getLocale(), ['ru'], true))
language: '{{ app()->getLocale() }}',
@endif
license_key: '{{ $tinymceLicenseKey }}',
plugins: 'emoticons lists table',
toolbar: 'bold italic | bullist numlist table | emoticons',
menubar: false,
mobile: {
menubar: false,
plugins: 'emoticons lists table',
toolbar: 'bold italic bullist numlist table emoticons',
},
referrer_policy: 'origin',
});
});
</script>
@endpushonce

View File

@ -37,11 +37,16 @@
<div class="menu__title">{{ __('site.Menu') }}</div> <div class="menu__title">{{ __('site.Menu') }}</div>
<ul> <ul>
<li><a href="{{ \App\Enums\Site\ProjectSection::Home->url($project, $websiteTranslations->getLanguage()) }}" @class(['active' => request()->route()->named(['home', 'language.home', 'project.home', 'project.language.home'])])>{{ $websiteTranslations->translate('site.About project') }}</a></li> <li><a href="{{ \App\Enums\Site\ProjectSection::Home->url($project, $websiteTranslations->getLanguage()) }}" @class(['active' => request()->route()->named(['home', 'language.home', 'project.home', 'project.language.home'])])>{{ $websiteTranslations->translate('site.About project') }}</a></li>
<li><a href="{{ \App\Enums\Site\ProjectSection::Feedback->url($project, $websiteTranslations->getLanguage()) }}" @class(['active' => request()->route()->named(['feedback', 'language.feedback', 'project.feedback', 'project.language.feedback'])])>{{ $websiteTranslations->translate('site.Feedback') }}</a></li>
</ul> </ul>
</nav> </nav>
<div class="section-container"> <div class="section-container">
<div class="content"> <div class="content">
<h1>@yield('h1', '')</h1> <h1>@yield('h1', '')</h1>
@includeWhen($errors->any(), 'layout.site._errors', ['errors' => $errors->all()])
@includeWhen(Session::has('success'), 'layout.site._success', ['success' => Session::get('success')])
{{ $slot }} {{ $slot }}
</div> </div>
<footer class="footer"> <footer class="footer">
@ -51,5 +56,6 @@
</div> </div>
</div> </div>
@vite('resources/site/js/app.js') @vite('resources/site/js/app.js')
@stack('scripts')
</body> </body>
</html> </html>

View File

@ -0,0 +1,7 @@
<div class="alert alert-danger">
<ul>
@foreach($errors as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>

View File

@ -0,0 +1,3 @@
<div class="alert alert-success">
{{ $success }}
</div>

View File

@ -0,0 +1,22 @@
@section('meta_title', $websiteTranslations->translate('site.Feedback'))
@section('h1', $websiteTranslations->translate('site.Feedback'))
<x-site.layout :project="$project" :websiteTranslations="$websiteTranslations">
@if(Session::has('success') !== true)
<form method="post" action="{{ \App\Enums\Site\ProjectSection::FeedbackSend->url($project, $websiteTranslations->getLanguage()) }}">
@csrf
<x-site.forms.input :title="$websiteTranslations->translate('site.attributes.name')" :websiteTranslations="$websiteTranslations" name="name" type="text" value="" autofocus />
<x-site.forms.input :title="$websiteTranslations->translate('site.attributes.email')" :websiteTranslations="$websiteTranslations" name="email" type="text" value="" />
<x-site.forms.textarea-wysiwyg :title="$websiteTranslations->translate('site.attributes.message')" :websiteTranslations="$websiteTranslations" required name="message" value="" />
<div class="form-block">
@captcha
@error('captcha-verified')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
<div class="form-block">
<button class="button" type="submit">{{ $websiteTranslations->translate('site.Feedback-send') }}</button>
</div>
</form>
@endif
</x-site.layout>

View File

@ -4,3 +4,10 @@
Route::get('/', [\App\Http\Controllers\Site\ProjectsController::class, 'index'])->name('home'); Route::get('/', [\App\Http\Controllers\Site\ProjectsController::class, 'index'])->name('home');
Route::get('/language/{language}', [\App\Http\Controllers\Site\ProjectsController::class, 'index'])->name('home-language'); Route::get('/language/{language}', [\App\Http\Controllers\Site\ProjectsController::class, 'index'])->name('home-language');
Route::get('feedback', [\App\Http\Controllers\Site\FeedbackController::class, 'index'])->name('feedback');
Route::get('feedback/language/{language}', [\App\Http\Controllers\Site\FeedbackController::class, 'index'])->name('feedback-language');
Route::get('feedback/success', [\App\Http\Controllers\Site\FeedbackController::class, 'index'])->name('feedback.success');
Route::get('feedback/success/language/{language}', [\App\Http\Controllers\Site\FeedbackController::class, 'index'])->name('feedback.success-language');
Route::post('feedback', [\App\Http\Controllers\Site\FeedbackController::class, 'send'])->name('feedback.send');
Route::post('feedback/language/{language}', [\App\Http\Controllers\Site\FeedbackController::class, 'send'])->name('feedback.send-language');

View File

@ -31,8 +31,12 @@
Route::get('translations/{language}', [\App\Http\Controllers\Admin\Projects\TranslationsController::class, 'edit'])->name('translations.edit')->where(['language' => '[0-9]+']); Route::get('translations/{language}', [\App\Http\Controllers\Admin\Projects\TranslationsController::class, 'edit'])->name('translations.edit')->where(['language' => '[0-9]+']);
Route::post('translations/{language}', [\App\Http\Controllers\Admin\Projects\TranslationsController::class, 'update'])->name('translations.update')->where(['language' => '[0-9]+']); Route::post('translations/{language}', [\App\Http\Controllers\Admin\Projects\TranslationsController::class, 'update'])->name('translations.update')->where(['language' => '[0-9]+']);
Route::get('feedbacks', [\App\Http\Controllers\Admin\Projects\FeedbacksController::class, 'project'])->name('feedbacks.index');
})->where(['project' => '[0-9]+']); })->where(['project' => '[0-9]+']);
Route::get('feedbacks', [\App\Http\Controllers\Admin\Projects\FeedbacksController::class, 'index'])->name('feedbacks.index');
Route::post('languages/new-language', [\App\Http\Controllers\Admin\LanguagesController::class, 'newLanguage'])->name('new-language'); Route::post('languages/new-language', [\App\Http\Controllers\Admin\LanguagesController::class, 'newLanguage'])->name('new-language');
}); });