added subscriptions

This commit is contained in:
2023-01-24 16:30:49 +10:00
parent 4c83399d4a
commit 0b1fee1cdc
15 changed files with 690 additions and 73 deletions

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Filters;
use App\Models\Subscriber;
class SubscriptionFilter extends FilterAbstract
{
/**
* The model class to filter
*
* @var mixed
*/
protected $class = '\App\Models\Subscription';
/**
* Return an array of attributes visible in the results
*
* @param array $attributes Attributes currently visible.
* @param User|null $user Current logged in user or null.
* @return mixed
*/
protected function seeAttributes(array $attributes, mixed $user)
{
if ($user?->hasPermission('admin/users') !== true) {
return ['id', 'email', 'confirmed_at'];
}
}
}

View File

@@ -2,27 +2,12 @@
namespace App\Http\Controllers\Api;
use App\Enum\HttpResponseCodes;
use App\Filters\UserFilter;
use App\Http\Requests\UserUpdateRequest;
use App\Http\Requests\UserStoreRequest;
use App\Http\Requests\UserForgotPasswordRequest;
use App\Http\Requests\UserForgotUsernameRequest;
use App\Http\Requests\UserRegisterRequest;
use App\Http\Requests\UserResendVerifyEmailRequest;
use App\Http\Requests\UserResetPasswordRequest;
use App\Http\Requests\UserVerifyEmailRequest;
use App\Models\Subscription;
use App\Filters\SubscriptionFilter;
use App\Http\Requests\SubscriptionRequest;
use App\Jobs\SendEmailJob;
use App\Mail\ChangedEmail;
use App\Mail\ChangedPassword;
use App\Mail\ChangeEmailVerify;
use App\Mail\ForgotUsername;
use App\Mail\ForgotPassword;
use App\Mail\EmailVerify;
use App\Models\User;
use App\Models\UserCode;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use App\Mail\SubscriptionConfirm;
use App\Mail\SubscriptionUnsubscribed;
class SubscriptionController extends ApiController
{
@@ -32,16 +17,16 @@ class SubscriptionController extends ApiController
public function __construct()
{
$this->middleware('auth:sanctum')
->except([]);
->except(['store', 'destroyByEmail']);
}
/**
* Display a listing of the resource.
* Display a listing of subscribers.
*
* @param \App\Filters\UserFilter $filter Filter object.
* @param \App\Filters\SubscriptionFilter $filter Filter object.
* @return \Illuminate\Http\Response
*/
public function index(UserFilter $filter)
public function index(SubscriptionFilter $filter)
{
$collection = $filter->filter();
return $this->respondAsResource(
@@ -51,76 +36,97 @@ class SubscriptionController extends ApiController
}
/**
* Store a newly created user in the database.
* Store a subscriber email in the database.
*
* @param UserStoreRequest $request The user update request.
* @param SubscriptionRequest $request The subscriber update request.
* @return \Illuminate\Http\Response
*/
public function store(UserStoreRequest $request)
public function store(SubscriptionRequest $request)
{
if ($request->user()->hasPermission('admin/user') !== true) {
return $this->respondForbidden();
if (Subscription::where('email', $request->email)->first() !== null) {
return $this->respondWithErrors(['email' => 'This email address has already subscribed']);
}
$user = User::create($request->all());
return $this->respondAsResource((new UserFilter($request))->filter($user), [], HttpResponseCodes::HTTP_CREATED);
Subscription::create($request->all());
dispatch((new SendEmailJob($request->email, new SubscriptionConfirm($request->email))))->onQueue('mail');
return $this->respondCreated();
}
/**
* Display the specified user.
*
* @param UserFilter $filter The user filter.
* @param User $user The user model.
* @param SubscriptionFilter $filter The subscription filter.
* @param Subscription $subscription The subscription model.
* @return \Illuminate\Http\Response
*/
public function show(UserFilter $filter, User $user)
public function show(SubscriptionFilter $filter, Subscription $subscription)
{
return $this->respondAsResource($filter->filter($user));
return $this->respondAsResource($filter->filter($subscription));
}
/**
* Update the specified resource in storage.
*
* @param UserUpdateRequest $request The user update request.
* @param User $user The specified user.
* @param SubscriptionRequest $request The subscription update request.
* @param Subscription $subscription The specified subscription.
* @return \Illuminate\Http\Response
*/
public function update(UserUpdateRequest $request, User $user)
public function update(SubscriptionRequest $request, Subscription $subscription)
{
$input = [];
$updatable = ['username', 'first_name', 'last_name', 'email', 'phone', 'password'];
// $input = [];
// $updatable = ['username', 'first_name', 'last_name', 'email', 'phone', 'password'];
if ($request->user()->hasPermission('admin/user') === true) {
$updatable = array_merge($updatable, ['email_verified_at']);
} elseif ($request->user()->is($user) !== true) {
return $this->respondForbidden();
}
// if ($request->user()->hasPermission('admin/user') === true) {
// $updatable = array_merge($updatable, ['email_verified_at']);
// } elseif ($request->user()->is($user) !== true) {
// return $this->respondForbidden();
// }
$input = $request->only($updatable);
if (array_key_exists('password', $input) === true) {
$input['password'] = Hash::make($request->input('password'));
}
// $input = $request->only($updatable);
// if (array_key_exists('password', $input) === true) {
// $input['password'] = Hash::make($request->input('password'));
// }
$user->update($input);
// $user->update($input);
return $this->respondAsResource((new UserFilter($request))->filter($user));
// return $this->respondAsResource((new UserFilter($request))->filter($user));
}
/**
* Remove the user from the database.
*
* @param User $user The specified user.
* @param Subscription $subscription The specified subscription.
* @return \Illuminate\Http\Response
*/
public function destroy(User $user)
public function destroy(Subscription $subscription)
{
if ($user->hasPermission('admin/user') === false) {
return $this->respondForbidden();
// if ($user->hasPermission('admin/user') === false) {
// return $this->respondForbidden();
// }
$email = $subscription->email;
$subscription->delete();
return $this->respondNoContent();
}
/**
* Remove the user from the database.
*
* @param SubscriptionRequest $request The specified subscription.
* @return \Illuminate\Http\Response
*/
public function destroyByEmail(SubscriptionRequest $request)
{
$subscription = Subscription::where('email', $request->email)->first();
if ($subscription !== null) {
$subscription->delete();
dispatch((new SendEmailJob($request->email, new SubscriptionUnsubscribed($request->email))))->onQueue('mail');
}
$user->delete();
return $this->respondNoContent();
}
}

View File

@@ -40,6 +40,8 @@ class BaseRequest extends FormRequest
$rules = $this->mergeRules($rules, $this->postRules());
} elseif (method_exists($this, 'putRules') === true && request()->isMethod('put') === true) {
$rules = $this->mergeRules($rules, $this->postRules());
} elseif (method_exists($this, 'destroyRules') === true && request()->isMethod('delete') === true) {
$rules = $this->mergeRules($rules, $this->destroyRules());
}
return $rules;

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests;
use App\Rules\Recaptcha;
class SubscriptionRequest extends BaseRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function postRules()
{
return [
'email' => 'required|email',
'captcha_token' => [new Recaptcha()],
];
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function destroyRules()
{
return [
'email' => 'required|email',
'captcha_token' => [new Recaptcha()],
];
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class SubscriptionConfirm extends Mailable
{
use Queueable;
use SerializesModels;
/**
* The email address.
*
* @var string
*/
public $email;
/**
* Create a new message instance.
*
* @param string $email The email address.
* @return void
*/
public function __construct(string $email)
{
$this->email = $email;
}
/**
* Get the message envelope.
*
* @return \Illuminate\Mail\Mailables\Envelope
*/
public function envelope()
{
return new Envelope(
subject: '🗞️ You\'re on the mailing list!',
);
}
/**
* Get the message content definition.
*
* @return \Illuminate\Mail\Mailables\Content
*/
public function content()
{
return new Content(
view: 'emails.user.subscription_confirm',
text: 'emails.user.subscription_confirm_plain',
);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class SubscriptionUnsubscribed extends Mailable
{
use Queueable;
use SerializesModels;
/**
* The email address.
*
* @var string
*/
public $email;
/**
* Create a new message instance.
*
* @param string $email The email address.
* @return void
*/
public function __construct(string $email)
{
$this->email = $email;
}
/**
* Get the message envelope.
*
* @return \Illuminate\Mail\Mailables\Envelope
*/
public function envelope()
{
return new Envelope(
subject: 'You have been unsubscribed',
);
}
/**
* Get the message content definition.
*
* @return \Illuminate\Mail\Mailables\Content
*/
public function content()
{
return new Content(
view: 'emails.user.subscription_unsubscribed',
text: 'emails.user.subscription_unsubscribed_plain',
);
}
}

View File

@@ -92,33 +92,33 @@
/* Padding */
@each $index, $size in $spacer {
.p-#{$index} {
padding: #{$size};
padding: #{$size} !important;
}
.pt-#{$index} {
padding-top: #{$size};
padding-top: #{$size} !important;
}
.pb-#{$index} {
padding-bottom: #{$size};
padding-bottom: #{$size} !important;
}
.pl-#{$index} {
padding-left: #{$size};
padding-left: #{$size} !important;
}
.pr-#{$index} {
padding-right: #{$size};
padding-right: #{$size} !important;
}
.px-#{$index} {
padding-left: #{$size};
padding-right: #{$size};
padding-left: #{$size} !important;
padding-right: #{$size} !important;
}
.py-#{$index} {
padding-top: #{$size};
padding-bottom: #{$size};
padding-top: #{$size} !important;
padding-bottom: #{$size} !important;
}
}

