Compare commits

...

2 Commits

Author SHA1 Message Date
7f380303ab Adds the whole disclaimer feature 2026-03-16 15:22:17 +01:00
29a94899da Fixes the configuration file 2026-03-16 14:34:07 +01:00
23 changed files with 1050 additions and 37 deletions

74
app/Configs/Content.php Normal file
View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Configs;
use Illuminate\Support\Arr;
use Laravel\Nova\Fields\Markdown;
/**
* Config Field class for the "content" config group.
* Defines fields, defaults, and Nova fields for content settings.
*/
final class Content
{
public string $title = 'Content';
public string $description = 'Content settings';
public string $key = 'content';
/**
* Returns the field definitions with their type and default value for this config group.
*/
public function getFieldKeys(): array
{
return [
'disclaimer' => [
'type' => 'markdown',
'default' => '',
],
];
}
/**
* Returns the default value for the given field key, or null if the key does not exist.
*/
public function getDefault(string $key): mixed
{
$fields = $this->getFieldKeys();
if (! Arr::has($fields, $key)) {
return null;
}
return Arr::get($fields, "{$key}.default");
}
/**
* Returns Nova field instances for this config group, each wired with
* resolveUsing (read from json_value with default fallback) and
* fillUsing (write back into json_value) callbacks.
*/
public function getNovaFields(): array
{
return [
Markdown::make('Disclaimer', 'disclaimer')
->resolveUsing(function (mixed $value, mixed $resource): string {
$jsonValue = Arr::get((array) $resource->json_value, 'disclaimer');
if ($jsonValue === null || $jsonValue === '') {
return (string) $this->getDefault('disclaimer');
}
return (string) $jsonValue;
})
->fillUsing(function (mixed $request, mixed $model, string $attribute, string $requestAttribute): void {
$current = Arr::wrap((array) $model->json_value);
Arr::set($current, $attribute, $request->{$requestAttribute});
$model->json_value = $current;
}),
];
}
}

View File

@@ -4,16 +4,29 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\Config as ConfigService;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
final class LandingController extends Controller final class LandingController extends Controller
{ {
/** /**
* Display the landing page. * Display the landing page with a flag indicating whether a disclaimer is configured.
*/ */
public function index(): Response public function index(ConfigService $config): Response
{ {
return Inertia::render('Landing'); return Inertia::render('Landing', [
'hasDisclaimer' => $config->contentDisclaimer !== '',
]);
}
/**
* Display the disclaimer page with the configured markdown content.
*/
public function disclaimer(ConfigService $config): Response
{
return Inertia::render('Disclaimer', [
'content' => $config->contentDisclaimer,
]);
} }
} }

View File

@@ -103,15 +103,17 @@ private function calculateAndUpdateScore(Screening $screening, array $answers):
} }
/** /**
* Calculate the total score from the answers. * Calculate the total score from the answers yes = 1, unknown = 0.5, no = 0.
*/ */
private function calculateScore(array $answers): int private function calculateScore(array $answers): float
{ {
$score = 0; $score = 0;
foreach ($answers as $value) { foreach ($answers as $value) {
if ($value === 'yes') { if ($value === 'yes') {
$score++; $score++;
} elseif ($value === 'unknown') {
$score += 0.5;
} }
} }

View File

@@ -205,11 +205,14 @@ public function result(Session $session): InertiaResponse
{ {
$session->load('category'); $session->load('category');
$durationSeconds = $session->created_at->diffInSeconds($session->completed_at);
return Inertia::render('Session/Result', [ return Inertia::render('Session/Result', [
'session' => $session, 'session' => $session,
'score' => $session->score, 'score' => $session->score,
'result' => $session->result, 'result' => $session->result,
'categoryName' => $session->category->name, 'categoryName' => $session->category->name,
'durationSeconds' => $durationSeconds,
]); ]);
} }

View File

@@ -25,7 +25,7 @@ public function rules(): array
{ {
return [ return [
'answers' => ['required', 'array', 'size:10'], 'answers' => ['required', 'array', 'size:10'],
'answers.*' => ['required', 'string', 'in:yes,no'], 'answers.*' => ['required', 'string', 'in:yes,unknown,no'],
]; ];
} }
@@ -40,7 +40,7 @@ public function messages(): array
'answers.size' => 'All 10 screening questions must be answered.', 'answers.size' => 'All 10 screening questions must be answered.',
'answers.*.required' => 'Each screening question must have an answer.', 'answers.*.required' => 'Each screening question must have an answer.',
'answers.*.string' => 'Each answer must be a valid text value.', 'answers.*.string' => 'Each answer must be a valid text value.',
'answers.*.in' => 'Each answer must be either "yes" or "no".', 'answers.*.in' => 'Each answer must be "yes", "I don\'t know", or "no".',
]; ];
} }
} }

