Added user view for orders with cancel and new time.
continuous-integration/drone/push Build is passing Details

Fixed some issues with wrong timeslots on the backend.
main
Filip Borum Poulsen 3 years ago
parent 38a34b3c56
commit 2ef1023a90

@ -93,7 +93,7 @@ export default {
<style scoped> <style scoped>
.popup { .popup {
position: absolute; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;

@ -0,0 +1,192 @@
<template>
<div class="popup">
<div class="UserNewTime" @click.stop>
<VDatePicker class="dateSelector" v-model.range="range" mode="date" :rules="datePickerRules" timezone="utc"
:input-debounce="50" />
<div class="dates">
<div class="date" v-for="date in dateGroupedTimeslots">
{{ date.date }}
<div class="timeslots">
<div class="timeslot" v-for="timeslot in date.timeslots" @click="selectTimeslot(timeslot)">
{{ formatTime(timeslot.startDate) }} - {{ formatTime(timeslot.endDate) }}
</div>
</div>
</div>
</div>
<div class="closeButton" @click="$emit('closePopup')">x</div>
</div>
</div>
</template>
<script lang="ts">
import type { Timeslot } from '@/interfaces/timeslot';
import type { DateGroupedTimeslotsList } from '@/interfaces/trainerWithTimeslots';
import type { Order } from '@/interfaces/order';
import type { PropType } from 'vue';
import dayjs from 'dayjs';
import LocalizedFormat from "dayjs/plugin/localizedFormat"
import utc from "dayjs/plugin/utc"
import { } from "dayjs/locale/da";
dayjs.extend(LocalizedFormat);
dayjs.extend(utc);
export default {
name: "Home",
emits: ["closePopup", "refreshList"],
props: {
order: {
type: Object as PropType<Order>,
required: true
}
},
data() {
return {
range: {
start: new Date(),
end: dayjs().add(7, "days").toDate()
},
datePickerRules: {
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0
},
timeslots: [] as Timeslot[]
}
},
computed: {
dateGroupedTimeslots() {
const dates: DateGroupedTimeslotsList = {};
this.timeslots.forEach(timeslot => {
const day: string = new Date(timeslot.startDate).toISOString().split("T")[0];
if (!dates[day]) {
dates[day] = {
date: this.formatDate(new Date(day)),
timeslots: [] as Timeslot[]
};
}
dates[day].timeslots.push(timeslot);
});
return dates;
}
},
watch: {
range: {
handler() {
this.fetchTimeslots();
},
deep: true
}
},
methods: {
async selectTimeslot(timeslot: Timeslot) {
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/order/${this.order.id}`, {
credentials: import.meta.env.DEV ? "include" : undefined,
method: "PUT",
body: JSON.stringify({ startDate: timeslot.startDate, endDate: timeslot.endDate }),
headers: {
"Content-Type": "Application/json"
}
});
if (res.status === 401) {
this.$router.push({ path: "/login", query: { ref: this.$route.path } });
}
else if (res.status === 204) {
this.$emit("refreshList");
this.$emit("closePopup");
}
},
formatDate(date: Date): string {
let output: string = dayjs(date).locale("da").format("dddd D. MMM YYYY");
let outputStringArray = output.split("");
outputStringArray[0] = outputStringArray[0].toLocaleUpperCase();
output = outputStringArray.join("");
return output;
},
formatTime(date: Date): string {
let output: string = dayjs(date).utcOffset(0).locale("da").format("HH:mm");
return output;
},
getSearchQueryFilters(): string {
const startDateFilter = `startDate=${this.range.start.toISOString()}`;
const endDateFilter = `endDate=${this.range.end.toISOString()}`;
const filters = [startDateFilter, endDateFilter].join("&");
return filters;
},
async fetchTimeslots() {
const filters = this.getSearchQueryFilters();
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/order/${this.order.id}/newtimeslots?${filters}`, {
credentials: import.meta.env.DEV ? "include" : undefined
});
if (res.status === 200) {
this.timeslots = await res.json();
}
}
}
}
</script>
<style scoped>
.popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #000a;
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
}
.UserNewTime {
background-color: white;
border-radius: 0.5rem;
padding: 30px;
min-width: 50%;
margin: 50px;
position: relative;
display: grid;
grid-template-columns: 1fr 3fr;
grid-template-rows: auto auto;
grid-column-gap: 50px;
grid-row-gap: 30px;
}
.dates {
padding: 20px;
}
.timeslots {
border-top: 1px solid #cbd5e1;
display: flex;
flex-wrap: wrap;
}
.timeslot {
padding: 5px;
border: 1px solid #cbd5e1;
margin: 5px;
cursor: pointer;
}
.timeslot:hover {
border-color: black;
}
.closeButton {
position: absolute;
top: 10px;
right: 20px;
font-size: 2em;
cursor: pointer;
}
.closeButton:hover {
text-decoration: underline;
}
</style>

@ -0,0 +1,204 @@
<template>
<div class="popup" @click="$emit('closePopup')">
<div class="Order" @click.stop v-show="!showNewTimes">
<div class="trainer">
{{ order.trainer.first_name }} {{ order.trainer.last_name }}
</div>
<div class="center">
{{ order.trainer.center_name }}
</div>
<div class="date">
{{ formatDate(order.startDate) }}
</div>
<div class="time">
{{ formatTime(order.startDate) }} -
{{ formatTime(order.endDate) }}
</div>
<div class="price">
{{ formatPrice(order.price) }}
</div>
<div class="buttons">
<div class="moveButton" @click="moveOrder()" v-if="showButtons">
Flyt tid
</div>
<div class="cancelButton" @click="cancelOrder(order.id)" v-if="showButtons">
Afbestil
</div>
</div>
<div class="closeButton" @click="$emit('closePopup')">x</div>
</div>
<UserNewTime :order="order" @closePopup="$emit('closePopup')" @refreshList="$emit('refreshList')" v-if="showNewTimes"></UserNewTime>
</div>
</template>
<script lang="ts">
import UserNewTime from '@/components/UserNewTime.vue'
import type { PropType } from 'vue';
import dayjs from 'dayjs';
import LocalizedFormat from "dayjs/plugin/localizedFormat"
import utc from "dayjs/plugin/utc"
import { } from "dayjs/locale/da";
import type { Order } from '@/interfaces/order';
dayjs.extend(LocalizedFormat);
dayjs.extend(utc);
export default {
name: "UserOrderPopup",
emits: ["closePopup", "refreshList"],
components: {
UserNewTime
},
props: {
order: {
type: Object as PropType<Order>,
required: true
}
},
data() {
return {
showNewTimes: false
};
},
computed: {
showButtons(): boolean {
console.log(this.order.status !== 'CancelledByTrainer' , this.order.status !== 'CancelledByUser' , (new Date(this.order.startDate.split("Z")[0])).getTime() > (new Date()).getTime());
console.log(this.order.startDate.split("Z")[0]);
console.log(1, new Date(this.order.startDate), new Date(this.order.startDate.split("Z")[0]));
console.log(new Date(this.order.startDate.split("Z")[0]), new Date());
return this.order.status !== 'CancelledByTrainer' && this.order.status !== 'CancelledByUser' && (new Date(this.order.startDate.split("Z")[0])).getTime() > (new Date()).getTime();
}
},
methods: {
moveOrder() {
this.showNewTimes = true;
},
async cancelOrder(id: number) {
if (id === undefined) return;
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/order/${id}/cancel`, {
credentials: import.meta.env.DEV ? "include" : undefined,
method: "POST"
});
if (res.status === 401) {
this.$router.push({ path: "/login", query: { ref: this.$route.path } });
}
else if (res.status === 204) {
this.$emit('refreshList');
this.$emit('closePopup');
}
},
formatDate(date: string): string {
let output: string = dayjs(date).locale("da").format("dddd D. MMM YYYY");
let outputStringArray = output.split("");
outputStringArray[0] = outputStringArray[0].toLocaleUpperCase();
output = outputStringArray.join("");
return output;
},
formatTime(date: string): string {
let output: string = dayjs(date).utcOffset(0).locale("da").format("HH:mm");
return output;
},
formatPrice(price: number): string {
const DanishKrone = new Intl.NumberFormat('dk', {
style: 'currency',
currency: 'DKK',
});
return DanishKrone.format(price / 100);
}
}
}
</script>
<style scoped>
.popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #000a;
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
}
.Order {
background-color: white;
border-radius: 0.5rem;
padding: 30px;
display: flex;
flex-direction: column;
min-width: 50%;
position: relative;
}
.trainer {
font-size: 1.8em;
}
.center {
opacity: 0.8;
font-size: 1.25em;
}
.date {
font-size: 1em;
}
.time {
font-size: 1em;
}
.price {
font-size: 1.25em;
}
.buttons {
align-self: flex-end;
display: flex;
gap: 20px;
}
.cancelButton {
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
padding: 10px;
cursor: pointer;
font-size: 1.25em;
}
.cancelButton:hover {
text-decoration: underline;
border-color: black;
}
.moveButton {
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
padding: 10px;
cursor: pointer;
font-size: 1.25em;
}
.moveButton:hover {
text-decoration: underline;
border-color: black;
}
.closeButton {
position: absolute;
top: 10px;
right: 20px;
font-size: 2em;
cursor: pointer;
}
.closeButton:hover {
text-decoration: underline;
}
</style>

@ -0,0 +1,11 @@
import type { Trainer } from "./trainer"
export interface Order {
id: number
startDate: string
endDate: string
createdAt: string
status: string
price: number
trainer: Trainer
}

@ -38,8 +38,8 @@
padding: 50px; padding: 50px;
} }
.dateSelector { .Home :deep(.dateSelector) {
grid-area: 1 / 1 / 2 / 2; grid-area: 1 / 1 / 4 / 2;
} }
.centerSelector { .centerSelector {

@ -1,23 +1,140 @@
<template> <template>
<div class="Orders"> <div class="Orders">
orders <div class="order" v-for="order of orders" :key="order.id" @click="openPopup(order)"
:class="{ cancelled: order.status === 'CancelledByTrainer' || order.status === 'CancelledByUser' }">
<div class="trainer">
{{ order.trainer.first_name }} {{ order.trainer.last_name }}
</div>
<div class="center">
{{ order.trainer.center_name }}
</div>
<div class="time">
{{ formatDate(order.startDate) }} {{ formatTime(order.startDate) }} - {{ formatTime(order.endDate) }}
</div>
<div class="status">
{{ formatStatus(order.status) }}
</div>
</div>
<UserOrderPopup @closePopup="closePopup" @refreshList="fetchOrders" :order="selectedOrder" v-if="showPopup">
</UserOrderPopup>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import UserOrderPopup from '@/components/UserOrderPopup.vue'
import type { Order } from '@/interfaces/order';
import dayjs from 'dayjs';
import LocalizedFormat from "dayjs/plugin/localizedFormat"
import utc from "dayjs/plugin/utc"
import { } from "dayjs/locale/da";
dayjs.extend(LocalizedFormat);
dayjs.extend(utc);
export default { export default {
name: "UserOrdersView", name: "UserOrdersView",
data() {
return {
orders: [] as Order[],
showPopup: false,
selectedOrder: {} as Order
};
},
components: {
UserOrderPopup
},
methods: { methods: {
closePopup() {
this.showPopup = false;
},
openPopup(order: Order) {
this.selectedOrder = order;
this.showPopup = true;
},
async fetchOrders() { async fetchOrders() {
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/orders`, { const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/order`, {
credentials: import.meta.env.DEV ? "include" : undefined credentials: import.meta.env.DEV ? "include" : undefined
}); });
if (res.status === 401 || res.status === 403) { if (res.status === 401 || res.status === 403) {
this.$router.push({ path: "/login", query: { ref: this.$route.path } }); this.$router.push({ path: "/login", query: { ref: this.$route.path } });
} }
if (res.status === 200) {
this.orders = await res.json();
}
},
formatDate(date: string): string {
let output: string = dayjs(date).locale("da").format("dddd D. MMM YYYY");
let outputStringArray = output.split("");
outputStringArray[0] = outputStringArray[0].toLocaleUpperCase();
output = outputStringArray.join("");
return output;
},
formatTime(date: string): string {
let output: string = dayjs(date).utcOffset(0).locale("da").format("HH:mm");
return output;
},
formatStatus(status: string): string {
switch (status) {
case "Successful":
return "Bestilt"
case "Processing":
return "Afventer betaling"
case "CancelledByTrainer":
return "Aflyst af træneren"
case "CancelledByUser":
return "Aflyst af dig"
default:
return "";
}
} }
} },
created() {
this.fetchOrders();
},
} }
</script> </script>
<style scoped></style> <style scoped>
.Orders {
display: flex;
/* flex-direction: column; */
align-items: center;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
width: 100%;
}
.order {
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
padding: 30px;
display: flex;
flex-direction: column;
cursor: pointer;
}
.order:hover {
border: 1px solid black;
}
.trainer {
font-size: 1.8em;
}
.center {
opacity: 0.8;
font-size: 1.25em;
}
.order.cancelled .status {
color: lightcoral;
}
</style>

@ -27,6 +27,10 @@ if (process.env.STRIPE_WEBHOOK_SECRET === undefined) {
throw ("The environment variable 'STRIPE_WEBHOOK_SECRET' has not been set"); throw ("The environment variable 'STRIPE_WEBHOOK_SECRET' has not been set");
} }
if (process.env.BASE_URL === undefined) {
throw ("The environment variable 'BASE_URL' has not been set");
}
export const public_key: string = fs.readFileSync(process.env.PUBLIC_KEY_LOCATION, { encoding: "utf-8" }); export const public_key: string = fs.readFileSync(process.env.PUBLIC_KEY_LOCATION, { encoding: "utf-8" });
export const private_key: string = fs.readFileSync(process.env.PRIVATE_KEY_LOCATION, { encoding: "utf-8" }); export const private_key: string = fs.readFileSync(process.env.PRIVATE_KEY_LOCATION, { encoding: "utf-8" });
@ -36,3 +40,6 @@ export const port: string = process.env.PORT;
export const stripePrivateKey: string = process.env.STRIPE_SECRET_KEY; export const stripePrivateKey: string = process.env.STRIPE_SECRET_KEY;
export const stripePublicKey: string = process.env.STRIPE_PUBLIC_KEY; export const stripePublicKey: string = process.env.STRIPE_PUBLIC_KEY;
export const stripeWebhookSecret: string = process.env.STRIPE_WEBHOOK_SECRET; export const stripeWebhookSecret: string = process.env.STRIPE_WEBHOOK_SECRET;
export const baseURL: string = process.env.BASE_URL;

@ -14,6 +14,7 @@ import { AuthedRequest } from "../interfaces/auth";
import Stripe from 'stripe'; import Stripe from 'stripe';
import { stripe } from "../stripe"; import { stripe } from "../stripe";
import Trainer from "../interfaces/trainer"; import Trainer from "../interfaces/trainer";
import { baseURL } from "../environment";
dayjs.extend(isoWeek) dayjs.extend(isoWeek)
dayjs.extend(utc) dayjs.extend(utc)
@ -174,8 +175,8 @@ router.post("/order", UserAuth, async (req: AuthedRequest, res: Response) => {
], ],
expires_at: Math.floor(Date.now()/1000 + 60*31), expires_at: Math.floor(Date.now()/1000 + 60*31),
mode: 'payment', mode: 'payment',
success_url: `http://localhost:5173/orders`, success_url: `${baseURL}/user/orders`,
cancel_url: `http://localhost:5173`, cancel_url: `${baseURL}`,
customer_email: email customer_email: email
}); });

@ -83,6 +83,11 @@ router.put("/order/:id", UserAuth, async (req: AuthedRequest, res: Response) =>
return res.status(400).send(validation.error.details); return res.status(400).send(validation.error.details);
} }
const dateValidation = Joi.date().min(Date.now()).validate(order.start_time)
if (dateValidation.error !== undefined) {
return res.status(400).send({ message: "Timeslot has already occurred" });
}
const orderBody: OrderBody = validation.value; const orderBody: OrderBody = validation.value;
const startDate: dayjs.Dayjs = dayjs(orderBody.startDate).utcOffset(0); const startDate: dayjs.Dayjs = dayjs(orderBody.startDate).utcOffset(0);
@ -105,10 +110,15 @@ router.put("/order/:id", UserAuth, async (req: AuthedRequest, res: Response) =>
) as weekly_timeslot_available, ) as weekly_timeslot_available,
EXISTS( EXISTS(
SELECT 1 FROM public.reserved_timeslots SELECT 1 FROM public.reserved_timeslots
JOIN orders ON reserved_timeslots.id = orders.timeslot_id
WHERE WHERE
((start_time >= $5 AND start_time < $6) ((start_time >= $5 AND start_time < $6)
OR (end_time > $5 AND end_time <= $6)) OR (end_time > $5 AND end_time <= $6))
AND trainer_id = $1 AND trainer_id = $1
AND (
orders.order_status = 'Confirmed'
OR orders.order_status = 'Created'
)
) as time_already_reserved; ) as time_already_reserved;
`, [ `, [
order.trainer_id, order.trainer_id,

@ -61,11 +61,6 @@ router.get("/order/:id/newtimeslots", UserAuth, async (req: AuthedRequest, res:
const order: OrderLookup = lookupResult.rows[0]; const order: OrderLookup = lookupResult.rows[0];
const dateValiadtion = Joi.date().min(Date.now()).validate(order.start_time)
if (dateValiadtion.error !== undefined) {
return res.status(400).send({ message: "Timeslot has already occurred" });
}
const filterValidation = newTimeslotPropsalFilterSchema.validate(req.query, { abortEarly: false, stripUnknown: true }); const filterValidation = newTimeslotPropsalFilterSchema.validate(req.query, { abortEarly: false, stripUnknown: true });
if (filterValidation.error !== undefined) { if (filterValidation.error !== undefined) {
return res.status(400).send(filterValidation.error.details); return res.status(400).send(filterValidation.error.details);
@ -157,6 +152,9 @@ router.get("/order/:id/newtimeslots", UserAuth, async (req: AuthedRequest, res:
} }
} }
if (startTime.isBefore(dayjs()))
continue weeklyTimeslots;
const proposedDuration: number = startTime.diff(endTime); const proposedDuration: number = startTime.diff(endTime);
if (proposedDuration !== originalDuration) if (proposedDuration !== originalDuration)
continue; continue;

@ -182,6 +182,10 @@ router.get("/timeslot", async (req: Request, res: Response) => {
} }
const startTime = dayjs(`${day.toISOString().split("T")[0]}T${timeslot.start_time}`); const startTime = dayjs(`${day.toISOString().split("T")[0]}T${timeslot.start_time}`);
const endTime = dayjs(`${day.toISOString().split("T")[0]}T${timeslot.end_time}`); const endTime = dayjs(`${day.toISOString().split("T")[0]}T${timeslot.end_time}`);
if (startTime.isBefore(dayjs()))
continue timeslots;
for (const reservedTimeslot of reservedTimeslots) { for (const reservedTimeslot of reservedTimeslots) {
const reservedTimeStart = dayjs(reservedTimeslot.start_time); const reservedTimeStart = dayjs(reservedTimeslot.start_time);
const reservedTimeEnd = dayjs(reservedTimeslot.end_time); const reservedTimeEnd = dayjs(reservedTimeslot.end_time);

Loading…
Cancel
Save