Compare commits

..

60 Commits

Author SHA1 Message Date
Shift
2f7d5febdf Add .shift to open Pull Request 2025-11-16 21:17:12 +00:00
44f359ff9c fix timings 2025-11-16 23:15:49 +10:00
20f36d519a fix timings 2025-11-16 23:14:55 +10:00
e358e9fb5d fix timings 2025-11-16 23:13:28 +10:00
b882d92328 fix timings 2025-11-16 23:08:34 +10:00
3257aa9ee9 added bot checks 2025-11-16 23:04:50 +10:00
0bcd6f5e86 added bot checks 2025-11-16 23:00:21 +10:00
75d958856a rename controller 2025-11-16 22:59:07 +10:00
71eb00d010 unsubscribe fixes 2025-11-16 22:56:16 +10:00
eab3d062f5 unsubscribe fixes 2025-11-16 22:48:17 +10:00
1afa22e2f4 logging 2025-11-16 22:19:40 +10:00
b85d039c36 fix var name 2025-11-16 22:14:04 +10:00
c1a4fd13d5 fix var name 2025-11-16 22:04:01 +10:00
9a1ffe835c fix var name 2025-11-16 22:02:32 +10:00
c3b9482d35 obsolete directives 2025-11-16 22:00:14 +10:00
bc8f9149dc fix unsubscribe link 2025-11-16 21:57:41 +10:00
c60213257b force SSL 2025-11-16 21:41:14 +10:00
6a78ba2bb2 composer updates 2025-11-16 21:12:40 +10:00
a5f7ce8393 updated subscription elements 2025-11-16 21:10:34 +10:00
4e1505c5c2 updated subscription elements 2025-11-16 19:07:07 +10:00
e967bdde71 updated footer and added about page 2025-11-16 16:20:41 +10:00
74e9e39722 updated address 2025-11-16 16:02:34 +10:00
0df4033fca package updates 2025-11-16 15:41:11 +10:00
e02770cc85 added roave/security-advisories 2025-11-16 15:32:35 +10:00
3687af2656 remove blog posts 2025-11-16 15:31:29 +10:00
b168931266 upgraded packages 2025-11-10 16:46:10 +10:00
b669dd319e fixed bad left offset of backdrop in dropdown 2025-11-10 16:43:26 +10:00
e37b9a30a4 dependency updates 2025-08-28 20:17:42 +10:00
436d4b8acf update 2025-08-28 20:12:49 +10:00
a2eb1d5d1b search bar focus and select fix 2025-08-28 20:12:31 +10:00
be4fdb2f80 updated to handle local caching 2025-08-28 20:03:30 +10:00
538f324ff4 captcha cleanup and added 2fa logins 2024-09-28 11:51:28 +10:00
59ca73519d added instructions 2024-09-28 09:23:16 +10:00
6bc2b888a4 change timer 2024-09-28 09:18:43 +10:00
be8b2d48b3 update newsletter schedule 2024-09-27 22:38:11 +10:00
5f631a5c3d remove user data 2024-09-27 22:26:58 +10:00
fea3756eab fix bad checkbox variable 2024-09-27 22:26:25 +10:00
6d8db2cd80 fix bad variable name 2024-09-27 22:23:57 +10:00
9725f4944f fix bad variable name 2024-09-27 22:23:01 +10:00
9b1b92d0cf added email subscriptions 2024-09-27 22:17:39 +10:00
b10b6b712e added email subscriptions 2024-09-27 22:16:29 +10:00
db018e9120 fix invalid tag 2024-09-27 19:58:57 +10:00
1444bc9aa4 fallback if firstname is missing 2024-09-27 19:56:32 +10:00
9e7fc79fa1 add search option to navbar slide out 2024-09-27 18:08:25 +10:00
06460d9677 update home to shipping address 2024-09-27 18:04:12 +10:00
beed9f9c11 update home to shipping address 2024-09-27 17:59:27 +10:00
38b3d5d367 positioning updates 2024-09-27 17:30:00 +10:00
ad080b19a2 fix asset links 2024-09-27 14:47:59 +10:00
274d9759b6 fix small screen layouts 2024-09-27 14:26:46 +10:00
d992570ee8 fix number formatting 2024-09-27 14:22:18 +10:00
d72c08b4c9 fix selection 2024-09-27 14:22:03 +10:00
7baea36628 fix single decimal point pricing 2024-09-27 13:39:59 +10:00
b20c79b679 updated search and added past workshop page 2024-09-27 13:33:50 +10:00
5cbebd8840 remove 2024-09-27 11:26:24 +10:00
d36979cbbd updated 2024-09-27 11:25:49 +10:00
1c28cd7902 updated 2024-09-27 11:24:55 +10:00
df19e43112 update includes 2024-09-27 11:24:47 +10:00
5a65517d2b added past index route 2024-09-27 11:19:32 +10:00
49eb388041 updated mast to support tabs 2024-09-27 11:17:49 +10:00
659ae2e3ac bts update 2024-09-27 10:27:54 +10:00
94 changed files with 6501 additions and 2498 deletions

3
.gitignore vendored
View File

@@ -15,7 +15,7 @@ app/storage/
# Laravel 5 & Lumen specific
public/storage
public/hot
public/hot*
# Laravel 5 & Lumen specific with changed public path
public_html/storage
@@ -259,3 +259,4 @@ phpcbf.phar
### PHPStorm ###
.idea/

4
.shift Normal file
View File

@@ -0,0 +1,4 @@
This file was added by Shift #161875 in order to open a
Pull Request since no other commits were made.
You should remove this file.

View File