42
app/Models/Config.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
final class Config extends Model
{
/** The primary key column for config entries is a string key, not an auto-incrementing integer. */
protected $primaryKey = 'key';
/** Disable auto-incrementing since the primary key is a string. */
public $incrementing = false;
/** The primary key type is a string. */
protected $keyType = 'string';
/** Allow mass assignment on all columns. */
protected $guarded = [];
/**
* Returns the attribute cast definitions for this model,
* ensuring json_value is always hydrated as a PHP array.
*/
protected function casts(): array
{
return [
'json_value' => 'array',
];
}
/**
* Scope to filter config records by their string key identifier.
*/
public function scopeForKey(Builder $query, string $key): Builder
{
return $query->where('key', $key);
}
}

139
app/Nova/ConfigResource.php Normal file
View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Nova;
use Laravel\Nova\Fields\DateTime;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Http\Requests\NovaRequest;
final class ConfigResource extends Resource
{
/**
* The model the resource corresponds to.
*
* @var class-string<\App\Models\Config>
*/
public static string $model = \App\Models\Config::class;
/**
* The columns that should be searched.
*
* @var array
*/
public static $search = ['key'];
/**
* The logical group associated with the resource.
*
* @var string
*/
public static $group = 'Other';
/**
* Get the displayable label of the resource.
*/
public static function label(): string
{
return 'Settings';
}
/**
* Get the displayable singular label of the resource.
*/
public static function singularLabel(): string
{
return 'Setting';
}
/**
* Resolves the display title from the config class title or falls back to the raw key.
*/
public function title(): string
{
return $this->getConfigClass()?->title ?? $this->resource->key;
}
/**
* Get the fields displayed by the resource.
*
* @return array<int, \Laravel\Nova\Fields\Field|\Laravel\Nova\Panel|\Laravel\Nova\ResourceTool|\Illuminate\Http\Resources\MergeValue>
*/
public function fields(NovaRequest $request): array
{
return array_merge(
[
Text::make('Name', 'key')
->resolveUsing(fn ($value) => $this->getConfigClass()?->title ?? $value)
->exceptOnForms(),
Text::make('Description', 'key')
->resolveUsing(fn () => $this->getConfigClass()?->description ?? '')
->exceptOnForms(),
],
array_map(
fn ($field) => $field->hideFromIndex(),
$this->getConfigClass()?->getNovaFields() ?? []
),
[
DateTime::make('Created At')->exceptOnForms()->hideFromIndex()->sortable()->filterable(),
DateTime::make('Updated At')->exceptOnForms()->hideFromIndex()->sortable()->filterable(),
],
);
}
/**
* Get the cards available for the request.
*
* @return array<int, \Laravel\Nova\Card>
*/
public function cards(NovaRequest $request): array
{
return [];
}
/**
* Get the filters available for the resource.
*
* @return array<int, \Laravel\Nova\Filters\Filter>
*/
public function filters(NovaRequest $request): array
{
return [];
}
/**
* Get the lenses available for the resource.
*
* @return array<int, \Laravel\Nova\Lenses\Lens>
*/
public function lenses(NovaRequest $request): array
{
return [];
}
/**
* Get the actions available for the resource.
*
* @return array<int, \Laravel\Nova\Actions\Action>
*/
public function actions(NovaRequest $request): array
{
return [];
}
/**
* Resolves the config class instance for this resource's key via the Config service.
*/
private function getConfigClass(): ?object
{
$key = $this->resource?->key;
if ($key === null) {
return null;
}
return app(\App\Services\Config::class)->getConfigClassForGroup($key);
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Config;
use App\Models\User;
final class ConfigPolicy
{
/**
* Determine whether the user can view any config records.
* Config records are auto-created by the Config Service, so all users may view the list.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view a specific config record.
*/
public function view(User $user, Config $config): bool
{
return true;
}
/**
* Determine whether the user can update the config record.
* All users with Nova access may edit config values.
*/
public function update(User $user, Config $config): bool
{
return true;
}
/**
* Determine whether the user can create config records.
* Config records are auto-created by the Config Service manual creation is not permitted.
*/
public function create(User $user): bool
{
return false;
}
/**
* Determine whether the user can delete the config record.
* Config records are managed by the Config Service and must not be manually deleted.
*/
public function delete(User $user, Config $config): bool
{
return false;
}
/**
* Determine whether the user can restore the config record.
*/
public function restore(User $user, Config $config): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the config record.
*/
public function forceDelete(User $user, Config $config): bool
{
return false;
}
}

View File

@@ -16,7 +16,7 @@ final class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
// $this->app->singleton(\App\Services\Config::class);
} }
/** /**

View File

@@ -4,6 +4,7 @@
use App\Models\User; use App\Models\User;
use App\Nova\CategoryResource; use App\Nova\CategoryResource;
use App\Nova\ConfigResource;
use App\Nova\Dashboards\Main; use App\Nova\Dashboards\Main;
use App\Nova\LogResource; use App\Nova\LogResource;
use App\Nova\QuestionGroupResource; use App\Nova\QuestionGroupResource;
@@ -46,6 +47,10 @@ public function boot(): void
MenuSection::make('Users', [ MenuSection::make('Users', [
MenuItem::resource(\App\Nova\User::class), MenuItem::resource(\App\Nova\User::class),
])->icon('users')->collapsible(), ])->icon('users')->collapsible(),
MenuSection::make('Settings', [
MenuItem::resource(ConfigResource::class),
])->icon('cog')->collapsible(),
]; ];
}); });
} }

126
app/Services/Config.php Normal file
View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Config as ConfigModel;
use Illuminate\Database\QueryException;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final class Config
{
/** Typed public property for the content group's disclaimer field. */
public string $contentDisclaimer = '';
/** Indexed config class instances keyed by their group key string. */
private array $configClasses = [];
/**
* Auto-discovers Config Field classes from app/Configs/, loads or creates
* their database rows, and populates typed public properties with cast values.
*/
public function __construct()
{
$this->discoverConfigClasses();
$this->hydrateProperties();
}
/**
* Scans app/Configs/ for PHP files, instantiates each class,
* and indexes them by their key property.
*/
private function discoverConfigClasses(): void
{
$files = glob(app_path('Configs/*.php')) ?: [];
foreach ($files as $file) {
$className = 'App\\Configs\\'.pathinfo($file, PATHINFO_FILENAME);
$instance = new $className;
$this->configClasses[$instance->key] = $instance;
}
}
/**
* Iterates over all discovered config classes, ensures a DB row exists,
* resolves typed field values, and assigns them to public properties.
*/
private function hydrateProperties(): void
{
try {
$dbRecords = ConfigModel::all()->keyBy('key');
foreach ($this->configClasses as $key => $instance) {
$record = $this->ensureDbRecord($key, $dbRecords);
$jsonValue = Arr::wrap((array) ($record->json_value ?? []));
$this->assignFieldProperties($key, $instance, $jsonValue);
}
} catch (QueryException) {
// Silently skip if configs table does not yet exist during migrations.
}
}
/**
* Ensures a database record exists for the given config key,
* creating one with an empty json_value if it does not.
*/
private function ensureDbRecord(string $key, Collection $dbRecords): ConfigModel
{
if ($dbRecords->has($key)) {
return $dbRecords->get($key);
}
return ConfigModel::firstOrCreate(
['key' => $key],
['json_value' => ''],
);
}
/**
* Resolves each field value from json_value (with default fallback),
* applies type casting, and assigns to the matching typed public property.
*/
private function assignFieldProperties(string $key, object $instance, array $jsonValue): void
{
foreach ($instance->getFieldKeys() as $fieldKey => $definition) {
$type = Arr::get($definition, 'type', 'string');
$rawValue = Arr::get($jsonValue, $fieldKey);
if ($rawValue === null || $rawValue === '') {
$rawValue = $instance->getDefault($fieldKey);
}
$castedValue = $this->castValue($rawValue, $type);
$propertyName = Str::camel($key).Str::studly($fieldKey);
if (property_exists($this, $propertyName)) {
$this->{$propertyName} = $castedValue;
}
}
}
/**
* Casts a raw value to the PHP type defined by the config field type string.
*/
private function castValue(mixed $value, string $type): mixed
{
return match ($type) {
'string', 'markdown' => (string) $value,
'int', 'integer' => (int) $value,
'bool', 'boolean' => (bool) $value,
'float' => (float) $value,
default => $value,
};
}
/**
* Returns the Config Field class instance for a given group key,
* or null when no class has been discovered for that key.
*/
public function getConfigClassForGroup(string $group): ?object
{
return Arr::get($this->configClasses, $group);
}
}