View File

@@ -65,6 +65,14 @@ export const routes = [
},
component: () => import("@/views/Rules.vue"),
},
{
path: "/unsubscribe",
name: "unsubscribe",
meta: {
title: "Unsubscribe",
},
component: () => import("@/views/Unsubscribe.vue"),
},
{
path: "/terms",
name: "terms",

View File

@@ -110,25 +110,67 @@
Join our mailing list to receive tips, tricks and be notified of
upcoming workshops.
</p>
<div class="form-row">
<SMInput ref="email" type="email" placeholder="Email address" />
<SMButton label="Subscribe" @click="handleSubscribe" />
</div>
<SMDialog :loading="formLoading" class="p-0">
<form @submit.prevent="handleSubscribe">
<div class="form-row">
<SMMessage
v-if="formMessage.message"
:type="formMessage.type"
:message="formMessage.message"
:icon="formMessage.icon" />
<SMInput
v-model="subscribeFormData.email.value"
placeholder="Email address"
:error="subscribeFormData.email.error"
@blur="fieldValidate(subscribeFormData.email)" />
<SMCaptchaNotice />
<SMButton type="submit" label="Subscribe" />
</div>
</form>
</SMDialog>
</SMContainer>
</SMContainer>
</template>
<script setup lang="ts">
import axios from "axios";
import { ref } from "vue";
import { reactive, ref } from "vue";
import { buildUrlQuery, excerpt } from "../helpers/common";
import {
useValidation,
isValidated,
fieldValidate,
restParseErrors,
} from "../helpers/validation";
import SMInput from "../components/SMInput.vue";
import SMButton from "../components/SMButton.vue";
import SMCarousel from "../components/SMCarousel.vue";
import SMCarouselSlide from "../components/SMCarouselSlide.vue";
import SMMessage from "../components/SMMessage.vue";
import SMCaptchaNotice from "../components/SMCaptchaNotice.vue";
import SMDialog from "../components/SMDialog.vue";
import { useReCaptcha } from "vue-recaptcha-v3";
const slides = ref([]);
const email = ref(null);
const { executeRecaptcha, recaptchaLoaded } = useReCaptcha();
const subscribeFormData = reactive({
email: {
value: "",
error: "",
rules: {
required: true,
required_message: "An email address is needed.",
email: true,
email_message: "That does not appear to be an email address.",
},
},
});
const formMessage = reactive({
message: "",
type: "error",
icon: "",
});
const formLoading = ref(false);
const handleLoad = async () => {
slides.value = [];
@@ -179,10 +221,34 @@ const handleLoad = async () => {
}
};
const handleSubscribe = () => {
console.log("form");
const handleSubscribe = async () => {
formLoading.value = true;
formMessage.icon = "";
formMessage.type = "error";
formMessage.message = "";
try {
if (isValidated(subscribeFormData)) {
await recaptchaLoaded();
const captcha = await executeRecaptcha("submit");
await axios.post("subscriptions", {
email: subscribeFormData.email.value,
captcha_token: captcha,
});
subscribeFormData.email.value = "";
formMessage.type = "success";
formMessage.message = "Your email address has been subscribed.";
}
} catch (err) {
restParseErrors(subscribeFormData, [formMessage, "message"], err);
}
formLoading.value = false;
};
useValidation(subscribeFormData);
handleLoad();
</script>

View File

@@ -0,0 +1,118 @@
<template>
<SMContainer>
<SMRow>
<SMDialog narrow :loading="formLoading">
<template v-if="!formDone">
<h1>Unsubscribe</h1>
<p>
If you would like to unsubscribe from our mailing list,
you have come to the right page!
</p>
<SMMessage
v-if="formMessage.message"
:type="formMessage.type"
:message="formMessage.message"
:icon="formMessage.icon" />
<form @submit.prevent="submit">
<SMInput
v-model="formData.email.value"
name="email"
label="Email"
required
:error="formData.email.error"
@blur="fieldValidate(formData.email)" />
<SMCaptchaNotice />
<SMFormFooter>
<template #right>
<SMButton type="submit" label="Unsubscribe" />
</template>
</SMFormFooter>
</form>
</template>
<template v-else>
<h1>Unsubscribed</h1>
<p class="text-center">
You have now been unsubscribed from our newsletter.
</p>
<SMRow class="pb-2">
<SMColumn class="justify-content-center">
<SMButton :to="{ name: 'home' }" label="Home" />
</SMColumn>
</SMRow>
</template>
</SMDialog>
</SMRow>
</SMContainer>
</template>
<script setup lang="ts">
import { ref, reactive } from "vue";
import SMInput from "../components/SMInput.vue";
import SMButton from "../components/SMButton.vue";
import SMFormFooter from "../components/SMFormFooter.vue";
import SMDialog from "../components/SMDialog.vue";
import SMMessage from "../components/SMMessage.vue";
import axios from "axios";
import { useRoute } from "vue-router";
import {
useValidation,
isValidated,
fieldValidate,
restParseErrors,
} from "../helpers/validation";
import SMCaptchaNotice from "../components/SMCaptchaNotice.vue";
import { useReCaptcha } from "vue-recaptcha-v3";
const { executeRecaptcha, recaptchaLoaded } = useReCaptcha();
const formLoading = ref(false);
const formDone = ref(false);
const formMessage = reactive({
message: "",
type: "error",
icon: "",
});
const formData = reactive({
email: {
value: "",
error: "",
rules: {
required: true,
required_message: "An email address is required.",
email: true,
email_message: "That does not look like an email address.",
},
},
});
useValidation(formData);
const submit = async () => {
formLoading.value = true;
formMessage.type = "error";
formMessage.icon = "fa-solid fa-circle-exclamation";
formMessage.message = "";
try {
if (isValidated(formData)) {
await recaptchaLoaded();
const captcha = await executeRecaptcha("submit");
await axios.delete("subscriptions", {
email: formData.email.value,
captcha_token: captcha,
});
formDone.value = true;
}
} catch (err) {
restParseErrors(formData, [formMessage, "message"], err);
}
formLoading.value = false;
};
if (useRoute().query.email !== undefined) {
formData.email.value = useRoute().query.email;
submit();
}
</script>

View File

@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>STEMMechanics - Subscription</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap');
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-size: 1.1rem;
font-family: Nunito, Arial, Helvetica, sans-serif !important;
color: #000000;
padding: 2rem;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale
}
div.main {
margin: 0 auto;
background-color: #ffffff;
overflow: hidden;
}
div.footer {
margin: 2rem auto;
max-width: 48rem;
font-size: 70%;
text-align: center;
}
a.brand {
display: block;
margin-bottom: 2rem;
padding: 0 2rem;
}
a.brand:hover {
text-decoration: none;
}
h2 {
margin-bottom: 2rem;
}
p {
margin-bottom: 1rem;
}
a.brand img {
width: 100%;
max-width: 100%;
object-fit: contain;
}
.code {
display: block;
font-size: 200%;
text-align: center;
margin-top: 2rem;
margin-bottom: 2rem;
letter-spacing: 0.5rem;
}
.feedback {
font-size: 90%;
text-align: center;
}
.border {
border-top: 1px solid #ddd;
margin-bottom: 2rem;
}
a, a:visited, a:hover {
color: #2563EB;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="main">
<a href="https://www.stemmechanics.com.au/" class="brand">
<img alt="STEMMechanics Logo" src="{{ $message->embed(public_path('img').'/logo.png') }}">
</a>
<h2>Howdy there,</h2>
<p>At your request, you are now subscribed to our newsletter giving you tips, tricks and letting you know when new workshops are scheduled.</p>
<p>If this wasn't you, you can unsubscribe by visiting <a href="https://www.stemmechanics.com.au/unsubscribe?email={{ $email }}">stemmechanics.com.au/unsubscribe</a></p>
<div class="border"></div>
<p class="feedback">Need help or got feedback? <a href="https://www.stemmechanics.com.au/contact">Contact us</a> or touch base at <a href="https://twitter.com/stemmechanics">@stemmechanics</a>.</p>
</div>
<div class="footer">Sent by STEMMechanics &middot; <a href="https://www.stemmechanics.com.au/">Visit our Website</a> &middot; <a href="https://twitter.com/stemmechanics">@stemmechanics</a><br>PO Box 36, Edmonton, QLD 4869, Australia</div>
</body>
</html>

View File

@@ -0,0 +1,14 @@
Howdy there,
At your request, you are now subscribed to our newsletter giving you tips, tricks and letting you know when new workshops are scheduled.
If this wasn't you, you can unsubscribe by visiting the following URL in your browser:
https://www.stemmechanics.com.au/unsubscribe
Need help or got feedback? Contact us at https://www.stemmechanics.com.au/contact or touch base on twitter at @stemmechanics
--
Sent by STEMMechanics
https://www.stemmechanics.com.au/
PO Box 36, Edmonton, QLD 4869, Australia

View File

@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>STEMMechanics - Subscription</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap');
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-size: 1.1rem;
font-family: Nunito, Arial, Helvetica, sans-serif !important;
color: #000000;
padding: 2rem;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale
}
div.main {
margin: 0 auto;
background-color: #ffffff;
overflow: hidden;
}
div.footer {
margin: 2rem auto;
max-width: 48rem;
font-size: 70%;
text-align: center;
}
a.brand {
display: block;
margin-bottom: 2rem;
padding: 0 2rem;
}
a.brand:hover {
text-decoration: none;
}
h2 {
margin-bottom: 2rem;
}
p {
margin-bottom: 1rem;
}
a.brand img {
width: 100%;
max-width: 100%;
object-fit: contain;
}
.code {
display: block;
font-size: 200%;
text-align: center;
margin-top: 2rem;
margin-bottom: 2rem;
letter-spacing: 0.5rem;
}
.feedback {
font-size: 90%;
text-align: center;
}
.border {
border-top: 1px solid #ddd;
margin-bottom: 2rem;
}
a, a:visited, a:hover {
color: #2563EB;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="main">
<a href="https://www.stemmechanics.com.au/" class="brand">
<img alt="STEMMechanics Logo" src="{{ $message->embed(public_path('img').'/logo.png') }}">
</a>
<h2>Howdy there,</h2>
<p>At your request, you are now unsubscribed from our newsletter.</p>
<div class="border"></div>
<p class="feedback">Need help or got feedback? <a href="https://www.stemmechanics.com.au/contact">Contact us</a> or touch base at <a href="https://twitter.com/stemmechanics">@stemmechanics</a>.</p>
</div>
<div class="footer">Sent by STEMMechanics &middot; <a href="https://www.stemmechanics.com.au/">Visit our Website</a> &middot; <a href="https://twitter.com/stemmechanics">@stemmechanics</a><br>PO Box 36, Edmonton, QLD 4869, Australia</div>
</body>
</html>

View File

@@ -0,0 +1,10 @@
Howdy there,
At your request, you have been unsubscribed from our newsletter.
Need help or got feedback? Contact us at https://www.stemmechanics.com.au/contact or touch base on twitter at @stemmechanics
--
Sent by STEMMechanics
https://www.stemmechanics.com.au/
PO Box 36, Edmonton, QLD 4869, Australia

View File

@@ -7,6 +7,7 @@ use App\Http\Controllers\Api\PostController;
use App\Http\Controllers\Api\EventController;
use App\Http\Controllers\Api\MediaController;
use App\Http\Controllers\Api\ContactController;
use App\Http\Controllers\Api\SubscriptionController;
/*
|--------------------------------------------------------------------------
@@ -37,6 +38,7 @@ Route::apiResource('posts', PostController::class);
Route::apiResource('events', EventController::class);
Route::apiResource('subscriptions', SubscriptionController::class);
Route::delete('subscriptions', [SubscriptionController::class, 'destroyByEmail']);
Route::post('/contact', [ContactController::class, 'send']);