@@ -6,10 +6,11 @@ use App\Helpers;
use App\Jobs\SendEmail;
use App\Mail\UserEmailUpdateRequest;
use App\Models\User;
use App\Providers\QRCodeProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use RobThree\Auth\Algorithm;
use RobThree\Auth\TwoFactorAuth;
class AccountController extends Controller
{
@@ -50,11 +51,11 @@ class AccountController extends Controller
'email' => ['required', 'email', 'unique:users,email,' . $user->id],
'phone' => 'required',
'home_address' => 'required_with:home_city,home_postcode,home_country,home_state',
'home_city' => 'required_with:home_address,home_postcode,home_country,home_state',
'home_postcode' => 'required_with:home_address,home_city,home_country,home_state',
'home_country' => 'required_with:home_address,home_city,home_postcode,home_state',
'home_state' => 'required_with:home_address,home_city,home_postcode,home_country',
'shipping_address' => 'required_with:shipping_city,shipping_postcode,shipping_country,shipping_state',
'shipping_city' => 'required_with:shipping_address,shipping_postcode,shipping_country,shipping_state',
'shipping_postcode' => 'required_with:shipping_address,shipping_city,shipping_country,shipping_state',
'shipping_country' => 'required_with:shipping_address,shipping_city,shipping_postcode,shipping_state',
'shipping_state' => 'required_with:shipping_address,shipping_city,shipping_postcode,shipping_country',
'billing_address' => 'required_with:billing_city,billing_postcode,billing_country,billing_state',
'billing_city' => 'required_with:billing_address,billing_postcode,billing_country,billing_state',
@@ -68,11 +69,11 @@ class AccountController extends Controller
'email.email' => __('validation.custom_messages.email_invalid'),
'phone.required' => __('validation.custom_messages.phone_required'),
'home_address.required' => __('validation.custom_messages.home_address_required'),
'home_city.required' => __('validation.custom_messages.home_city_required'),
'home_postcode.required' => __('validation.custom_messages.home_postcode_required'),
'home_country.required' => __('validation.custom_messages.home_country_required'),
'home_state.required' => __('validation.custom_messages.home_state_required'),
'shipping_address.required' => __('validation.custom_messages.shipping_address_required'),
'shipping_city.required' => __('validation.custom_messages.shipping_city_required'),
'shipping_postcode.required' => __('validation.custom_messages.shipping_postcode_required'),
'shipping_country.required' => __('validation.custom_messages.shipping_country_required'),
'shipping_state.required' => __('validation.custom_messages.shipping_state_required'),
'billing_address.required' => __('validation.custom_messages.billing_address_required'),
'billing_city.required' => __('validation.custom_messages.billing_city_required'),
@@ -130,4 +131,110 @@ class AccountController extends Controller
session()->flash('message-type', 'success');
return redirect()->route('index');
}
public static function getTFAInstance()
{
$tfa = new TwoFactorAuth(new QRCodeProvider(), 'STEMMechanics', 6, 30, Algorithm::Sha512);
$tfa->ensureCorrectTime();
return $tfa;
}
public function show_tfa()
{
$user = auth()->user();
if ($user->tfa_secret === null) {
$tfa = self::getTFAInstance();
$secret = $tfa->createSecret();
return response()->json([
'secret' => $secret,
]);
} else {
abort(404);
}
}
public function show_tfa_image(Request $request)
{
$user = auth()->user();
if ($user->tfa_secret === null && $request->has('secret')) {
$tfa = self::getTFAInstance();
$qrCodeProvider = new QRCodeProvider();
$qrCode = $qrCodeProvider->getQRCodeImage(
$tfa->getQRText($user->email, $request->get('secret')),
200
);
return response()->stream(function () use ($qrCode) {
echo $qrCode;
}, 200, ['Content-Type' => $qrCodeProvider->getMimeType()]);
} else {
abort(404);
}
}
public function post_tfa(Request $request)
{
$user = auth()->user();
if ($user->tfa_secret === null && $request->has('secret') && $request->has('code')) {
$secret = $request->get('secret');
$code = $request->get('code');
$tfa = self::getTFAInstance();
if ($tfa->verifyCode($secret, $code, 4)) {
$user->tfa_secret = $secret;
$user->save();
$codes = $user->generateBackupCodes();
return response()->json([
'success' => true,
'codes' => $codes
]);
} else {
return response()->json([
'success' => false,
]);
}
} else {
abort(403);
}
}
public function destroy_tfa(Request $request)
{
$user = auth()->user();
if ($user->tfa_secret !== null) {
$user->tfa_secret = null;
$user->save();
$user->backupCodes()->delete();
return response()->json([
'success' => true,
]);
} else {
abort(403);
}
}
public function post_tfa_reset_backup_codes(Request $request)
{
$user = auth()->user();
if ($user->tfa_secret !== null) {
$codes = $user->generateBackupCodes();
return response()->json([
'success' => true,
'codes' => $codes
]);
} else {
abort(403);
}
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Jobs\SendEmail;
use App\Mail\UserEmailUpdateConfirm;
use App\Mail\UserLogin;
use App\Mail\UserLoginBackupCode;
use App\Mail\UserRegister;
use App\Mail\UserWelcome;
use App\Models\Token;
@@ -47,13 +48,60 @@ class AuthController extends Controller
{
$request->validate([
'email' => 'required|email',
'captcha' => 'required_captcha',
], [
'email.required' => __('validation.custom_messages.email_required'),
'email.email' => __('validation.custom_messages.email_invalid'),
]);
$forceEmailLogin = false;
if($request->has('code')) {
$user = User::where('email', $request->email)->whereNotNull('email_verified_at')->first();
if($user) {
$tfa = AccountController::getTFAInstance();
if ($request->code && $tfa->verifyCode($user->tfa_secret, $request->code, 4)) {
$data = ['url' => session()->pull('url.intended', null)];
return $this->loginByUser($user, $data);
}
}
return view('auth.login-2fa', ['email' => $request->email])->withErrors([
'code' => 'The 2FA code is not valid',
]);
}
if($request->has('backup_code')) {
$user = User::where('email', $request->email)->whereNotNull('email_verified_at')->first();
if($user) {
if($user->verifyBackupCode($request->backup_code)) {
$data = ['url' => session()->pull('url.intended', null)];
dispatch(new SendEmail($user->email, new UserLoginBackupCode($user->email)))->onQueue('mail');
return $this->loginByUser($user, $data);
}
}
return view('auth.login-2fa', ['email' => $request->email, 'method' => 'backup'])->withErrors([
'backup_code' => 'The backup code is not valid',
]);
}
if($request->has('method')) {
if($request->get('method') === 'email') {
$forceEmailLogin = true;
} else {
abort(404);
}
}
$user = User::where('email', $request->email)->whereNotNull('email_verified_at')->first();
if($user) {
if ($user) {
if (!$forceEmailLogin && $user->tfa_secret !== null) {
return view('auth.login-2fa', ['user' => $user]);
}
$token = $user->tokens()->create([
'type' => 'login',
'data' => ['url' => session()->pull('url.intended', null)],
@@ -190,6 +238,7 @@ class AuthController extends Controller
{
$request->validate([
'email' => 'required|email',
'captcha' => 'required_captcha',
], [
'email.required' => __('validation.custom_messages.email_required'),
'email.email' => __('validation.custom_messages.email_invalid')

View File

@@ -1,261 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Event;
use Carbon\Carbon;
use Illuminate\Http\Request;
class EventController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(Request $request)
{
$homeView = true;
$search = $request->get('search', '');
$query = Event::query();
if(!auth()->user()?->admin) {
$query = $query->where('status', '!=', 'draft');
}
if($request->has('search') && $request->search !== '') {
$homeView = false;
$query = $query->where(function ($query) use ($request) {
$query->where('title', 'like', '%' . $request->search . '%')
->orWhere('content', 'like', '%' . $request->search . '%');
});
}
if($request->has('location') && $request->location !== '') {
$homeView = false;
$query = $query->whereHas('location', function ($query) use ($request) {
$query->where('name', 'like', '%' . $request->location . '%');
});
}
if($request->has('date') && $request->date !== '') {
$homeView = false;
$dates = explode('-', $request->date);
$dates = array_map('trim', $dates);
$dates = array_map(function($date) {
$date = trim($date);
if(preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return $date;
}
if(preg_match('/^(\d{2})-(\d{2})-(\d{2})$/', $date, $matches)) {
return '20' . $matches[1] . '-' . $matches[2] . '-' . $matches[3];
}
if(preg_match('/^\d{4}-\d{2}$/', $date)) {
return $date . '-01';
}
if(preg_match('/^\d{4}$/', $date)) {
return $date . '-01-01';
}
if(preg_match('/^(\d{2})\/(\d{2})\/(\d{2})$/', $date, $matches)) {
return '20' . $matches[3] . '-' . $matches[2] . '-' . $matches[1];
}
if(preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $date, $matches)) {
return $matches[3] . '-' . $matches[2] . '-' . $matches[1];
}
return '';
}, $dates);
if(count($dates) == 2) {
// If there are two dates, filter between starts_at and ends_at
$query = $query->whereDate('starts_at', '>=', $dates[0])
->whereDate('ends_at', '<=', $dates[1]);
} else {
// If there is one date, filter starts_at that date or newer
$query = $query->whereDate('starts_at', '>=', $dates[0]);
}
}
if($homeView) {
$query = $query->where('starts_at', '>=', Carbon::now()->subDays(8))
->orderBy('starts_at', 'asc');
} else {
$query = $query->orderBy('starts_at', 'asc');
}
$events = $query->paginate(12);
return view('event.index', [
'events' => $events,
'search' => $search,
]);
}
/**
* Display a listing of the resource.
*/
public function admin_index(Request $request)
{
$query = Event::query();
if($request->has('search')) {
$query->where('title', 'like', '%' . $request->search . '%');
$query->orWhere('content', 'like', '%' . $request->search . '%');
}
$events = $query->orderBy('starts_at', 'desc')->paginate(12)->onEachSide(1);
return view('admin.event.index', [
'events' => $events
]);
}
/**
* Show the form for creating a new resource.
*/
public function admin_create()
{
return view('admin.event.edit');
}
/**
* Store a newly created resource in storage.
*/
public function admin_store(Request $request)
{
$request->validate([
'title' => 'required',
'content' => 'required',
'starts_at' => 'required',
'ends_at' => 'required|after:starts_at',
'publish_at' => 'required',
'closes_at' => 'required',
'status' => 'required',
'hero_media_name' => 'required|exists:media,name',
'registration_data' => 'required_unless:registration,none',
], [
'title.required' => __('validation.custom_messages.title_required'),
'content.required' => __('validation.custom_messages.content_required'),
'starts_at.required' => __('validation.custom_messages.starts_at_required'),
'ends_at.required' => __('validation.custom_messages.ends_at_required'),
'ends_at.after' => __('validation.custom_messages.ends_at_after'),
'publish_at.required' => __('validation.custom_messages.publish_at_required'),
'closes_at.required' => __('validation.custom_messages.closes_at_required'),
'status.required' => __('validation.custom_messages.status_required'),
'hero_media_name.required' => __('validation.custom_messages.hero_media_name_required'),
'hero_media_name.exists' => __('validation.custom_messages.hero_media_name_exists'),
'registration_data.required_unless' => __('validation.custom_messages.registration_data_required_unless'),
]);
$eventData = $request->all();
$eventData['user_id'] = auth()->user()->id;
if($eventData['status'] === 'open' && Carbon::parse($eventData['starts_at'])->lt(Carbon::now())) {
$eventData['status'] = 'closed';
}
$event = Event::create($eventData);
$event->updateFiles($request->input('files'));
session()->flash('message', 'Event has been created');
session()->flash('message-title', 'Event created');
session()->flash('message-type', 'success');
return redirect()->route('admin.event.index');
}
/**
* Display the specified resource.
*/
public function show(Event $event)
{
if(!auth()->user()?->admin && $event->status == 'draft') {
abort(404);
}
return view('event.show', ['event' => $event]);
}
/**
* Show the form for editing the specified resource.
*/
public function admin_edit(Event $event)
{
return view('admin.event.edit', ['event' => $event]);
}
/**
* Update the specified resource in storage.
*/
public function admin_update(Request $request, Event $event)
{
$request->validate([
'title' => 'required',
'content' => 'required',
'starts_at' => 'required',
'ends_at' => 'required|after:starts_at',
'publish_at' => 'required',
'closes_at' => 'required',
'status' => 'required',
'hero_media_name' => 'required|exists:media,name',
'registration_data' => 'required_unless:registration,none',
], [
'title.required' => __('validation.custom_messages.title_required'),
'content.required' => __('validation.custom_messages.content_required'),
'starts_at.required' => __('validation.custom_messages.starts_at_required'),
'ends_at.required' => __('validation.custom_messages.ends_at_required'),
'ends_at.after' => __('validation.custom_messages.ends_at_after'),
'publish_at.required' => __('validation.custom_messages.publish_at_required'),
'closes_at.required' => __('validation.custom_messages.closes_at_required'),
'status.required' => __('validation.custom_messages.status_required'),
'hero_media_name.required' => __('validation.custom_messages.hero_media_name_required'),
'hero_media_name.exists' => __('validation.custom_messages.hero_media_name_exists'),
'registration_data.required_unless' => __('validation.custom_messages.registration_data_required_unless'),
]);
$eventData = $request->all();
if($eventData['status'] === 'open' && Carbon::parse($eventData['starts_at'])->lt(Carbon::now())) {
$eventData['status'] = 'closed';
}
$event->update($eventData);
$event->updateFiles($request->input('files'));
session()->flash('message', 'Event has been updated');
session()->flash('message-title', 'Event updated');
session()->flash('message-type', 'success');
return redirect()->route('admin.event.index');
}
/**
* Remove the specified resource from storage.
*/
public function admin_destroy(Event $event)
{
$event->delete();
session()->flash('message', 'Event has been deleted');
session()->flash('message-title', 'Event deleted');
session()->flash('message-type', 'danger');
return redirect()->route('admin.event.index');
}
/**
* Duplicate the specified resource.
*/
public function admin_duplicate(Event $event)
{
$newWorkshop = $event->replicate();
$newWorkshop->title = $newWorkshop->title . ' (copy)';
$newWorkshop->status = 'draft';
$newWorkshop->save();
foreach($event->files as $file) {
$newWorkshop->files()->attach($file->name);
}
session()->flash('message', 'Event has been duplicated');
session()->flash('message-title', 'Event duplicated');
session()->flash('message-type', 'success');
return redirect()->route('admin.event.edit', $newWorkshop);
}
}

View File

@@ -3,18 +3,18 @@
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\Event;
use App\Models\Workshop;
class HomeController extends Controller
{
public function index()
{
$posts = Post::query()->orderBy('created_at', 'desc')->limit(4)->get();
$events = Event::query()->where('starts_at', '>', now())->where('status', '!=', 'private')->orderBy('starts_at', 'asc')->limit(4)->get();
$workshops = Workshop::query()->where('starts_at', '>', now())->where('status', '!=', 'private')->orderBy('starts_at', 'asc')->limit(4)->get();
return view('home', [
'posts' => $posts,
'events' => $events,
'workshops' => $workshops,
]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\Workshop;
use Carbon\Carbon;
use Illuminate\Http\Request;
class SearchController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(Request $request)
{
$search = $request->get('q', '');
$search_words = explode(' ', $search); // Split the search query into words[1]
$workshopQuery = Workshop::query()->where('status', '!=', 'draft');
$workshopQuery->where(function ($query) use ($search_words) {
foreach ($search_words as $word) {
$query->orWhere(function ($subQuery) use ($word) {
$subQuery->where('title', 'like', '%' . $word . '%')
->orWhere('content', 'like', '%' . $word . '%')
->orWhereHas('location', function ($locationQuery) use ($word) {
$locationQuery->where('name', 'like', '%' . $word . '%');
});
});
}
});
$workshops = $workshopQuery->orderBy('starts_at', 'desc')
->paginate(6, ['*'], 'workshop');
$postQuery = Post::query()->where('status', 'published');
$postQuery->where(function ($query) use ($search_words) {
foreach ($search_words as $word) {
$query->where(function ($subQuery) use ($word) {
$subQuery->where('title', 'like', '%' . $word . '%')
->orWhere('content', 'like', '%' . $word . '%');
});
}
});
$posts = $postQuery->orderBy('created_at', 'desc')
->paginate(6, ['*'], 'post')
->onEachSide(1);
return view('search', [
'workshops' => $workshops,
'posts' => $posts,
'search' => $search,
]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Models\EmailSubscriptions;
use App\Models\SentEmail;
use Illuminate\Http\Request;
class SubscribeController extends Controller
{
/**
* Display a listing of the resource.
*/
public function destroy($email)
{
$emailModel = SentEmail::where('id', $email)->first();
if (!$emailModel) {
// Email not found, redirect to home page with a message
return redirect()->route('index')->with([
'message' => 'The unsubscribe link is invalid or has expired.',
'message-title' => 'Invalid Unsubscribe Link',
'message-type' => 'warning'
]);
}
// Existing unsubscribe logic
$subscriptions = EmailSubscriptions::where('email', $emailModel->recipient)->get();
if ($subscriptions->isEmpty()) {
session()->flash('message', 'You are already unsubscribed.');
session()->flash('message-title', 'Already Unsubscribed');
session()->flash('message-type', 'info');
} else {
EmailSubscriptions::where('email', $emailModel->recipient)->delete();
session()->flash('message', 'You have been successfully unsubscribed.');
session()->flash('message-title', 'Unsubscribed');
session()->flash('message-type', 'success');
}
return redirect()->route('index');
}
}

View File

@@ -49,11 +49,11 @@ class UserController extends Controller
'email' => 'email|unique:users',
'phone' => '',
'home_address' => 'required_with:home_city,home_postcode,home_country,home_state',
'home_city' => 'required_with:home_address,home_postcode,home_country,home_state',
'home_postcode' => 'required_with:home_address,home_city,home_country,home_state',
'home_country' => 'required_with:home_address,home_city,home_postcode,home_state',
'home_state' => 'required_with:home_address,home_city,home_postcode,home_country',
'shipping_address' => 'required_with:shipping_city,shipping_postcode,shipping_country,shipping_state',
'shipping_city' => 'required_with:shipping_address,shipping_postcode,shipping_country,shipping_state',
'shipping_postcode' => 'required_with:shipping_address,shipping_city,shipping_country,shipping_state',
'shipping_country' => 'required_with:shipping_address,shipping_city,shipping_postcode,shipping_state',
'shipping_state' => 'required_with:shipping_address,shipping_city,shipping_postcode,shipping_country',
'billing_address' => 'required_with:billing_city,billing_postcode,billing_country,billing_state',
'billing_city' => 'required_with:billing_address,billing_postcode,billing_country,billing_state',
@@ -67,11 +67,11 @@ class UserController extends Controller
'email.email' => __('validation.custom_messages.email_invalid'),
'phone.required' => __('validation.custom_messages.phone_required'),
'home_address.required' => __('validation.custom_messages.home_address_required'),
'home_city.required' => __('validation.custom_messages.home_city_required'),
'home_postcode.required' => __('validation.custom_messages.home_postcode_required'),
'home_country.required' => __('validation.custom_messages.home_country_required'),
'home_state.required' => __('validation.custom_messages.home_state_required'),
'shipping_address.required' => __('validation.custom_messages.shipping_address_required'),
'shipping_city.required' => __('validation.custom_messages.shipping_city_required'),
'shipping_postcode.required' => __('validation.custom_messages.shipping_postcode_required'),
'shipping_country.required' => __('validation.custom_messages.shipping_country_required'),
'shipping_state.required' => __('validation.custom_messages.shipping_state_required'),
'billing_address.required' => __('validation.custom_messages.billing_address_required'),
'billing_city.required' => __('validation.custom_messages.billing_city_required'),
@@ -107,11 +107,11 @@ class UserController extends Controller
'email' => ['email', Rule::unique('users')->ignore($user->id)],
'phone' => '',
'home_address' => 'required_with:home_city,home_postcode,home_country,home_state',
'home_city' => 'required_with:home_address,home_postcode,home_country,home_state',
'home_postcode' => 'required_with:home_address,home_city,home_country,home_state',
'home_country' => 'required_with:home_address,home_city,home_postcode,home_state',
'home_state' => 'required_with:home_address,home_city,home_postcode,home_country',
'shipping_address' => 'required_with:shipping_city,shipping_postcode,shipping_country,shipping_state',
'shipping_city' => 'required_with:shipping_address,shipping_postcode,shipping_country,shipping_state',
'shipping_postcode' => 'required_with:shipping_address,shipping_city,shipping_country,shipping_state',
'shipping_country' => 'required_with:shipping_address,shipping_city,shipping_postcode,shipping_state',
'shipping_state' => 'required_with:shipping_address,shipping_city,shipping_postcode,shipping_country',
'billing_address' => 'required_with:billing_city,billing_postcode,billing_country,billing_state',
'billing_city' => 'required_with:billing_address,billing_postcode,billing_country,billing_state',
@@ -125,11 +125,11 @@ class UserController extends Controller
'email.email' => __('validation.custom_messages.email_invalid'),
'phone.required' => __('validation.custom_messages.phone_required'),
'home_address.required' => __('validation.custom_messages.home_address_required'),
'home_city.required' => __('validation.custom_messages.home_city_required'),
'home_postcode.required' => __('validation.custom_messages.home_postcode_required'),
'home_country.required' => __('validation.custom_messages.home_country_required'),
'home_state.required' => __('validation.custom_messages.home_state_required'),
'shipping_address.required' => __('validation.custom_messages.shipping_address_required'),
'shipping_city.required' => __('validation.custom_messages.shipping_city_required'),
'shipping_postcode.required' => __('validation.custom_messages.shipping_postcode_required'),
'shipping_country.required' => __('validation.custom_messages.shipping_country_required'),
'shipping_state.required' => __('validation.custom_messages.shipping_state_required'),
'billing_address.required' => __('validation.custom_messages.billing_address_required'),
'billing_city.required' => __('validation.custom_messages.billing_city_required'),

View File

@@ -0,0 +1,209 @@
<?php
namespace App\Http\Controllers;
use App\Models\Workshop;
use Carbon\Carbon;
use Illuminate\Http\Request;
class WorkshopController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
$query = Workshop::query();
$query = $query->where('starts_at', '>=', Carbon::now()->subDays(8))
->orderBy('starts_at', 'asc');
$workshops = $query->paginate(12);
return view('workshop.index', [
'workshops' => $workshops
]);
}
/**
* Display a listing of the resource.
*/
public function past_index()
{
$query = Workshop::query();
$query = $query->where('starts_at', '<', Carbon::now())
->orderBy('starts_at', 'desc');
$workshops = $query->paginate(12);
return view('workshop.index', [
'workshops' => $workshops
]);
}
/**
* Display a listing of the resource.
*/
public function admin_index(Request $request)
{
$query = Workshop::query();
if($request->has('search')) {
$query->where('title', 'like', '%' . $request->search . '%');
$query->orWhere('content', 'like', '%' . $request->search . '%');
}
$workshops = $query->orderBy('starts_at', 'desc')->paginate(12)->onEachSide(1);
return view('admin.workshop.index', [
'workshops' => $workshops
]);
}
/**
* Show the form for creating a new resource.
*/
public function admin_create()
{
return view('admin.workshop.edit');
}
/**
* Store a newly created resource in storage.
*/
public function admin_store(Request $request)
{
$request->validate([
'title' => 'required',
'content' => 'required',
'starts_at' => 'required',
'ends_at' => 'required|after:starts_at',
'publish_at' => 'required',
'closes_at' => 'required',
'status' => 'required',
'hero_media_name' => 'required|exists:media,name',
'registration_data' => 'required_unless:registration,none',
], [
'title.required' => __('validation.custom_messages.title_required'),
'content.required' => __('validation.custom_messages.content_required'),
'starts_at.required' => __('validation.custom_messages.starts_at_required'),
'ends_at.required' => __('validation.custom_messages.ends_at_required'),
'ends_at.after' => __('validation.custom_messages.ends_at_after'),
'publish_at.required' => __('validation.custom_messages.publish_at_required'),
'closes_at.required' => __('validation.custom_messages.closes_at_required'),
'status.required' => __('validation.custom_messages.status_required'),
'hero_media_name.required' => __('validation.custom_messages.hero_media_name_required'),
'hero_media_name.exists' => __('validation.custom_messages.hero_media_name_exists'),
'registration_data.required_unless' => __('validation.custom_messages.registration_data_required_unless'),
]);
$workshopData = $request->all();
$workshopData['user_id'] = auth()->user()->id;
if($workshopData['status'] === 'open' && Carbon::parse($workshopData['starts_at'])->lt(Carbon::now())) {
$workshopData['status'] = 'closed';
}
$workshop = Workshop::create($workshopData);
$workshop->updateFiles($request->input('files'));
session()->flash('message', 'Workshop has been created');
session()->flash('message-title', 'Workshop created');
session()->flash('message-type', 'success');
return redirect()->route('admin.workshop.index');
}
/**
* Display the specified resource.
*/
public function show(Workshop $workshop)
{
if(!auth()->user()?->admin && $workshop->status == 'draft') {
abort(404);
}
return view('workshop.show', ['workshop' => $workshop]);
}
/**
* Show the form for editing the specified resource.
*/
public function admin_edit(Workshop $workshop)
{
return view('admin.workshop.edit', ['workshop' => $workshop]);
}
/**
* Update the specified resource in storage.
*/
public function admin_update(Request $request, Workshop $workshop)
{
$request->validate([
'title' => 'required',
'content' => 'required',
'starts_at' => 'required',
'ends_at' => 'required|after:starts_at',
'publish_at' => 'required',
'closes_at' => 'required',
'status' => 'required',
'hero_media_name' => 'required|exists:media,name',
'registration_data' => 'required_unless:registration,none',
], [
'title.required' => __('validation.custom_messages.title_required'),
'content.required' => __('validation.custom_messages.content_required'),
'starts_at.required' => __('validation.custom_messages.starts_at_required'),
'ends_at.required' => __('validation.custom_messages.ends_at_required'),
'ends_at.after' => __('validation.custom_messages.ends_at_after'),
'publish_at.required' => __('validation.custom_messages.publish_at_required'),
'closes_at.required' => __('validation.custom_messages.closes_at_required'),
'status.required' => __('validation.custom_messages.status_required'),
'hero_media_name.required' => __('validation.custom_messages.hero_media_name_required'),
'hero_media_name.exists' => __('validation.custom_messages.hero_media_name_exists'),
'registration_data.required_unless' => __('validation.custom_messages.registration_data_required_unless'),
]);
$workshopData = $request->all();
if($workshopData['status'] === 'open' && Carbon::parse($workshopData['starts_at'])->lt(Carbon::now())) {
$workshopData['status'] = 'closed';
}
$workshop->update($workshopData);
$workshop->updateFiles($request->input('files'));
session()->flash('message', 'Workshop has been updated');
session()->flash('message-title', 'Workshop updated');
session()->flash('message-type', 'success');
return redirect()->route('admin.workshop.index');
}
/**
* Remove the specified resource from storage.
*/
public function admin_destroy(Workshop $workshop)
{
$workshop->delete();
session()->flash('message', 'Workshop has been deleted');
session()->flash('message-title', 'Workshop deleted');
session()->flash('message-type', 'danger');
return redirect()->route('admin.workshop.index');
}
/**
* Duplicate the specified resource.
*/
public function admin_duplicate(Workshop $workshop)
{
$newWorkshop = $workshop->replicate();
$newWorkshop->title = $newWorkshop->title . ' (copy)';
$newWorkshop->status = 'draft';
$newWorkshop->save();
foreach($workshop->files as $file) {
$newWorkshop->files()->attach($file->name);
}
session()->flash('message', 'Workshop has been duplicated');
session()->flash('message-title', 'Workshop duplicated');
session()->flash('message-type', 'success');
return redirect()->route('admin.workshop.edit', $newWorkshop);
}
}

View File

@@ -2,12 +2,14 @@
namespace App\Jobs;
use App\Models\SentEmail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class SendEmail implements ShouldQueue
@@ -48,6 +50,18 @@ class SendEmail implements ShouldQueue
*/
public function handle(): void
{
// Record sent email
$sentEmail = SentEmail::create([
'recipient' => $this->to,
'mailable_class' => get_class($this->mailable)
]);
// Add unsubscribe link if mailable supports it
if (method_exists($this->mailable, 'withUnsubscribeLink')) {
$unsubscribeLink = route('unsubscribe', ['email' => $sentEmail->id]);
$this->mailable->withUnsubscribeLink($unsubscribeLink);
}
Mail::to($this->to)->send($this->mailable);
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Livewire;
use App\Jobs\SendEmail;
use Carbon\Carbon;
use Livewire\Component;
use App\Models\EmailSubscriptions;
use App\Mail\UserWelcome;
class EmailSubscribe extends Component
{
public string $email = '';
public bool $success = false;
public string $message = '';
public string $trap = '';
public int $renderedAt; // unix timestamp
protected $rules = [
'email' => 'required|email|max:255',
];
public function mount()
{
$this->renderedAt = now()->timestamp;
}
public function subscribe(): void
{
$this->validate();
// 1. Honeypot - if this hidden field is filled, treat as success but do nothing
if (! empty($this->trap)) {
$this->reset(['email', 'trap']);
$this->success = true;
$this->message = 'Thanks, you have been subscribed to our newsletter.';
return;
}
// 2. Block submits in first 10 seconds after render
if (now()->timestamp - $this->renderedAt < 4) {
$this->success = false;
$this->message = 'That was a bit quick. Please wait a few seconds and try again.';
return;
}
// 3. Enforce 30 seconds between attempts per session
$lastAttempt = session('subscribe_last_attempt'); // int timestamp or null
if (! is_int($lastAttempt)) {
$lastAttempt = null;
}
$now = time();
if ($lastAttempt && ($now - $lastAttempt) < 20) {
$this->success = false;
$this->message = 'Please wait a little before trying again.';
return;
}
session(['subscribe_last_attempt' => $now]);
// 4. Limit to 5 attempts per session (your existing logic)
$attempts = session('subscribe_attempts', 0);
if ($attempts >= 5) {
$this->success = false;
$this->message = 'Too many attempts. Please try again in a little while.';
return;
}
session(['subscribe_attempts' => $attempts + 1]);
// Look up existing subscription by email
$subscription = EmailSubscriptions::where('email', $this->email)->first();
// If already confirmed, do not create a new record or resend confirmation
if ($subscription && $subscription->confirmed) {
// Optionally you could set a different flag or message here
$this->success = false;
$this->message = 'That email is already subscribed to our newsletter.';
} else {
// If no subscription exists, create a new unconfirmed one
if (!$subscription) {
$subscription = EmailSubscriptions::create([
'email' => $this->email,
'confirmed' => Carbon::now()
]);
$subscription->save();
}
dispatch(new SendEmail($subscription->email, new UserWelcome($subscription->email)))->onQueue('mail');
$this->success = true;
$this->message = 'Thanks, you have been subscribed to our newsletter.';
}
$this->reset(['email', 'trap']);
}
public function render()
{
return view('livewire.email-subscribe');
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Mail;
use App\Models\Workshop;
use App\Traits\HasUnsubscribeLink;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
class UpcomingWorkshops extends Mailable
{
use Queueable, SerializesModels, HasUnsubscribeLink;
public $subject;
public $email;
public $workshops;
public function __construct($email, $subject = 'Upcoming Workshops 🌟')
{
$this->subject = $subject;
$this->email = $email;
$this->workshops = $this->getUpcomingWorkshops();
}
private function getUpcomingWorkshops()
{
$startDate = Carbon::now()->addDays(3);
$endDate = Carbon::now()->addDays(42);
return Workshop::select('workshops.*', 'locations.name as location_name')
->join('locations', 'workshops.location_id', '=', 'locations.id')
->whereIn('workshops.status', ['open','scheduled'])
->whereBetween('workshops.starts_at', [$startDate, $endDate])
->where('locations.name', 'not like', '%private%')
->orderBy('locations.name')
->orderBy('workshops.starts_at')
->get();
}
public function build()
{
// Bail if there are no upcoming workshops
if ($this->workshops->isEmpty()) {
return false;
}
return $this
->subject($this->subject)
->markdown('emails.upcoming-workshops')
->with([
'email' => $this->email,
'workshops' => $this->workshops,
'unsubscribeLink' => $this->unsubscribeLink
]);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Mail;
use App\Models\Ticket;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Spatie\LaravelPdf\Facades\Pdf;
class UserLoginBackupCode extends Mailable
{
use Queueable, SerializesModels;
public $email;
public function __construct($email)
{
$this->email = $email;
}
public function build()
{
return $this
->subject('Hey, did you recently log in?')
->markdown('emails.login-backup-code')
->with([
'email' => $this->email,
]);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Mail;
use App\Models\Ticket;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Spatie\LaravelPdf\Facades\Pdf;
class UserLoginTFADisabled extends Mailable
{
use Queueable, SerializesModels;
public $email;
public function __construct($email)
{
$this->email = $email;
}
public function build()
{
return $this
->subject('Two-factor authentication disabled on your account')
->markdown('emails.login-tfa-disabled')
->with([
'email' => $this->email,
]);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Mail;
use App\Models\Ticket;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Spatie\LaravelPdf\Facades\Pdf;
class UserLoginTFAEnabled extends Mailable
{
use Queueable, SerializesModels;
public $email;
public function __construct($email)
{
$this->email = $email;
}
public function build()
{
return $this
->subject('Two-factor authentication enabled on your account')
->markdown('emails.login-tfa-enabled')
->with([
'email' => $this->email,
]);
}
}

View File

@@ -2,14 +2,14 @@
namespace App\Mail;
use App\Traits\HasUnsubscribeLink;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class UserWelcome extends Mailable
{
use Queueable, SerializesModels;
use Queueable, SerializesModels, HasUnsubscribeLink;
public $email;
@@ -25,6 +25,7 @@ class UserWelcome extends Mailable
->markdown('emails.welcome')
->with([
'email' => $this->email,
'unsubscribeLink' => $this->unsubscribeLink
]);
}
}

View File

@@ -7,8 +7,6 @@ use Illuminate\Database\Eloquent\Model;
class EmailSubscriptions extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*

30
app/Models/SentEmail.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class SentEmail extends Model
{
protected $fillable = ['recipient', 'mailable_class'];
public $incrementing = false;
protected $keyType = 'string';
/**
* Boot function from Laravel.
*
* @return void
*/
protected static function boot(): void
{
parent::boot();
static::creating(function ($model) {
if (empty($model->{$model->getKeyName()}) === true) {
$model->{$model->getKeyName()} = strtolower(Str::random(15));
}
});
}
}

View File

@@ -2,12 +2,16 @@
namespace App\Models;
use App\Jobs\SendEmail;
use App\Mail\UserLoginTFADisabled;
use App\Mail\UserLoginTFAEnabled;
use App\Traits\UUID;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Hash;
class User extends Authenticatable implements MustVerifyEmail
{
@@ -24,19 +28,21 @@ class User extends Authenticatable implements MustVerifyEmail
'surname',
'email',
'phone',
'home_address',
'home_address2',
'home_city',
'home_postcode',
'home_state',
'home_country',
'shipping_address',
'shipping_address2',
'shipping_city',
'shipping_postcode',
'shipping_state',
'shipping_country',
'billing_address',
'billing_address2',
'billing_city',
'billing_postcode',
'billing_state',
'billing_country',
'subscribed'
'subscribed',
'tfa_secret',
'agree_tos',
];
/**
@@ -47,6 +53,7 @@ class User extends Authenticatable implements MustVerifyEmail
protected $hidden = [
'password',
'remember_token',
'tfa_secret'
];
/**
@@ -98,6 +105,15 @@ class User extends Authenticatable implements MustVerifyEmail
}
}
}
if ($user->isDirty('tfa_secret')) {
if($user->tfa_secret === null) {
$user->backupCodes()->delete();
dispatch(new SendEmail($user->email, new UserLoginTFADisabled($user->email)))->onQueue('mail');
} else {
dispatch(new SendEmail($user->email, new UserLoginTFAEnabled($user->email)))->onQueue('mail');
}
}
});
static::deleting(function ($user) {
@@ -176,4 +192,38 @@ class User extends Authenticatable implements MustVerifyEmail
{
return $this->admin === 1;
}
public function backupCodes()
{
return $this->hasMany(UserBackupCode::class);
}
public function generateBackupCodes()
{
$this->backupCodes()->delete();
$codes = [];
for ($i = 0; $i < 10; $i++) {
$code = strtoupper(bin2hex(random_bytes(4)));
$codes[] = $code;
UserBackupCode::create([
'user_id' => $this->id,
'code' => $code,
]);
}
return $codes;
}
public function verifyBackupCode($code)
{
$backupCodes = $this->backupCodes()->get();
foreach ($backupCodes as $backupCode) {
if (Hash::check($code, $backupCode->code)) {
$backupCode->delete();
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash;
class UserBackupCode extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'code'
];
/**
* Set the code attribute and automatically hash the code.
*
* @param string $value
* @return void
*/
public function setCodeAttribute($value)
{
$this->attributes['code'] = Hash::make($value);
}
/**
* Verify the given code against the stored hashed code.
*
* @param string $value
* @return bool
*/
public function verify($value)
{
return Hash::check($value, $this->code);
}
}

View File

@@ -7,7 +7,7 @@ use App\Traits\Slug;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Event extends Model
class Workshop extends Model
{
use HasFactory, Slug, HasFiles;

View File

@@ -3,7 +3,7 @@
namespace App\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -21,6 +21,10 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
if ($this->app->environment('production')) {
URL::forceScheme('https');
}
Blade::directive('includeSVG', function ($arguments) {
list($path, $styles) = array_pad(explode(',', str_replace(['(', ')', ' ', "'"], '', $arguments), 2), 2, '');
$svgContent = file_get_contents(public_path($path));

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\ServiceProvider;
class CaptchaServiceProvider extends ServiceProvider
{
private string $captchaKey = '6Lc6BIAUAAAAAABZzv6J9ZQ7J9Zzv6J9ZQ7J9Zzv';
private int $timeThreshold = 750;
/**
* Register services.
*/
public function register(): void
{
//
}
/**
* Bootstrap services.
*/
public function boot(): void
{
Blade::directive('captcha', function () {
return <<<EOT
<input type="text" name="captcha" autocomplete="off" style="position:absolute;left:-9999px;top:-9999px">
<script>
document.addEventListener('DOMContentLoaded', function() {
const errors = {!! json_encode(\$errors->getMessages()) !!};
if(errors && errors.captcha && errors.captcha.length) {
SM.alert('', errors.captcha[0], 'danger');
}
});
</script>
EOT;
});
Blade::directive('captchaScripts', function () {
return <<<EOT
<script>
document.addEventListener('DOMContentLoaded', function() {
window.setTimeout(function() {
const captchaList = document.querySelectorAll('input[name="captcha"]');
captchaList.forEach(function(captcha) {
if(captcha.value === '') {
captcha.value = '$this->captchaKey';
}
});
}, $this->timeThreshold);
});
</script>
EOT;
});
Validator::extend('required_captcha', function ($attribute, $value, $parameters, $validator) {
return $value === $this->captchaKey;
}, 'The form captcha failed to validate. Please try again.');
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Providers;
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
use RobThree\Auth\Providers\Qr\IQRCodeProvider;
class QRCodeProvider implements IQRCodeProvider
{
public function getMimeType(): string
{
return 'image/svg+xml';
}
public function getQRCodeImage(string $qrText, int $size): string
{
$options = new QROptions;
$options->outputBase64 = false;
$options->imageTransparent = true;
return (new QRCode($options))->render($qrText);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Traits;
trait HasUnsubscribeLink
{
protected $unsubscribeLink;
public function withUnsubscribeLink($link)
{
$this->unsubscribeLink = $link;
return $this;
}
}

View File

@@ -6,6 +6,8 @@ use Illuminate\Support\Str;
trait Slug
{
protected $appendsSlug = ['slug'];
/**
* Boot function from Laravel.
*
@@ -20,6 +22,16 @@ trait Slug
});
}
/**
* Initialize the trait.
*
* @return void
*/
public function initializeSlug(): void
{
$this->appends = array_merge($this->appends ?? [], $this->appendsSlug);
}
/**
* Get the value indicating whether the IDs are incrementing.
*
@@ -47,7 +59,7 @@ trait Slug
*/
public function getRouteKey()
{
return $this->slug();
return $this->slug;
}
/**
@@ -68,7 +80,7 @@ trait Slug
*
* @return string
*/
public function slug()
public function getSlugAttribute()
{
return Str::slug($this->title) . '-' . $this->id;
}

0
artisan Executable file → Normal file
View File

View File

@@ -2,4 +2,5 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\CaptchaServiceProvider::class,
];

View File

@@ -6,15 +6,18 @@
"license": "MIT",
"require": {
"php": "^8.2",
"ext-imagick": "*",
"chillerlan/php-qrcode": "^5.0",
"gehrisandro/tailwind-merge-laravel": "^1.2",
"intervention/image": "^3.5",
"laravel/framework": "^11.0",
"laravel/tinker": "^2.9",
"livewire/livewire": "^3.4",
"php-ffmpeg/php-ffmpeg": "^1.2",
"ext-imagick": "*"
"robthree/twofactorauth": "^3.0"
},
"require-dev": {
"roave/security-advisories": "dev-latest",
"fakerphp/faker": "^1.23",
"laravel/pint": "^1.13",
"laravel/sail": "^1.26",

3682
composer.lock generated

File diff suppressed because it is too large Load Diff

80
config/flare.php Normal file
View File

@@ -0,0 +1,80 @@
<?php
use Spatie\FlareClient\FlareMiddleware\AddGitInformation;
use Spatie\FlareClient\FlareMiddleware\RemoveRequestIp;
use Spatie\FlareClient\FlareMiddleware\CensorRequestBodyFields;
use Spatie\FlareClient\FlareMiddleware\CensorRequestHeaders;
use Spatie\LaravelIgnition\FlareMiddleware\AddDumps;
use Spatie\LaravelIgnition\FlareMiddleware\AddEnvironmentInformation;
use Spatie\LaravelIgnition\FlareMiddleware\AddExceptionInformation;
use Spatie\LaravelIgnition\FlareMiddleware\AddJobs;
use Spatie\LaravelIgnition\FlareMiddleware\AddLogs;
use Spatie\LaravelIgnition\FlareMiddleware\AddQueries;
use Spatie\LaravelIgnition\FlareMiddleware\AddNotifierName;
return [
/*
|
|--------------------------------------------------------------------------
| Flare API key
|--------------------------------------------------------------------------
|
| Specify Flare's API key below to enable error reporting to the service.
|
| More info: https://flareapp.io/docs/general/projects
|
*/
'key' => env('FLARE_KEY'),
/*
|--------------------------------------------------------------------------
| Middleware
|--------------------------------------------------------------------------
|
| These middleware will modify the contents of the report sent to Flare.
|
*/
'flare_middleware' => [
RemoveRequestIp::class,
AddGitInformation::class,
AddNotifierName::class,
AddEnvironmentInformation::class,
AddExceptionInformation::class,
AddDumps::class,
AddLogs::class => [
'maximum_number_of_collected_logs' => 200,
],
AddQueries::class => [
'maximum_number_of_collected_queries' => 200,
'report_query_bindings' => true,
],
AddJobs::class => [
'max_chained_job_reporting_depth' => 5,
],
CensorRequestBodyFields::class => [
'censor_fields' => [
'password',
'password_confirmation',
],
],
CensorRequestHeaders::class => [
'headers' => [
'API-KEY',
]
]
],
/*
|--------------------------------------------------------------------------
| Reporting log statements
|--------------------------------------------------------------------------
|
| If this setting is `false` log statements won't be sent as events to Flare,
| no matter which error level you specified in the Flare log channel.
|
*/
'send_logs_as_events' => true,
];

277
config/ignition.php Normal file
View File

@@ -0,0 +1,277 @@
<?php
use Spatie\Ignition\Solutions\SolutionProviders\BadMethodCallSolutionProvider;
use Spatie\Ignition\Solutions\SolutionProviders\MergeConflictSolutionProvider;
use Spatie\Ignition\Solutions\SolutionProviders\UndefinedPropertySolutionProvider;
use Spatie\LaravelIgnition\Recorders\DumpRecorder\DumpRecorder;
use Spatie\LaravelIgnition\Recorders\JobRecorder\JobRecorder;
use Spatie\LaravelIgnition\Recorders\LogRecorder\LogRecorder;
use Spatie\LaravelIgnition\Recorders\QueryRecorder\QueryRecorder;
use Spatie\LaravelIgnition\Solutions\SolutionProviders\DefaultDbNameSolutionProvider;
use Spatie\LaravelIgnition\Solutions\SolutionProviders\GenericLaravelExceptionSolutionProvider;
use Spatie\LaravelIgnition\Solutions\SolutionProviders\IncorrectValetDbCredentialsSolutionProvider;
use Spatie\LaravelIgnition\Solutions\SolutionProviders\InvalidRouteActionSolutionProvider;
use Spatie\LaravelIgnition\Solutions\SolutionProviders\MissingAppKeySolutionProvider;
use Spatie\LaravelIgnition\Solutions\SolutionProviders\MissingColumnSolutionProvider;
use Spatie\LaravelIgnition\Solutions\SolutionProviders\MissingImportSolutionProvider;
use Spatie\LaravelIgnition\Solutions\SolutionProviders\MissingLivewireComponentSolutionProvider;
use Spatie\LaravelIgnition\Solutions\SolutionProviders\MissingMixManifestSolutionProvider;
use Spatie\LaravelIgnition\Solutions\SolutionProviders\MissingViteManifestSolutionProvider;
use Spatie\LaravelIgnition\Solutions\SolutionProviders\RunningLaravelDuskInProductionProvider;
use Spatie\LaravelIgnition\Solutions\SolutionProviders\TableNotFoundSolutionProvider;
use Spatie\LaravelIgnition\Solutions\SolutionProviders\UndefinedViewVariableSolutionProvider;
use Spatie\LaravelIgnition\Solutions\SolutionProviders\UnknownValidationSolutionProvider;
use Spatie\LaravelIgnition\Solutions\SolutionProviders\ViewNotFoundSolutionProvider;
use Spatie\LaravelIgnition\Solutions\SolutionProviders\OpenAiSolutionProvider;
return [
/*
|--------------------------------------------------------------------------
| Editor
|--------------------------------------------------------------------------
|
| Choose your preferred editor to use when clicking any edit button.
|
| Supported: "phpstorm", "vscode", "vscode-insiders", "textmate", "emacs",
| "sublime", "atom", "nova", "macvim", "idea", "netbeans",
| "xdebug", "phpstorm-remote"
|
*/
'editor' => env('IGNITION_EDITOR', 'phpstorm'),
/*
|--------------------------------------------------------------------------
| Theme
|--------------------------------------------------------------------------
|
| Here you may specify which theme Ignition should use.
|
| Supported: "light", "dark", "auto"
|
*/
'theme' => env('IGNITION_THEME', 'auto'),
/*
|--------------------------------------------------------------------------
| Sharing
|--------------------------------------------------------------------------
|
| You can share local errors with colleagues or others around the world.
| Sharing is completely free and doesn't require an account on Flare.
|
| If necessary, you can completely disable sharing below.
|
*/
'enable_share_button' => env('IGNITION_SHARING_ENABLED', true),
/*
|--------------------------------------------------------------------------
| Register Ignition commands
|--------------------------------------------------------------------------
|
| Ignition comes with an additional make command that lets you create
| new solution classes more easily. To keep your default Laravel
| installation clean, this command is not registered by default.
|
| You can enable the command registration below.
|
*/
'register_commands' => env('REGISTER_IGNITION_COMMANDS', false),
/*
|--------------------------------------------------------------------------
| Solution Providers
|--------------------------------------------------------------------------
|
| You may specify a list of solution providers (as fully qualified class
| names) that shouldn't be loaded. Ignition will ignore these classes
| and possible solutions provided by them will never be displayed.
|
*/
'solution_providers' => [
// from spatie/ignition
BadMethodCallSolutionProvider::class,
MergeConflictSolutionProvider::class,
UndefinedPropertySolutionProvider::class,
// from spatie/laravel-ignition
IncorrectValetDbCredentialsSolutionProvider::class,
MissingAppKeySolutionProvider::class,
DefaultDbNameSolutionProvider::class,
TableNotFoundSolutionProvider::class,
MissingImportSolutionProvider::class,
InvalidRouteActionSolutionProvider::class,
ViewNotFoundSolutionProvider::class,
RunningLaravelDuskInProductionProvider::class,
MissingColumnSolutionProvider::class,
UnknownValidationSolutionProvider::class,
MissingMixManifestSolutionProvider::class,
MissingViteManifestSolutionProvider::class,
MissingLivewireComponentSolutionProvider::class,
UndefinedViewVariableSolutionProvider::class,
GenericLaravelExceptionSolutionProvider::class,
OpenAiSolutionProvider::class,
],
/*
|--------------------------------------------------------------------------
| Ignored Solution Providers
|--------------------------------------------------------------------------
|
| You may specify a list of solution providers (as fully qualified class
| names) that shouldn't be loaded. Ignition will ignore these classes
| and possible solutions provided by them will never be displayed.
|
*/
'ignored_solution_providers' => [
],
/*
|--------------------------------------------------------------------------
| Runnable Solutions
|--------------------------------------------------------------------------
|
| Some solutions that Ignition displays are runnable and can perform
| various tasks. By default, runnable solutions are only enabled when your
| app has debug mode enabled and the environment is `local` or
| `development`.
|
| Using the `IGNITION_ENABLE_RUNNABLE_SOLUTIONS` environment variable, you
| can override this behaviour and enable or disable runnable solutions
| regardless of the application's environment.
|
| Default: env('IGNITION_ENABLE_RUNNABLE_SOLUTIONS')
|
*/
'enable_runnable_solutions' => env('IGNITION_ENABLE_RUNNABLE_SOLUTIONS'),
/*
|--------------------------------------------------------------------------
| Remote Path Mapping
|--------------------------------------------------------------------------
|
| If you are using a remote dev server, like Laravel Homestead, Docker, or
| even a remote VPS, it will be necessary to specify your path mapping.
|
| Leaving one, or both of these, empty or null will not trigger the remote
| URL changes and Ignition will treat your editor links as local files.
|
| "remote_sites_path" is an absolute base path for your sites or projects
| in Homestead, Vagrant, Docker, or another remote development server.
|
| Example value: "/home/vagrant/Code"
|
| "local_sites_path" is an absolute base path for your sites or projects
| on your local computer where your IDE or code editor is running on.
|
| Example values: "/Users/<name>/Code", "C:\Users\<name>\Documents\Code"
|
*/
'remote_sites_path' => env('IGNITION_REMOTE_SITES_PATH', base_path()),
'local_sites_path' => env('IGNITION_LOCAL_SITES_PATH', ''),
/*
|--------------------------------------------------------------------------
| Housekeeping Endpoint Prefix
|--------------------------------------------------------------------------
|
| Ignition registers a couple of routes when it is enabled. Below you may
| specify a route prefix that will be used to host all internal links.
|
*/
'housekeeping_endpoint_prefix' => '_ignition',
/*
|--------------------------------------------------------------------------
| Settings File
|--------------------------------------------------------------------------
|
| Ignition allows you to save your settings to a specific global file.
|
| If no path is specified, a file with settings will be saved to the user's
| home directory. The directory depends on the OS and its settings but it's
| typically `~/.ignition.json`. In this case, the settings will be applied
| to all of your projects where Ignition is used and the path is not
| specified.
|
| However, if you want to store your settings on a project basis, or you
| want to keep them in another directory, you can specify a path where
| the settings file will be saved. The path should be an existing directory
| with correct write access.
| For example, create a new `ignition` folder in the storage directory and
| use `storage_path('ignition')` as the `settings_file_path`.
|
| Default value: '' (empty string)
*/
'settings_file_path' => '',
/*
|--------------------------------------------------------------------------
| Recorders
|--------------------------------------------------------------------------
|
| Ignition registers a couple of recorders when it is enabled. Below you may
| specify a recorders will be used to record specific events.
|
*/
'recorders' => [
DumpRecorder::class,
JobRecorder::class,
LogRecorder::class,
QueryRecorder::class,
],
/*
* When a key is set, we'll send your exceptions to Open AI to generate a solution
*/
'open_ai_key' => env('IGNITION_OPEN_AI_KEY'),
/*
|--------------------------------------------------------------------------
| Include arguments
|--------------------------------------------------------------------------
|
| Ignition show you stack traces of exceptions with the arguments that were
| passed to each method. This feature can be disabled here.
|
*/
'with_stack_frame_arguments' => true,
/*
|--------------------------------------------------------------------------
| Argument reducers
|--------------------------------------------------------------------------
|
| Ignition show you stack traces of exceptions with the arguments that were
| passed to each method. To make these variables more readable, you can
| specify a list of classes here which summarize the variables.
|
*/
'argument_reducers' => [
\Spatie\Backtrace\Arguments\Reducers\BaseTypeArgumentReducer::class,
\Spatie\Backtrace\Arguments\Reducers\ArrayArgumentReducer::class,
\Spatie\Backtrace\Arguments\Reducers\StdClassArgumentReducer::class,
\Spatie\Backtrace\Arguments\Reducers\EnumArgumentReducer::class,
\Spatie\Backtrace\Arguments\Reducers\ClosureArgumentReducer::class,
\Spatie\Backtrace\Arguments\Reducers\DateTimeArgumentReducer::class,
\Spatie\Backtrace\Arguments\Reducers\DateTimeZoneArgumentReducer::class,
\Spatie\Backtrace\Arguments\Reducers\SymphonyRequestArgumentReducer::class,
\Spatie\LaravelIgnition\ArgumentReducers\ModelArgumentReducer::class,
\Spatie\LaravelIgnition\ArgumentReducers\CollectionArgumentReducer::class,
\Spatie\Backtrace\Arguments\Reducers\StringableArgumentReducer::class,
],
];

50
config/tinker.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Console Commands
|--------------------------------------------------------------------------
|
| This option allows you to add additional Artisan commands that should
| be available within the Tinker environment. Once the command is in
| this array you may execute the command in Tinker using its name.
|
*/
'commands' => [
// App\Console\Commands\ExampleCommand::class,
],
/*
|--------------------------------------------------------------------------
| Auto Aliased Classes
|--------------------------------------------------------------------------
|
| Tinker will not automatically alias classes in your vendor namespaces
| but you may explicitly allow a subset of classes to get aliased by
| adding the names of each of those classes to the following list.
|
*/
'alias' => [
//
],
/*
|--------------------------------------------------------------------------
| Classes That Should Not Be Aliased
|--------------------------------------------------------------------------
|
| Typically, Tinker automatically aliases classes as you require them in
| Tinker. However, you may wish to never alias certain classes, which
| you may accomplish by listing the classes in the following array.
|
*/
'dont_alias' => [
'App\Nova',
],
];

View File

@@ -26,11 +26,11 @@ class UserFactory extends Factory
'email_verified_at' => now(),
'remember_token' => Str::random(10),
'home_address' => fake()->streetAddress(),
'home_city' => fake()->city(),
'home_state' => '',
'home_postcode' => fake()->postcode(),
'home_country' => fake()->country(),
'shipping_address' => fake()->streetAddress(),
'shipping_city' => fake()->city(),
'shipping_state' => '',
'shipping_postcode' => fake()->postcode(),
'shipping_country' => fake()->country(),
'billing_address' => fake()->streetAddress(),
'billing_city' => fake()->city(),

View File

@@ -3,13 +3,13 @@
namespace Database\Factories;
use App\Models\Location;
use App\Models\Event;
use App\Models\Workshop;
use DateInterval;
use Illuminate\Database\Eloquent\Factories\Factory;
class WorkshopFactory extends Factory
{
protected $model = Event::class;
protected $model = Workshop::class;
public function definition(): array
{

View File

@@ -1,37 +0,0 @@
<?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::rename('workshops', 'events');
Schema::table('tickets', function (Blueprint $table) {
$table->dropForeign(['workshop_id']);
$table->renameColumn('workshop_id', 'event_id');
$table->foreign('event_id')->references('id')->on('events');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::rename('events', 'workshops');
Schema::table('tickets', function (Blueprint $table) {
$table->dropForeign(['event_id']);
$table->renameColumn('event_id', 'workshop_id');
$table->foreign('workshops_id')->references('id')->on('workshops');
});
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('tfa_secret')->nullable();
$table->boolean('agree_tos')->default(false);
});
DB::table('users')->update(['agree_tos' => true]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('agree_tos');
$table->dropColumn('tfa_secret');
});
}
};

View File

@@ -0,0 +1,29 @@
<?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('user_backup_codes', function (Blueprint $table) {
$table->id();
$table->foreignUuid('user_id')->constrained()->onDelete('cascade');
$table->string('code', 256);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_backup_codes');
}
};

View File

@@ -0,0 +1,39 @@
<?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::table('users', function (Blueprint $table) {
$table->renameColumn('home_address', 'shipping_address');
$table->renameColumn('home_address2', 'shipping_address2');
$table->renameColumn('home_city', 'shipping_city');
$table->renameColumn('home_state', 'shipping_state');
$table->renameColumn('home_postcode', 'shipping_postcode');
$table->renameColumn('home_country', 'shipping_country');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->renameColumn('shipping_address', 'home_address');
$table->renameColumn('shipping_address2', 'home_address2');
$table->renameColumn('shipping_city', 'home_city');
$table->renameColumn('shipping_state', 'home_state');
$table->renameColumn('shipping_postcode', 'home_postcode');
$table->renameColumn('shipping_country', 'home_country');
});
}
};

View File

@@ -0,0 +1,29 @@
<?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('sent_emails', function (Blueprint $table) {
$table->string('id', 15)->primary();
$table->string('recipient');
$table->string('mailable_class');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sent_emails');
}
};

View File

@@ -7,7 +7,7 @@ use App\Models\Media;
use App\Models\Post;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use App\Models\Event;
use App\Models\Workshop;
use Database\Factories\LocationFactory;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Storage;

View File

@@ -0,0 +1,5 @@
<?php
return [
'clamav' => ':attribute contains virus.',
];

1883
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@
"@tiptap/extension-text-align": "^2.3.0",
"@tiptap/extension-typography": "^2.3.0",
"@tiptap/extension-underline": "^2.3.0",
"@tiptap/pm": "^2.5.1",
"@tiptap/pm": "^2.3.0",
"@tiptap/starter-kit": "^2.3.0"
}
}

View File

@@ -1,3 +1,14 @@
<If "%{HTTP_HOST} =~ /(\.local|^localhost(:\d+)?$)/">
# Disable caching in dev
<IfModule mod_headers.c>
Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
Header set Pragma "no-cache"
Header set Expires "0"
Header unset ETag
</IfModule>
FileETag None
</If>
<IfModule mod_deflate.c>
# Enable on-the-fly compression for various file types.
AddOutputFilterByType DEFLATE application/javascript
@@ -54,10 +65,12 @@
# Block access to .git directory
RewriteRule .*\.git/.* - [L,R=404]
# Force HTTPS and www subdomain
RewriteCond %{HTTPS} off [OR]
RewriteCond %{HTTP_HOST} !^www\. [NC]
RewriteRule ^ https://www.stemmechanics.com.au%{REQUEST_URI} [L,R=301]
# <If "%{HTTP_HOST} !~ /(\.local|^localhost(:\d+)?$)/">
# # Force HTTPS and www in prod
# RewriteCond %{HTTPS} off [OR]
# RewriteCond %{HTTP_HOST} !^www\. [NC]
# RewriteRule ^ https://www.stemmechanics.com.au%{REQUEST_URI} [L,R=301]
# </If>
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .

BIN
public/about.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -16,6 +16,23 @@ let SM = {
Swal.fire(data);
},
confirm: (title, content, button, callback) => {
Swal.fire({
position: 'top',
icon: 'warning',
iconColor: '#b91c1c',
title: title,
html: content,
showCancelButton: true,
confirmButtonText: button,
confirmButtonColor: '#b91c1c',
cancelButtonText: 'Cancel',
reverseButtons: true
}).then((result) => {
callback(result.isConfirmed);
});
},
copyToClipboard: (text) => {
const copyContent = async () => {
try {
@@ -29,21 +46,21 @@ let SM = {
copyContent().then(() => { /* empty */});
},
updateBillingAddress: () => {
const checkboxElement = document.querySelector('input[name="billing_same_home"]');
updateShippingAddress: () => {
const checkboxElement = document.querySelector('input[name="shipping_same_billing"]');
if (checkboxElement) {
const itemNames = ['address', 'address2', 'city', 'state', 'postcode', 'country'];
if (checkboxElement.checked) {
itemNames.forEach((itemName) => {
const element = document.querySelector(`input[name="billing_${itemName}"]`);
element.value = document.querySelector(`input[name="home_${itemName}"]`).value;
const element = document.querySelector(`input[name="shipping_${itemName}"]`);
element.value = document.querySelector(`input[name="billing_${itemName}"]`).value;
element.setAttribute('readonly', 'true');
});
} else {
itemNames.forEach((itemName) => {
const element = document.querySelector(`input[name="billing_${itemName}"]`);
const element = document.querySelector(`input[name="shipping_${itemName}"]`);
element.removeAttribute('readonly');
});
}
@@ -347,6 +364,6 @@ let SM = {
};
document.addEventListener('DOMContentLoaded', () => {
SM.updateBillingAddress();
SM.updateShippingAddress();
SM.updateAllThumbnails();
});

View File

@@ -13,11 +13,11 @@ return [
'surname_required' => 'A surname is required',
'phone_required' => 'A phone number is required',
'home_address_required' => 'A home address is required',
'home_city_required' => 'A home city is required',
'home_postcode_required' => 'A home postcode is required',
'home_country_required' => 'A home country is required',
'home_state_required' => 'A home state is required',
'shipping_address_required' => 'A shipping address is required',
'shipping_city_required' => 'A shipping city is required',
'shipping_postcode_required' => 'A shipping postcode is required',
'shipping_country_required' => 'A shipping country is required',
'shipping_state_required' => 'A shipping state is required',
'billing_address_required' => 'A billing address is required',
'billing_city_required' => 'A billing city is required',

View File

@@ -0,0 +1,19 @@
<x-layout>
<x-mast>About STEMMechanics</x-mast>
<div class="bg-no-repeat bg-cover h-96" style="background-image:url({{asset('about.webp')}})"></div>
<x-container class="pt-8">
<p class="mb-4">STEMMechanics is a hands on education studio based in Cairns, created and operated by James Collins. Drawing on years of experience delivering digital learning, STEM programs and creative technology workshops across Queensland, James built STEMMechanics to give communities practical, engaging ways to understand technology.</p>
<p class="mb-4">James' background includes work with State Library of Queensland, Education Queensland and a wide range of community organisations, where he delivered digital literacy programs, ICT support, eSports events, STEM initiatives and media workshops in both metropolitan and remote regions. This mix of technical, educational and community based experience shaped the approach that defines STEMMechanics today.</p>
<p class="mb-4">At its core, STEMMechanics exists because learners understand technology best when they can build, test, break, remake and explore it themselves. James believes in hands on, curiosity driven learning that develops confidence, problem solving skills and genuine interest in STEM. Every program is designed to be practical, creative and accessible.</p>
<p class="mb-4">STEMMechanics operates from a dedicated, private workshop in Cairns where James prototypes new ideas, experiments with electronics and mechanical builds, develops software tools and designs the kits used in his workshops. This workshop is the testing ground for everything delivered to schools and communities across the region.</p>
<x-heading>What STEMMechanics Does</x-heading>
<h3 class="ml-4 font-bold">STEM Workshops</h3>
<p class="ml-4 mb-4">STEMMechanics delivers a wide range of workshop programs including coding, robotics, Micro:bit activities, cardboard engineering, mechanical motion, paper circuits and hands on build projects. All sessions are tailored to the age group, learning outcomes and context of each community.</p>
<h3 class="ml-4 font-bold">Digital Media & Creative Tech</h3>
<p class="ml-4 mb-4">Workshops include stop motion animation, filmmaking, digital storytelling and introductory media production. These programs support students in developing both creative and technical skills.</p>
<h3 class="ml-4 font-bold">Community & School Programs</h3>
<p class="ml-4 mb-4">STEMMechanics partners with schools, regional councils, libraries and community groups to deliver project based STEM initiatives, themed workshop blocks and multi day programs.</p>
</x-container>
</x-layout>

View File

@@ -1,21 +1,21 @@
@php
$user = auth()->user();
$billing_same_home = $user->home_address === $user->billing_address
&& $user->home_address2 === $user->billing_address2
&& $user->home_city === $user->billing_city
&& $user->home_state === $user->billing_state
&& $user->home_postcode === $user->billing_postcode
&& $user->home_country === $user->billing_country;
$shipping_same_billing = $user->shipping_address === $user->billing_address
&& $user->shipping_address2 === $user->billing_address2
&& $user->shipping_city === $user->billing_city
&& $user->shipping_state === $user->billing_state
&& $user->shipping_postcode === $user->billing_postcode
&& $user->shipping_country === $user->billing_country;
@endphp
<x-layout>
<x-mast>Account Settings</x-mast>
<x-container>
<form method="POST" action="{{ route('account.update') }}" x-data x-on:submit.prevent="SM.updateBillingAddress(); $el.submit()">
<form method="POST" action="{{ route('account.update') }}" x-data x-on:submit.prevent="SM.updateShippingAddress(); $el.submit()">
@csrf
<h3 class="text-lg font-bold mt-4 mb-3">Contact Information</h3>
<div class="flex gap-8">
<div class="flex flex-col sm:gap-8 sm:flex-row">
<div class="flex-1">
<x-ui.input label="First name" name="firstname" value="{{ $user->firstname }}" />
</div>
@@ -23,7 +23,7 @@ $billing_same_home = $user->home_address === $user->billing_address
<x-ui.input label="Surname" name="surname" value="{{ $user->surname }}" />
</div>
</div>
<div class="flex gap-8">
<div class="flex flex-col sm:gap-8 sm:flex-row">
<div class="flex-1">
<x-ui.input type="email" label="Email" name="email" value="{{ $user->email }}" info="{{ $user->email_update_pending ? 'Pending request to change to ' . $user->email_update_pending : '' }}"/>
</div>
@@ -42,25 +42,77 @@ $billing_same_home = $user->home_address === $user->billing_address
</div>
</section>
<section x-data="{ open: true }">
<section x-data="{ open: false }">
<a href="#" class="flex items-center" @click.prevent="open = !open">
<i :class="{'transform': !open, '-rotate-90': !open, 'translate-y-0.5': true}" class="fa-solid fa-angle-down text-lg transition-transform mr-2"></i>
<h3 class="text-lg font-bold mt-4 mb-3">Home Address</h3>
<i :class="{'transform': !open, '-rotate-90': !open, 'translate-y-0.5': true}"
class="fa-solid fa-angle-down text-lg transition-transform mr-2"></i>
<h3 class="text-lg font-bold mt-4 mb-3">Two Factor Authentication</h3>
</a>
<div x-show="open">
<x-ui.input label="Address" name="home_address" value="{{ $user->home_address }}" />
<x-ui.input label="Address 2" name="home_address2" value="{{ $user->home_address2 }}" />
<x-ui.input label="City" name="home_city" value="{{ $user->home_city }}" />
<div class="flex gap-8">
<div class="flex-1">
<x-ui.input label="State" name="home_state" value="{{ $user->home_state }}" />
<div class="px-4 mb-4" x-show="open">
<div class="flex items-center border border-gray-300 rounded bg-white pl-2 pr-4 py-3 mb-4">
<div class="bg-gray-200 rounded-full w-14 h-14 flex items-center justify-center flex-shrink-0">
<i class="fa-solid fa-envelope text-2xl"></i>
</div>
<div class="flex-1">
<x-ui.input label="Postcode" name="home_postcode" value="{{ $user->home_postcode }}" />
<div class="mx-4 flex-grow">
<p class="flex mb-2">
<span class="text-sm font-bold mr-2">Use Email</span>
<span class="text-xs bg-green-500 text-white rounded px-2 py-0.5">Enabled</span>
</p>
<p class="text-xs">Use the security link sent to your email address as your two-factor authentication (2FA). The security link will be sent to the address associated with your account.</p>
</div>
</div>
<div class="border border-gray-300 rounded bg-white pl-2 pr-4 py-3">
<div class="flex items-center">
<div class="bg-gray-200 rounded-full w-14 h-14 flex items-center justify-center flex-shrink-0">
<i class="fa-solid fa-mobile-screen-button text-2xl"></i>
</div>
<div class="mx-4 flex-grow">
<p class="flex mb-2">
<span class="text-sm font-bold mr-2">Use Authenticator App</span>
<span x-cloak x-show="!$store.tfa.enabled" class="text-xs bg-red-500 text-white rounded px-2 py-0.5">Disabled</span>
<span x-cloak x-show="$store.tfa.enabled" class="text-xs bg-green-500 text-white rounded px-2 py-0.5">Enabled</span>
</p>
<p class="text-xs">Use an Authenticator App as your two-factor authenticator. When you sign in you'll be asked to use the security code provided by your Authenticator App.</p>
</div>
<div class="flex flex-col text-nowrap gap-2">
<x-ui.button x-show="!$store.tfa.enabled" id="tfa_button" type="button" color="primary-outline" x-data x-on:click.prevent="setupTFA()">Setup</x-ui.button>
<x-ui.button x-show="$store.tfa.enabled" type="button" color="danger-outline" x-data x-on:click.prevent="destroyTFA()">Disable</x-ui.button>
<a href="#" x-show="$store.tfa.enabled" x-on:click.prevent="resetBackupCodes($event)" class="text-xs link">Reset Backup Codes</a>
</div>
</div>
<div class="mt-4 pt-4 border-t flex items-center justify-center gap-4" x-cloak x-show="$store.tfa.show && !$store.tfa.loading">
<img src="/loading.gif" id="tfa_image_loader" alt="loading" width="100" height="100"/>
<img src="" id="tfa_image" alt="QR Code" width="150" height="150" style="display:none" onload="this.style.display='block';document.getElementById('tfa_image_loader').style.display='none';"/>
<div>
<p class="text-xs mb-2">Scan the QR Code into your Authenticator App and enter the code provided below</p>
<div class="flex items-center gap-4 justify-center">
<x-ui.input name="code" id="code" class="mb-0" />
<x-ui.button class="mt-1" type="button" color="primary-outline" x-on:click.prevent="linkTFA()">Link</x-ui.button>
</div>
</div>
</div>
<div class="mt-4 pt-4 border-t flex justify-center" x-cloak x-show="$store.tfa.loading">
<img src="/loading.gif" alt="loading" width="100" height="100"/>
</div>
<div class="mt-4 pt-4 border-t flex justify-center" x-cloak x-show="$store.tfa.codes && !$store.tfa.loading">
<div class="w-[34rem] flex items-center gap-4">
<div class="w-[18rem] mx-auto">
<p class="text-sm font-bold mb-1">Save your Backup Codes</p>
<ul class="ml-6 mb-4 text-xs list-disc">
<li>Keep these backup codes safe</li>
<li>You can only use each one once</li>
<li>They will not be shown again</li>
<li>Any existing codes can no longer be used</li>
</ul>
</div>
<div class="w-[16rem] bg-gray-200 p-4 text-sm font-mono flex flex-wrap justify-center">
<template x-for="(code, idx) in $store.tfa.codes" :key="idx">
<p class="mx-4" x-text="code"></p>
</template>
</div>
</div>
</div>
</div>
<x-ui.input label="Country" name="home_country" value="{{ $user->home_country }}" />
</div>
</section>
@@ -70,19 +122,40 @@ $billing_same_home = $user->home_address === $user->billing_address
<h3 class="text-lg font-bold mt-4 mb-3">Billing Address</h3>
</a>
<div x-show="open">
<x-ui.checkbox label="Same as home address" name="billing_same_home" checked="{{ $billing_same_home }}" x-data x-on:click="SM.updateBillingAddress" />
<x-ui.input label="Address" name="billing_address" value="{{ $user->billing_address }}" readonly="{{ $billing_same_home }}" />
<x-ui.input label="Address 2" name="billing_address2" value="{{ $user->billing_address2 }}" readonly="{{ $billing_same_home }}" />
<x-ui.input label="City" name="billing_city" value="{{ $user->billing_city }}" readonly="{{ $billing_same_home }}" />
<div class="flex gap-8">
<x-ui.input label="Address" name="billing_address" value="{{ $user->billing_address }}" />
<x-ui.input label="Address 2" name="billing_address2" value="{{ $user->billing_address2 }}" />
<x-ui.input label="City" name="billing_city" value="{{ $user->billing_city }}" />
<div class="flex flex-col sm:gap-8 sm:flex-row">
<div class="flex-1">
<x-ui.input label="State" name="billing_state" value="{{ $user->billing_state }}" readonly="{{ $billing_same_home }}" />
<x-ui.input label="State" name="billing_state" value="{{ $user->billing_state }}" />
</div>
<div class="flex-1">
<x-ui.input label="Postcode" name="billing_postcode" value="{{ $user->billing_postcode }}" readonly="{{ $billing_same_home }}" />
<x-ui.input label="Postcode" name="billing_postcode" value="{{ $user->billing_postcode }}" />
</div>
</div>
<x-ui.input label="Country" name="billing_country" value="{{ $user->billing_country }}" readonly="{{ $billing_same_home }}" />
<x-ui.input label="Country" name="billing_country" value="{{ $user->billing_country }}" />
</div>
</section>
<section x-data="{ open: true }">
<a href="#" class="flex items-center" @click.prevent="open = !open">
<i :class="{'transform': !open, '-rotate-90': !open, 'translate-y-0.5': true}" class="fa-solid fa-angle-down text-lg transition-transform mr-2"></i>
<h3 class="text-lg font-bold mt-4 mb-3">Shipping Address</h3>
</a>
<div x-show="open">
<x-ui.checkbox label="Same as billing address" name="shipping_same_billing" checked="{{ $shipping_same_billing }}" x-data x-on:click="SM.updateShippingAddress" />
<x-ui.input label="Address" name="shipping_address" value="{{ $user->shipping_address }}" readonly="{{ $shipping_same_billing }}" />
<x-ui.input label="Address 2" name="shipping_address2" value="{{ $user->shipping_address2 }}" readonly="{{ $shipping_same_billing }}" />
<x-ui.input label="City" name="shipping_city" value="{{ $user->shipping_city }}" readonly="{{ $shipping_same_billing }}" />
<div class="flex flex-col sm:gap-8 sm:flex-row">
<div class="flex-1">
<x-ui.input label="State" name="shipping_state" value="{{ $user->shipping_state }}" readonly="{{ $shipping_same_billing }}" />
</div>
<div class="flex-1">
<x-ui.input label="Postcode" name="shipping_postcode" value="{{ $user->shipping_postcode }}" readonly="{{ $shipping_same_billing }}" />
</div>
</div>
<x-ui.input label="Country" name="shipping_country" value="{{ $user->shipping_country }}" readonly="{{ $shipping_same_billing }}" />
</div>
</section>
@@ -97,3 +170,106 @@ $billing_same_home = $user->home_address === $user->billing_address
</form>
</x-container>
</x-layout>
{{ $codes ?? '' }}
<script>
document.addEventListener('alpine:init', () => {
Alpine.store('tfa', {
show: false,
secret: null,
enabled: {{ $user->tfa_secret !== null ? 'true' : 'false'}},
codes: null,
loading: false
});
});
function setupTFA() {
document.getElementById('tfa_button').disabled = true;
axios.get('/account/2fa')
.then(response => {
if(response.data.secret) {
Alpine.store('tfa').show = true;
Alpine.store('tfa').secret = response.data.secret;
document.getElementById('tfa_image').src = '/account/2fa/image?secret=' + response.data.secret;
} else {
SM.alert('2FA Error', 'An error occurred while setting up two-factor authentication. Please try again later', 'danger');
}
})
.catch(() => {
SM.alert('2FA Error', 'An error occurred while setting up two-factor authentication. Please try again later', 'danger');
});
}
function linkTFA() {
Alpine.store('tfa').loading = true;
axios.post('/account/2fa', {
code: document.getElementById('code').value,
secret: Alpine.store('tfa').secret,
})
.then(response => {
console.log(response.data);
if(response.data.success) {
SM.alert('2FA Linked', 'Two-factor authentication has been successfully linked to your account', 'success');
document.getElementById('tfa_button').disabled = false;
document.getElementById('code').value = '';
document.getElementById('tfa_image').src = '';
Alpine.store('tfa').show = false;
Alpine.store('tfa').enabled = true;
Alpine.store('tfa').codes = response.data.codes;
} else {
SM.alert('2FA Error', 'An error occurred while linking two-factor authentication. Please try again later', 'danger');
}
})
.catch(() => {
SM.alert('2FA Error', 'An error occurred while linking two-factor authentication. Please try again later', 'danger');
})
.finally(() => {
Alpine.store('tfa').loading = false;
});
}
function resetBackupCodes(event) {
event.target.classList.add('disabled');
Alpine.store('tfa').codes = null;
Alpine.store('tfa').loading = true;
axios.post('/account/2fa/reset-backup-codes')
.then(response => {
if(response.data.success) {
Alpine.store('tfa').codes = response.data.codes;
} else {
SM.alert('2FA Error', 'An error occurred while resetting your backup codes. Please try again later', 'danger');
}
})
.catch(() => {
SM.alert('2FA Error', 'An error occurred while resetting your backup codes. Please try again later', 'danger');
})
.finally(() => {
event.target.classList.remove('disabled');
Alpine.store('tfa').loading = false;
});
}
function destroyTFA() {
SM.confirm('Disable 2FA', 'Are you sure you want to remove two-factor authentication from your account?', 'Disable', (confirm) => {
if(confirm) {
axios.delete('/account/2fa')
.then(response => {
if (response.data.success) {
SM.alert('2FA Disabled', 'Two-factor authentication has been successfully disabled on your account', 'success');
Alpine.store('tfa').enabled = false;
Alpine.store('tfa').codes = null;
} else {
SM.alert('2FA Error', 'An error occurred while disabling two-factor authentication. Please try again later', 'danger');
}
})
.catch(() => {
SM.alert('2FA Error', 'An error occurred while disabling two-factor authentication. Please try again later', 'danger');
});
}
}, {
confirmButtonText: 'Disable'
});
}
</script>

View File

@@ -2,7 +2,7 @@
<x-mast backRoute="admin.user.index" backTitle="Users">Create User</x-mast>
<x-container>
<form method="POST" action="{{ route('admin.user.store') }}" x-data x-on:submit.prevent="SM.updateBillingAddress(); $el.submit()">
<form method="POST" action="{{ route('admin.user.store') }}" x-data x-on:submit.prevent="SM.updateShippingAddress(); $el.submit()">
@csrf
<h3 class="text-lg font-bold mt-4 mb-3">Contact Information</h3>
<div class="flex gap-8">
@@ -22,34 +22,12 @@
</div>
</div>
<section x-data="{ open: true }">
<a href="#" class="flex items-center" @click.prevent="open = !open">
<i :class="{'transform': !open, '-rotate-90': !open, 'translate-y-0.5': true}" class="fa-solid fa-angle-down text-lg transition-transform mr-2"></i>
<h3 class="text-lg font-bold mt-4 mb-3">Home Address</h3>
</a>
<div x-show="open">
<x-ui.input label="Address" name="home_address" />
<x-ui.input label="Address 2" name="home_address2" />
<x-ui.input label="City" name="home_city" />
<div class="flex gap-8">
<div class="flex-1">
<x-ui.input label="State" name="home_state" />
</div>
<div class="flex-1">
<x-ui.input label="Postcode" name="home_postcode" />
</div>
</div>
<x-ui.input label="Country" name="home_country" />
</div>
</section>
<section x-data="{ open: true }">
<a href="#" class="flex items-center" @click.prevent="open = !open">
<i :class="{'transform': !open, '-rotate-90': !open, 'translate-y-0.5': true}" class="fa-solid fa-angle-down text-lg transition-transform mr-2"></i>
<h3 class="text-lg font-bold mt-4 mb-3">Billing Address</h3>
</a>
<div x-show="open">
<x-ui.checkbox label="Same as home address" name="billing_same_home" checked="true" x-data x-on:click="SM.updateBillingAddress" />
<x-ui.input label="Address" name="billing_address" />
<x-ui.input label="Address 2" name="billing_address2" />
<x-ui.input label="City" name="billing_city" />
@@ -65,6 +43,28 @@
</div>
</section>
<section x-data="{ open: true }">
<a href="#" class="flex items-center" @click.prevent="open = !open">
<i :class="{'transform': !open, '-rotate-90': !open, 'translate-y-0.5': true}" class="fa-solid fa-angle-down text-lg transition-transform mr-2"></i>
<h3 class="text-lg font-bold mt-4 mb-3">Shipping Address</h3>
</a>
<div x-show="open">
<x-ui.checkbox label="Same as billing address" name="shipping_same_billing" checked="true" x-data x-on:click="SM.updateShippingAddress" />
<x-ui.input label="Address" name="shipping_address" />
<x-ui.input label="Address 2" name="shipping_address2" />
<x-ui.input label="City" name="shipping_city" />
<div class="flex gap-8">
<div class="flex-1">
<x-ui.input label="State" name="shipping_state" />
</div>
<div class="flex-1">
<x-ui.input label="Postcode" name="shipping_postcode" />
</div>
</div>
<x-ui.input label="Country" name="shipping_country" />
</div>
</section>
<div class="flex justify-end mt-8">
<x-ui.button type="submit">Create</x-ui.button>
</div>

View File

@@ -1,19 +1,19 @@
@props(['user'])
@php
$billing_same_home = $user->home_address === $user->billing_address
&& $user->home_address2 === $user->billing_address2
&& $user->home_city === $user->billing_city
&& $user->home_state === $user->billing_state
&& $user->home_postcode === $user->billing_postcode
&& $user->home_country === $user->billing_country;
$shipping_same_billing = $user->shipping_address === $user->billing_address
&& $user->shipping_address2 === $user->billing_address2
&& $user->shipping_city === $user->billing_city
&& $user->shipping_state === $user->billing_state
&& $user->shipping_postcode === $user->billing_postcode
&& $user->shipping_country === $user->billing_country;
@endphp
<x-layout>
<x-mast backRoute="admin.user.index" backTitle="Users">Edit User</x-mast>
<x-container>
<form method="POST" action="{{ route('admin.user.update', $user) }}" x-data x-on:submit.prevent="SM.updateBillingAddress(); $el.submit()">
<form method="POST" action="{{ route('admin.user.update', $user) }}" x-data x-on:submit.prevent="SM.updateShippingAddress(); $el.submit()">
@method('PUT')
@csrf
<h3 class="text-lg font-bold mt-4 mb-3">Contact Information</h3>
@@ -34,14 +34,13 @@
</div>
</div>
{{ $user }}
<section x-data="{ open: true }">
<a href="#" class="flex items-center" @click.prevent="open = !open">
<i :class="{'transform': !open, '-rotate-90': !open, 'translate-y-0.5': true}" class="fa-solid fa-angle-down text-lg transition-transform mr-2"></i>
<h3 class="text-lg font-bold mt-4 mb-3">Email Subscriptions</h3>
</a>
<div x-show="open">
<x-ui.checkbox label="Upcoming Workshops" name="billing_same_home" checked="{{ $billing_same_home }}" />
<x-ui.checkbox label="Upcoming Workshops" name="subscribed" checked="{{ $user->subscribed }}" />
</div>
</section>
@@ -51,18 +50,18 @@
<h3 class="text-lg font-bold mt-4 mb-3">Home Address</h3>
</a>
<div x-show="open">
<x-ui.input label="Address" name="home_address" value="{{ $user->home_address }}" />
<x-ui.input label="Address 2" name="home_address2" value="{{ $user->home_address2 }}" />
<x-ui.input label="City" name="home_city" value="{{ $user->home_city }}" />
<x-ui.input label="Address" name="billing_address" value="{{ $user->billing_address }}" />
<x-ui.input label="Address 2" name="billing_address2" value="{{ $user->billing_address2 }}" />
<x-ui.input label="City" name="billing_city" value="{{ $user->billing_city }}" />
<div class="flex gap-8">
<div class="flex-1">
<x-ui.input label="State" name="home_state" value="{{ $user->home_state }}" />
<x-ui.input label="State" name="billing_state" value="{{ $user->billing_state }}" />
</div>
<div class="flex-1">
<x-ui.input label="Postcode" name="home_postcode" value="{{ $user->home_postcode }}" />
<x-ui.input label="Postcode" name="billing_postcode" value="{{ $user->billing_postcode }}" />
</div>
</div>
<x-ui.input label="Country" name="home_country" value="{{ $user->home_country }}" />
<x-ui.input label="Country" name="billing_country" value="{{ $user->billing_country }}" />
</div>
</section>
@@ -72,19 +71,19 @@
<h3 class="text-lg font-bold mt-4 mb-3">Billing Address</h3>
</a>
<div x-show="open">
<x-ui.checkbox label="Same as home address" name="billing_same_home" checked="{{ $billing_same_home }}" x-data x-on:click="SM.updateBillingAddress" />
<x-ui.input label="Address" name="billing_address" value="{{ $user->billing_address }}" readonly="{{ $billing_same_home }}" />
<x-ui.input label="Address 2" name="billing_address2" value="{{ $user->billing_address2 }}" readonly="{{ $billing_same_home }}" />
<x-ui.input label="City" name="billing_city" value="{{ $user->billing_city }}" readonly="{{ $billing_same_home }}" />
<x-ui.checkbox label="Same as billing address" name="shipping_same_billing" checked="{{ $shipping_same_billing }}" x-data x-on:click="SM.updateShippingAddress" />
<x-ui.input label="Address" name="shipping_address" value="{{ $user->shipping_address }}" readonly="{{ $shipping_same_billing }}" />
<x-ui.input label="Address 2" name="shipping_address2" value="{{ $user->shipping_address2 }}" readonly="{{ $shipping_same_billing }}" />
<x-ui.input label="City" name="shipping_city" value="{{ $user->shipping_city }}" readonly="{{ $shipping_same_billing }}" />
<div class="flex gap-8">
<div class="flex-1">
<x-ui.input label="State" name="billing_state" value="{{ $user->billing_state }}" readonly="{{ $billing_same_home }}" />
<x-ui.input label="State" name="shipping_state" value="{{ $user->shipping_state }}" readonly="{{ $shipping_same_billing }}" />
</div>
<div class="flex-1">
<x-ui.input label="Postcode" name="billing_postcode" value="{{ $user->billing_postcode }}" readonly="{{ $billing_same_home }}" />
<x-ui.input label="Postcode" name="shipping_postcode" value="{{ $user->shipping_postcode }}" readonly="{{ $shipping_same_billing }}" />
</div>
</div>
<x-ui.input label="Country" name="billing_country" value="{{ $user->billing_country }}" readonly="{{ $billing_same_home }}" />
<x-ui.input label="Country" name="shipping_country" value="{{ $user->shipping_country }}" readonly="{{ $shipping_same_billing }}" />
</div>
</section>

View File

@@ -1,33 +1,33 @@
@php
$eventContent = isset($event) ? $event->content : '';
$workshopContent = isset($workshop) ? $workshop->content : '';
@endphp
<x-layout>
<x-mast backRoute="admin.event.index" backTitle="Workshops">{{ isset($event) ? 'Edit' : 'Create' }} Workshop</x-mast>
<x-mast backRoute="admin.workshop.index" backTitle="Workshops">{{ isset($workshop) ? 'Edit' : 'Create' }} Workshop</x-mast>
<x-container class="mt-4">
<form x-data="{type:'physical',registration:'{{old('registration', $event->registration ?? 'none')}}'}" method="POST" action="{{ route('admin.event.' . (isset($event) ? 'update' : 'store'), $event ?? []) }}">
@isset($event)
<form x-data="{type:'physical',registration:'{{old('registration', $workshop->registration ?? 'none')}}'}" method="POST" action="{{ route('admin.workshop.' . (isset($workshop) ? 'update' : 'store'), $workshop ?? []) }}">
@isset($workshop)
@method('PUT')
@endisset
@csrf
<div class="mb-4">
<x-ui.input label="Title" name="title" value="{!! isset($event) ? $event->title : '' !!}" />
<x-ui.input label="Title" name="title" value="{!! isset($workshop) ? $workshop->title : '' !!}" />
</div>
<div class="mb-4">
<x-ui.media label="Image" name="hero_media_name" value="{{ $event->hero_media_name ?? '' }}" allow_uploads="true" />
<x-ui.media label="Image" name="hero_media_name" value="{{ $workshop->hero_media_name ?? '' }}" allow_uploads="true" />
</div>
<div class="flex flex-col sm:flex-row sm:gap-8">
<div class="flex-1">
<x-ui.select label="Type" name="type" x-model="type">
<option value="physical" {{ ($event->location_id ?? '') !== '' || !isset($event) ? 'selected' : '' }}>Physical</option>
<option value="online" {{ ($event->location_id ?? '') === null ? 'selected' : '' }}>Online</option>
<option value="physical" {{ ($workshop->location_id ?? '') !== '' || !isset($workshop) ? 'selected' : '' }}>Physical</option>
<option value="online" {{ ($workshop->location_id ?? '') === null ? 'selected' : '' }}>Online</option>
</x-ui.select>
</div>
<div class="flex-1">
<span x-show="type==='physical'">
<x-ui.select label="Location" name="location_id">
@foreach(\App\Models\Location::orderByRaw("name = 'Online' DESC, name ASC")->get() as $location)
<option value="{{ $location->id }}" {{ ($event->location_id ?? '') === $location->id ? 'selected' : '' }}>{{ $location->name }}</option>
<option value="{{ $location->id }}" {{ ($workshop->location_id ?? '') === $location->id ? 'selected' : '' }}>{{ $location->name }}</option>
@endforeach
</x-ui.select>
</span>
@@ -35,26 +35,26 @@
</div>
<div class="flex flex-col sm:flex-row sm:gap-8">
<div class="flex-1">
<x-ui.input type="datetime-local" label="Start Date" name="starts_at" value="{{ \App\Helpers::timestampNoSeconds($event->starts_at ?? '') }}" onchange="updatedStartsAt()"/>
<x-ui.input type="datetime-local" label="Start Date" name="starts_at" value="{{ \App\Helpers::timestampNoSeconds($workshop->starts_at ?? '') }}" onchange="updatedStartsAt()"/>
</div>
<div class="flex-1">
<x-ui.input type="datetime-local" label="End Date" name="ends_at" value="{{ \App\Helpers::timestampNoSeconds($event->ends_at ?? '') }}" />
<x-ui.input type="datetime-local" label="End Date" name="ends_at" value="{{ \App\Helpers::timestampNoSeconds($workshop->ends_at ?? '') }}" />
</div>
</div>
<div class="flex flex-col sm:flex-row sm:gap-8">
<div class="flex-1">
<x-ui.select label="Status" name="status">
<option value="draft" {{ ($event->status ?? '') === 'draft' ? 'selected' : '' }}>Draft</option>
<option value="open" {{ ($event->status ?? '') === 'open' ? 'selected' : '' }}>Open</option>
<option value="private" {{ ($event->status ?? '') === 'private' ? 'selected' : '' }}>Private</option>
<option value="full" {{ ($event->status ?? '') === 'full' ? 'selected' : '' }}>Full</option>
<option value="scheduled" {{ ($event->status ?? '') === 'scheduled' ? 'selected' : '' }}>Scheduled</option>
<option value="closed" {{ ($event->status ?? '') === 'closed' ? 'selected' : '' }}>Closed</option>
<option value="cancelled" {{ ($event->status ?? '') === 'cancelled' ? 'selected' : '' }}>Cancelled</option>
<option value="draft" {{ ($workshop->status ?? '') === 'draft' ? 'selected' : '' }}>Draft</option>
<option value="open" {{ ($workshop->status ?? '') === 'open' ? 'selected' : '' }}>Open</option>
<option value="private" {{ ($workshop->status ?? '') === 'private' ? 'selected' : '' }}>Private</option>
<option value="full" {{ ($workshop->status ?? '') === 'full' ? 'selected' : '' }}>Full</option>
<option value="scheduled" {{ ($workshop->status ?? '') === 'scheduled' ? 'selected' : '' }}>Scheduled</option>
<option value="closed" {{ ($workshop->status ?? '') === 'closed' ? 'selected' : '' }}>Closed</option>
<option value="cancelled" {{ ($workshop->status ?? '') === 'cancelled' ? 'selected' : '' }}>Cancelled</option>
</x-ui.select>
</div>
<div class="flex-1">
<x-ui.input type="datetime-local" label="Publish Date" name="publish_at" value="{{ \App\Helpers::timestampNoSeconds($event->publish_at ?? '') }}" onchange="updatedPublishAt()" />
<x-ui.input type="datetime-local" label="Publish Date" name="publish_at" value="{{ \App\Helpers::timestampNoSeconds($workshop->publish_at ?? '') }}" onchange="updatedPublishAt()" />
</div>
</div>
<div class="flex flex-col sm:flex-row sm:gap-8">
@@ -62,44 +62,44 @@
&nbsp;
</div>
<div class="flex-1">
<x-ui.input type="datetime-local" label="Closes Date" name="closes_at" value="{{ \App\Helpers::timestampNoSeconds($event->closes_at ?? '') }}" />
<x-ui.input type="datetime-local" label="Closes Date" name="closes_at" value="{{ \App\Helpers::timestampNoSeconds($workshop->closes_at ?? '') }}" />
</div>
</div>
<div class="flex flex-col sm:flex-row sm:gap-8">
<div class="flex-1">
<x-ui.input label="Price" name="price" info="Leave blank to hide from public. Also supports Free, TBD or TBC" value="{{ $event->price ?? '' }}" />
<x-ui.input label="Price" name="price" info="Leave blank to hide from public. Also supports Free, TBD or TBC" value="{{ $workshop->price ?? '' }}" />
</div>
<div class="flex-1">
<x-ui.input label="Ages" name="ages" info="Leave blank to hide from public" value="{{ $event->ages ?? '8+' }}" />
<x-ui.input label="Ages" name="ages" info="Leave blank to hide from public" value="{{ $workshop->ages ?? '8+' }}" />
</div>
</div>
<div class="flex flex-col sm:flex-row sm:gap-8">
<div class="flex-1">
<x-ui.select label="Registration" name="registration" x-model="registration" onchange="document.getElementsByName('registration_data').forEach((e)=>e.value='')">
<option value="none" {{ (old('registration', $event->registration ?? '')) === 'none' ? 'selected' : '' }}>None</option>
<option value="link" {{ (old('registration', $event->registration ?? '')) === 'link' ? 'selected' : '' }}>External Link</option>
<option value="email" {{ (old('registration', $event->registration ?? '')) === 'email' ? 'selected' : '' }}>External Email</option>
<option value="message" {{ (old('registration', $event->registration ?? '')) === 'message' ? 'selected' : '' }}>Custom Message</option>
<option value="none" {{ (old('registration', $workshop->registration ?? '')) === 'none' ? 'selected' : '' }}>None</option>
<option value="link" {{ (old('registration', $workshop->registration ?? '')) === 'link' ? 'selected' : '' }}>External Link</option>
<option value="email" {{ (old('registration', $workshop->registration ?? '')) === 'email' ? 'selected' : '' }}>External Email</option>
<option value="message" {{ (old('registration', $workshop->registration ?? '')) === 'message' ? 'selected' : '' }}>Custom Message</option>
</x-ui.select>
</div>
<div class="flex-1">
<span x-show="registration==='link'">
<x-ui.input label="Registration URL" name="registration_url" id="registration_url" value="{{ $event->registration_data ?? '' }}" error="{{ $errors->first('registration_data') }}" />
<x-ui.input label="Registration URL" name="registration_url" id="registration_url" value="{!! isset($workshop) ? $workshop->registration_data : '' !!}" error="{{ $errors->first('registration_data') }}" />
</span>
<span x-show="registration==='email'">
<x-ui.input label="Registration Email" name="registration_email" id="registration_email" value="{{ $event->registration_data ?? '' }}" error="{{ $errors->first('registration_data') }}" />
<x-ui.input label="Registration Email" name="registration_email" id="registration_email" value="{{ $workshop->registration_data ?? '' }}" error="{{ $errors->first('registration_data') }}" />
</span>
<span x-show="registration==='message'">
<x-ui.input label="Registration Message" name="registration_message" id="registration_message" value="{{ $event->registration_data ?? '' }}" error="{{ $errors->first('registration_data') }}" />
<x-ui.input label="Registration Message" name="registration_message" id="registration_message" value="{{ $workshop->registration_data ?? '' }}" error="{{ $errors->first('registration_data') }}" />
</span>
<input type="hidden" name="registration_data" id="registration_data" value="{{ $event->registration_data ?? '' }}">
<input type="hidden" name="registration_data" id="registration_data" value="{{ $workshop->registration_data ?? '' }}">
</div>
</div>
<div class="mb-4">
<x-ui.editor
label="Content"
name="content"
value="{!! $eventContent !!}"
value="{!! $workshopContent !!}"
></x-ui.editor>
</div>
<div class="mb-4">
@@ -107,14 +107,14 @@
label="Files"
name="files"
editor="true"
value="{!! isset($event) ? $event->files()->orderBy('name')->get() : '' !!}"
value="{!! isset($workshop) ? $workshop->files()->orderBy('name')->get() : '' !!}"
></x-ui.filelist>
</div>
<div class="flex justify-end gap-4 mt-8">
@isset($event)
<x-ui.button type="button" color="danger" x-data x-on:click.prevent="SM.confirmDelete('{{ csrf_token() }}', 'Delete workshop?', 'Are you sure you want to delete this workshop? This action cannot be undone', '{{ route('admin.event.destroy', $event) }}')">Delete</x-ui.button>
@isset($workshop)
<x-ui.button type="button" color="danger" x-data x-on:click.prevent="SM.confirmDelete('{{ csrf_token() }}', 'Delete workshop?', 'Are you sure you want to delete this workshop? This action cannot be undone', '{{ route('admin.workshop.destroy', $workshop) }}')">Delete</x-ui.button>
@endisset
<x-ui.button type="submit">{{ isset($event) ? 'Save' : 'Create' }}</x-ui.button>
<x-ui.button type="submit">{{ isset($workshop) ? 'Save' : 'Create' }}</x-ui.button>
</div>
</form>
</x-container>

View File

@@ -4,14 +4,14 @@
<x-container>
<div class="flex my-4 items-center">
<div class="flex-1">
<x-ui.button type="link" href="{{ route('admin.event.create') }}">Create</x-ui.button>
<x-ui.button type="link" href="{{ route('admin.workshop.create') }}">Create</x-ui.button>
</div>
<div class="flex-1">
<x-ui.search name="search" label="Search" />
</div>
</div>
@if($events->isEmpty())
@if($workshops->isEmpty())
<x-none-found item="workshops" search="{{ request()->get('search') }}" />
@else
<x-ui.table>
@@ -23,24 +23,24 @@
<th>Action</th>
</x-slot:header>
<x-slot:body>
@foreach ($events as $event)
@foreach ($workshops as $workshop)
<tr>
<td class="flex items-center">
<img src="{{ $event->hero->thumbnail }}" class="max-h-12 max-w-12 -ml-2 -my-3 mr-3 inline rounded" alt="{{ $event->hero->title }}" />
<img src="{{ $workshop->hero->thumbnail }}" class="max-h-12 max-w-12 -ml-2 -my-3 mr-3 inline rounded" alt="{{ $workshop->hero->title }}" />
<div>
<div class="whitespace-normal">{{ $event->title }}</div>
<div class="lg:hidden text-xs text-gray-500">{{ $event->location->name }} ({{ ucwords($event->status) }})</div>
<div class="md:hidden text-xs text-gray-500">{{ \Carbon\Carbon::parse($event->starts_at)->format('j/m/Y g:i a') }}</div>
<div class="whitespace-normal">{{ $workshop->title }}</div>
<div class="lg:hidden text-xs text-gray-500">{{ $workshop->location->name }} ({{ ucwords($workshop->status) }})</div>
<div class="md:hidden text-xs text-gray-500">{{ \Carbon\Carbon::parse($workshop->starts_at)->format('j/m/Y g:i a') }}</div>
</div>
</td>
<td class="hidden lg:table-cell">{{ ucwords($event->status) }}</td>
<td class="hidden lg:table-cell">{{ $event->location->name }}</td>
<td class="hidden md:table-cell">{{ \Carbon\Carbon::parse($event->starts_at)->format('M j Y, g:i a') }}</td>
<td class="hidden lg:table-cell">{{ ucwords($workshop->status) }}</td>
<td class="hidden lg:table-cell">{{ $workshop->location->name }}</td>
<td class="hidden md:table-cell">{{ \Carbon\Carbon::parse($workshop->starts_at)->format('M j Y, g:i a') }}</td>
<td>
<div class="flex justify-center gap-3">
<a href="{{ route('admin.event.edit', $event) }}" class="hover:text-primary-color" title="Edit"><i class="fa-solid fa-pen-to-square"></i></a>
<a href="{{ route('admin.event.duplicate', $event) }}" class="hover:text-primary-color" title="Duplicate"><i class="fa-regular fa-copy"></i></a>
<a href="#" class="hover:text-red-600" x-data x-on:click.prevent="SM.confirmDelete('{{ csrf_token() }}', 'Delete workshop?', 'Are you sure you want to delete this workshop? This action cannot be undone', '{{ route('admin.event.destroy', $event) }}')" title="Delete"><i class="fa-solid fa-trash"></i></a>
<a href="{{ route('admin.workshop.edit', $workshop) }}" class="hover:text-primary-color" title="Edit"><i class="fa-solid fa-pen-to-square"></i></a>
<a href="{{ route('admin.workshop.duplicate', $workshop) }}" class="hover:text-primary-color" title="Duplicate"><i class="fa-regular fa-copy"></i></a>
<a href="#" class="hover:text-red-600" x-data x-on:click.prevent="SM.confirmDelete('{{ csrf_token() }}', 'Delete workshop?', 'Are you sure you want to delete this workshop? This action cannot be undone', '{{ route('admin.workshop.destroy', $workshop) }}')" title="Delete"><i class="fa-solid fa-trash"></i></a>
</div>
</td>
</tr>
@@ -48,7 +48,7 @@
</x-slot:body>
</x-ui.table>
{{ $events->appends(request()->query())->links() }}
{{ $workshops->appends(request()->query())->links() }}
@endif
</x-container>

View File

@@ -0,0 +1,68 @@
@php
if(!isset($email)) {
$email = '';
if(isset($user)) {
$email = $user->email;
}
}
@endphp
<x-layout :bodyClass="'image-background'">
<div x-data="{show:'{{ $method ?? 'tfa' }}'}">
<x-dialog x-cloak x-show="show==='tfa'" formaction="{{ route('login.store') }}">
<x-slot:title>
<a class="link absolute left-0" href="{{ route('login') }}"><i class="fa-solid fa-angle-left"></i></a>
Please enter 2FA code
</x-slot:title>
<x-slot:header>
<p class="text-sm">Two-factor authentication (2FA) is enabled for your account. Please enter a code to log in.</p>
</x-slot:header>
<input type="hidden" name="email" value="{{ $email }}"/>
<x-ui.input type="text" name="code" label="Code" floating autofocus error="{{ $errors->first('code') }}"/>
<x-slot:footer>
<div class="text-xs">
Having trouble? <a class="link" href="#" x-on:click.prevent="show='other'">Sign in another way</a>
</div>
<x-ui.button type="submit">Verify</x-ui.button>
</x-slot:footer>
</x-dialog>
<x-dialog x-cloak x-show="show==='other'">
@captcha
<x-slot:title>
<a class="link absolute left-0" href="#" x-on:click.prevent="show='tfa'"><i class="fa-solid fa-angle-left"></i></a>
Sign in another way
</x-slot:title>
<x-slot:header>Select the method to sign in to your account</x-slot:header>
<div class="flex flex-col gap-4 mb-4">
<form method="post" action="{{ route('login.store') }}">
@csrf
@captcha
<input type="hidden" name="email" value="{{ $email }}" />
<input type="hidden" name="method" value="email" />
<x-ui.button type="submit" class="w-full">Email Link</x-ui.button>
</form>
<x-ui.button type="button" x-on:click.prevent="show='backup'">Enter Backup Code</x-ui.button>
</div>
<x-slot:footer>
<div class="text-xs">If you need support for accessing your account, please contact STEMMechanics support at <a href="mailto:hello@stemmechanics.com.au" class="link">hello@stemmechanics.com.au</a></div>
</x-slot:footer>
</x-dialog>
<x-dialog x-cloak x-show="show==='backup'" formaction="{{ route('login.store') }}">
<x-slot:title>
<a class="link absolute left-0" href="#" x-on:click.prevent="show='other'"><i class="fa-solid fa-angle-left"></i></a>
Please enter a backup code
</x-slot:title>
<x-slot:header>
<p class="text-sm">Enter one of your backup codes below to log in. Once a backup codes are a 1 time use only.</p>
</x-slot:header>
@captcha
<input type="hidden" name="email" value="{{ $email }}"/>
<x-ui.input type="text" name="backup_code" label="Backup Code" floating autofocus error="{{ $errors->first('backup_code') }}" />
<x-slot:footer center>
<x-ui.button class="self-end" type="submit">Verify</x-ui.button>
</x-slot:footer>
</x-dialog>
</div>
</x-layout>

View File

@@ -0,0 +1,14 @@
<x-layout :bodyClass="'image-background'">
<x-dialog formaction="{{ route('login.store') }}">
@captcha
<x-slot:title>Sign in another way</x-slot:title>
<x-slot:header>Select the method to sign in to your account</x-slot:header>
<div class="flex flex-col gap-4 mb-4">
<x-ui.button type="button" onclick="loginUsingEmail()">Email Link</x-ui.button>
<x-ui.button type="button" onclick="loginUsingEmail()">Enter Backup Code</x-ui.button>
</div>
<x-slot:footer>
<div class="text-xs">If you need support for accessing your account, please contact STEMMechanics support at <a href="mailto:hello@stemmechanics.com.au" class="link">hello@stemmechanics.com.au</a></div>
</x-slot:footer>
</x-dialog>
</x-layout>

View File

@@ -1,5 +1,6 @@
<x-layout :bodyClass="'image-background'">
<x-dialog formaction="{{ route('login.store') }}">
@captcha
@if(session('status') == 'not-found')
<x-slot:title>Sorry, we didn't recognize that email</x-slot:title>
<x-slot:header>
@@ -8,7 +9,7 @@
@else
<x-slot:title>Sign in with email</x-slot:title>
<x-slot:header>
<p>Enter the email address associated with your account, and we'll send a magic link to your inbox.</p>
<p>Enter the email address associated with your account</p>
</x-slot:header>
@endif
<x-ui.input type="email" name="email" label="Email" floating autofocus />

View File

@@ -1,7 +1,7 @@
<div class="flex items-center justify-center flex-grow py-24">
<div class="flex items-center justify-center flex-grow py-24" {{ $attributes }}>
<div class="w-full mx-2 max-w-lg p-8 pb-6 bg-white rounded-md shadow-deep">
@isset($title)
<h2 class="text-2xl font-bold mb-4 text-center">{{ $title }}</h2>
<h2 class="text-2xl font-bold mb-4 text-center relative">{{ $title }}</h2>
@endisset
@isset($header)
<div class="flex items-center gap-4 mb-4">

View File

@@ -11,6 +11,7 @@
</ul>
<ul class="sm:w-1/3 flex flex-col gap-0.5 text-center sm:text-left">
<li><h3 class="font-bold mb-2">STEMMechanics</h3></li>
<li><a href="{{ route('about') }}" class="text-sm hover:text-primary-color">About</a></li>
<li><a href="{{ route('contact') }}" class="text-sm hover:text-primary-color">Contact Us</a></li>
<li><a href="{{ route('code-of-conduct') }}" class="text-sm hover:text-primary-color">Code of Conduct</a></li>
<li><a href="{{ route('terms-conditions') }}" class="text-sm hover:text-primary-color">Terms & Conditions</a></li>

View File

@@ -31,6 +31,7 @@
</script>
@endif
@stack('scripts')
@captchaScripts
@livewireScripts
</body>
</html>

View File

@@ -1,6 +1,16 @@
<x-container class="bg-primary-color-light text-white py-10">
<h1 class="font-bold text-4xl">{{ $slot }}</h1>
<h1 class="font-bold text-4xl">{{ $title ?? $slot }}</h1>
@if(isset($description))
<div class="text-lg">{{ $description }}</div>
@endif
@if(isset($backRoute) && isset($backTitle))
<a href="{{ route($backRoute) }}" class="text-lg hover:text-gray-300"><i class="fa-solid fa-angle-left mr-3"></i>{{ $backTitle }}</a>
@endif
@isset($tabs)
<div class="mt-4 -mb-10 flex justify-end">
@foreach($tabs as $tab)
<a href="{{ $tab['route'] }}" class="rounded-t-md px-4 py-2 {{ ('/' . request()->path() === parse_url($tab['route'], PHP_URL_PATH) ? 'bg-gray-100 text-primary-color-dark' : 'text-white hover:bg-primary-color-dark') }} transition-colors">{{ $tab['title'] }}</a>
@endforeach
</div>
@endisset
</x-container>

View File

@@ -1,60 +1,137 @@
<nav class="shadow bg-white">
<div class="mx-auto max-w-7xl px-2 sm:px-6 lg:px-8 relative" x-data="{open:false}">
<nav class="shadow bg-white" x-data="{showSearch:false}" x-init="
document.addEventListener('keydown', (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'f') {
event.preventDefault();
$data.showSearch = true;
}
})
">
<div class="mx-auto max-w-7xl px-2 relative" x-data="{pageMenuOpen:false,userMenuOpen:false}">
<div class="relative flex h-16 items-center justify-between">
<div class="absolute inset-y-0 left-0 flex items-center sm:hidden">
<!-- Mobile menu button-->
<button type="button" class="relative inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-700 hover:text-white focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white" aria-controls="mobile-menu" aria-expanded="false">
<span class="absolute -inset-0.5"></span>
<span class="sr-only">Open main menu</span>
{{-- <img src="/assets/logo.svg" alt="STEMMechanics" onload="SVGInject(this)" style="color:purple"/>--}}
<div class="ml-4 mr-2 {{ !auth()->user()?->admin ? 'sm:hidden' : '' }}">
<button type="button" @click="pageMenuOpen=!pageMenuOpen" @keydown.escape="pageMenuOpen=false" class="relative flex w-6 text-gray-400 hover:text-white" id="user-menu-button" aria-expanded="false" aria-haspopup="true">
<span class="sr-only">Open page menu</span>
<i class="fa fa-bars text-gray-800 hover:text-sky-500 transition"></i>
</button>
</div>
<div class="flex flex-1 items-center justify-center sm:items-stretch sm:justify-start">
<div class="flex flex-1 items-center justify-center sm:justify-start ml-2">
<div class="flex flex-shrink-0 items-center">
<a href="{{ route('index') }}">
@includeSVG('logo.svg', 'width:14rem;margin-top:-0.2rem;color:black')
</a>
</div>
</div>
<div class="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
<div class="flex items-center">
<div class="hidden sm:ml-6 sm:block mr-4">
<div class="flex space-x-2">
<a href="{{ route('post.index') }}" class="text-gray-900 hover:text-sky-500 px-3 py-2 text-sm font-medium transition duration-300 ease-in-out transform hover:-translate-y-0.5" aria-current="page">Blog</a>
<a href="{{ route('event.index') }}" class="text-gray-900 hover:text-sky-500 px-3 py-2 text-sm font-medium transition duration-300 ease-in-out transform hover:-translate-y-0.5">Workshops</a>
</div>
</div>
<div class="ml-3">
<div>
<button type="button" @click="open=!open" @keydown.escape="open=false" class="relative flex w-6 text-gray-400 hover:text-white" id="user-menu-button" aria-expanded="false" aria-haspopup="true">
<span class="sr-only">Open user menu</span>
<svg class="w-6 h-6 text-gray-800 hover:text-sky-500 transition" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M5 7h14M5 12h14M5 17h14"/>
</svg>
{{-- <a href="{{ route('post.index') }}" class="text-gray-900 hover:text-sky-500 px-3 py-2 text-sm font-medium transition duration-300 ease-in-out" aria-current="page">Blog</a>--}}
<a href="{{ route('about') }}" class="text-gray-900 hover:text-sky-500 px-1 md:px-3 py-2 text-sm font-medium transition duration-300 ease-in-out">About</a>
<a href="{{ route('workshop.index') }}" class="text-gray-900 hover:text-sky-500 px-1 md:px-3 py-2 text-sm font-medium transition duration-300 ease-in-out">Workshops</a>
<a href="{{ route('contact') }}" class="text-gray-900 hover:text-sky-500 px-1 md:px-3 py-2 text-sm font-medium transition duration-300 ease-in-out">Contact</a>
<button type="button" class="text-gray-900 hover:text-sky-500 text-sm md:pl-1 font-medium transition duration-300 ease-in-out" @click.prevent="showSearch=true">
<i class="fa fa-search"></i>
</button>
</div>
</div>
<div class="mr-3 md:mx-3">
<button type="button" @click="userMenuOpen=!userMenuOpen" @keydown.escape="userMenuOpen=false" class="relative flex w-6 text-gray-400 hover:text-white" id="user-menu-button" aria-expanded="false" aria-haspopup="true">
<span class="sr-only">Open user menu</span>
<i class="fa-regular fa-user-circle text-gray-800 hover:text-sky-500 transition"></i>
</button>
</div>
</div>
</div>
<div x-show="open" @click.away="open=false" x-cloak class="absolute w-full right-0 sm:right-5 sm:top-9 z-50 sm:mt-2 sm:w-48 origin-top-right sm:rounded-md bg-white py-3 px-2 shadow-lg border-t sm:ring-1 ring-black ring-opacity-25 focus:outline-none" role="menu" aria-orientation="vertical" aria-labelledby="user-menu-button" tabindex="-1">
<a href="{{ route('post.index') }}" class="sm:hidden block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-regular fa-newspaper w-4 mr-2"></i>Blog</a>
<a href="{{ route('event.index') }}" class="sm:hidden block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-solid fa-bullhorn w-4 mr-2"></i>Workshops</a>
<div class="sm:hidden border-t border-gray-200 my-2"></div>
@if(auth()->guest())
<a href="{{ route('register') }}" class="block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-solid fa-pen-to-square w-4 mr-2"></i>Register</a>
<a href="{{ route('login') }}" class="block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-solid fa-right-to-bracket w-4 mr-2"></i>Log in</a>
@else
<div x-show="pageMenuOpen" @click.away="pageMenuOpen=false" x-cloak class="fixed left-0 top-0 h-full w-full z-20" role="menu" aria-labelledby="page-menu-button" tabindex="-1">
<div x-show="pageMenuOpen" @click="pageMenuOpen=false" class="absolute inset-0 bg-black bg-opacity-40 backdrop-blur-sm"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-300"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"></div>
<div x-show="pageMenuOpen" class="relative h-full left-0 top-0 w-96 max-w-full bg-white z-50 shadow-lg p-4"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform -translate-x-full"
x-transition:enter-end="opacity-100 transform translate-x-0"
x-transition:leave="transition ease-in duration-300"
x-transition:leave-start="opacity-100 transform translate-x-0"
x-transition:leave-end="opacity-0 transform -translate-x-full">
<div class="flex justify-between mb-4">
<div>
@includeSVG('logo.svg', 'width:10em;color:black')
</div>
<button @click="pageMenuOpen=false" class="hover:text-red-500">
<i class="fa fa-times"></i>
</button>
</div>
<div class="sm:hidden block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1" @click.prevent="showSearch=true">
<i class="fa fa-search w-4 mr-2"></i>Search
</div>
{{-- <a href="{{ route('post.index') }}" class="sm:hidden block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-regular fa-newspaper w-4 mr-2"></i>Blog</a>--}}
<a href="{{ route('about') }}" class="sm:hidden block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-solid fa-circle-info w-4 mr-2"></i>About</a>
<a href="{{ route('workshop.index') }}" class="sm:hidden block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-solid fa-bullhorn w-4 mr-2"></i>Workshops</a>
<a href="{{ route('contact') }}" class="sm:hidden block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-regular fa-envelope w-4 mr-2"></i>Contact</a>
@if(auth()->user()?->admin)
<div class="sm:hidden border-t border-gray-200 my-2"></div>
<div class="block text-xs font-semibold text-gray-500 px-2 py-1">Admin</div>
<a href="{{ route('admin.location.index') }}" class="block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-solid fa-location-dot w-4 mr-2"></i>Locations</a>
<a href="{{ route('admin.media.index') }}" class="block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-solid fa-photo-film w-4 mr-2"></i>Media</a>
<a href="{{ route('admin.post.index') }}" class="block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-regular fa-newspaper w-4 mr-2"></i>Posts</a>
{{-- <a href="{{ route('admin.post.index') }}" class="block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-regular fa-newspaper w-4 mr-2"></i>Posts</a>--}}
<a href="{{ route('admin.user.index') }}" class="block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-solid fa-users w-4 mr-2"></i>Users</a>
<a href="{{ route('admin.event.index') }}" class="block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-regular fa-calendar w-4 mr-2"></i>Events</a>
<div class="border-t border-gray-200 my-2"></div>
<a href="{{ route('admin.workshop.index') }}" class="block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-solid fa-bullhorn w-4 mr-2"></i>Workshops</a>
@endif
<a href="{{ route('account.show') }}" class="block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-solid fa-user-pen w-4 mr-2"></i>Account</a>
<a href="{{ route('logout') }}" class="block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-solid fa-right-from-bracket w-4 mr-2"></i>Log out</a>
@endif
</div>
</div>
<div
x-show="userMenuOpen"
@click.away="userMenuOpen=false"
x-cloak
>
<div x-show="userMenuOpen" @click="userMenuOpen=false" class="fixed left-0 w-screen z-20 h-screen bg-black bg-opacity-40 backdrop-blur-sm"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-300"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"></div>
<div
x-show="userMenuOpen"
class="absolute w-full right-0 sm:right-5 sm:top-12 z-50 sm:mt-2 sm:w-64 origin-top-right sm:rounded-md bg-white py-3 px-2 shadow-lg border-t sm:ring-1 ring-black ring-opacity-25 focus:outline-none">
@if(auth()->guest())
<a href="{{ route('register') }}" class="block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-solid fa-pen-to-square w-4 mr-2"></i>Register</a>
<a href="{{ route('login') }}" class="block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-solid fa-right-to-bracket w-4 mr-2"></i>Log in</a>
@else
<div class="text-lg font-semibold px-4 py-1 text-gray-700">Welcome {{ auth()->user()->firstname ?? strstr(auth()->user()->email, '@', true) }}</div>
<div class="border-t border-gray-200 my-2"></div>
<a href="{{ route('account.show') }}" class="block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-solid fa-user-pen w-4 mr-2"></i>Account</a>
<a href="{{ route('logout') }}" class="block px-4 py-2 text-sm text-gray-700 rounded transition hover:bg-sky-600 hover:text-white" role="menuitem" tabindex="-1"><i class="fa-solid fa-right-from-bracket w-4 mr-2"></i>Log out</a>
@endif
</div>
</div>
</div>
<div class="fixed inset-0 z-50 flex items-center justify-center" x-cloak x-show="showSearch" x-on:click="showSearch=false" x-on:keydown.escape.window="showSearch=false" x-init="$watch('showSearch', value => {
if(value) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const el = document.getElementsByName('q')[0];
if (!el) return;
el.focus({ preventScroll: true });
if (typeof el.select === 'function') el.select();
// iOS fallback:
if (el.setSelectionRange) el.setSelectionRange(0, el.value.length);
})
})
}
})">
<div class="absolute inset-0 backdrop-blur-sm bg-opacity-40 bg-black"></div>
<div class="relative w-full mx-8 max-w-2xl bg-gray-50 p-2 rounded-lg shadow-lg" x-on:click.stop>
<form action="{{ route('search.index') }}" method="GET">
<x-ui.search type="text" name="q" label="Search..." />
</form>
</div>
</div>
</nav>

View File

@@ -1,45 +1,46 @@
@props(['event'])
@props(['workshop'])
@php
$statusClass = $event->status;
$statusTitle = $event->status;
$statusClass = $workshop->status;
$statusTitle = $workshop->status;
if($event->status === 'scheduled') {
if($workshop->status === 'scheduled') {
$statusClass = 'soon';
$statusTitle = 'Open soon';
}
@endphp
<a href="{{ route('event.show', $event) }}" class="flex flex-col bg-white border rounded-lg overflow-hidden hover:shadow-lg hover:scale-[101%] transition-all relative {{ $attributes->get('class') }}">
<a href="{{ route('workshop.show', $workshop) }}" class="flex flex-col bg-white border rounded-lg overflow-hidden hover:shadow-lg hover:scale-[101%] transition-all relative {{ $attributes->get('class') }}">
<div class="shadow border rounded px-3 py-2 absolute top-2 left-2 flex flex-col justify-center items-center bg-white">
<div class="text-gray-600 font-bold leading-none">{{ $event->starts_at->format('j') }}</div>
<div class="text-gray-600 text-xs uppercase">{{ $event->starts_at->format('M') }}</div>
<div class="text-gray-600 font-bold leading-none">{{ $workshop->starts_at->format('j') }}</div>
<div class="text-gray-600 text-xs uppercase">{{ $workshop->starts_at->format('M') }}</div>
</div>
<div class="border border-white border-opacity-50 absolute flex items-center justify-center top-5 -right-9 bg-gray-500 w-36 text-sm text-white font-bold uppercase py-1 rotate-45 h-8 sm-banner-{{ strtolower($statusClass) }}">{{ $statusTitle }}</div>
<img src="{{ $event->hero?->url }}?md" alt="{{ $event->title }}" class="w-full h-64 object-cover object-center">
<img src="{{ $workshop->hero?->url }}?md" alt="{{ $workshop->title }}" class="w-full h-64 object-cover object-center">
<div class="flex-grow p-4 flex flex-col">
<h2 class="flex-grow {{ strlen($event->title) > 25 ? 'text-lg' : 'text-xl' }} font-bold mb-2">{{ $event->title }}</h2>
<h2 class="flex-grow {{ strlen($workshop->title) > 25 ? 'text-lg' : 'text-xl' }} font-bold mb-2">{{ $workshop->title }}</h2>
<div class="text-gray-600 text-sm mb-1 flex gap-2">
<div class="w-6 flex items-center justify-center">
<i class="fa-regular fa-calendar"></i>
</div>{{ $event->starts_at->format('j/m/Y @ g:i a') }}
</div>{{ $workshop->starts_at->format('j/m/Y @ g:i a') }}
</div>
<div class="text-gray-600 text-sm mb-1 flex gap-2">
<div class="w-6 flex items-center justify-center">
<i class="fa-solid fa-location-dot"></i>
</div>{{ $event->location->name }}
</div>{{ $workshop->location->name }}
</div>
@if($event->ages)
@if($workshop->ages)
<div class="text-gray-600 text-sm mb-1 flex gap-2">
<div class="w-6 flex items-center justify-center">
<i class="fa-regular fa-face-smile"></i>
</div>{{ isset($event->ages) && $event->ages !== '' ? 'Ages ' . $event->ages : 'All ages' }}
</div>{{ $workshop->ages ? 'Ages ' . $workshop->ages : 'All ages' }}
</div>
@endif
<div class="text-gray-600 text-sm mb-1 flex gap-2">
<div class="w-6 flex items-center justify-center">
<i class="fa-solid fa-dollar-sign"></i>
</div>{{ isset($event->price) && $event->price !== '' && $event->price !== '0' ? $event->price : 'Free' }}
</div>
{{ $workshop->price && $workshop->price !== '0' ? number_format((float)$workshop->price, 2) : 'Free' }}
</div>
</div>
</a>

View File

@@ -4,16 +4,19 @@
$colorClasses = [
'outline' => 'hover:bg-gray-500 focus-visible:outline-primary-color text-gray-800 border border-gray-400 bg-white hover:text-white',
'primary' => 'hover:bg-primary-color-dark focus-visible:outline-primary-color bg-primary-color text-white',
'primary-sm' => '!font-normal !text-xs !px-4 !py-1 hover:bg-primary-color-dark focus-visible:outline-primary-color bg-primary-color text-white',
'primary-outline' => 'hover:bg-primary-color-dark focus-visible:outline-primary-color text-primary-color border border-primary-color bg-white hover:text-white',
'primary-outline-sm' => '!font-normal !text-xs !px-4 !py-1 hover:bg-primary-color-dark focus-visible:outline-primary-color text-primary-color border border-primary-color bg-white hover:text-white',
'danger' => 'hover:bg-danger-color-dark focus-visible:outline-danger-color bg-danger-color text-white',
'success' => 'hover:bg-success-color-dark focus-visible:outline-success-color bg-success-color text-white'
'danger-outline' => 'hover:bg-danger-color-dark focus-visible:outline-danger-color text-danger-color border border-danger-color bg-white hover:text-white',
'success' => 'hover:bg-success-color-dark focus-visible:outline-success-color bg-success-color text-white',
'dark' => 'hover:bg-gray-900 focus-visible:outline-gray-800 bg-gray-800 text-white'
][$color];
$commonClasses = @twMerge(['whitespace-nowrap', 'text-center','justify-center','rounded-md','px-8','py-1.5','text-sm','font-semibold','leading-6','shadow-sm','focus-visible:outline','focus-visible:outline-2','focus-visible:outline-offset-2','transition'], ($class ?? ''));
@endphp
@if($type == 'submit' || $type == 'button')
@if($type === 'submit' || $type === 'button')
<button type="{{ $type }}" class="{{ $colorClasses . ' ' . $commonClasses }}" {{ $attributes }}>{{ $slot }}</button>
@elseif($type == 'link')
@elseif($type === 'link')
<a href="{{ $href ?? '#' }}" target="{{ $target ?? '_self' }}" class="{{ $colorClasses . ' ' . $commonClasses }}" {{ $attributes }}">{{ $slot }}</a>
@endif

View File

@@ -1,4 +1,4 @@
@props(['type' => 'text', 'name', 'label' => '', 'value' => '', 'floating' => false, 'noLabel' => false, 'readonly' => false, 'info', 'error' => null, 'labelNotice' => null])
@props(['type' => 'text', 'name', 'label' => '', 'value' => '', 'floating' => false, 'noLabel' => false, 'readonly' => false, 'info', 'error' => null, 'labelNotice' => null, 'placeholder' => '', 'fieldClasses' => '' ])
@php
if($error === null) {
@@ -6,7 +6,7 @@
}
$hasError = $error !== '';
$classes = 'disabled:bg-gray-100 bg-white block px-2.5 pb-2.5 w-full text-sm text-gray-900 rounded-lg border appearance-nonefocus:outline-none focus:ring-0 focus:border-blue-600 ' . ($hasError ? 'border-red-600 ring-red-600 focus:border-red-600 focus:ring-red-600' : 'border-gray-300 focus:border-indigo-300 focus:ring-indigo-300');
$classes = 'disabled:bg-gray-100 bg-white block px-2.5 pb-2.5 w-full text-sm text-gray-900 rounded-lg border appearance-none focus:outline-none focus:ring-0 focus:border-blue-600 ' . ($hasError ? 'border-red-600 ring-red-600 focus:border-red-600 focus:ring-red-600' : 'border-gray-300 focus:border-indigo-300 focus:ring-indigo-300');
$value = old($name, $value);
@endphp
@@ -14,27 +14,27 @@
@if($floating)
<div class="relative">
@if($type === 'textarea')
<textarea class="{{ twMerge(['pt-4'], $classes) }}" name="{{ $name }}" {{ $readonly ? 'readonly' : '' }} {{ $attributes }}>{{ $value }}</textarea>
<textarea class="{{ twMerge(['pt-4'], $classes, $attributes->get('fieldClasses')) }}" name="{{ $name }}" {{ $readonly ? 'readonly' : '' }} {{ $attributes }}>{{ $value }}</textarea>
@else
<input class="{{ twMerge(['pt-4'], $classes) }}" autocomplete="off" placeholder=" " value="{{ $value }}" type="{{ $type }}" name="{{ $name }}" {{ $readonly ? 'readonly' : '' }} {{ $attributes }} />
<input class="{{ twMerge(['pt-4'], $classes, $attributes->get('fieldClasses')) }}" autocomplete="off" placeholder=" " value="{{ $value }}" type="{{ $type }}" name="{{ $name }}" {{ $readonly ? 'readonly' : '' }} {{ $attributes }} />
@endif
<label for="{{ $name }}" class="absolute text-sm text-gray-500 duration-300 transform -translate-y-4 scale-75 top-2 z-10 origin-[0] bg-white px-2 peer-focus:px-2 peer-focus:text-blue-600 peer-placeholder-shown:scale-100 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:top-1/2 peer-focus:top-2 peer-focus:scale-75 peer-focus:-translate-y-4 rtl:peer-focus:translate-x-1/4 rtl:peer-focus:left-auto start-1">{{ $label }}</label>
</div>
@elseif($noLabel)
<div class="relative">
@if($type === 'textarea')
<textarea class="{{ twMerge(['pt-2.5'], $classes) }}" name="{{ $name }}" placeholder="{{ $label }}" {{ $readonly ? 'readonly' : '' }} {{ $attributes }}>{{ $value }}</textarea>
<textarea class="{{ twMerge(['pt-2.5'], $classes, $fieldClasses) }}" name="{{ $name }}" placeholder="{{ $label }}" {{ $readonly ? 'readonly' : '' }} {{ $attributes }}>{{ $value }}</textarea>
@else
<input class="{{ twMerge(['pt-2.5'], $classes) }}" autocomplete="off" placeholder="{{ $label }}" value="{{ $value }}" type="{{ $type }}" name="{{ $name }}" {{ $readonly ? 'readonly' : '' }} {{ $attributes }} />
<input class="{{ twMerge(['pt-2.5'], $classes, $fieldClasses) }}" autocomplete="off" placeholder="{{ $label }}" value="{{ $value }}" type="{{ $type }}" name="{{ $name }}" {{ $readonly ? 'readonly' : '' }} {{ $attributes }} />
@endif
</div>
@else
<div>
<label for="{{ $name }}" class="block text-sm pl-1">{{ $label }}{!! isset($labelNotice) && $labelNotice !== '' ? '<i class="fa-solid fa-triangle-exclamation ml-1 text-gray-500 hover:text-black" data-tooltip="' . $labelNotice . '"></i>' : '' !!}</label>
@if($type === 'textarea')
<textarea class="{{ twMerge(['pt-2.5','mt-1','h-96'], $classes) }}" name="{{ $name }}" {{ $readonly ? 'readonly' : '' }} {{ $attributes->whereDoesntStartWith('x-') }}>{{ $value }}</textarea>
<textarea class="{{ twMerge(['pt-2.5','mt-1','h-96'], $classes, $fieldClasses) }}" name="{{ $name }}" {{ $readonly ? 'readonly' : '' }} {{ $attributes->whereDoesntStartWith('x-') }}>{{ $value }}</textarea>
@else
<input class="{{ twMerge(['pt-2.5','mt-1'], $classes) }}" autocomplete="off" placeholder=" " value="{{ $value }}" type="{{ $type }}" name="{{ $name }}" {{ $readonly ? 'readonly' : '' }} {{ $attributes }} />
<input class="{{ twMerge(['pt-2.5','mt-1'], $classes, $fieldClasses) }}" autocomplete="off" placeholder=" " value="{{ $value }}" type="{{ $type }}" name="{{ $name }}" {{ $readonly ? 'readonly' : '' }} {{ $attributes }} />
@endif
</div>
@endif

View File

@@ -5,7 +5,7 @@
@endphp
<form method="GET" action="{{ url()->current() }}" class="{{ $attributes->get('class') }}">
<div class="flex relative" x-data="{search:'{{request()->get('search')}}'}">
<div class="flex relative" x-data="{search:'{{request()->get('q')}}'}">
<input class="{{ $classes }}" autocomplete="off" placeholder="{{ $label }}" x-model="search" type="{{ $type }}" name="{{ $name }}" />
<x-ui.button type="submit" class="rounded-l-none px-6"><i class="fa-solid fa-magnifying-glass"></i></x-ui.button>
<i x-show="search" cloak class="absolute z-10 top-1/2 right-[4.5rem] transform -translate-y-1/2 text-gray-300 hover:text-gray-400 cursor-pointer fa-solid fa-circle-xmark" x-data x-on:click="search='';$nextTick(()=>{if('{{request()->get('search')}}'!==''){$el.closest('form').submit();}})"></i>

View File

@@ -12,8 +12,8 @@
<p class="mb-2">We do not have a physical address as our workshops are delivered across Queensland. Visit the workshops page for each specific location.</p>
<p class="mb-4">Official mail can be sent to the following postal address:</p>
<p class="mb-2 text-center">STEMMechanics<br />
1/4 Jordan Street<br />
Edmonton, QLD, 4869<br />
63 Dalton Street<br />
Westcourt, QLD, 4870<br />
Australia</p>
<p class="mb-2 text-center"><strong class="font-semibold">ABN</strong>: 15 772 281 735</p>
</x-container>

View File

@@ -0,0 +1,13 @@
@component('mail::message', ['email' => $email])
<p>Hey there!</p>
<p>We just wanted to let you know that someone just logged in using a backup code.</p>
<p>If this was you, then it is all good!</p>
<p>If it's not, we recommend you let us know by replying to this email and resetting your backup codes by:</p>
<ul>
<li>Logging into your account on STEMMechanics</li>
<li>Visit your account page</li>
<li>Under <strong>Two Factor Authentication</strong> - Click <i>Reset Backup Codes</i></li>
</ul>
<p>Warm regards,</p>
<p>—James 😁</p>
@endcomponent

View File

@@ -0,0 +1,8 @@
@component('mail::message', ['email' => $email])
<p>Hey there!</p>
<p>We just wanted to let you know that using an <strong>Authenticator App</strong> to log in to your account on STEMMechanics has been <strong>Disabled</strong>.</p>
<p>If this was you, then it is all good! - Any previous <i>Backup TFA Codes</i> can no longer be used.</p>
<p>If it's not, we recommend you let us know by replying to this email.</p>
<p>Regards,</p>
<p>—James 😁</p>
@endcomponent

View File

@@ -0,0 +1,8 @@
@component('mail::message', ['email' => $email])
<p>Hey there!</p>
<p>We just wanted to let you know that using an <strong>Authenticator App</strong> to log in to your account on STEMMechanics has been <strong>Enabled</strong>.</p>
<p>If this was you, then it is all good!</p>
<p>If it's not, we recommend you let us know by replying to this email.</p>
<p>Regards,</p>
<p>—James 😁</p>
@endcomponent

View File

@@ -0,0 +1,29 @@
@component('mail::message', ['email' => $email])
<p>Hey there!</p>
<p>Check out our exciting workshops coming up in the next few weeks:</p>
<p class="center">
@php
$currentLocation = null;
@endphp
@foreach($workshops as $workshop)
@if($workshop->location->name !== $currentLocation)
<h2 style="margin-top: 32px; margin-bottom: 6px">{{ $workshop->location->name }}</h2>
@php
$currentLocation = $workshop->location->name;
@endphp
@endif
<p style="margin-bottom: 6px">{{ $workshop->starts_at->format('D, j M, g:i A') . ' - ' }}<a href="{{ route('workshop.show', $workshop->slug) }}">{{ $workshop->title }}</a> ({{ ($workshop->price && is_numeric($workshop->price) && $workshop->price != '0' ? '$' . number_format((float)$workshop->price, 2) : 'Free') . ( $workshop->status === 'scheduled' ? ' / Opens soon' : '') }})</p>
@endforeach
<p class="tall center" style="margin-top: 32px">
@component('mail::button', ['url' => 'https://stemmechanics.com.au/workshops'])
View All Workshops
@endcomponent
</p>
<p>We hope to see you at one of our upcoming workshops!</p>
<p>Warm regards,</p>
<p> James 😁</p>
@slot('subcopy')
<h4>Why did I get this email?</h4>
<p class="sub">You received this email as you are subscribed to our upcoming workshop email list. If you wish no longer receive this email, you can <a href="{{ $unsubscribeLink }}">unsubscribe here</a>.</p>
@endslot
@endcomponent

View File

@@ -1,12 +1,12 @@
@component('mail::message', ['email' => $email])
@component('mail::message', ['email' => $email, 'unsubscribe' => $unsubscribeLink])
<p>Welcome to the community!</p>
<p>Really glad to have you here and can't wait to see you at one of our workshops.</p>
<p>You'll get information about upcoming workshops as it comes out.</p>
<p>Even though this is (of course) an automated email, just wanted to say thanks for registering and intro myself.</p>
<p>Even though this is (of course) an automated email, just wanted to say thanks and intro myself.</p>
<p>If you didn't know, I'm James and I'm the founder of STEMMechanics. I promise not to spam you, sell your data, or send you anything I don't think is absolutely necessary.</p>
<p>You know a bit about me but I don't know really anything about you...</p>
<p><strong>If you're up for it</strong>, reply to this email and tell me a bit about yourself and also let me know what workshops you are interested in?</p>
<p><strong>If you're up for it</strong>, reply to this email or join us in <a href="https://discord.gg/yNzk4x7mpD">Discord</a> and tell me a bit about yourself and also let me know what workshops you are interested in?</p>
<p>I read and reply to every one 😁</p>
<p>Talk soon</p>
<p>—James</p>
<p> James</p>
@endcomponent

View File

@@ -0,0 +1,5 @@
@extends('errors::minimal')
@section('title', __('Unauthorized'))
@section('code', '401')
@section('message', __('Unauthorized'))

View File

@@ -0,0 +1,5 @@
@extends('errors::minimal')
@section('title', __('Payment Required'))
@section('code', '402')
@section('message', __('Payment Required'))

View File

@@ -0,0 +1,5 @@
@extends('errors::minimal')
@section('title', __('Page Expired'))
@section('code', '419')
@section('message', __('Page Expired'))

View File

@@ -0,0 +1,5 @@
@extends('errors::minimal')
@section('title', __('Too Many Requests'))
@section('code', '429')
@section('message', __('Too Many Requests'))

View File

@@ -0,0 +1,5 @@
@extends('errors::minimal')
@section('title', __('Server Error'))
@section('code', '500')
@section('message', __('Server Error'))

View File

@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title')</title>
<!-- Styles -->
<style>
html, body {
background-color: #fff;
color: #636b6f;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-weight: 100;
height: 100vh;
margin: 0;
}
.full-height {
height: 100vh;
}
.flex-center {
align-items: center;
display: flex;
justify-content: center;
}
.position-ref {
position: relative;
}
.content {
text-align: center;
}
.title {
font-size: 36px;
padding: 20px;
}
</style>
</head>
<body>
<div class="flex-center position-ref full-height">
<div class="content">
<div class="title">
@yield('message')
</div>
</div>
</div>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -1,30 +0,0 @@
<x-layout>
<x-slot name="title">Workshops</x-slot>
<x-mast>Workshops</x-mast>
<section class="bg-gray-100">
<x-container class="my-4">
<x-ui.search class="md:hidden" name="search" label="Search" value="{{ request()->get('search') }}" />
<form class="hidden md:flex gap-4" method="GET" action="{{ request()->url() }}">
<x-ui.input no-label class="my-0 flex-1" type="text" name="search" label="Keywords" value="{{ request()->get('search') }}"/>
<x-ui.input no-label class="my-0 flex-1" type="text" name="location" label="Location" value="{{ request()->get('location') }}"/>
<x-ui.input no-label class="my-0 flex-1" type="text" name="date" label="Date Range" value="{{ request()->get('date') }}"/>
<x-ui.button type="submit"><i class="fa-solid fa-magnifying-glass"></i></x-ui.button>
</form>
</x-container>
@if($events->isEmpty())
<x-container class="mt-8">
<x-none-found item="workshops" search="{{ request()->get('search') }}" />
</x-container>
@else
<x-container class="mt-4" inner-class="grid md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
@foreach ($events as $event)
<x-panel-event :event="$event" />
@endforeach
</x-container>
<x-container>
{{ $events->appends(request()->query())->links() }}
</x-container>
@endif
</section>
</x-layout>

View File

@@ -1,64 +0,0 @@
<x-layout>
<x-container>
<x-ui.image-hero :image="$event->hero?->url" class="my-8" />
<div class="flex sm:gap-16 gap-4 flex-col sm:flex-row">
<div class="flex flex-col flex-1">
<h1 class="text-3xl font-bold mb-6">{!! $event->title !!}</h1>
<article class="content mb-4">{!! $event->content !!}</article>
<x-ui.filelist class="mt-16" value="{!! $event->files()->orderBy('name')->get() !!}" />
</div>
<div class="flex flex-col sm:pt-8 basis-64 flex-grow-0 flex-shrink-0">
@if($event->status === 'closed')
<div class="sm-registration-closed">Registration for this event has closed.</div>
@elseif($event->status === 'full')
<div class="sm-registration-full">This workshop is currently full.</div>
@elseif($event->status === 'private')
<div class="sm-registration-private">This is a private event. Please contact the organiser for details.</div>
@elseif($event->status === 'scheduled')
<div class="sm-registration-scheduled">Registration for this workshop will open soon.</div>
@elseif($event->status === 'cancelled')
<div class="sm-registration-cancelled">This workshop has been cancelled.</div>
@elseif($event->registration === 'none')
<div class="sm-registration-none">Registration not required for this event. Arrive early to avoid disappointment as seating maybe limited.</div>
@elseif($event->registration === 'link')
<x-ui.button href="{{ $event->registration_data }}" class="my-4">Register for Event</x-ui.button>
@elseif($event->registration === 'email')
<div class="sm-registration-email">Registration for this event by emailing <a href="mailto:{{ $event->registration_data }}" class="link">{{ $event->registration_data }}</a>.</div>
@elseif($event->registration === 'message')
<div class="sm-registration-message">{{ $event->registration_data }}</div>
@endif
@if(auth()->user()?->admin)
<x-ui.button class="mb-4" color="primary-outline" href="{{ route('admin.event.edit', $event) }}">Edit Workshop</x-ui.button>
@endif
<h2 class="text-gray-600 text-lg font-bold mt-4 mb-2"><i class="mr-1 fa-regular fa-calendar"></i> Date/Time</h2>
<p class="text-gray-600 text-sm pl-6 mb-6">{!! implode('<br />', \App\Helpers::createTimeDurationStr($event->starts_at, $event->ends_at)) !!}</p>
<h2 class="text-gray-600 text-lg font-bold mb-2"><i class="mr-1 fa-solid fa-location-dot"></i> Location</h2>
<div class="text-gray-600 text-sm pl-6 mb-6">
@if($event->location->url)
<a href="{{ $event->location->url }}" class="link">
@endif
<p>{{ $event->location->name }}</p>
@if($event->location->url)
</a>
@endif
@if($event->location->address_url)
<a href="{{ $event->location->address_url }}" class="link" target="_blank">
@endif
<p class="text-xs">{{ $event->location->address }}</p>
@if($event->location->address_url)
</a>
@endif
</div>
<h2 class="text-gray-600 text-lg font-bold mb-2"><i class="mr-1 fa-regular fa-face-smile"></i> {{ isset($event->ages) && $event->ages !== '' ? 'Ages ' . $event->ages : 'All ages' }}</h2>
@if(\App\Helpers::isUnderAge($event->ages))
<p class="text-gray-600 text-xs pl-3 ml-2 mb-6 border-l-4 border-l-yellow-400">Parental supervision may be required for children 8 years of age and under.</p>
@endif
<h2 class="text-gray-600 text-lg font-bold mb-2"><i class="mr-1 fa-solid fa-dollar-sign"></i> {{ isset($event->price) && $event->price !== '' && $event->price !== '0' ? $event->price : 'Free' }}</h2>
{{-- @if(isset($event->price) && $event->price !== '' && $event->price !== '0' && strtolower($event->price) !== 'free')--}}
{{-- <p class="text-gray-600 text-xs pl-3 ml-2 mb-6 border-l-4 border-l-green-500">Payment by cash or EFTPOS accepted. Please ensure correct change.</p>--}}
{{-- @endif--}}
</div>
</div>
</x-container>
</x-layout>

View File

@@ -1,6 +1,6 @@
<x-layout id="home">
<x-slot name="title">Home</x-slot>
<section id="banner" class="bg-center bg-no-repeat bg-cover" style="background-image:linear-gradient(to right, rgba(0,0,0,.7),rgba(0,0,0,.2)),url('/home-hero.webp')">
<section id="banner" class="bg-center bg-no-repeat bg-cover" style="background-image:linear-gradient(to right, rgba(0,0,0,.7),rgba(0,0,0,.2)),url({{asset('home-hero.webp')}})">
<x-container class="py-32 relative">
<h2 class="text-3xl text-white font-bold mb-4">Join the fun!</h2>
<p class="text-white max-w-[42rem] mb-3">To keep up with our ever-changing world, it's important to encourage and support a new generation of curious minds who love science, engineering, art, and leadership.</p>
@@ -8,24 +8,38 @@
<p class="absolute bottom-3 right-5 bg-black bg-opacity-75 text-white text-xs px-3 py-1 rounded">Steady Hand Game in Ravenshoe</p>
</x-container>
</section>
<section id="news" class="py-12">
<section id="events" class="py-12">
<x-container>
<h2 class="text-2xl font-bold mb-6">Latest Posts</h2>
@if($posts->isEmpty())
<x-none-found item="posts" message="No posts have been published at this time" title="" />
<h2 class="text-2xl font-bold mb-6">Upcoming workshops</h2>
@if($workshops->isEmpty())
<x-none-found item="workshops" message="No workshops have been scheduled at this time" title="" />
@else
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
@foreach($posts as $index => $post)
<x-panel-post :post="$post" class="{{ $index === 3 ? 'lg:hidden' : '' }}" />
@foreach($workshops as $index => $workshop)
<x-panel-workshop :workshop="$workshop" class="{{ $index === 3 ? 'lg:hidden' : '' }}" />
@endforeach
</div>
@endif
</x-container>
</section>
{{-- <section id="news" class="py-12">--}}
{{-- <x-container>--}}
{{-- <h2 class="text-2xl font-bold mb-6">Latest Posts</h2>--}}
{{-- @if($posts->isEmpty())--}}
{{-- <x-none-found item="posts" message="No posts have been published at this time" title="" />--}}
{{-- @else--}}
{{-- <div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">--}}
{{-- @foreach($posts as $index => $post)--}}
{{-- <x-panel-post :post="$post" class="{{ $index === 3 ? 'lg:hidden' : '' }}" />--}}
{{-- @endforeach--}}
{{-- </div>--}}
{{-- @endif--}}
{{-- </x-container>--}}
{{-- </section>--}}
<section id="skills">
<x-container class="bg-gray-200 py-32 my-8" inner-class="flex flex-row gap-16">
<x-container class="bg-gray-200 py-32" inner-class="flex flex-row gap-16">
<div class="flex-1 min-h-72 hidden md:block">
<div class="h-full bg-no-repeat bg-center bg-cover rounded-lg" style="background-image:url('/home-green-screen.webp')"></div>
<div class="h-full bg-no-repeat bg-center bg-cover rounded-lg" style="background-image:url({{asset('home-green-screen.webp')}})"></div>
</div>
<div class="flex flex-col flex-1 text-center">
<h2 class="text-3xl mb-4 text-center md:text-left">Build skills while having a great time</h2>
@@ -33,47 +47,33 @@
<div class="self-center">
<p class="mb-6 text-left">To keep up with our ever-changing world, it's important to encourage and support a new generation of curious minds who love science, engineering, art, and leadership.</p>
<div class="flex flex-grow justify-center items-center">
<x-ui.button color="success" href="{{ route('event.index') }}" class="font-normal">Explore Workshops</x-ui.button>
<x-ui.button color="success" href="{{ route('workshop.index') }}" class="font-normal">Explore Workshops</x-ui.button>
</div>
</div>
<div class="ml-8 hidden sm:block md:hidden">
<div class="h-48 w-48 bg-no-repeat bg-center bg-cover rounded-full" style="background-image:url('/home-green-screen.webp')"></div>
<div class="h-48 w-48 bg-no-repeat bg-center bg-cover rounded-full" style="background-image:url({{asset('home-green-screen.webp')}})"></div>
</div>
</div>
</div>
</x-container>
</section>
<section id="events" class="pt-4 pb-8">
<x-container>
<h2 class="text-2xl font-bold mb-6">Upcoming workshops</h2>
@if($events->isEmpty())
<x-none-found item="workshops" message="No workshops have been scheduled at this time" title="" />
@else
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
@foreach($events as $index => $event)
<x-panel-event :event="$event" class="{{ $index === 3 ? 'lg:hidden' : '' }}" />
@endforeach
</div>
@endif
</x-container>
</section>
<section id="minecraft" class="bg-center bg-no-repeat bg-cover" style="background-image:url('/home-minecraft.webp')">
<section id="minecraft" class="bg-center bg-no-repeat bg-cover" style="background-image:url({{asset('home-minecraft.webp')}})">
<x-container class="text-white py-32">
<h2 class="text-3xl mb-4">Play Minecraft with us</h2>
<p class="mb-4">We invite you to join us on our <a href="https://stemcraft.com.au/" class="link">Minecraft server</a> where you can participate in weekly challenges and mini-games.</p>
<div class="mb-4 flex gap-4">
<img src="/home-minecraft-edu.webp" class="h-12" />
<img src="{{ asset('home-minecraft-edu.webp') }}" alt="Minecraft Education" class="h-12" />
<p>We also run workshops on our minecraft server, both online and offline, where you ca learn to make it rain rabbits, or grow flowers wherever you walk!</p>
</div>
<div class="flex justify-center">
<img src="/home-minecraft-address.webp" class="h-12" />
<img src="{{ asset('home-minecraft-address.webp') }}" alt="play.stemcraft.com.au" class="h-12" />
</div>
</x-container>
</section>
<section id="support">
<x-container class="bg-gray-200 py-32 -mb-12" inner-class="flex flex-row gap-16">
<div class="hidden sm:block flex-1">
<div class="h-full bg-no-repeat bg-center bg-cover rounded-lg" style="background-image:url('/home-discord.webp')"></div>
<div class="h-full bg-no-repeat bg-center bg-cover rounded-lg" style="background-image:url({{ asset('home-discord.webp') }})"></div>
</div>
<div class="flex-1 text-center">
<h2 class="text-3xl mb-4 text-left">And the support doesn't stop!</h2>
@@ -85,4 +85,13 @@
</div>
</x-container>
</section>
<section id="subscribe">
<x-container class="bg-primary-color-dark py-24 -mb-12" inner-class="flex justify-center">
<div class="max-w-[52rem]">
<h2 class="text-3xl mb-0 text-white">Want to know whats coming up?</h2>
<p class="mb-6 text-left text-white">Sign up and well send you updates on new workshops, special sessions and whats happening around STEMMechanics.</p>
<livewire:email-subscribe />
</div>
</x-container>
</section>
</x-layout>

View File

@@ -0,0 +1,39 @@
<div>
<form wire:submit.prevent="subscribe" class="flex flex-row justify-center">
<input
type="text"
name="name"
wire:model.defer="trap"
autocomplete="off"
tabindex="-1"
class="hidden"
/>
<x-ui.input
type="email"
name="email"
label="Email"
no-label
wire:model.defer="email"
class="m-0"
field-classes="rounded-r-none sm:w-96"
/>
{{-- Submit button --}}
<x-ui.button color="dark" type="submit" class="rounded-l-none">
Subscribe
</x-ui.button>
</form>
@if($message)
@if($success)
<p class="mt-4 text-sm text-green-600 mx-auto border-green-800 bg-green-100 py-1 px-4 w-fit">
<i class="fa fa-check mr-2"></i>{{ $message }}
</p>
@else
<p class="mt-4 text-sm text-red-600 mx-auto border-red-800 bg-red-100 py-1 px-4 w-fit">
<i class="fa fa-exclamation-triangle mr-2"></i>{{ $message }}
</p>
@endif
@endif
</div>

View File

@@ -2,15 +2,9 @@
<x-slot name="title">Blog</x-slot>
<x-mast>Blog</x-mast>
<section class="bg-gray-100">
<x-container class="pt-4">
<form method="GET" action="{{ request()->url() }}">
<x-ui.search name="search" label="Search" value="{{ request()->get('search') }}" />
</form>
</x-container>
@if($posts->isEmpty())
<x-container class="mt-8">
<x-none-found item="posts" search="{{ request()->get('search') }}" />
<x-none-found item="posts" />
</x-container>
@else
<x-container class="mt-4" inner-class="grid md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">

View File

@@ -0,0 +1,40 @@
<x-layout>
<x-mast title="Search" description='Results for "{{ $search }}"' />
<x-container>
<section class="bg-gray-100">
<h2 class="text-2xl font-bold my-6">Posts</h2>
@if($posts->isEmpty())
<x-container class="mt-8">
<x-none-found item="posts" search="{{ request()->get('search') }}" />
</x-container>
@else
<x-container class="mt-4" inner-class="grid md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
@foreach ($posts as $post)
<x-panel-post :post="$post" />
@endforeach
</x-container>
<x-container>
{{ $posts->appends(request()->except('post'))->links('', ['pageName' => 'post']) }}
</x-container>
@endif
</section>
<section class="bg-gray-100">
<h2 class="text-2xl font-bold my-6">Workshops</h2>
@if($workshops->isEmpty())
<x-container class="mt-8">
<x-none-found item="workshops" search="{{ request()->get('search') }}" />
</x-container>
@else
<x-container class="mt-4" inner-class="grid md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
@foreach ($workshops as $workshop)
<x-panel-workshop :workshop="$workshop" />
@endforeach
</x-container>
<x-container>
{{ $workshops->appends(request()->except('workshop'))->links('', ['pageName' => 'workshop']) }}
</x-container>
@endif
</section>
</x-container>
</x-layout>

View File

@@ -18,7 +18,7 @@
<x-slot:footer>
<x-mail::footer>
<p>This email was sent to <a href="mailto:{{ $email }}">{{ $email }}</a><br />
<a href="{{ route('index') }}">{{ config('app.name') }}</a> | 1/4 Jordan Street | Edmonton, QLD 4869 Australia<br />
<a href="{{ route('index') }}">{{ config('app.name') }}</a> | 63 Dalton Street | Westcourt, QLD 4870 Australia<br />
© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }}<br />
<a href="{{ route('privacy') }}">Privacy Policy</a> | <a href="{{ route('terms-conditions') }}">Terms & Conditions</a> @isset($unsubscribe) | <a href="{{ $unsubscribe }}">Unsubscribe</a>@endisset
</p>

View File

@@ -43,7 +43,6 @@ h1 {
margin: 0;
padding: 0;
display: inline-block;
border: 1px solid red;
}
h2 {
@@ -68,7 +67,7 @@ h4 {
text-align: left;
}
p {
p, li {
font-size: 14px;
line-height: 1.2em;
margin-top: 0;

View File

@@ -15,7 +15,7 @@
This email was sent to {{ $email }}
STEMMechanics | 1/4 Jordan Street | Edmonton, QLD 4869 Australia
STEMMechanics | 63 Dalton Street | Westcourt, QLD 4870 Australia
© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }}
@isset($unsubscribe) Unsubscribe: {{ $unsubscribe }}@endisset

View File

@@ -0,0 +1,58 @@
<x-mail::message>
{{-- Greeting --}}
@if (! empty($greeting))
# {{ $greeting }}
@else
@if ($level === 'error')
# @lang('Whoops!')
@else
# @lang('Hello!')
@endif
@endif
{{-- Intro Lines --}}
@foreach ($introLines as $line)
{{ $line }}
@endforeach
{{-- Action Button --}}
@isset($actionText)
<?php
$color = match ($level) {
'success', 'error' => $level,
default => 'primary',
};
?>
<x-mail::button :url="$actionUrl" :color="$color">
{{ $actionText }}
</x-mail::button>
@endisset
{{-- Outro Lines --}}
@foreach ($outroLines as $line)
{{ $line }}
@endforeach
{{-- Salutation --}}
@if (! empty($salutation))
{{ $salutation }}
@else
@lang('Regards'),<br>
{{ config('app.name') }}
@endif
{{-- Subcopy --}}
@isset($actionText)
<x-slot:subcopy>
@lang(
"If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\n".
'into your web browser:',
[
'actionText' => $actionText,
]
) <span class="break-all">[{{ $displayableActionUrl }}]({{ $actionUrl }})</span>
</x-slot:subcopy>
@endisset
</x-mail::message>

View File

@@ -0,0 +1,23 @@
<x-layout>
<x-slot name="title">Workshops</x-slot>
<x-mast title="Workshops" :tabs="[
['title' => 'Upcoming', 'route' => route('workshop.index')],
['title' => 'Past', 'route' => route('workshop.past.index')],
]"/>
<section class="bg-gray-100">
@if($workshops->isEmpty())
<x-container class="mt-8">
<x-none-found item="workshops" />
</x-container>
@else
<x-container class="mt-4" inner-class="grid md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
@foreach ($workshops as $workshop)
<x-panel-workshop :workshop="$workshop" />
@endforeach
</x-container>
<x-container>
{{ $workshops->appends(request()->query())->links() }}
</x-container>
@endif
</section>
</x-layout>

View File

@@ -0,0 +1,69 @@
<x-layout>
<x-container>
<x-ui.image-hero :image="$workshop->hero?->url" class="my-8" />
<div class="flex sm:gap-16 gap-4 flex-col sm:flex-row">
<div class="flex flex-col flex-1">
<h1 class="text-3xl font-bold mb-6">{!! $workshop->title !!}</h1>
<article class="content mb-4">{!! $workshop->content !!}</article>
<x-ui.filelist class="mt-16" value="{!! $workshop->files()->orderBy('name')->get() !!}" />
</div>
<div class="flex flex-col sm:pt-8 basis-64 flex-grow-0 flex-shrink-0">
@if($workshop->status === 'closed')
<div class="sm-registration-closed">Registration for this event has closed.</div>
@elseif($workshop->status === 'full')
<div class="sm-registration-full">This workshop is currently full.</div>
@elseif($workshop->status === 'private')
<div class="sm-registration-private">This is a private event. Please contact the organiser for details.</div>
@if($workshop->registration === 'link')
<x-ui.button href="{!! $workshop->registration_data !!}" class="my-4">Register for Event</x-ui.button>
@elseif($workshop->registration === 'message')
<div class="sm-registration-message">{{ $workshop->registration_data }}</div>
@endif
@elseif($workshop->status === 'scheduled')
<div class="sm-registration-scheduled">Registration for this workshop will open soon.</div>
@elseif($workshop->status === 'cancelled')
<div class="sm-registration-cancelled">This workshop has been cancelled.</div>
@elseif($workshop->registration === 'none')
<div class="sm-registration-none">Registration not required for this event. Arrive early to avoid disappointment as seating maybe limited.</div>
@elseif($workshop->registration === 'link')
<x-ui.button href="{!! $workshop->registration_data !!}" class="my-4">Register for Event</x-ui.button>
@elseif($workshop->registration === 'email')
<div class="sm-registration-email">Registration for this event by emailing <a href="mailto:{{ $workshop->registration_data }}" class="link">{{ $workshop->registration_data }}</a>.</div>
@elseif($workshop->registration === 'message')
<div class="sm-registration-message">{{ $workshop->registration_data }}</div>
@endif
@if(auth()->user()?->admin)
<x-ui.button class="mb-4" color="primary-outline" href="{{ route('admin.workshop.edit', $workshop) }}">Edit Workshop</x-ui.button>
@endif
<h2 class="text-gray-600 text-lg font-bold mt-4 mb-2"><i class="mr-1 fa-regular fa-calendar"></i> Date/Time</h2>
<p class="text-gray-600 text-sm pl-6 mb-6">{!! implode('<br />', \App\Helpers::createTimeDurationStr($workshop->starts_at, $workshop->ends_at)) !!}</p>
<h2 class="text-gray-600 text-lg font-bold mb-2"><i class="mr-1 fa-solid fa-location-dot"></i> Location</h2>
<div class="text-gray-600 text-sm pl-6 mb-6">
@if($workshop->location->url)
<a href="{{ $workshop->location->url }}" class="link">
@endif
<p>{{ $workshop->location->name }}</p>
@if($workshop->location->url)
</a>
@endif
@if($workshop->location->address_url)
<a href="{{ $workshop->location->address_url }}" class="link" target="_blank">
@endif
<p class="text-xs">{{ $workshop->location->address }}</p>
@if($workshop->location->address_url)
</a>
@endif
</div>
<h2 class="text-gray-600 text-lg font-bold mb-2"><i class="mr-1 fa-regular fa-face-smile"></i> {{ isset($workshop->ages) && $workshop->ages !== '' ? 'Ages ' . $workshop->ages : 'All ages' }}</h2>
@if(\App\Helpers::isUnderAge($workshop->ages))
<p class="text-gray-600 text-xs pl-3 ml-2 mb-6 border-l-4 border-l-yellow-400">Parental supervision may be required for children 8 years of age and under.</p>
@endif
<h2 class="text-gray-600 text-lg font-bold mb-2"><i class="mr-1 fa-solid fa-dollar-sign"></i> {{ isset($workshop->price) && $workshop->price !== '' && $workshop->price !== '0' ? $workshop->price : 'Free' }}</h2>
{{-- @if(isset($workshop->price) && $workshop->price !== '' && $workshop->price !== '0' && strtolower($workshop->price) !== 'free')--}}
{{-- <p class="text-gray-600 text-xs pl-3 ml-2 mb-6 border-l-4 border-l-green-500">Payment by cash or EFTPOS accepted. Please ensure correct change.</p>--}}
{{-- @endif--}}
</div>
</div>
</x-container>
</x-layout>

View File

@@ -1,9 +1,41 @@
<?php
use App\Jobs\SendEmail;
use App\Mail\UpcomingWorkshops;
use App\Models\Media;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
/**
* The scheduler is run from a cronjob on the server every minute.
* To access the cronjob, run `crontab -u www-data -e` and add the following line:
* * * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
*/
Artisan::command('email:send', function() {
$subjects = [
'🚀 Your STEM Adventure Awaits!',
'⚡ Spark Your STEM Skills in a Workshop',
'🔬 Unleash Your Curiosity in a Workshop',
'🧠 Boost Your Brain with STEM Workshops',
'🌟 Become a STEM Star: Join Our Workshops',
'🔧 Tinker, Create, Learn in a Workshop',
'🎨 Where Science Meets Creativity',
'🏆 Level Up Your STEM Skills',
'🌈 Discover the STEM Spectrum',
'🔮 Future Innovators: Workshops Unveiled',
];
$subject = $subjects[array_rand($subjects)];
$subscribers = DB::table('email_subscriptions')
->whereNotNull('confirmed')
->get();
foreach ($subscribers as $subscriber) {
dispatch(new SendEmail($subscriber->email, new UpcomingWorkshops($subscriber->email, $subject)))->onQueue('mail');
}
})->purpose('Send newsletter to confirmed subscribers')->weeklyOn(3, '16:00');
Artisan::command('cleanup', function() {
@@ -19,13 +51,13 @@ Artisan::command('cleanup', function() {
->update(['status' => 'published']);
// Open scheduled workshops
DB::table('events')
DB::table('workshops')
->where('status', 'scheduled')
->where('publish_at', '<', now())
->update(['status' => 'open']);
// Close workshops
DB::table('events')
DB::table('workshops')
->whereIn('status', ['open', 'full', 'private'])
->where('closes_at', '<', now())
->update(['status' => 'closed']);

View File

@@ -6,24 +6,32 @@ use App\Http\Controllers\HomeController;
use App\Http\Controllers\LocationController;
use App\Http\Controllers\MediaController;
use App\Http\Controllers\PostController;
use App\Http\Controllers\SearchController;
use App\Http\Controllers\SubscribeController;
use App\Http\Controllers\UserController;
use App\Http\Controllers\EventController;
use App\Http\Controllers\WorkshopController;
use Illuminate\Support\Facades\Route;
Route::get('/', [HomeController::class, 'index'])->name('index');
Route::redirect('/events', '/workshops', 301);
Route::redirect('/events/{event}', '/workshops/{event}', 301);
//Route::get('posts', [PostController::class, 'index'])->name('post.index');
//Route::get('posts/{post}', [PostController::class, 'show'])->name('post.show');
Route::get('workshops', [WorkshopController::class, 'index'])->name('workshop.index');
Route::get('workshops/past', [WorkshopController::class, 'past_index'])->name('workshop.past.index');
Route::get('workshops/{workshop}', [WorkshopController::class, 'show'])->name('workshop.show');
Route::get('/posts', [PostController::class, 'index'])->name('post.index');
Route::get('/posts/{post}', [PostController::class, 'show'])->name('post.show');
Route::get('/workshops', [EventController::class, 'index'])->name('event.index');
Route::get('/workshops/{event}', [EventController::class, 'show'])->name('event.show');
Route::get('search', [SearchController::class, 'index'])->name('search.index');
Route::get('unsubscribe/{email}', [SubscribeController::class, 'destroy'])->name('unsubscribe');
Route::middleware('auth')->group(function () {
Route::get('/account', [AccountController::class, 'show'])->name('account.show');
Route::post('/account', [AccountController::class, 'update'])->name('account.update');
Route::delete('/account', [AccountController::class, 'destroy'])->name('account.destroy');
Route::get('/account/2fa', [AccountController::class, 'show_tfa'])->name('account.show.tfa');
Route::get('/account/2fa/image', [AccountController::class, 'show_tfa_image'])->name('account.show.tfa.image');
Route::post('/account/2fa', [AccountController::class, 'post_tfa'])->name('account.post.tfa');
Route::post('/account/2fa/reset-backup-codes', [AccountController::class, 'post_tfa_reset_backup_codes'])->name('account.post.tfa.reset-backup-codes');
Route::delete('/account/2fa', [AccountController::class, 'destroy_tfa'])->name('account.destroy.tfa');
});
Route::get('/login', [AuthController::class, 'showLogin'])->name('login');
@@ -33,6 +41,10 @@ Route::get('/register', [AuthController::class, 'showRegister'])->name('register
Route::post('/register', [AuthController::class, 'postRegister'])->name('register.store');
Route::get('/update-email', [AuthController::class, 'updateEmail'])->name('update.email');
Route::get('/about', function () {
return view('about');
})->name('about');
Route::get('/contact', function () {
return view('contact');
})->name('contact');
@@ -67,12 +79,12 @@ Route::middleware('admin')->group(function () {
Route::put('/admin/locations/{location}', [LocationController::class, 'update'])->name('admin.location.update');
Route::delete('/admin/locations/{location}', [LocationController::class, 'destroy'])->name('admin.location.destroy');
Route::get('/admin/posts', [PostController::class, 'admin_index'])->name('admin.post.index');
Route::get('/admin/posts/create', [PostController::class, 'admin_create'])->name('admin.post.create');
Route::post('/admin/posts', [PostController::class, 'admin_store'])->name('admin.post.store');
Route::get('/admin/posts/{post}', [PostController::class, 'admin_edit'])->name('admin.post.edit');
Route::put('/admin/posts/{post}', [PostController::class, 'admin_update'])->name('admin.post.update');
Route::delete('/admin/posts/{post}', [PostController::class, 'admin_destroy'])->name('admin.post.destroy');
// Route::get('/admin/posts', [PostController::class, 'admin_index'])->name('admin.post.index');
// Route::get('/admin/posts/create', [PostController::class, 'admin_create'])->name('admin.post.create');
// Route::post('/admin/posts', [PostController::class, 'admin_store'])->name('admin.post.store');
// Route::get('/admin/posts/{post}', [PostController::class, 'admin_edit'])->name('admin.post.edit');
// Route::put('/admin/posts/{post}', [PostController::class, 'admin_update'])->name('admin.post.update');
// Route::delete('/admin/posts/{post}', [PostController::class, 'admin_destroy'])->name('admin.post.destroy');
Route::get('/admin/users', [UserController::class, 'index'])->name('admin.user.index');
Route::get('/admin/users/create', [UserController::class, 'create'])->name('admin.user.create');
@@ -81,11 +93,12 @@ Route::middleware('admin')->group(function () {
Route::put('/admin/users/{user}', [UserController::class, 'update'])->name('admin.user.update');
Route::delete('/admin/users/{user}', [UserController::class, 'destroy'])->name('admin.user.destroy');
Route::get('/admin/events', [EventController::class, 'admin_index'])->name('admin.event.index');
Route::get('/admin/events/create', [EventController::class, 'admin_create'])->name('admin.event.create');
Route::get('/admin/events/{event}/duplicate', [EventController::class, 'admin_duplicate'])->name('admin.event.duplicate');
Route::post('/admin/events', [EventController::class, 'admin_store'])->name('admin.event.store');
Route::get('/admin/events/{event}', [EventController::class, 'admin_edit'])->name('admin.event.edit');
Route::put('/admin/events/{event}', [EventController::class, 'admin_update'])->name('admin.event.update');
Route::delete('/admin/events/{event}', [EventController::class, 'admin_destroy'])->name('admin.event.destroy');
Route::get('/admin/workshops', [WorkshopController::class, 'admin_index'])->name('admin.workshop.index');
Route::get('/admin/workshops/create', [WorkshopController::class, 'admin_create'])->name('admin.workshop.create');
Route::get('/admin/workshops/{workshop}/duplicate', [WorkshopController::class, 'admin_duplicate'])->name('admin.workshop.duplicate');
Route::post('/admin/workshops', [WorkshopController::class, 'admin_store'])->name('admin.workshop.store');
Route::get('/admin/workshops/{workshop}', [WorkshopController::class, 'admin_edit'])->name('admin.workshop.edit');
Route::put('/admin/workshops/{workshop}', [WorkshopController::class, 'admin_update'])->name('admin.workshop.update');
Route::delete('/admin/workshops/{workshop}', [WorkshopController::class, 'admin_destroy'])->name('admin.workshop.destroy');
});