View File

@@ -183,7 +183,7 @@
*/ */
'actions' => [ 'actions' => [
'resource' => \Laravel\Nova\Actions\ActionResource::class, 'resource' => null,
], ],
/* /*

View File

@@ -16,7 +16,7 @@ public function up(): void
Schema::create('screenings', function (Blueprint $table) { Schema::create('screenings', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->integer('score')->nullable(); $table->decimal('score', 4, 1)->nullable();
$table->boolean('passed')->nullable(); $table->boolean('passed')->nullable();
$table->timestamps(); $table->timestamps();
}); });

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Creates the configs table used to store database-driven key/value configuration.
*/
public function up(): void
{
Schema::create('configs', function (Blueprint $table) {
$table->string('key')->primary();
$table->json('json_value')->nullable();
$table->timestamps();
});
}
/**
* Drops the configs table on rollback.
*/
public function down(): void
{
Schema::dropIfExists('configs');
}
};

13
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@inertiajs/vue3": "^2.3.13", "@inertiajs/vue3": "^2.3.13",
"@vitejs/plugin-vue": "^6.0.4", "@vitejs/plugin-vue": "^6.0.4",
"marked": "^17.0.4",
"vue": "^3.5.27" "vue": "^3.5.27"
}, },
"devDependencies": { "devDependencies": {
@@ -3257,6 +3258,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/marked": {
"version": "17.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz",
"integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@@ -22,6 +22,7 @@
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@inertiajs/vue3": "^2.3.13", "@inertiajs/vue3": "^2.3.13",
"@vitejs/plugin-vue": "^6.0.4", "@vitejs/plugin-vue": "^6.0.4",
"marked": "^17.0.4",
"vue": "^3.5.27" "vue": "^3.5.27"
} }
} }

View File

@@ -0,0 +1,257 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { Head } from '@inertiajs/vue3'
import { ArrowLeftIcon } from '@heroicons/vue/24/outline'
import { marked } from 'marked'
import AppLayout from '@/Layouts/AppLayout.vue'
import AppButton from '@/Components/AppButton.vue'
defineOptions({ layout: AppLayout })
const props = defineProps({
content: String,
})
const parsedContent = computed(() => {
if (!props.content) return ''
return marked(props.content)
})
const bottomBackButtonRef = ref(null)
const showStickyBack = ref(false)
let observer = null
onMounted(() => {
if (!bottomBackButtonRef.value) return
observer = new IntersectionObserver(
([entry]) => { showStickyBack.value = !entry.isIntersecting },
{ threshold: 0 }
)
observer.observe(bottomBackButtonRef.value.$el || bottomBackButtonRef.value)
})
onUnmounted(() => {
observer?.disconnect()
})
</script>
<template>
<Head title="Disclaimer" />
<div class="max-w-3xl mx-auto px-4 py-10">
<!-- Sticky back button appears when bottom back button scrolls out of view -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 translate-x-4"
enter-to-class="opacity-100 translate-x-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-x-0"
leave-to-class="opacity-0 translate-x-4"
>
<div v-if="showStickyBack" class="fixed top-[88px] right-6 z-40">
<AppButton variant="ghost" size="lg" href="/">
<ArrowLeftIcon class="h-5 w-5" />
Back
</AppButton>
</div>
</Transition>
<!-- Page heading -->
<div class="mb-10">
<h1 class="text-2xl font-bold text-white">Disclaimer</h1>
<div class="h-px bg-gradient-to-r from-primary/40 via-primary/10 to-transparent mt-4"></div>
</div>
<!-- Rendered markdown content -->
<div class="prose-dark" v-html="parsedContent" />
<!-- Bottom back button -->
<div class="mt-12 pt-8 border-t border-white/[0.06]">
<AppButton ref="bottomBackButtonRef" variant="ghost" size="lg" href="/">
<ArrowLeftIcon class="h-5 w-5" />
Back
</AppButton>
</div>
</div>
</template>
<style scoped>
.prose-dark :deep(h1) {
color: #ffffff;
font-size: 1.875rem;
font-weight: 700;
line-height: 1.3;
margin-top: 2rem;
margin-bottom: 1rem;
}
.prose-dark :deep(h2) {
color: #ffffff;
font-size: 1.5rem;
font-weight: 700;
line-height: 1.35;
margin-top: 1.75rem;
margin-bottom: 0.875rem;
}
.prose-dark :deep(h3) {
color: #ffffff;
font-size: 1.25rem;
font-weight: 600;
line-height: 1.4;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.prose-dark :deep(h4) {
color: #ffffff;
font-size: 1.1rem;
font-weight: 600;
line-height: 1.4;
margin-top: 1.25rem;
margin-bottom: 0.625rem;
}
.prose-dark :deep(p) {
color: #d1d5db; /* text-gray-300 */
line-height: 1.75;
margin-bottom: 1rem;
}
.prose-dark :deep(a) {
color: #d1ec51; /* text-primary */
text-decoration: underline;
text-underline-offset: 2px;
transition: color 0.15s ease;
}
.prose-dark :deep(a:hover) {
color: #b5d136; /* text-primary-dark */
}
.prose-dark :deep(ul) {
color: #d1d5db;
list-style: none;
padding-left: 1.5rem;
margin-bottom: 1rem;
}
.prose-dark :deep(ul li) {
position: relative;
margin-bottom: 0.5rem;
line-height: 1.75;
}
.prose-dark :deep(ul li::before) {
content: '—';
color: #d1ec51; /* text-primary */
position: absolute;
left: -1.5rem;
font-weight: 600;
}
.prose-dark :deep(ol) {
color: #d1d5db;
list-style: none;
padding-left: 2rem;
margin-bottom: 1rem;
counter-reset: ol-counter;
}
.prose-dark :deep(ol li) {
position: relative;
margin-bottom: 0.5rem;
line-height: 1.75;
counter-increment: ol-counter;
}
.prose-dark :deep(ol li::before) {
content: counter(ol-counter) '.';
color: #d1ec51; /* text-primary */
position: absolute;
left: -2rem;
font-weight: 600;
}
.prose-dark :deep(blockquote) {
border-left: 3px solid #d1ec51; /* border-primary */
padding-left: 1.25rem;
margin: 1.5rem 0;
font-style: italic;
color: #9ca3af; /* text-gray-400 */
}
.prose-dark :deep(blockquote p) {
color: #9ca3af;
margin-bottom: 0;
}
.prose-dark :deep(pre) {
background-color: rgba(31, 41, 55, 0.5); /* bg-gray-800/50 */
border: 1px solid rgba(75, 85, 99, 0.4);
border-radius: 0.5rem;
padding: 1.25rem;
overflow-x: auto;
margin-bottom: 1rem;
}
.prose-dark :deep(pre code) {
background-color: transparent;
padding: 0;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, Consolas, monospace;
font-size: 0.875rem;
color: #d1d5db;
}
.prose-dark :deep(code) {
background-color: rgba(31, 41, 55, 0.5); /* bg-gray-800/50 */
border: 1px solid rgba(75, 85, 99, 0.3);
border-radius: 0.25rem;
padding: 0.125rem 0.375rem;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, Consolas, monospace;
font-size: 0.875em;
color: #d1ec51; /* text-primary */
}
.prose-dark :deep(strong) {
color: #ffffff;
font-weight: 600;
}
.prose-dark :deep(em) {
color: #d1d5db;
font-style: italic;
}
.prose-dark :deep(hr) {
border: none;
border-top: 1px solid #4b5563; /* border-gray-600 */
margin: 2rem 0;
}
.prose-dark :deep(table) {
width: 100%;
border-collapse: collapse;
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
.prose-dark :deep(th) {
color: #ffffff;
font-weight: 600;
text-align: left;
padding: 0.625rem 0.875rem;
border-bottom: 2px solid #4b5563;
}
.prose-dark :deep(td) {
color: #d1d5db;
padding: 0.625rem 0.875rem;
border-bottom: 1px solid rgba(75, 85, 99, 0.4);
}
.prose-dark :deep(tr:last-child td) {
border-bottom: none;
}
</style>

View File

@@ -1,17 +1,28 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref } from 'vue'
import { Head, router, usePage } from '@inertiajs/vue3' import { Head, router, usePage } from '@inertiajs/vue3'
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/20/solid'
import AppLayout from '@/Layouts/AppLayout.vue' import AppLayout from '@/Layouts/AppLayout.vue'
import AppButton from '@/Components/AppButton.vue' import AppButton from '@/Components/AppButton.vue'
defineOptions({ layout: AppLayout }) defineOptions({ layout: AppLayout })
const props = defineProps({
hasDisclaimer: Boolean,
})
const page = usePage() const page = usePage()
const disclaimerAgreed = ref(false)
const isAuthenticated = computed(() => { const isAuthenticated = computed(() => {
return page.props.auth?.user != null return page.props.auth?.user != null
}) })
const canContinue = computed(() => {
return !props.hasDisclaimer || disclaimerAgreed.value
})
const userInfo = computed(() => { const userInfo = computed(() => {
const user = page.props.auth?.user const user = page.props.auth?.user
if (!user) return null if (!user) return null
@@ -51,7 +62,69 @@ const handleContinue = () => {
You will first complete a short pre-screening questionnaire, followed by a detailed category-specific checklist You will first complete a short pre-screening questionnaire, followed by a detailed category-specific checklist
to determine whether to pursue (Go), decline (No Go), or escalate (Consult Leadership) an opportunity. to determine whether to pursue (Go), decline (No Go), or escalate (Consult Leadership) an opportunity.
</p> </p>
<AppButton v-if="isAuthenticated" size="lg" @click="handleContinue" data-cy="start-screening">
<!-- Disclaimer checkbox only shown when authenticated and a disclaimer exists -->
<div v-if="isAuthenticated && hasDisclaimer" class="flex items-start justify-center gap-3 mb-6">
<label class="flex items-start gap-3 cursor-pointer select-none group">
<!-- Hidden native checkbox -->
<input
v-model="disclaimerAgreed"
type="checkbox"
class="sr-only peer"
/>
<!-- Custom checkbox -->
<span
class="
mt-0.5 flex-shrink-0
w-5 h-5 rounded
border-2 border-gray-500
bg-transparent
flex items-center justify-center
transition-all duration-200
peer-checked:bg-primary peer-checked:border-primary
group-hover:border-gray-300
"
aria-hidden="true"
>
<!-- Checkmark visible when checked via peer -->
<svg
class="w-3 h-3 text-gray-900 opacity-0 transition-opacity duration-200 peer-checked:opacity-100"
:class="{ 'opacity-100': disclaimerAgreed }"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 6l3 3 5-5"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<!-- Label text -->
<span class="text-sm text-gray-400 text-left leading-relaxed">
I have read and agree to the
<a
href="/disclaimer"
target="_blank"
class="text-primary underline underline-offset-2 inline-flex items-center gap-0.5 hover:text-primary-dark transition-colors duration-150"
>
disclaimer
<ArrowTopRightOnSquareIcon class="w-3.5 h-3.5 flex-shrink-0" />
</a>
</span>
</label>
</div>
<AppButton
v-if="isAuthenticated"
size="lg"
:disabled="!canContinue"
@click="handleContinue"
data-cy="start-screening"
>
Continue Continue
</AppButton> </AppButton>
<AppButton v-else size="lg" href="/login" external> <AppButton v-else size="lg" href="/login" external>

View File

@@ -1,5 +1,7 @@
<script setup> <script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { Head, router } from '@inertiajs/vue3' import { Head, router } from '@inertiajs/vue3'
import { ArrowLeftIcon } from '@heroicons/vue/24/outline'
import AppLayout from '@/Layouts/AppLayout.vue' import AppLayout from '@/Layouts/AppLayout.vue'
import AppButton from '@/Components/AppButton.vue' import AppButton from '@/Components/AppButton.vue'
@@ -34,12 +36,47 @@ const handleStartCategory = (categoryId) => {
screening_id: props.screening.id, screening_id: props.screening.id,
}) })
} }
const bottomBackButtonRef = ref(null)
const showStickyBack = ref(false)
let observer = null
onMounted(() => {
if (!bottomBackButtonRef.value) return
observer = new IntersectionObserver(
([entry]) => { showStickyBack.value = !entry.isIntersecting },
{ threshold: 0 }
)
observer.observe(bottomBackButtonRef.value.$el || bottomBackButtonRef.value)
})
onUnmounted(() => {
observer?.disconnect()
})
</script> </script>
<template> <template>
<Head title="Screening Result" /> <Head title="Screening Result" />
<div class="max-w-4xl mx-auto px-4 py-8"> <div class="max-w-4xl mx-auto px-4 py-8">
<!-- Sticky back button -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 translate-x-4"
enter-to-class="opacity-100 translate-x-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-x-0"
leave-to-class="opacity-0 translate-x-4"
>
<div v-if="showStickyBack" class="fixed top-[88px] right-6 z-40">
<AppButton variant="ghost" size="lg" href="/" data-cy="sticky-back">
<ArrowLeftIcon class="h-5 w-5" />
Back
</AppButton>
</div>
</Transition>
<h1 class="text-3xl font-bold text-white mb-6">Pre-Screening Result</h1> <h1 class="text-3xl font-bold text-white mb-6">Pre-Screening Result</h1>
<!-- Score Display --> <!-- Score Display -->
@@ -57,21 +94,14 @@ const handleStartCategory = (categoryId) => {
</div> </div>
</div> </div>
<!-- Failed: Show Again button -->
<div v-if="!passed" class="flex justify-center">
<AppButton size="lg" href="/">
Again
</AppButton>
</div>
<!-- Passed: Show category picker --> <!-- Passed: Show category picker -->
<div v-else data-cy="category-select"> <div v-if="passed" data-cy="category-select">
<h2 class="text-2xl font-semibold text-white mb-4">Select a Category</h2> <h2 class="text-2xl font-semibold text-white mb-4">Select a Category</h2>
<div class="space-y-3"> <div class="space-y-3">
<div <div
v-for="category in categories" v-for="category in categories"
:key="category.id" :key="category.id"
class="bg-surface/50 rounded-lg p-4 flex items-center justify-between hover:bg-surface/70 transition-colors" class="rounded-lg p-4 flex items-center justify-between hover:bg-white/5 transition-colors"
> >
<span class="text-white font-medium">{{ category.name }}</span> <span class="text-white font-medium">{{ category.name }}</span>
<AppButton size="md" @click="handleStartCategory(category.id)"> <AppButton size="md" @click="handleStartCategory(category.id)">
@@ -80,5 +110,13 @@ const handleStartCategory = (categoryId) => {
</div> </div>
</div> </div>
</div> </div>
<!-- Bottom back button -->
<div class="mt-12 pt-8 border-t border-white/[0.06]">
<AppButton ref="bottomBackButtonRef" variant="ghost" size="lg" href="/" data-cy="back-to-landing">
<ArrowLeftIcon class="h-5 w-5" />
Back
</AppButton>
</div>
</div> </div>
</template> </template>

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { Head, useForm } from '@inertiajs/vue3' import { Head, useForm } from '@inertiajs/vue3'
import { ArrowLeftIcon } from '@heroicons/vue/24/outline'
import AppLayout from '@/Layouts/AppLayout.vue' import AppLayout from '@/Layouts/AppLayout.vue'
import AppButton from '@/Components/AppButton.vue' import AppButton from '@/Components/AppButton.vue'
import RadioButtonGroup from '@/Components/RadioButtonGroup.vue' import RadioButtonGroup from '@/Components/RadioButtonGroup.vue'
@@ -32,23 +33,70 @@ const handleSubmit = () => {
form.put(`/screening/${props.screening.id}`) form.put(`/screening/${props.screening.id}`)
} }
const currentScore = computed(() => {
return Object.values(form.answers).reduce((score, value) => {
if (value === 'yes') return score + 1
if (value === 'unknown') return score + 0.5
return score
}, 0)
})
watch(currentScore, (score) => {
console.log('Current screening score:', score)
})
const allAnswered = computed(() => { const allAnswered = computed(() => {
return Object.values(form.answers).every(v => v !== null) return Object.values(form.answers).every(v => v !== null)
}) })
const bottomBackButtonRef = ref(null)
const showStickyBack = ref(false)
let observer = null
onMounted(() => {
if (!bottomBackButtonRef.value) return
observer = new IntersectionObserver(
([entry]) => { showStickyBack.value = !entry.isIntersecting },
{ threshold: 0 }
)
observer.observe(bottomBackButtonRef.value.$el || bottomBackButtonRef.value)
})
onUnmounted(() => {
observer?.disconnect()
})
</script> </script>
<template> <template>
<Head title="Pre-Screening Questions" /> <Head title="Pre-Screening Questions" />
<div class="max-w-4xl mx-auto px-4 py-8"> <div class="max-w-4xl mx-auto px-4 py-8">
<!-- Sticky back button -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 translate-x-4"
enter-to-class="opacity-100 translate-x-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-x-0"
leave-to-class="opacity-0 translate-x-4"
>
<div v-if="showStickyBack" class="fixed top-[88px] right-6 z-40">
<AppButton variant="ghost" size="lg" href="/" data-cy="sticky-back">
<ArrowLeftIcon class="h-5 w-5" />
Back
</AppButton>
</div>
</Transition>
<h1 class="text-3xl font-bold text-white mb-2">Pre-Screening Questions</h1> <h1 class="text-3xl font-bold text-white mb-2">Pre-Screening Questions</h1>
<p class="text-gray-400 mb-8">Answer all 10 questions to proceed. Each "Yes" answer scores 1 point. You need at least 5 points to pass.</p> <p class="text-gray-400 mb-8">Please answer all questions to proceed.</p>
<div class="space-y-4 mb-8"> <div class="space-y-4 mb-8">
<div <div
v-for="(question, index) in questions" v-for="(question, index) in questions"
:key="index" :key="index"
class="bg-surface/50 rounded-lg p-5" class="rounded-lg p-5"
:data-cy="`screening-answer-${index + 1}`" :data-cy="`screening-answer-${index + 1}`"
> >
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
@@ -60,6 +108,7 @@ const allAnswered = computed(() => {
:name="`question-${index + 1}`" :name="`question-${index + 1}`"
:options="[ :options="[
{ value: 'yes', label: 'Yes' }, { value: 'yes', label: 'Yes' },
{ value: 'unknown', label: 'I don\'t know' },
{ value: 'no', label: 'No' }, { value: 'no', label: 'No' },
]" ]"
/> />
@@ -71,10 +120,17 @@ const allAnswered = computed(() => {
</div> </div>
</div> </div>
<div class="flex justify-end"> <div class="mt-12 pt-8 border-t border-white/[0.06]">
<AppButton size="lg" @click="handleSubmit" :loading="form.processing" :disabled="!allAnswered || form.processing" data-cy="submit-screening"> <div class="flex justify-between items-center">
Submit <AppButton ref="bottomBackButtonRef" variant="ghost" size="lg" href="/" data-cy="back-to-landing">
</AppButton> <ArrowLeftIcon class="h-5 w-5" />
Back
</AppButton>
<AppButton size="lg" @click="handleSubmit" :loading="form.processing" :disabled="!allAnswered || form.processing" data-cy="submit-screening">
Submit
</AppButton>
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -24,6 +24,25 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
durationSeconds: {
type: Number,
default: 0,
},
})
const formattedDuration = computed(() => {
const total = props.durationSeconds
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
const seconds = total % 60
if (hours > 0) {
return `${hours}h ${minutes}m`
}
if (minutes > 0) {
return `${minutes}m ${seconds}s`
}
return `${seconds}s`
}) })
const resultDisplay = computed(() => { const resultDisplay = computed(() => {
@@ -85,16 +104,24 @@ const resultDisplay = computed(() => {
</div> </div>
<!-- Session Details --> <!-- Session Details -->
<div class="bg-surface/50 rounded-lg p-6 mb-8"> <div class="rounded-lg p-6 mb-8">
<h2 class="text-xl font-semibold text-white mb-4">Session Details</h2> <h2 class="text-xl font-semibold text-white mb-4">Session Details</h2>
<dl class="grid grid-cols-2 gap-4 text-sm"> <dl class="grid grid-cols-3 gap-4 text-sm">
<div> <div>
<dt class="text-gray-400">Category</dt> <dt class="text-gray-400">Category</dt>
<dd class="text-white font-medium">{{ categoryName }}</dd> <dd class="text-white font-medium">{{ categoryName }}</dd>
</div> </div>
<div> <div class="flex justify-center">
<dt class="text-gray-400">Completed</dt> <div>
<dd class="text-white font-medium">{{ new Date(session.completed_at).toLocaleDateString() }}</dd> <dt class="text-gray-400">Completed</dt>
<dd class="text-white font-medium">{{ new Date(session.completed_at).toLocaleDateString() }}</dd>
</div>
</div>
<div class="flex justify-end">
<div>
<dt class="text-gray-400">Duration</dt>
<dd class="text-white font-medium">{{ formattedDuration }}</dd>
</div>
</div> </div>
</dl> </dl>
</div> </div>

View File

@@ -1,8 +1,9 @@
<script setup> <script setup>
import { computed, reactive, ref, watch, nextTick } from 'vue' import { computed, onMounted, onUnmounted, reactive, ref, watch, nextTick } from 'vue'
import { Head, useForm, router } from '@inertiajs/vue3' import { Head, useForm, router } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue' import AppLayout from '@/Layouts/AppLayout.vue'
import AppButton from '@/Components/AppButton.vue' import AppButton from '@/Components/AppButton.vue'
import { ArrowLeftIcon } from '@heroicons/vue/24/outline'
import QuestionCard from '@/Components/QuestionCard.vue' import QuestionCard from '@/Components/QuestionCard.vue'
defineOptions({ layout: AppLayout }) defineOptions({ layout: AppLayout })
@@ -39,6 +40,24 @@ const initializeAnswers = () => {
} }
initializeAnswers() initializeAnswers()
const bottomBackButtonRef = ref(null)
const showStickyBack = ref(false)
let observer = null
onMounted(() => {
if (!bottomBackButtonRef.value) return
observer = new IntersectionObserver(
([entry]) => { showStickyBack.value = !entry.isIntersecting },
{ threshold: 0 }
)
observer.observe(bottomBackButtonRef.value.$el || bottomBackButtonRef.value)
})
onUnmounted(() => {
observer?.disconnect()
})
// Validation state // Validation state
const validationErrors = ref({}) const validationErrors = ref({})
const processing = ref(false) const processing = ref(false)
@@ -57,6 +76,7 @@ const saveAnswer = (questionId) => {
}, { }, {
preserveScroll: true, preserveScroll: true,
preserveState: true, preserveState: true,
showProgress: false,
only: ['answers'], only: ['answers'],
onStart: () => { processing.value = true }, onStart: () => { processing.value = true },
onFinish: () => { processing.value = false }, onFinish: () => { processing.value = false },
@@ -81,6 +101,7 @@ const saveComments = () => {
additionalComments.put(`/sessions/${props.session.id}`, { additionalComments.put(`/sessions/${props.session.id}`, {
preserveScroll: true, preserveScroll: true,
preserveState: true, preserveState: true,
showProgress: false,
}) })
}, 1000) }, 1000)
} }
@@ -175,6 +196,27 @@ const completeSession = async () => {
<Head :title="`${session.category.name} Questionnaire`" /> <Head :title="`${session.category.name} Questionnaire`" />
<div class="max-w-3xl mx-auto px-4 py-10"> <div class="max-w-3xl mx-auto px-4 py-10">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 translate-x-4"
enter-to-class="opacity-100 translate-x-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-x-0"
leave-to-class="opacity-0 translate-x-4"
>
<div v-if="showStickyBack" class="fixed top-[88px] right-6 z-40">
<AppButton
variant="ghost"
size="lg"
:href="`/screening/${session.screening_id}/result`"
data-cy="sticky-back"
>
<ArrowLeftIcon class="h-5 w-5" />
Back
</AppButton>
</div>
</Transition>
<!-- Title area --> <!-- Title area -->
<div class="mb-10"> <div class="mb-10">
<h1 class="text-2xl font-bold text-white">{{ session.category.name }} Questionnaire</h1> <h1 class="text-2xl font-bold text-white">{{ session.category.name }} Questionnaire</h1>
@@ -260,7 +302,8 @@ const completeSession = async () => {
</Transition> </Transition>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<AppButton variant="ghost" size="lg" :href="`/screening/${session.screening_id}/result`" data-cy="back-to-screening"> <AppButton ref="bottomBackButtonRef" variant="ghost" size="lg" :href="`/screening/${session.screening_id}/result`" data-cy="back-to-screening">
<ArrowLeftIcon class="h-5 w-5" />
Back Back
</AppButton> </AppButton>

View File

@@ -10,6 +10,7 @@
// Landing page (public) // Landing page (public)
Route::get('/', [LandingController::class, 'index'])->name('landing'); Route::get('/', [LandingController::class, 'index'])->name('landing');
Route::get('/disclaimer', [LandingController::class, 'disclaimer'])->name('disclaimer');
// Authentication routes // Authentication routes
Route::get('/login', [SocialiteController::class, 'redirect'])->name('login'); Route::get('/login', [SocialiteController::class, 'redirect'])->name('login');
@@ -17,7 +18,7 @@
Route::post('/logout', [SocialiteController::class, 'logout'])->name('logout')->middleware('auth'); Route::post('/logout', [SocialiteController::class, 'logout'])->name('logout')->middleware('auth');
// Questionnaire routes (authenticated) // Questionnaire routes (authenticated)
Route::middleware('auth')->group(function () { Route::middleware('auth')->missing(fn () => redirect('/'))->group(function () {
// Screening routes // Screening routes
Route::post('/screening', [ScreeningController::class, 'store'])->name('screening.store'); Route::post('/screening', [ScreeningController::class, 'store'])->name('screening.store');
Route::get('/screening/{screening}', [ScreeningController::class, 'show'])->name('screening.show'); Route::get('/screening/{screening}', [ScreeningController::class, 'show'])->name('screening.show');
@@ -42,4 +43,3 @@
return redirect('/'); return redirect('/');
}); });