Repository: ArcoMul/leihbase
Pull request #49: Reservation overview
Author: login: ArcoMul
Created: 2025-05-16T10:41:11Z | Merged: 2025-05-23T10:58:26Z

**TODO**
- [x] Reservation overview page
- [x] Cancellation flow
- [x] Update e-mail templates 
- [x] Cancellation confirmation e-mail
- [x] Make accordion accissible


Diff:
diff --git a/app/assets/styles/main.scss b/app/assets/styles/main.scss
index 9c60bc4..521a259 100644
--- a/app/assets/styles/main.scss
+++ b/app/assets/styles/main.scss
@@ -57,6 +57,7 @@
     --spacing-7: 1.75rem;
     --spacing-8: 2rem;
 
+    --fluid-spacing-2: clamp(0.125rem, 0.75vw, 0.25rem);
     --fluid-spacing-4: clamp(0.5rem, 2vw, 1rem);
     --fluid-spacing-8: clamp(1rem, 4.5vw, 2rem);
     --fluid-spacing-10: clamp(1.5rem, 4.5vw, 2.5rem);
diff --git a/app/components/Accordion/Accordion.model.ts b/app/components/Accordion/Accordion.model.ts
new file mode 100644
index 0000000..8499d48
--- /dev/null
+++ b/app/components/Accordion/Accordion.model.ts
@@ -0,0 +1,4 @@
+export const openKey = Symbol() as InjectionKey<Ref<string[]>>;
+export const idKey = Symbol() as InjectionKey<string>;
+export const isOpenKey = Symbol() as InjectionKey<ComputedRef<boolean>>;
+export const toggleKey = Symbol() as InjectionKey<(id: string) => void>;
diff --git a/app/components/Accordion/Accordion.vue b/app/components/Accordion/Accordion.vue
new file mode 100644
index 0000000..5d7e81d
--- /dev/null
+++ b/app/components/Accordion/Accordion.vue
@@ -0,0 +1,40 @@
+<template>
+  <div>
+    <slot></slot>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { toggleKey, openKey } from "./Accordion.model";
+
+const { single } = defineProps<{
+  // If only one accordion item can be open at the same time
+  single?: boolean;
+}>();
+
+const open = ref<string[]>([]);
+
+function toggle(id: string) {
+  const index = open.value.indexOf(id);
+  if (index === -1) {
+    if (single) {
+      open.value = [id];
+    } else {
+      open.value.push(id);
+    }
+    return;
+  }
+  open.value.splice(index, 1);
+}
+
+provide(openKey, open);
+provide(toggleKey, toggle);
+</script>
+
+<style scoped>
+div {
+  display: grid;
+  grid-auto-rows: auto;
+  gap: var(--fluid-spacing-2);
+}
+</style>
diff --git a/app/components/Accordion/AccordionContent.vue b/app/components/Accordion/AccordionContent.vue
new file mode 100644
index 0000000..fb8325c
--- /dev/null
+++ b/app/components/Accordion/AccordionContent.vue
@@ -0,0 +1,36 @@
+<template>
+  <div
+    :id="id + '-content'"
+    :aria-labelledby="id + '-trigger'"
+    :class="{ content: true, open: isOpen }"
+  >
+    <div>
+      <slot></slot>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { idKey, isOpenKey } from "./Accordion.model";
+
+const id = inject(idKey);
+const isOpen = inject(isOpenKey);
+</script>
+
+<style scoped>
+div.content {
+  display: grid;
+  grid-template-rows: 0fr;
+  overflow: hidden;
+  transition: grid-template-rows 200ms, visibility 200ms;
+  visibility: hidden;
+}
+div.content.open {
+  grid-template-rows: 1fr;
+  visibility: visible;
+}
+div.content > div {
+  padding: var(--fluid-spacing-4) 0 var(--fluid-spacing-8) 0;
+  overflow: hidden;
+}
+</style>
diff --git a/app/components/Accordion/AccordionItem.vue b/app/components/Accordion/AccordionItem.vue
new file mode 100644
index 0000000..e6a851a
--- /dev/null
+++ b/app/components/Accordion/AccordionItem.vue
@@ -0,0 +1,16 @@
+<template>
+  <slot></slot>
+</template>
+
+<script setup lang="ts">
+import { idKey, isOpenKey, openKey } from "./Accordion.model";
+
+const { id } = defineProps<{ id: string }>();
+
+const open = inject(openKey);
+provide(idKey, id);
+provide(
+  isOpenKey,
+  computed(() => !!open?.value.includes(id))
+);
+</script>
diff --git a/app/components/Accordion/AccordionTrigger.vue b/app/components/Accordion/AccordionTrigger.vue
new file mode 100644
index 0000000..ff09a3c
--- /dev/null
+++ b/app/components/Accordion/AccordionTrigger.vue
@@ -0,0 +1,41 @@
+<template>
+  <Button
+    variant="secondary"
+    :id="id + '-trigger'"
+    :class="{ open: isOpen }"
+    :disabled="disabled"
+    :aria-expanded="isOpen ? 'true' : 'false'"
+    :aria-controls="id + '-content'"
+    @click="toggle!(id!)"
+  >
+    <slot></slot>
+    <NavArrowDown />
+  </Button>
+</template>
+
+<script setup lang="ts">
+import { toggleKey, idKey, isOpenKey } from "./Accordion.model";
+import { NavArrowDown } from "@iconoir/vue";
+
+defineProps<{
+  // If the accordion trigger is disabled, not interactable
+  disabled: boolean;
+}>();
+
+const id = inject(idKey);
+const isOpen = inject(isOpenKey);
+const toggle = inject(toggleKey);
+</script>
+
+<style scoped>
+button {
+  width: 100%;
+  justify-content: space-between;
+}
+button svg {
+  transition: transform 200ms;
+}
+button.open svg {
+  transform: scaleY(-1);
+}
+</style>
diff --git a/app/components/Button.vue b/app/components/Button.vue
index 7eab99a..ac0e1b2 100644
--- a/app/components/Button.vue
+++ b/app/components/Button.vue
@@ -67,6 +67,9 @@ button {
     border: 0;
     color: var(--primary-text-color);
     font-weight: var(--font-weight-semibold);
+    &:hover {
+      background-color: color-mix(in srgb, var(--primary-color) 80%, #000);
+    }
   }
   &.variant-secondary {
     background-color: var(--secondary-color);
diff --git a/app/components/Card.vue b/app/components/Card.vue
index 9bfdcc3..c35c4ae 100644
--- a/app/components/Card.vue
+++ b/app/components/Card.vue
@@ -1,14 +1,20 @@
 <template>
-  <div><slot></slot></div>
+  <div :class="{ padding }"><slot></slot></div>
 </template>
 
+<script lang="ts" setup>
+withDefaults(defineProps<{ padding: boolean }>(), { padding: true });
+</script>
+
 <style scoped>
 div {
-  padding: var(--fluid-spacing-12);
   background-color: color-mix(in srgb, var(--background-color) 85%, #fff);
   border-radius: calc(var(--border-radius) * 2);
   border-color: color-mix(in srgb, var(--background-color) 90%, #000);
   border-width: 1px;
   border-style: solid;
+  &.padding {
+    padding: var(--fluid-spacing-12);
+  }
 }
 </style>
diff --git a/app/components/Dialog.vue b/app/components/Dialog.vue
index 8988e88..639e69c 100644
--- a/app/components/Dialog.vue
+++ b/app/components/Dialog.vue
@@ -1,27 +1,21 @@
 <template>
-  <div
-    :class="{
-      background: true,
-      'header-offset': headerOffset,
-      open,
-      'was-open': wasOpen,
-    }"
-    @click="open = false"
-  ></div>
-  <div
+  <dialog
+    ref="dialog"
     data-base-theme
     :class="{
       dialog: true,
-      'header-offset': headerOffset,
       open,
       'was-open': wasOpen,
       inset,
     }"
+    :aria-labelledby="id + '-title'"
+    @close="close"
   >
     <header>
       <div class="title">
-        <h2>{{ title }}</h2>
-        <button @click="open = false">
+        <h2 :id="id + '-title'">{{ title }}</h2>
+        <button @click="close">
+          <span class="sr-only">{{ t("close") }}</span>
           <Xmark />
         </button>
       </div>
@@ -30,101 +24,91 @@
     <div class="body">
       <slot></slot>
     </div>
-  </div>
+  </dialog>
 </template>
 
 <script lang="ts" setup>
 import { Xmark } from "@iconoir/vue";
+import { templateRef } from "@vueuse/core";
+
+const { t } = useI18n({
+  useScope: "local",
+});
+
+const id = useId();
 
 const wasOpen = ref(false);
+const dialog = templateRef("dialog");
 
 defineProps<{
   inset: boolean;
   title: string;
-  headerOffset?: boolean;
 }>();
 
-const open = defineModel<boolean>("open");
+const emit = defineEmits<{ close: [] }>();
 
+const open = defineModel<boolean>("open");
 watch(open, (newValue) => {
+  if (newValue) {
+    dialog.value.showModal();
+  } else {
+    dialog.value.close();
+    emit("close");
+  }
   wasOpen.value = newValue || wasOpen.value;
 });
-</script>
 
-<style lang="scss" scoped>
-@use "~/assets/styles/breakpoints.scss";
-
-.background {
-  position: fixed;
-  left: 0;
-  top: 0;
-  width: 100%;
-  height: 100%;
-  z-index: 30;
-  background-color: black;
-  animation-name: background-out;
-  animation-duration: 0;
-  animation-fill-mode: both;
-  visibility: hidden;
-  opacity: 0;
-  &.header-offset {
-    top: var(--navbar-height);
-    height: calc(100% - var(--navbar-height));
-  }
-  &.was-open {
-    animation-duration: 0.2s;
-  }
-  &.open {
-    animation-name: background-in;
-  }
-}
+onMounted(() => {
+  dialog.value.addEventListener("click", (e: MouseEvent) => {
+    if (e.target === dialog.value) {
+      open.value = false;
+    }
+  });
+});
 
-@keyframes background-in {
-  from {
-    visibility: hidden;
-    opacity: 0;
-  }
-  to {
-    visibility: visible;
-    opacity: 0.2;
-  }
+function close() {
+  open.value = false;
 }
+</script>
 
-@keyframes background-out {
-  from {
-    visibility: visible;
-    opacity: 0.2;
-  }
-  to {
-    visibility: hidden;
-    opacity: 0;
-  }
-}
+<style lang="scss" scoped>
+@use "~/assets/styles/breakpoints.scss";
 
-.dialog {
-  position: fixed;
+dialog {
   left: 50%;
   top: -100%;
   transform: translate(-50%, -50%);
   width: min(600px, 100%);
-  background-color: white;
-  z-index: 40;
-  height: 100vh;
+  height: 100%;
+  max-height: 100%;
+  max-width: 100%;
   display: flex;
   flex-direction: column;
+  z-index: 40;
+  overflow-y: visible;
+  padding: 0;
+  background-color: white;
+  border: 0;
   border-radius: var(--border-radius);
   animation-name: dialog-out;
   animation-duration: 0s;
   animation-fill-mode: both;
   visibility: hidden;
-  &.header-offset {
-    top: calc(-100% + var(--navbar-height));
-    max-height: calc(90vh - var(--navbar-height));
+
+  // Back-drop
+  &::backdrop {
+    background-color: black;
+    opacity: 0;
+    transition: opacity 200ms;
+  }
+  &[open]::backdrop {
+    opacity: 0.2;
   }
+
   &.was-open {
     animation-duration: 0.2s;
   }
-  &.open {
+  &[open] {
     animation-name: dialog-in;
   }
   &.inset {
@@ -141,7 +125,7 @@ watch(open, (newValue) => {
     }
   }
   @media screen and (min-width: breakpoints.$breakpoint-sm) {
-    height: auto;
+    height: min-content;
     max-height: 95vh;
     max-width: 95vw;
   }
@@ -191,3 +175,14 @@ header {
   overflow-y: scroll;
 }
 </style>
+
+<i18n lang="json">
+{
+  "en": {
+    "close": "Close"
+  },
+  "de": {
+    "close": "Schließen"
+  }
+}
+</i18n>
diff --git a/app/components/admin/AdminReservationsTable.vue b/app/components/admin/AdminReservationsTable.vue
index 2aab7ce..5c54469 100644
--- a/app/components/admin/AdminReservationsTable.vue
+++ b/app/components/admin/AdminReservationsTable.vue
@@ -70,7 +70,7 @@
             <Check v-if="reservation.ended" class="check" />
             <Xmark v-else class="xmark" />
           </td>
-          <td>€{{ reservation.deposit }}</td>
+          <td>{{ formatCurrency(reservation.deposit, locale) }}</td>
           <td>
             <Tooltip v-if="reservation.note" :html="reservation.note">
               <MessageText class="note-icon" />
@@ -86,6 +86,7 @@
 import { formatDate, isSameDate } from "~/lib/date";
 import type { Reservation } from "~/models/reservation";
 import { Check, Xmark, MessageText } from "@iconoir/vue";
+import { formatCurrency } from "~/lib/currency";
 
 const { t, locale } = useI18n({
   useScope: "local",
diff --git a/app/components/modules/NavBar.vue b/app/components/modules/NavBar.vue
index 42e1c24..d7dddf3 100644
--- a/app/components/modules/NavBar.vue
+++ b/app/components/modules/NavBar.vue
@@ -42,11 +42,11 @@
                 {{ t("profile") }}
               </DropdownMenuItem>
             </li>
-            <!-- <li v-if="isValid">
+            <li v-if="isValid">
               <NuxtLink to="/reservations">
                 {{ t("reservations") }}
               </NuxtLink>
-            </li> -->
+            </li>
             <li v-if="isValid && userStore.isAdmin">
               <DropdownMenuItem :as="NuxtLink" to="/admin">
                 {{ t("admin") }}
diff --git a/app/composables/useReservationCancellation.ts b/app/composables/useReservationCancellation.ts
new file mode 100644
index 0000000..8fb43a4
--- /dev/null
+++ b/app/composables/useReservationCancellation.ts
@@ -0,0 +1,57 @@
+import type { Reservation } from "~/models/reservation";
+import type { Location } from "~/models/location";
+
+export enum ReservationCancellationStatus {
+  Default = "default",
+  Loading = "loading",
+  Success = "success",
+  Error = "error",
+}
+
+export function useReservationCancellation(
+  reservation: () => Reservation | undefined,
+  location: () => Location | undefined,
+  onCancel: () => void
+) {
+  const { pb } = usePocketbase();
+  const { t } = useI18n();
+
+  const message = ref();
+  const status = ref<ReservationCancellationStatus>(
+    ReservationCancellationStatus.Default
+  );
+
+  watch(reservation, () => {
+    status.value = ReservationCancellationStatus.Default;
+  });
+
+  async function handleReservationCancel() {
+    const _reservation = reservation();
+    const _location = location();
+    if (!_reservation || !_location) {
+      console.error(
+        "Trying to handleReservationCancel without reservation or location"
+      );
+      return;
+    }
+    status.value = ReservationCancellationStatus.Loading;
+    try {
+      await pb.collection("reservations").update<Reservation>(_reservation.id, {
+        cancelled: true,
+      });
+      status.value = ReservationCancellationStatus.Success;
+      message.value = t("cancellation_success_message");
+      onCancel();
+    } catch (e) {
+      status.value = ReservationCancellationStatus.Error;
+      message.value = t("cancellation_unknown_error_message", [
+        _location.email,
+      ]);
+    }
+  }
+  return {
+    message,
+    status,
+    cancel: handleReservationCancel,
+  };
+}
diff --git a/app/i18n/i18n.config.ts b/app/i18n/i18n.config.ts
index 79f2539..c3018be 100644
--- a/app/i18n/i18n.config.ts
+++ b/app/i18n/i18n.config.ts
@@ -39,6 +39,10 @@ export default defineI18nConfig(() => ({
       previous_month: "Previous Month",
       next_month: "Next Month",
       closed_at: "Closed at",
+      // reservation cancellation
+      cancellation_success_message: "Your reservation has been cancelled.",
+      cancellation_unknown_error_message:
+        "Something went wrong while cancelling your reservation. Please try again, or send an e-mail to {0}.",
     },
     de: {
       week_days: {
@@ -77,6 +81,11 @@ export default defineI18nConfig(() => ({
       previous_month: "Vorheriger Monat",
       next_month: "Nächster Monat",
       closed_at: "Zu am",
+      // reservation cancellation
+      cancellation_success_message:
+        "Deine Reservierung ist erfolgreich storniert.",
+      cancellation_unknown_error_message:
+        "Beim stornieren deine Reservierung ist was schief gelaufen. Bitte versuche es erneut, oder schicke eine E-Mail an {0}.",
     },
   },
 }));
diff --git a/app/lib/reservation.ts b/app/lib/reservation.ts
index 1fcee1b..f24fca6 100644
--- a/app/lib/reservation.ts
+++ b/app/lib/reservation.ts
@@ -1,4 +1,5 @@
 import { DateTime } from "luxon";
+import { ReservationStatus, type Reservation } from "~/models/reservation";
 
 const startOfToday = DateTime.now().startOf("day");
 const endOfToday = DateTime.now().startOf("day");
@@ -10,3 +11,15 @@ export function isToday(reservation: { start: string; end: string }): boolean {
     DateTime.fromJSDate(new Date(reservation.end)).startOf("day") >= endOfToday
   );
 }
+
+export function getReservationStatus(reservation: Reservation) {
+  if (reservation.cancelled) return ReservationStatus.Cancelled;
+  if (reservation.ended) return ReservationStatus.Ended;
+  if (reservation.started) {
+    if (new Date(reservation.end) < startOfToday) {
+      return ReservationStatus.Overdue;
+    }
+    return ReservationStatus.Started;
+  }
+  return ReservationStatus.New;
+}
diff --git a/app/pages/reservations/_components/ReservationCardButton.vue b/app/pages/reservations/_components/ReservationCardButton.vue
new file mode 100644
index 0000000..158b008
--- /dev/null
+++ b/app/pages/reservations/_components/ReservationCardButton.vue
@@ -0,0 +1,100 @@
+<template>
+  <Button
+    variant="secondary"
+    :class="['card', 'status-' + getReservationStatus(reservation)]"
+  >
+    <img
+      :src="`${config.public.pocketbase.clientBaseUrl}/api/files/products/${reservation.product}/${product.images[0]}${thumbs.sm}`"
+    />
+    <div class="details">
+      <h2>{{ product?.name }}</h2>
+      <p>
+        {{ formatDate(reservation.start, DateTime.DATE_MED, locale) }}
+        -
+        {{ formatDate(reservation.end, DateTime.DATE_MED, locale) }}
+      </p>
+      <span class="tag">
+        {{ t(getReservationStatus(reservation)) }}
+      </span>
+    </div>
+  </Button>
+</template>
+
+<script setup lang="ts">
+import { DateTime } from "luxon";
+import { formatDate } from "~/lib/date";
+import { type Reservation } from "~/models/reservation";
+import type { Product } from "~/models/product";
+import { getReservationStatus } from "~/lib/reservation";
+
+const config = useRuntimeConfig();
+
+const {
+  product: { thumbs },
+} = useAppConfig();
+
+const { t, locale } = useI18n({
+  useScope: "local",
+});
+
+defineProps<{ reservation: Reservation; product?: Product }>();
+</script>
+
+<style scoped lang="scss">
+.card {
+  display: flex;
+  padding: 0.5rem !important;
+  gap: var(--fluid-spacing-4);
+  text-align: left;
+  width: 100%;
+  align-items: stretch;
+  h2 {
+    font-size: 1rem;
+    margin: 0;
+  }
+  img {
+    width: 7rem;
+    height: 7rem;
+    object-fit: cover;
+    border-radius: var(--border-radius);
+    flex-shrink: 0;
+  }
+  .details {
+    width: 100%;
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+  }
+  p {
+    margin: 0;
+  }
+  .tag {
+    border: 1px solid var(--text-color-light);
+    color: var(--text-color);
+    padding: 0.25rem 0.333rem;
+    font-size: var(--font-size-sm);
+    line-height: 1;
+    border-radius: var(--border-radius);
+    margin-top: auto;
+  }
+}
+</style>
+
+<i18n lang="json">
+{
+  "en": {
+    "new": "New",
+    "cancelled": "Cancelled",
+    "started": "Picked-up",
+    "ended": "Returned",
+    "overdue": "Overdue"
+  },
+  "de": {
+    "new": "Neu",
+    "cancelled": "Storniert",
+    "started": "Abgehollt",
+    "ended": "Zurückgebracht",
+    "overdue": "Überfällig"
+  }
+}
+</i18n>
diff --git a/app/pages/reservations/_components/ReservationDetailDialog.vue b/app/pages/reservations/_components/ReservationDetailDialog.vue
new file mode 100644
index 0000000..3a00397
--- /dev/null
+++ b/app/pages/reservations/_components/ReservationDetailDialog.vue
@@ -0,0 +1,217 @@
+<template>
+  <Dialog inset title="Reservierung" v-model:open="open">
+    <div v-if="reservation && product && location" class="product">
+      <img
+        :src="`${config.public.pocketbase.clientBaseUrl}/api/files/products/${product.id}/${product.images[0]}${thumbs.sm}`"
+      />
+      <div>
+        <p>
+          <strong>{{ t("product") }}</strong>
+          <br />
+          <a :href="`/link/product/${product.id}`" target="_blank">
+            {{ product.name }}
+          </a>
+        </p>
+        <p>
+          <strong>{{ t("location") }}</strong>
+          <br />
+          <a :href="`/l/${location.slug}`" target="_blank">
+            {{ location.name }}
+          </a>
+        </p>
+        <p>
+          <strong>{{ t("borrow_period") }}</strong>
+          <br />
+          {{ formatDate(reservation.start, DateTime.DATE_MED, locale) }}
+          -
+          {{ formatDate(reservation.end, DateTime.DATE_MED, locale) }}
+        </p>
+        <p
+          v-if="
+            status !== ReservationStatus.New &&
+            status !== ReservationStatus.Cancelled
+          "
+        >
+          <strong>{{ t("deposit") }}</strong>
+          <br />
+          {{ formatCurrency(reservation.deposit, locale) }}
+        </p>
+      </div>
+    </div>
+
+    <Accordion v-if="reservation" :single="true">
+      <AccordionItem id="reservation-dialog-cancel">
+        <AccordionTrigger
+          :disabled="
+            status === ReservationStatus.Ended ||
+            status === ReservationStatus.Overdue ||
+            status === ReservationStatus.Cancelled
+          "
+        >
+          {{ t("cancel_trigger") }}
+        </AccordionTrigger>
+        <AccordionContent>
+          <div v-if="status === ReservationStatus.New">
+            <p>{{ t("cancel_text") }}</p>
+            <Button
+              v-if="
+                cancellationStatus === ReservationCancellationStatus.Default ||
+                cancellationStatus === ReservationCancellationStatus.Loading
+              "
+              :loading="
+                cancellationStatus === ReservationCancellationStatus.Loading
+              "
+              @click="cancel"
+            >
+              {{ t("cancel_button") }}
+            </Button>
+            <Alert
+              v-else-if="
+                cancellationStatus === ReservationCancellationStatus.Success
+              "
+              variant="success"
+            >
+              {{ cancellationMessage }}
+            </Alert>
+            <Alert
+              v-else-if="
+                cancellationStatus === ReservationCancellationStatus.Error
+              "
+              variant="error"
+            >
+              {{ cancellationMessage }}
+            </Alert>
+          </div>
+          <div v-else-if="status === ReservationStatus.Started">
+            <p>
+              {{ t("cancel_not_possible_text", [location?.email]) }}
+            </p>
+          </div>
+        </AccordionContent>
+      </AccordionItem>
+      <AccordionItem id="reservation-dialog-change-period">
+        <AccordionTrigger
+          :disabled="
+            status === ReservationStatus.Ended ||
+            status === ReservationStatus.Overdue ||
+            status === ReservationStatus.Cancelled
+          "
+          >{{ t("change_period_trigger") }}</AccordionTrigger
+        >
+        <AccordionContent>
+          <p>{{ t("change_period_text", [location?.email]) }}</p>
+        </AccordionContent>
+      </AccordionItem>
+    </Accordion>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { DateTime } from "luxon";
+import Accordion from "~/components/Accordion/Accordion.vue";
+import AccordionContent from "~/components/Accordion/AccordionContent.vue";
+import AccordionItem from "~/components/Accordion/AccordionItem.vue";
+import AccordionTrigger from "~/components/Accordion/AccordionTrigger.vue";
+import {
+  ReservationCancellationStatus,
+  useReservationCancellation,
+} from "~/composables/useReservationCancellation";
+import { formatDate } from "~/lib/date";
+import { getReservationStatus } from "~/lib/reservation";
+import { formatCurrency } from "~/lib/currency";
+import type { Location } from "~/models/location";
+import type { Product } from "~/models/product";
+import { ReservationStatus, type Reservation } from "~/models/reservation";
+
+const config = useRuntimeConfig();
+const {
+  product: { thumbs },
+} = useAppConfig();
+
+const { t, locale } = useI18n({
+  useScope: "local",
+});
+
+const props = defineProps<{
+  reservation?: Reservation;
+  product?: Product;
+  location?: Location;
+}>();
+
+const emit = defineEmits<{ update: [] }>();
+
+const open = ref(false);
+watch(
+  () => props.reservation,
+  (newValue) => {
+    open.value = !!newValue;
+  }
+);
+
+const status = computed<ReservationStatus | null>(() => {
+  if (props.reservation) {
+    return getReservationStatus(props.reservation);
+  }
+  return null;
+});
+
+const {
+  cancel,
+  status: cancellationStatus,
+  message: cancellationMessage,
+} = useReservationCancellation(
+  () => props.reservation,
+  () => props.location,
+  () => emit("update")
+);
+</script>
+
+<style lang="scss" scoped>
+.product {
+  display: flex;
+  align-items: flex-start;
+  background-color: var(--background-color);
+  padding: var(--fluid-spacing-4);
+  gap: var(--fluid-spacing-4);
+  border-radius: var(--border-radius);
+  margin-bottom: var(--fluid-spacing-4);
+  img {
+    width: 33.33%;
+    aspect-ratio: 1/1;
+    border-radius: var(--border-radius);
+    object-fit: cover;
+  }
+  p:last-child {
+    margin: 0;
+  }
+}
+</style>
+
+<i18n lang="json">
+{
+  "en": {
+    "product": "Product",
+    "location": "Location",
+    "borrow_period": "Borrow period",
+    "deposit": "Deposit",
+    "cancel_trigger": "Cancel reservation",
+    "cancel_text": "Do you no longer wish to borrow the item?? Make the item available again by cancelling your reservation. Please note that this action cannot be undone.",
+    "cancel_button": "Cancel reservation",
+    "cancel_not_possible_text": "This reservation can't be cancelled as it already started, please send an e-mail to {0} for any questions or remarks.",
+    "change_period_trigger": "Update reservation period",
+    "change_period_text": "To update the reservation period, please send an e-mail to {0}."
+  },
+  "de": {
+    "product": "Gegenstand",
+    "location": "Standort",
+    "borrow_period": "Leihfrist",
+    "deposit": "Pfand",
+    "cancel_trigger": "Reservierung stornieren",
+    "cancel_text": "Möchtest du den Gegenstand nicht mehr ausleihen? Mache den Gegenstand duch die Stornierung deiner Reservierung wieder verfügbar. Bitte beachte dass diese Aktion nicht rückgängig gemacht werden kann.",
+    "cancel_button": "Reservierung stornieren",
+    "cancel_not_possible_text": "Diese Reservierung kann nicht storniert worden, da sie bereits begonnen hat. Bei Fragen oder Problemen schicke uns bitte eine E-Mail an {0}.",
+    "change_period_trigger": "Leihfrist änderen",
+    "change_period_text": "Um deine Leihfrist zu änderen, schicke bitte eine E-Mail an {0}."
+  }
+}
+</i18n>
diff --git a/app/pages/reservations/index.vue b/app/pages/reservations/index.vue
new file mode 100644
index 0000000..ab3e274
--- /dev/null
+++ b/app/pages/reservations/index.vue
@@ -0,0 +1,188 @@
+<template>
+  <Container width="lg" centered>
+    <section>
+      <h1>{{ t("reservations") }}</h1>
+      <p class="intro">
+        {{ t("intro") }}
+      </p>
+    </section>
+    <section>
+      <h2>{{ t("active") }}</h2>
+      <ul class="cards">
+        <li v-for="reservation in current" :key="reservation.id">
+          <ReservationCardButton
+            :reservation="reservation"
+            :product="data?.products[reservation.product]"
+            @click="selectedReservation = reservation"
+          />
+        </li>
+      </ul>
+    </section>
+    <section>
+      <h2>{{ t("past") }}</h2>
+      <ul class="cards">
+        <li v-for="reservation in past" :key="reservation.id">
+          <ReservationCardButton
+            :reservation="reservation"
+            :product="data?.products[reservation.product]"
+            @click="selectedReservation = reservation"
+          />
+        </li>
+      </ul>
+    </section>
+  </Container>
+  <ReservationDetailDialog
+    :reservation="selectedReservation"
+    :product="
+      selectedReservation
+        ? data?.products[selectedReservation?.product]
+        : undefined
+    "
+    :location="
+      selectedReservation
+        ? data?.locations[selectedReservation?.location]
+        : undefined
+    "
+    @update="refresh"
+    @close="selectedReservation = undefined"
+  />
+</template>
+
+<script setup lang="ts">
+import { ReservationStatus, type Reservation } from "~/models/reservation";
+import ReservationDetailDialog from "./_components/ReservationDetailDialog.vue";
+import type { Product } from "~/models/product";
+import { getReservationStatus } from "~/lib/reservation";
+import type { Location } from "~/models/location";
+import ReservationCardButton from "./_components/ReservationCardButton.vue";
+
+const { pb, isValid, logout } = usePocketbase();
+const router = useRouter();
+
+const selectedReservation = ref<Reservation>();
+
+const { t } = useI18n({
+  useScope: "local",
+});
+
+const { data, refresh, status } = await useAsyncData<{
+  reservations: Reservation[];
+  products: { [key: string]: Product };
+  locations: { [key: string]: Location };
+}>(
+  "user_reservations",
+  async () => {
+    const reservations = await pb.collection("reservations").getFullList({
+      filter: pb.filter("user = {:user}", {
+        user: pb.authStore?.model?.["id"],
+      }),
+      sort: "-start",
+      requestKey: "user_reservations",
+    });
+    const products = await pb.collection("public_products").getFullList({
+      filter: reservations.map((r) => `id="${r.product}"`).join("||"),
+      requestKey: "reservations_product",
+    });
+    const locations = await pb.collection("public_locations").getFullList({
+      filter: reservations.map((r) => `id="${r.location}"`).join("||"),
+      requestKey: "reservations_location",
+    });
+    return {
+      reservations: structuredClone(reservations) as Reservation[],
+      products: products.reduce((map, p) => {
+        map[p.id] = p as Product;
+        return map;
+      }, {} as { [key: string]: Product }),
+      locations: locations.reduce((map, p) => {
+        map[p.id] = p as Location;
+        return map;
+      }, {} as { [key: string]: Location }),
+    };
+  },
+  { lazy: true }
+);
+
+const current = computed(() =>
+  (data.value?.reservations || []).filter((reservation) => {
+    if (
+      getReservationStatus(reservation) !== ReservationStatus.Ended &&
+      getReservationStatus(reservation) !== ReservationStatus.Cancelled
+    ) {
+      return true;
+    }
+    return false;
+  })
+);
+
+const past = computed(() =>
+  (data.value?.reservations || []).filter((reservation) => {
+    if (
+      getReservationStatus(reservation) === ReservationStatus.Ended ||
+      getReservationStatus(reservation) === ReservationStatus.Cancelled
+    ) {
+      return true;
+    }
+    return false;
+  })
+);
+
+useHead({
+  title: t("reservations"),
+});
+
+if (!isValid.value) {
+  logout();
+  router.push("/login");
+}
+</script>
+
+<style lang="scss" scoped>
+@use "~/assets/styles/_breakpoints.scss";
+
+section {
+  margin-bottom: var(--fluid-spacing-8);
+}
+
+.intro {
+  max-width: var(--max-text-width);
+}
+
+.cards {
+  display: flex;
+  list-style: none;
+  gap: var(--fluid-spacing-4);
+  margin: 0;
+  padding: 0;
+  flex-wrap: wrap;
+  li {
+    width: 100%;
+  }
+  @media (min-width: breakpoints.$breakpoint-md) {
+    li {
+      width: calc((100% - (var(--fluid-spacing-4) * 1)) * 1 / 2);
+    }
+  }
+  @media (min-width: breakpoints.$breakpoint-lg) {
+    li {
+      width: calc((100% - (var(--fluid-spacing-4) * 2)) * 1 / 3);
+    }
+  }
+}
+</style>
+
+<i18n lang="json">
+{
+  "en": {
+    "reservations": "Reservations",
+    "intro": "Here you can manage your reservations. Check your current and past loans, extend deadlines, or cancel reservations as needed.",
+    "active": "Active",
+    "past": "Past"
+  },
+  "de": {
+    "reservations": "Reservierungen",
+    "intro": "Hier kannst du deine Reservierungen verwalten. Schau dir deine aktuellen und vergangenen Ausleihen an, verlängere Fristen oder storniere Reservierungen bei Bedarf.",
+    "active": "Aktive",
+    "past": "Vergangene"
+  }
+}
+</i18n>
diff --git a/pocketbase/pb_hooks/lib/email.js b/pocketbase/pb_hooks/lib/email.js
new file mode 100644
index 0000000..3b44ddd
--- /dev/null
+++ b/pocketbase/pb_hooks/lib/email.js
@@ -0,0 +1,41 @@
+/**
+ * @param {models.Record} location
+ * @param {Object} emailProps
+ * @returns
+ */
+function sendLocationNotificationEmail(location, emailProps) {
+  /** @type {typeof import('./location')} */
+  const { getNotificationEmailAddresses } = require(`${__hooks}/lib/location`);
+
+  const notificationEmailAddresses = getNotificationEmailAddresses(location);
+  if (notificationEmailAddresses.length > 0) {
+    notificationEmailAddresses.forEach((to) => {
+      const email = new MailerMessage({
+        from: {
+          address: $app.settings().meta.senderAddress,
+          name: $app.settings().meta.senderName,
+        },
+        to: [{ address: to }],
+        ...emailProps,
+      });
+      $app.newMailClient().send(email);
+    });
+  }
+}
+
+function sendUserEmail(user, emailProps) {
+  const email = new MailerMessage({
+    from: {
+      address: $app.settings().meta.senderAddress,
+      name: $app.settings().meta.senderName,
+    },
+    to: [{ address: user.get("email") }],
+    ...emailProps,
+  });
+  $app.newMailClient().send(email);
+}
+
+module.exports = {
+  sendLocationNotificationEmail,
+  sendUserEmail,
+};
diff --git a/pocketbase/pb_hooks/lib/emails.de.js b/pocketbase/pb_hooks/lib/emails.de.js
index cd29005..cbc5070 100644
--- a/pocketbase/pb_hooks/lib/emails.de.js
+++ b/pocketbase/pb_hooks/lib/emails.de.js
@@ -10,7 +10,6 @@ module.exports = (function () {
   }
 
   const reservationConfirmationEmail = ({
-    locationEmail,
     userName,
     productUrl,
     productName,
@@ -33,17 +32,14 @@ ${
   lendingConditionsLink
     ? `<a href="${lendingConditionsLink}">die Leihbedingungen</a>`
     : "die Leihbedingungen"
-} zu unterschreiben und ${
+} zu unterschreiben, uns deinen Ausweis zum Abgleich zu zeigen und ${
       deposit ? `${formatCurrency(deposit)} ` : ""
     }Pfand zu hinterlegen.<br>
 <br>
-Solltest Du Deine Reservierung stornieren müssen, freuen wir 
-uns über eine kurze Email${
-      locationEmail
-        ? ` an
-<a href="mailto:${locationEmail}">${locationEmail}</a>.`
-        : "."
-    }<br>
+Deine Reservierung stornieren oder die Leihfrist ändern kannst du in deiner
+<a href="${
+      $app.settings().meta.appUrl
+    }/reservations">Reservierungsübersicht</a>.<br>
 <br>
 Wir freuen uns, dass du dich für Leihen statt Kaufen entscheidest!<br>
 <br>
@@ -60,7 +56,7 @@ Deine Leihbar`,
     end,
     message,
   }) => ({
-    subject: `Neue Reservierung von ${userName}: ${productName}`,
+    subject: `Neue Reservierung: ${userName} - ${productName}`,
     html: `Hi,<br>
 <br>
 Eine neue Reservierung von ${userName}
@@ -82,6 +78,7 @@ ${
   });
 
   const reservationStartReminderEmail = ({
+    userName,
     locationName,
     productName,
     start,
@@ -89,14 +86,16 @@ ${
     endHour,
   }) => ({
     subject: `Abholen des Gegenstands '${productName}'`,
-    html: `Hi,<br>
+    html: `Hi ${userName},<br>
 <br>
-du hast bei ${locationName} den Gegenstand '${productName}' reserviert. Der Gegenstand liegt
-morgen (${formatDate(start)}) ${
-      startHour && endHour ? `zwischen ${startHour} und ${endHour}` : ""
-    }
-bereit. Bitte denke daran, deinen Gegenstand abzuholen. Falls du ihn nicht mehr benötigst,
-antworte auf diese Mail, um deine Reservierung zu stornieren.<br>
+du hast bei ${locationName} den Gegenstand '${productName}' reserviert.
+Der Gegenstand liegt morgen (${formatDate(start)})
+${startHour && endHour ? `zwischen ${startHour} und ${endHour}` : ""}
+bereit. Bitte denke daran, deinen Gegenstand abzuholen.<br>
+<br>
+Falls du ihn nicht mehr benötigst, kannst du deine Reservierung in deiner
+<a href="${$app.settings().meta.appUrl}/reservations">Reservierungsübersicht</a>
+stornieren.<br>
 <br>
 Danke, dass du dich für Leihen statt Kaufen entscheidest!<br>
 <br>
@@ -105,6 +104,7 @@ Dein LeihBar-Team<br>`,
   });
 
   const reservationEndReminderEmail = ({
+    userName,
     locationName,
     productName,
     end,
@@ -112,7 +112,7 @@ Dein LeihBar-Team<br>`,
     endHour,
   }) => ({
     subject: `Zurückbringen des Gegenstands '${productName}'`,
-    html: `Hi,<br>
+    html: `Hi ${userName},<br>
 <br>
 wir hoffen, mit dem Gegenstand ${productName} hat alles gut funktioniert!
 <br>
@@ -125,16 +125,59 @@ ${
 <br>`
     : ""
 }
+Falls du den Gegenstand länger behalten möchtest, kannst du die Reservierung in
+deiner
+<a href="${$app.settings().meta.appUrl}/reservations">Reservierungsübersicht</a>
+verlängern.<br>
+<br>
 Danke, und bis morgen!<br>
 <br>
 Liebe Grüße<br>
 dein LeihBar-Team`,
   });
 
+  const cancellationConfirmationEmail = ({
+    userName,
+    productUrl,
+    productName,
+  }) => ({
+    subject: `Stornierungsbestätigung für ${productName}`,
+    html: `Hi ${userName},<br>
+<br>
+hiermit bestätigen wir die Stornierung deiner Reservierung für den Gegenstand
+"<a href="${productUrl}">${productName}</a>".<br>
+<br>
+Danke fürs Weitergeben, der Gegenstand ist jetzt wieder verfügbar für andere Nachbar:innen!<br>
+<br>
+Gerne bis zum nächste Mal!<br>
+<br>
+Liebe Grüße<br>
+Deine Leihbar`,
+  });
+
+  const reservationCancellationLocationEmail = ({
+    productUrl,
+    productName,
+    userName,
+    userEmail,
+    start,
+    end,
+  }) => ({
+    subject: `Stornierung: ${userName} - ${productName}`,
+    html: `Der Reservierung von ${userName} (<a href="mailto:${userEmail}">${userEmail}</a>) für den Gegenstand ${productName} ist storniert.<br>
+<br>
+<strong>Details</strong><br>
+Gegenstand: <a href="${productUrl}">${productName}</a><br>
+Beginn: ${formatDate(start)}<br>
+Ende: ${formatDate(end)}`,
+  });
+
   return {
     reservationConfirmationEmail,
     reservationConfirmationLocationEmail,
     reservationStartReminderEmail,
     reservationEndReminderEmail,
+    cancellationConfirmationEmail,
+    reservationCancellationLocationEmail,
   };
 })();
diff --git a/pocketbase/pb_hooks/lib/emails.en.js b/pocketbase/pb_hooks/lib/emails.en.js
index b6fbdb2..4bef633 100644
--- a/pocketbase/pb_hooks/lib/emails.en.js
+++ b/pocketbase/pb_hooks/lib/emails.en.js
@@ -10,7 +10,6 @@ module.exports = (function () {
   }
 
   const reservationConfirmationEmail = ({
-    locationEmail,
     userName,
     productUrl,
     productName,
@@ -36,9 +35,10 @@ of borrowing on site and to pay a ${
       deposit ? `${formatCurrency(deposit)} ` : ""
     } deposit.<br>
 <br>
-If you need to cancel your reservation, please send a short email${
-      locationEmail ? ` to <a href="${locationEmail}">${locationEmail}</a>` : ""
-    }.<br>
+You can cancel your reservation or change the borrowing period in your
+<a href="${
+      $app.settings().meta.appUrl
+    }/reservations">reservation overview</a>.<br>
 <br>
 We're happy that you have decided to borrow instead of buy!<br>
 <br>
@@ -55,7 +55,7 @@ Your Leihbar`,
     end,
     message,
   }) => ({
-    subject: `New reservation by ${userName}: ${productName}`,
+    subject: `New reservation: ${userName} - ${productName}`,
     html: `Hi,<br>
 <br>
 A new reservation by ${userName} (<a href="mailto:${userEmail}">${userEmail}</a>).<br>
@@ -76,6 +76,7 @@ ${
   });
 
   const reservationStartReminderEmail = ({
+    userName,
     locationName,
     productName,
     start,
@@ -83,12 +84,14 @@ ${
     endHour,
   }) => ({
     subject: `Pick-up of '${productName}'`,
-    html: `Hi,<br>
+    html: `Hi ${userName},<br>
 <br>
 you reserved a ${productName} at ${locationName}. The item can be picked up tomorrow
 (${formatDate(start)}) ${
       startHour && endHour ? `between ${startHour} and ${endHour}` : ""
-    }. Please answer on this e-mail, in case you want to cancel the reservation.<br>
+    }.<br>
+If you no longer need it, you can cancel your reservation in your
+<a href="${$app.settings().meta.appUrl}/reservations">reservation overview</a>.
 <br>
 Thank you that you choose for borrowing instead of buying!<br>
 <br>
@@ -97,6 +100,7 @@ Your LeihBar<br>`,
   });
 
   const reservationEndReminderEmail = ({
+    userName,
     locationName,
     productName,
     end,
@@ -104,7 +108,7 @@ Your LeihBar<br>`,
     endHour,
   }) => ({
     subject: `Return of '${productName}'`,
-    html: `Hi,<br>
+    html: `Hi ${userName},<br>
 <br>
 we hope everything worked out well with the item '${productName}'!
 <br>
@@ -117,14 +121,56 @@ ${
 <br>`
     : ""
 }
+If you would like to keep the item for longer, you can extend the reservation in
+your
+<a href="${
+      $app.settings().meta.appUrl
+    }/reservations">reservation overview</a>.<br>
+<br>
 Thank you, and see you tomorrow!<br>
 Your LeihBar-Team`,
   });
 
+  const cancellationConfirmationEmail = ({
+    userName,
+    productUrl,
+    productName,
+  }) => ({
+    subject: `Cancellation confirmation for ${productName}`,
+    html: `Hi ${userName},<br>
+<br>
+We hereby confirm the cancellation of your reservation for the item "<a href="${productUrl}">${productName}</a>".<br>
+<br>
+Thanks for sharing, the item is now available again for other neighbours!<br>
+<br>
+See you next time!<br>
+<br>
+Your Leihbar`,
+  });
+
+  const reservationCancellationLocationEmail = ({
+    productUrl,
+    productName,
+    userName,
+    userEmail,
+    start,
+    end,
+  }) => ({
+    subject: `Cancellation: ${userName} - ${productName}`,
+    html: `The reservation of ${userName} (<a href="mailto:${userEmail}">${userEmail}</a>) for the item "${productName}" has been cancelled.<br>
+<br>
+<strong>Details</strong><br>
+Item: <a href="${productUrl}">${productName}</a><br>
+Start: ${formatDate(start)}<br>
+End: ${formatDate(end)}`,
+  });
+
   return {
     reservationConfirmationEmail,
     reservationConfirmationLocationEmail,
     reservationStartReminderEmail,
     reservationEndReminderEmail,
+    cancellationConfirmationEmail,
+    reservationCancellationLocationEmail,
   };
 })();
diff --git a/pocketbase/pb_hooks/lib/reservation.js b/pocketbase/pb_hooks/lib/reservation.js
index e43d3c6..ee13f35 100644
--- a/pocketbase/pb_hooks/lib/reservation.js
+++ b/pocketbase/pb_hooks/lib/reservation.js
@@ -177,6 +177,7 @@ function getReminderEmail(reservation, type) {
     return {
       to: [{ address: user.getString("email") }],
       ...reservationStartReminderEmail({
+        userName: user.get("name"),
         locationName: location.get("name"),
         productName: product.get("name"),
         start,
@@ -194,6 +195,7 @@ function getReminderEmail(reservation, type) {
     return {
       to: [{ address: user.get("email") }],
       ...reservationEndReminderEmail({
+        userName: user.get("name"),
         locationName: location.get("name"),
         productName: product.get("name"),
         end,
diff --git a/pocketbase/pb_hooks/reservation.record.pb.js b/pocketbase/pb_hooks/reservation.record.pb.js
index 9ce160e..e74ede0 100644
--- a/pocketbase/pb_hooks/reservation.record.pb.js
+++ b/pocketbase/pb_hooks/reservation.record.pb.js
@@ -126,8 +126,11 @@ onRecordAfterCreateRequest((e) => {
 
   /** @type {typeof import('./lib/reservation')} */
   const { saveSentEmail } = require(`${__hooks}/lib/reservation`);
-  /** @type {typeof import('./lib/location')} */
-  const { getNotificationEmailAddresses } = require(`${__hooks}/lib/location`);
+  /** @type {typeof import('./lib/email')} */
+  const {
+    sendLocationNotificationEmail,
+    sendUserEmail,
+  } = require(`${__hooks}/lib/email`);
   /** @type {typeof import('./lib/emails.en')} */
   const {
     reservationConfirmationEmail,
@@ -168,41 +171,26 @@ onRecordAfterCreateRequest((e) => {
   const end = new Date(record.get("end").string().split(" ")[0]);
 
   // Notify location
-  const notificationEmailAddresses = getNotificationEmailAddresses(location);
-  if (notificationEmailAddresses.length > 0) {
-    notificationEmailAddresses.forEach((to) => {
-      const email = new MailerMessage({
-        from: {
-          address: $app.settings().meta.senderAddress,
-          name: $app.settings().meta.senderName,
-        },
-        to: [{ address: to }],
-        ...reservationConfirmationLocationEmail({
-          productUrl: `${
-            $app.settings().meta.appUrl
-          }/link/product/${product.get("id")}`,
-          productName,
-          userName,
-          userEmail: user.get("email"),
-          start,
-          end,
-          message: record.get("message"),
-        }),
-      });
-      $app.newMailClient().send(email);
-    });
-  }
+  sendLocationNotificationEmail(
+    location,
+    reservationConfirmationLocationEmail({
+      productUrl: `${$app.settings().meta.appUrl}/link/product/${product.get(
+        "id"
+      )}`,
+      productName,
+      userName,
+      userEmail: user.get("email"),
+      start,
+      end,
+      message: record.get("message"),
+    })
+  );
 
   // Notify user, if the user is the one making the reservation
   if (user && requestUser && requestUser.get("id") === user.get("id")) {
-    const email = new MailerMessage({
-      from: {
-        address: $app.settings().meta.senderAddress,
-        name: $app.settings().meta.senderName,
-      },
-      to: [{ address: user.get("email") }],
-      ...reservationConfirmationEmail({
-        locationEmail: location.get("email"),
+    sendUserEmail(
+      user,
+      reservationConfirmationEmail({
         productUrl: `${$app.settings().meta.appUrl}/link/product/${product.get(
           "id"
         )}`,
@@ -211,18 +199,31 @@ onRecordAfterCreateRequest((e) => {
         start,
         end,
         deposit: product.get("deposit"),
-      }),
-    });
-    $app.newMailClient().send(email);
+      })
+    );
     // Store that email has been sent
     saveSentEmail(record, "confirmation");
   }
 }, "reservations");
 
 onRecordAfterUpdateRequest((e) => {
+  const locale = $os.getenv("CONFIG_LOCALE") || "en";
+
   /** @type {typeof import('./lib/reservation')} */
   const { removeSentEmail } = require(`${__hooks}/lib/reservation`);
 
+  /** @type {typeof import('./lib/email')} */
+  const {
+    sendLocationNotificationEmail,
+    sendUserEmail,
+  } = require(`${__hooks}/lib/email`);
+
+  /** @type {typeof import('./lib/emails.en')} */
+  const {
+    cancellationConfirmationEmail,
+    reservationCancellationLocationEmail,
+  } = require(`${__hooks}/lib/emails.${locale}`);
+
   let { record } = e;
   const originalRecord = record.originalCopy();
 
@@ -234,4 +235,44 @@ onRecordAfterUpdateRequest((e) => {
   if (end > originalEnd) {
     record = removeSentEmail(record, "end_reminder");
   }
+
+  // Reservation got cancelled, send confirmations
+  if (!originalRecord.getBool("cancelled") && record.getBool("cancelled")) {
+    $app.dao().expandRecord(record, ["product", "location", "user"], null);
+    const product = record.expandedOne("product");
+    const location = record.expandedOne("location");
+    const productName = product.get("name");
+    const user = record.expandedOne("user");
+    const start = new Date(record.get("start").string().split(" ")[0]);
+    const end = new Date(record.get("end").string().split(" ")[0]);
+
+    // User
+    if (user) {
+      sendUserEmail(
+        user,
+        cancellationConfirmationEmail({
+          productUrl: `${
+            $app.settings().meta.appUrl
+          }/link/product/${product.get("id")}`,
+          productName,
+          userName: user.get("name"),
+        })
+      );
+    }
+
+    // Location
+    sendLocationNotificationEmail(
+      location,
+      reservationCancellationLocationEmail({
+        productUrl: `${$app.settings().meta.appUrl}/link/product/${product.get(
+          "id"
+        )}`,
+        productName,
+        userName: user.get("name"),
+        userEmail: user.get("email"),
+        start,
+        end,
+      })
+    );
+  }
 }, "reservations");
diff --git a/tests/docker-compose.yaml b/tests/docker-compose.yaml
index 7e1d64e..b6818c6 100644
--- a/tests/docker-compose.yaml
+++ b/tests/docker-compose.yaml
@@ -16,6 +16,9 @@ services:
       NUXT_PUBLIC_POCKETBASE_SERVER_BASE_URL: http://pocketbase:8081
       NUXT_PUBLIC_POCKETBASE_CLIENT_BASE_URL: http://localhost:8081
       NUXT_PLAUSIBLE_TRACKING_DOMAIN: ""
+    depends_on:
+      pocketbase:
+        condition: service_healthy
     networks:
       - leihbar_tests
 
@@ -33,6 +36,10 @@ services:
       # If a reservation needs to have a user, or not
       CONFIG_RESERVATION_REQUIRE_USER: "false"
       CONFIG_LENDING_CONDITIONS_LINK: "https://example.com/lending-conditions"
+    healthcheck:
+      test: wget --no-verbose --spider http://localhost:8081/api/health || exit 1
+      interval: 1s
+      retries: 3
     volumes:
       - ../pocketbase/pb_migrations:/pb/pb_migrations
       - ../pocketbase/pb_hooks:/pb/pb_hooks
diff --git a/tests/lib/product.ts b/tests/lib/product.ts
index 33684d7..2570ec6 100644
--- a/tests/lib/product.ts
+++ b/tests/lib/product.ts
@@ -1,9 +1,7 @@
 import { expect } from "@playwright/test";
-import { init } from "../services/pocketbase";
+import { pocketbase } from "../services/pocketbase";
 import { waitForClientMount } from "./utils";
 
-const pb = await init();
-
 /**
  * Navigates to the product page,
  * given a product id
@@ -22,6 +20,7 @@ export async function navigateToProductPage(page, productId) {
  */
 export async function createProduct() {
   // Create test product
+  const pb = await pocketbase();
   const location = (await pb.collection("location").getFullList()).at(0);
   if (!location) {
     throw new Error("createProduct: couldn't find location");
@@ -45,6 +44,7 @@ export async function createProduct() {
  * Returns the last created reservation given a product id
  */
 export async function getLastProductReservation(productId) {
+  const pb = await pocketbase();
   const reservation = await pb
     .collection("reservations")
     .getFirstListItem(`product = '${productId}'`, { sort: "created" });
@@ -55,6 +55,7 @@ export async function getLastProductReservation(productId) {
  * Updates the created reservation given a product id
  */
 export async function updateLastProductReservation(productId, data) {
+  const pb = await pocketbase();
   const reservation = await getLastProductReservation(productId);
   return await pb.collection("reservations").update(reservation.id, data);
 }
diff --git a/tests/lib/user.ts b/tests/lib/user.ts
index e950375..c4cc1a4 100644
--- a/tests/lib/user.ts
+++ b/tests/lib/user.ts
@@ -1,11 +1,10 @@
-import { init } from "../services/pocketbase";
-
-const pb = await init();
+import { pocketbase } from "../services/pocketbase";
 
 export async function createUser(
   email = "test@example.com",
   password = "testtest"
 ) {
+  const pb = await pocketbase();
   try {
     const user = await pb.collection("users").create({
       name: "Test User",
diff --git a/tests/services/pocketbase.ts b/tests/services/pocketbase.ts
index 04e73b4..eac50fa 100644
--- a/tests/services/pocketbase.ts
+++ b/tests/services/pocketbase.ts
@@ -1,18 +1,21 @@
 import PocketBase from "pocketbase";
 
-export async function init(
+let pb;
+
+export async function pocketbase(
   email = "test@example.com",
   password = "1234567890",
   admin = true
 ) {
-  const pb = new PocketBase("http://127.0.0.1:8081");
-
-  // Auth
-  if (admin) {
-    await pb.admins.authWithPassword(email, password);
+  if (pb) {
+    return pb;
   } else {
-    await pb.collection("users").authWithPassword(email, password);
+    pb = new PocketBase("http://127.0.0.1:8081");
+    if (admin) {
+      await pb.admins.authWithPassword(email, password);
+    } else {
+      await pb.collection("users").authWithPassword(email, password);
+    }
+    return pb;
   }
-
-  return pb;
 }
diff --git a/tests/tests/global.setup.ts b/tests/tests/global.setup.ts
index 9387039..a4e9104 100644
--- a/tests/tests/global.setup.ts
+++ b/tests/tests/global.setup.ts
@@ -1,13 +1,14 @@
 import { expect, test as setup } from "@playwright/test";
-import { init } from "../services/pocketbase";
+import { pocketbase } from "../services/pocketbase";
 import { createUser } from "../lib/user";
 
-const pb = await init();
-
 setup.describe.configure({ mode: "serial" });
 
 setup.describe("setup", () => {
-  setup("create location, product and user", async ({}) => {
+  setup("create location, product and user", async ({ page }) => {
+    const pb = await pocketbase();
+    await expect(pb).toBeDefined();
+
     // Configure mailing
     await pb.settings.update({
       smtp: {
diff --git a/tests/tests/signup.test.ts b/tests/tests/signup.test.ts
index 49e0126..16c057d 100644
--- a/tests/tests/signup.test.ts
+++ b/tests/tests/signup.test.ts
@@ -6,7 +6,7 @@ test.describe("signup", () => {
     await page.goto("/");
     await waitForHydration(page);
     await page.locator("header").getByTestId("menu-button").click();
-    await page.locator("main").getByTestId("signup-link").click();
+    await page.locator("header").getByTestId("signup-link").click();
     await page.waitForURL(/\/signup/);
     await expect(page.getByTestId("signup-h1")).toBeVisible();
   });


Reviews:
user: login: ArcoMul
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T10:43:12Z
user: login: ArcoMul
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T10:44:03Z
user: login: ArcoMul
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T10:46:29Z
user: login: maxverlaufen
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-16T10:52:45Z
user: login: maxverlaufen
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-16T10:54:43Z
user: login: maxverlaufen
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-16T10:55:13Z

Review comments:
diff_hunk: @@ -0,0 +1,188 @@
+<template>
+  <Container width="lg" centered>
+    <section>
+      <h1>{{ t("reservations") }}</h1>
+      <p class="intro">
+        {{ t("intro") }}
+      </p>
+    </section>
+    <section>
+      <h2>{{ t("active") }}</h2>
+      <ul class="cards">
+        <li v-for="reservation in current" :key="reservation.id">
+          <ReservationCardButton
+            :reservation="reservation"
+            :product="data?.products[reservation.product]"
+            @click="selectedReservation = reservation"
+          />
+        </li>
+      </ul>
+    </section>
+    <section>
+      <h2>{{ t("past") }}</h2>
+      <ul class="cards">
+        <li v-for="reservation in past" :key="reservation.id">
+          <ReservationCardButton
+            :reservation="reservation"
+            :product="data?.products[reservation.product]"
+            @click="selectedReservation = reservation"
+          />
+        </li>
+      </ul>
+    </section>
+  </Container>
+  <ReservationDetailDialog
+    :reservation="selectedReservation"
+    :product="
+      selectedReservation
+        ? data?.products[selectedReservation?.product]
+        : undefined
+    "
+    :location="
+      selectedReservation
+        ? data?.locations[selectedReservation?.location]
+        : undefined
+    "
+    @update="refresh"
+    @close="selectedReservation = undefined"
+  />
+</template>
+
+<script setup lang="ts">
+import { ReservationStatus, type Reservation } from "~/models/reservation";
+import ReservationDetailDialog from "./_components/ReservationDetailDialog.vue";
+import type { Product } from "~/models/product";
+import { getReservationStatus } from "~/lib/reservation";
+import type { Location } from "~/models/location";
+import ReservationCardButton from "./_components/ReservationCardButton.vue";
+
+const { pb, isValid, logout } = usePocketbase();
+const router = useRouter();
+
+const selectedReservation = ref<Reservation>();
+
+const { t } = useI18n({
+  useScope: "local",
+});
+
+const { data, refresh, status } = await useAsyncData<{
+  reservations: Reservation[];
+  products: { [key: string]: Product };
+  locations: { [key: string]: Location };
+}>(
+  "user_reservations",
+  async () => {
+    const reservations = await pb.collection("reservations").getFullList({
+      filter: pb.filter("user = {:user}", {
+        user: pb.authStore?.model?.["id"],
+      }),
+      sort: "-start",
+      requestKey: "user_reservations",
+    });
+    const products = await pb.collection("public_products").getFullList({
+      filter: reservations.map((r) => `id="${r.product}"`).join("||"),
+      requestKey: "reservations_product",
+    });
+    const locations = await pb.collection("public_locations").getFullList({
+      filter: reservations.map((r) => `id="${r.location}"`).join("||"),
+      requestKey: "reservations_location",
+    });
+    return {
+      reservations: structuredClone(reservations) as Reservation[],
+      products: products.reduce((map, p) => {
+        map[p.id] = p as Product;
+        return map;
+      }, {} as { [key: string]: Product }),
+      locations: locations.reduce((map, p) => {
+        map[p.id] = p as Location;
+        return map;
+      }, {} as { [key: string]: Location }),
+    };
+  },
+  { lazy: true }
+);
+
+const current = computed(() =>
+  (data.value?.reservations || []).filter((reservation) => {
+    if (
+      getReservationStatus(reservation) !== ReservationStatus.Ended &&
+      getReservationStatus(reservation) !== ReservationStatus.Cancelled
+    ) {
+      return true;
+    }
+    return false;
+  })
+);
+
+const past = computed(() =>
+  (data.value?.reservations || []).filter((reservation) => {
+    if (
+      getReservationStatus(reservation) === ReservationStatus.Ended ||
+      getReservationStatus(reservation) === ReservationStatus.Cancelled
+    ) {
+      return true;
+    }
+    return false;
+  })
+);
+
+useHead({
+  title: t("reservations"),
+});
+
+if (!isValid.value) {
+  logout();
+  router.push("/login");
+}
+</script>
+
+<style lang="scss" scoped>
+@use "~/assets/styles/_breakpoints.scss";
+
+section {
+  margin-bottom: var(--fluid-spacing-8);
+}
+
+.intro {
+  max-width: var(--max-text-width);
+}
+
+.cards {
+  display: flex;
+  list-style: none;
+  gap: var(--fluid-spacing-4);
+  margin: 0;
+  padding: 0;
+  flex-wrap: wrap;
+  li {
+    width: 100%;
+  }
+  @media (min-width: breakpoints.$breakpoint-md) {
+    li {
+      width: calc((100% - (var(--fluid-spacing-4) * 1)) * 1 / 2);
+    }
+  }
+  @media (min-width: breakpoints.$breakpoint-lg) {
+    li {
+      width: calc((100% - (var(--fluid-spacing-4) * 2)) * 1 / 3);
+    }
+  }
+}
+</style>
+
+<i18n lang="json">
+{
+  "en": {
+    "reservations": "Reservations",
+    "intro": "Here you can manage your reservations. Check your current and past loans, extend deadlines, or cancel reservations as needed.",
+    "active": "Active",
+    "past": "Past"
+  },
+  "de": {
+    "reservations": "Reservierungen",
+    "intro": "Hier kannst du deine Reservierungen verwalten. Schau dir deine aktuellen und vergangenen Ausleihen an, verlängere Fristen oder storniere Reservierungen bei Bedarf.",
+    "active": "Aktive",
+    "past": "Vergangene"
+  }
path: app/pages/reservations/index.vue
user: login: ArcoMul
body: @maxverlaufen Kannst du diesen Texten korrigieren, sieht so aus:

<img width="823" alt="image" src="https://github.com/user-attachments/assets/3388be36-1657-4bc2-b80b-3782848c57d1" />

created_at: 2025-05-16T10:43:11Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,100 @@
+<template>
+  <Button
+    variant="secondary"
+    :class="['card', 'status-' + getReservationStatus(reservation)]"
+  >
+    <img
+      :src="`${config.public.pocketbase.clientBaseUrl}/api/files/products/${reservation.product}/${product.images[0]}${thumbs.sm}`"
+    />
+    <div class="details">
+      <h2>{{ product?.name }}</h2>
+      <p>
+        {{ formatDate(reservation.start, DateTime.DATE_MED, locale) }}
+        -
+        {{ formatDate(reservation.end, DateTime.DATE_MED, locale) }}
+      </p>
+      <span class="tag">
+        {{ t(getReservationStatus(reservation)) }}
+      </span>
+    </div>
+  </Button>
+</template>
+
+<script setup lang="ts">
+import { DateTime } from "luxon";
+import { formatDate } from "~/lib/date";
+import { type Reservation } from "~/models/reservation";
+import type { Product } from "~/models/product";
+import { getReservationStatus } from "~/lib/reservation";
+
+const config = useRuntimeConfig();
+
+const {
+  product: { thumbs },
+} = useAppConfig();
+
+const { t, locale } = useI18n({
+  useScope: "local",
+});
+
+defineProps<{ reservation: Reservation; product?: Product }>();
+</script>
+
+<style scoped lang="scss">
+.card {
+  display: flex;
+  padding: 0.5rem !important;
+  gap: var(--fluid-spacing-4);
+  text-align: left;
+  width: 100%;
+  align-items: stretch;
+  h2 {
+    font-size: 1rem;
+    margin: 0;
+  }
+  img {
+    width: 7rem;
+    height: 7rem;
+    object-fit: cover;
+    border-radius: var(--border-radius);
+    flex-shrink: 0;
+  }
+  .details {
+    width: 100%;
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+  }
+  p {
+    margin: 0;
+  }
+  .tag {
+    border: 1px solid var(--text-color-light);
+    color: var(--text-color);
+    padding: 0.25rem 0.333rem;
+    font-size: var(--font-size-sm);
+    line-height: 1;
+    border-radius: var(--border-radius);
+    margin-top: auto;
+  }
+}
+</style>
+
+<i18n lang="json">
+{
+  "en": {
+    "new": "New",
+    "cancelled": "Cancelled",
+    "started": "Picked-up",
+    "ended": "Returned",
+    "overdue": "Overdue"
+  },
+  "de": {
+    "new": "Neu",
+    "cancelled": "Annuliert",
+    "started": "Abgehollt",
+    "ended": "Zurückgebracht",
+    "overdue": "Überfällig"
+  }
path: app/pages/reservations/_components/ReservationCardButton.vue
user: login: ArcoMul
body: @maxverlaufen Hier sind der Texten von die Reservierung Status auf diese Seite:

<img width="823" alt="image" src="https://github.com/user-attachments/assets/c4e6249a-56b2-467b-8777-216cd18eb833" />

created_at: 2025-05-16T10:44:03Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,197 @@
+<template>
+  <Dialog inset title="Reservierung" v-model:open="open">
+    <div v-if="reservation && product && location" class="product">
+      <img
+        :src="`${config.public.pocketbase.clientBaseUrl}/api/files/products/${product.id}/${product.images[0]}${thumbs.sm}`"
+      />
+      <div>
+        <p>
+          <strong>{{ t("product") }}</strong
+          ><br />
+          <a :href="`/link/product/${product.id}`" target="_blank">
+            {{ product.name }}
+          </a>
+        </p>
+        <p>
+          <strong>{{ t("location") }}</strong
+          ><br />
+          <a :href="`/l/${location.slug}`" target="_blank">
+            {{ location.name }}
+          </a>
+        </p>
+        <p>
+          <strong>{{ t("borrow_period") }}</strong
+          ><br />
+          {{ formatDate(reservation.start, DateTime.DATE_MED, locale) }}
+          -
+          {{ formatDate(reservation.end, DateTime.DATE_MED, locale) }}
+        </p>
+      </div>
+    </div>
+    <Accordion v-if="reservation" :single="true">
+      <AccordionItem id="cancel">
+        <AccordionTrigger
+          :disabled="
+            getReservationStatus(reservation) === ReservationStatus.Ended ||
+            getReservationStatus(reservation) === ReservationStatus.Cancelled
+          "
+        >
+          {{ t("cancel_trigger") }}
+        </AccordionTrigger>
+        <AccordionContent>
+          <div
+            v-if="getReservationStatus(reservation) === ReservationStatus.New"
+          >
+            <Button
+              v-if="
+                cancellationStatus === ReservationCancellationStatus.Default ||
+                cancellationStatus === ReservationCancellationStatus.Loading
+              "
+              :loading="
+                cancellationStatus === ReservationCancellationStatus.Loading
+              "
+              @click="cancel"
+            >
+              {{ t("cancel_button") }}
+            </Button>
+            <Alert
+              v-else-if="
+                cancellationStatus === ReservationCancellationStatus.Success
+              "
+              variant="success"
+            >
+              {{ cancellationMessage }}
+            </Alert>
+            <Alert
+              v-else-if="
+                cancellationStatus === ReservationCancellationStatus.Error
+              "
+              variant="error"
+            >
+              {{ cancellationMessage }}
+            </Alert>
+          </div>
+          <div
+            v-else-if="
+              getReservationStatus(reservation) === ReservationStatus.Started
+            "
+          >
+            <p>
+              {{ t("cancel_not_possible_text", [location?.email]) }}
+            </p>
+          </div>
+        </AccordionContent>
+      </AccordionItem>
+      <AccordionItem id="change-period">
+        <AccordionTrigger
+          :disabled="
+            getReservationStatus(reservation) === ReservationStatus.Ended ||
+            getReservationStatus(reservation) === ReservationStatus.Cancelled
+          "
+          >{{ t("change_period_trigger") }}</AccordionTrigger
+        >
+        <AccordionContent>
+          <p>{{ t("change_period_text", [location?.email]) }}</p>
+        </AccordionContent>
+      </AccordionItem>
+    </Accordion>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { DateTime } from "luxon";
+import Accordion from "~/components/Accordion/Accordion.vue";
+import AccordionContent from "~/components/Accordion/AccordionContent.vue";
+import AccordionItem from "~/components/Accordion/AccordionItem.vue";
+import AccordionTrigger from "~/components/Accordion/AccordionTrigger.vue";
+import {
+  ReservationCancellationStatus,
+  useReservationCancellation,
+} from "~/composables/useReservationCancellation";
+import { formatDate } from "~/lib/date";
+import { getReservationStatus } from "~/lib/reservation";
+import type { Location } from "~/models/location";
+import type { Product } from "~/models/product";
+import { ReservationStatus, type Reservation } from "~/models/reservation";
+
+const config = useRuntimeConfig();
+const {
+  product: { thumbs },
+} = useAppConfig();
+
+const { t, locale } = useI18n({
+  useScope: "local",
+});
+
+const props = defineProps<{
+  reservation?: Reservation;
+  product?: Product;
+  location?: Location;
+}>();
+
+const emit = defineEmits<{ update: [] }>();
+
+const open = ref(false);
+watch(
+  () => props.reservation,
+  (newValue) => {
+    open.value = !!newValue;
+  }
+);
+
+const {
+  cancel,
+  status: cancellationStatus,
+  message: cancellationMessage,
+} = useReservationCancellation(
+  () => props.reservation,
+  () => props.location,
+  () => emit("update")
+);
+</script>
+
+<style lang="scss" scoped>
+.product {
+  display: flex;
+  align-items: center;
+  background-color: var(--background-color);
+  padding: var(--fluid-spacing-4);
+  gap: var(--fluid-spacing-4);
+  border-radius: var(--border-radius);
+  margin-bottom: var(--fluid-spacing-4);
+  img {
+    width: 33.33%;
+    aspect-ratio: 1/1;
+    border-radius: var(--border-radius);
+    object-fit: cover;
+  }
+  p:last-child {
+    margin: 0;
+  }
+}
+</style>
+
+<i18n lang="json">
+{
+  "en": {
+    "product": "Product",
+    "location": "Location",
+    "borrow_period": "Borrow period",
+    "cancel_trigger": "Cancel reservation",
+    "cancel_button": "Cancel",
+    "cancel_not_possible_text": "This reservation can't be cancelled as it already started, please send an e-mail to {0} for any questions or remarks.",
+    "change_period_trigger": "Update reservation period",
+    "change_period_text": "To update the reservation period, please send an e-mail to {0}."
+  },
+  "de": {
+    "product": "Gegenstand",
+    "location": "Standort",
+    "borrow_period": "Leihfrist",
+    "cancel_trigger": "Reservierung stornieren",
+    "cancel_button": "Stornieren",
+    "cancel_not_possible_text": "Diese Reservierung kann nicht storniert worden, da der schon angefangen hat. Schicke bei weitere Fragen oder Problemen bitte ein E-Mail an {0}.",
+    "change_period_trigger": "Leihfrist änderen",
+    "change_period_text": "Um deine Leihfrist zu änderen, schicke bitte eine E-Mail an {0}."
+  }
path: app/pages/reservations/_components/ReservationDetailDialog.vue
user: login: ArcoMul
body: @maxverlaufen Und hier die Texten für das Reservierungs Detail Dialog:

<img width="782" alt="image" src="https://github.com/user-attachments/assets/9fb59434-5108-42f2-ad3c-3353e12e8968" />

created_at: 2025-05-16T10:46:29Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,188 @@
+<template>
+  <Container width="lg" centered>
+    <section>
+      <h1>{{ t("reservations") }}</h1>
+      <p class="intro">
+        {{ t("intro") }}
+      </p>
+    </section>
+    <section>
+      <h2>{{ t("active") }}</h2>
+      <ul class="cards">
+        <li v-for="reservation in current" :key="reservation.id">
+          <ReservationCardButton
+            :reservation="reservation"
+            :product="data?.products[reservation.product]"
+            @click="selectedReservation = reservation"
+          />
+        </li>
+      </ul>
+    </section>
+    <section>
+      <h2>{{ t("past") }}</h2>
+      <ul class="cards">
+        <li v-for="reservation in past" :key="reservation.id">
+          <ReservationCardButton
+            :reservation="reservation"
+            :product="data?.products[reservation.product]"
+            @click="selectedReservation = reservation"
+          />
+        </li>
+      </ul>
+    </section>
+  </Container>
+  <ReservationDetailDialog
+    :reservation="selectedReservation"
+    :product="
+      selectedReservation
+        ? data?.products[selectedReservation?.product]
+        : undefined
+    "
+    :location="
+      selectedReservation
+        ? data?.locations[selectedReservation?.location]
+        : undefined
+    "
+    @update="refresh"
+    @close="selectedReservation = undefined"
+  />
+</template>
+
+<script setup lang="ts">
+import { ReservationStatus, type Reservation } from "~/models/reservation";
+import ReservationDetailDialog from "./_components/ReservationDetailDialog.vue";
+import type { Product } from "~/models/product";
+import { getReservationStatus } from "~/lib/reservation";
+import type { Location } from "~/models/location";
+import ReservationCardButton from "./_components/ReservationCardButton.vue";
+
+const { pb, isValid, logout } = usePocketbase();
+const router = useRouter();
+
+const selectedReservation = ref<Reservation>();
+
+const { t } = useI18n({
+  useScope: "local",
+});
+
+const { data, refresh, status } = await useAsyncData<{
+  reservations: Reservation[];
+  products: { [key: string]: Product };
+  locations: { [key: string]: Location };
+}>(
+  "user_reservations",
+  async () => {
+    const reservations = await pb.collection("reservations").getFullList({
+      filter: pb.filter("user = {:user}", {
+        user: pb.authStore?.model?.["id"],
+      }),
+      sort: "-start",
+      requestKey: "user_reservations",
+    });
+    const products = await pb.collection("public_products").getFullList({
+      filter: reservations.map((r) => `id="${r.product}"`).join("||"),
+      requestKey: "reservations_product",
+    });
+    const locations = await pb.collection("public_locations").getFullList({
+      filter: reservations.map((r) => `id="${r.location}"`).join("||"),
+      requestKey: "reservations_location",
+    });
+    return {
+      reservations: structuredClone(reservations) as Reservation[],
+      products: products.reduce((map, p) => {
+        map[p.id] = p as Product;
+        return map;
+      }, {} as { [key: string]: Product }),
+      locations: locations.reduce((map, p) => {
+        map[p.id] = p as Location;
+        return map;
+      }, {} as { [key: string]: Location }),
+    };
+  },
+  { lazy: true }
+);
+
+const current = computed(() =>
+  (data.value?.reservations || []).filter((reservation) => {
+    if (
+      getReservationStatus(reservation) !== ReservationStatus.Ended &&
+      getReservationStatus(reservation) !== ReservationStatus.Cancelled
+    ) {
+      return true;
+    }
+    return false;
+  })
+);
+
+const past = computed(() =>
+  (data.value?.reservations || []).filter((reservation) => {
+    if (
+      getReservationStatus(reservation) === ReservationStatus.Ended ||
+      getReservationStatus(reservation) === ReservationStatus.Cancelled
+    ) {
+      return true;
+    }
+    return false;
+  })
+);
+
+useHead({
+  title: t("reservations"),
+});
+
+if (!isValid.value) {
+  logout();
+  router.push("/login");
+}
+</script>
+
+<style lang="scss" scoped>
+@use "~/assets/styles/_breakpoints.scss";
+
+section {
+  margin-bottom: var(--fluid-spacing-8);
+}
+
+.intro {
+  max-width: var(--max-text-width);
+}
+
+.cards {
+  display: flex;
+  list-style: none;
+  gap: var(--fluid-spacing-4);
+  margin: 0;
+  padding: 0;
+  flex-wrap: wrap;
+  li {
+    width: 100%;
+  }
+  @media (min-width: breakpoints.$breakpoint-md) {
+    li {
+      width: calc((100% - (var(--fluid-spacing-4) * 1)) * 1 / 2);
+    }
+  }
+  @media (min-width: breakpoints.$breakpoint-lg) {
+    li {
+      width: calc((100% - (var(--fluid-spacing-4) * 2)) * 1 / 3);
+    }
+  }
+}
+</style>
+
+<i18n lang="json">
+{
+  "en": {
+    "reservations": "Reservations",
+    "intro": "Here you can manage your reservations. Check your current and past loans, extend deadlines, or cancel reservations as needed.",
+    "active": "Active",
+    "past": "Past"
+  },
+  "de": {
+    "reservations": "Reservierungen",
+    "intro": "Hier kannst du deine Reservierungen verwalten. Schau dir deine aktuellen und vergangenen Ausleihen an, verlängere Fristen oder storniere Reservierungen bei Bedarf.",
+    "active": "Aktive",
+    "past": "Vergangene"
+  }
path: app/pages/reservations/index.vue
user: login: maxverlaufen
body: Da passt alles! Wir sollten nur einen einheitlichen Begriff für "cancelled" finden. Gerade gibt es "Annuliert" und "Storniert". Ich wäre für "Storniert", das ist meiner Meinung nach gängiger.
created_at: 2025-05-16T10:52:45Z
author_association: NONE
diff_hunk: @@ -0,0 +1,197 @@
+<template>
+  <Dialog inset title="Reservierung" v-model:open="open">
+    <div v-if="reservation && product && location" class="product">
+      <img
+        :src="`${config.public.pocketbase.clientBaseUrl}/api/files/products/${product.id}/${product.images[0]}${thumbs.sm}`"
+      />
+      <div>
+        <p>
+          <strong>{{ t("product") }}</strong
+          ><br />
+          <a :href="`/link/product/${product.id}`" target="_blank">
+            {{ product.name }}
+          </a>
+        </p>
+        <p>
+          <strong>{{ t("location") }}</strong
+          ><br />
+          <a :href="`/l/${location.slug}`" target="_blank">
+            {{ location.name }}
+          </a>
+        </p>
+        <p>
+          <strong>{{ t("borrow_period") }}</strong
+          ><br />
+          {{ formatDate(reservation.start, DateTime.DATE_MED, locale) }}
+          -
+          {{ formatDate(reservation.end, DateTime.DATE_MED, locale) }}
+        </p>
+      </div>
+    </div>
+    <Accordion v-if="reservation" :single="true">
+      <AccordionItem id="cancel">
+        <AccordionTrigger
+          :disabled="
+            getReservationStatus(reservation) === ReservationStatus.Ended ||
+            getReservationStatus(reservation) === ReservationStatus.Cancelled
+          "
+        >
+          {{ t("cancel_trigger") }}
+        </AccordionTrigger>
+        <AccordionContent>
+          <div
+            v-if="getReservationStatus(reservation) === ReservationStatus.New"
+          >
+            <Button
+              v-if="
+                cancellationStatus === ReservationCancellationStatus.Default ||
+                cancellationStatus === ReservationCancellationStatus.Loading
+              "
+              :loading="
+                cancellationStatus === ReservationCancellationStatus.Loading
+              "
+              @click="cancel"
+            >
+              {{ t("cancel_button") }}
+            </Button>
+            <Alert
+              v-else-if="
+                cancellationStatus === ReservationCancellationStatus.Success
+              "
+              variant="success"
+            >
+              {{ cancellationMessage }}
+            </Alert>
+            <Alert
+              v-else-if="
+                cancellationStatus === ReservationCancellationStatus.Error
+              "
+              variant="error"
+            >
+              {{ cancellationMessage }}
+            </Alert>
+          </div>
+          <div
+            v-else-if="
+              getReservationStatus(reservation) === ReservationStatus.Started
+            "
+          >
+            <p>
+              {{ t("cancel_not_possible_text", [location?.email]) }}
+            </p>
+          </div>
+        </AccordionContent>
+      </AccordionItem>
+      <AccordionItem id="change-period">
+        <AccordionTrigger
+          :disabled="
+            getReservationStatus(reservation) === ReservationStatus.Ended ||
+            getReservationStatus(reservation) === ReservationStatus.Cancelled
+          "
+          >{{ t("change_period_trigger") }}</AccordionTrigger
+        >
+        <AccordionContent>
+          <p>{{ t("change_period_text", [location?.email]) }}</p>
+        </AccordionContent>
+      </AccordionItem>
+    </Accordion>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { DateTime } from "luxon";
+import Accordion from "~/components/Accordion/Accordion.vue";
+import AccordionContent from "~/components/Accordion/AccordionContent.vue";
+import AccordionItem from "~/components/Accordion/AccordionItem.vue";
+import AccordionTrigger from "~/components/Accordion/AccordionTrigger.vue";
+import {
+  ReservationCancellationStatus,
+  useReservationCancellation,
+} from "~/composables/useReservationCancellation";
+import { formatDate } from "~/lib/date";
+import { getReservationStatus } from "~/lib/reservation";
+import type { Location } from "~/models/location";
+import type { Product } from "~/models/product";
+import { ReservationStatus, type Reservation } from "~/models/reservation";
+
+const config = useRuntimeConfig();
+const {
+  product: { thumbs },
+} = useAppConfig();
+
+const { t, locale } = useI18n({
+  useScope: "local",
+});
+
+const props = defineProps<{
+  reservation?: Reservation;
+  product?: Product;
+  location?: Location;
+}>();
+
+const emit = defineEmits<{ update: [] }>();
+
+const open = ref(false);
+watch(
+  () => props.reservation,
+  (newValue) => {
+    open.value = !!newValue;
+  }
+);
+
+const {
+  cancel,
+  status: cancellationStatus,
+  message: cancellationMessage,
+} = useReservationCancellation(
+  () => props.reservation,
+  () => props.location,
+  () => emit("update")
+);
+</script>
+
+<style lang="scss" scoped>
+.product {
+  display: flex;
+  align-items: center;
+  background-color: var(--background-color);
+  padding: var(--fluid-spacing-4);
+  gap: var(--fluid-spacing-4);
+  border-radius: var(--border-radius);
+  margin-bottom: var(--fluid-spacing-4);
+  img {
+    width: 33.33%;
+    aspect-ratio: 1/1;
+    border-radius: var(--border-radius);
+    object-fit: cover;
+  }
+  p:last-child {
+    margin: 0;
+  }
+}
+</style>
+
+<i18n lang="json">
+{
+  "en": {
+    "product": "Product",
+    "location": "Location",
+    "borrow_period": "Borrow period",
+    "cancel_trigger": "Cancel reservation",
+    "cancel_button": "Cancel",
+    "cancel_not_possible_text": "This reservation can't be cancelled as it already started, please send an e-mail to {0} for any questions or remarks.",
+    "change_period_trigger": "Update reservation period",
+    "change_period_text": "To update the reservation period, please send an e-mail to {0}."
+  },
+  "de": {
+    "product": "Gegenstand",
+    "location": "Standort",
+    "borrow_period": "Leihfrist",
+    "cancel_trigger": "Reservierung stornieren",
+    "cancel_button": "Stornieren",
+    "cancel_not_possible_text": "Diese Reservierung kann nicht storniert worden, da der schon angefangen hat. Schicke bei weitere Fragen oder Problemen bitte ein E-Mail an {0}.",
+    "change_period_trigger": "Leihfrist änderen",
+    "change_period_text": "Um deine Leihfrist zu änderen, schicke bitte eine E-Mail an {0}."
+  }
path: app/pages/reservations/_components/ReservationDetailDialog.vue
user: login: maxverlaufen
body: Diese Reservierung kann nicht storniert worden, da sie bereits begonnen hat. Bei weiterenFragen oder Problemen schicke uns bitte eine E-Mail an {0}."

Rest passt.
created_at: 2025-05-16T10:54:43Z
author_association: NONE
diff_hunk: @@ -0,0 +1,100 @@
+<template>
+  <Button
+    variant="secondary"
+    :class="['card', 'status-' + getReservationStatus(reservation)]"
+  >
+    <img
+      :src="`${config.public.pocketbase.clientBaseUrl}/api/files/products/${reservation.product}/${product.images[0]}${thumbs.sm}`"
+    />
+    <div class="details">
+      <h2>{{ product?.name }}</h2>
+      <p>
+        {{ formatDate(reservation.start, DateTime.DATE_MED, locale) }}
+        -
+        {{ formatDate(reservation.end, DateTime.DATE_MED, locale) }}
+      </p>
+      <span class="tag">
+        {{ t(getReservationStatus(reservation)) }}
+      </span>
+    </div>
+  </Button>
+</template>
+
+<script setup lang="ts">
+import { DateTime } from "luxon";
+import { formatDate } from "~/lib/date";
+import { type Reservation } from "~/models/reservation";
+import type { Product } from "~/models/product";
+import { getReservationStatus } from "~/lib/reservation";
+
+const config = useRuntimeConfig();
+
+const {
+  product: { thumbs },
+} = useAppConfig();
+
+const { t, locale } = useI18n({
+  useScope: "local",
+});
+
+defineProps<{ reservation: Reservation; product?: Product }>();
+</script>
+
+<style scoped lang="scss">
+.card {
+  display: flex;
+  padding: 0.5rem !important;
+  gap: var(--fluid-spacing-4);
+  text-align: left;
+  width: 100%;
+  align-items: stretch;
+  h2 {
+    font-size: 1rem;
+    margin: 0;
+  }
+  img {
+    width: 7rem;
+    height: 7rem;
+    object-fit: cover;
+    border-radius: var(--border-radius);
+    flex-shrink: 0;
+  }
+  .details {
+    width: 100%;
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+  }
+  p {
+    margin: 0;
+  }
+  .tag {
+    border: 1px solid var(--text-color-light);
+    color: var(--text-color);
+    padding: 0.25rem 0.333rem;
+    font-size: var(--font-size-sm);
+    line-height: 1;
+    border-radius: var(--border-radius);
+    margin-top: auto;
+  }
+}
+</style>
+
+<i18n lang="json">
+{
+  "en": {
+    "new": "New",
+    "cancelled": "Cancelled",
+    "started": "Picked-up",
+    "ended": "Returned",
+    "overdue": "Overdue"
+  },
+  "de": {
+    "new": "Neu",
+    "cancelled": "Annuliert",
+    "started": "Abgehollt",
+    "ended": "Zurückgebracht",
+    "overdue": "Überfällig"
+  }
path: app/pages/reservations/_components/ReservationCardButton.vue
user: login: maxverlaufen
body: Hier würde ich "Storniert" statt "Annuliert" vorschlagen.
created_at: 2025-05-16T10:55:13Z
author_association: NONE

PR comments:
user: login: ArcoMul
created_at: 2025-05-16T11:18:57Z
body: @maxverlaufen Was meinst du von diese Reservierungsbestätigung E-Mail update? 👇 

---

Hi XXX,

hiermit bestätigen wir die Reservierung des Gegenstandes "XXX"

Deine Reservierung läuft vom 13.5.2025 bis zum 27.5.2025.

Der Gegenstand kann am 13.5.2025 abgeholt werden. Wenn es das erste Mal ist, dass du etwas ausleihst, werden wir dich vor Ort bitten [die Leihbedingungen](https://leihbar-koeln.de/leihbedingungen) zu unterschreiben und X€ Pfand zu hinterlegen.

~~Solltest Du Deine Reservierung stornieren müssen, freuen wir uns über eine kurze Email an [verleih@leihbar-koeln.org](mailto:verleih@leihbar-koeln.org).~~

_Deine Reservierung stornieren oder der Leihfrist anderen kann in deiner [Reservierungsübersicht](https://katalog.leihbar-koeln.org/reservations)._

Wir freuen uns, dass du dich für Leihen statt Kaufen entscheidest!

Bis bald
Deine Leihbar
author_association: COLLABORATOR
user: login: maxverlaufen
created_at: 2025-05-16T11:22:36Z
body: > @maxverlaufen Was meinst du von diese Reservierungsbestätigung E-Mail update? 👇
> 
> Hi XXX,
> 
> hiermit bestätigen wir die Reservierung des Gegenstandes "XXX"
> 
> Deine Reservierung läuft vom 13.5.2025 bis zum 27.5.2025.
> 
> Der Gegenstand kann am 13.5.2025 abgeholt werden. Wenn es das erste Mal ist, dass du etwas ausleihst, werden wir dich vor Ort bitten [die Leihbedingungen](https://leihbar-koeln.de/leihbedingungen) zu unterschreiben, uns deinen Ausweis zum Abgleich zu zeigen und X€ Pfand zu hinterlegen.
> 
> ~Solltest Du Deine Reservierung stornieren müssen, freuen wir uns über eine kurze Email an [verleih@leihbar-koeln.org](mailto:verleih@leihbar-koeln.org).~
> 
> _Deine Reservierung stornieren oder die Leihfrist ändern kannst du in deiner [Reservierungsübersicht](https://katalog.leihbar-koeln.org/reservations)._
> 
> Wir freuen uns, dass du dich für Leihen statt Kaufen entscheidest!
> 
> Bis bald Deine Leihbar

Habs hier im quote angepasst.
author_association: NONE
user: login: ArcoMul
created_at: 2025-05-16T11:24:03Z
body: @maxverlaufen und Reservierungsabholung Erinnerung:

---

Hi,

du hast bei Leihbar Köln-Mülheim den Gegenstand 'XXX' reserviert. Der Gegenstand liegt morgen (16.5.2025) zwischen 17:00 und 19:00 bereit. Bitte denke daran, deinen Gegenstand abzuholen. ~~Falls du ihn nicht mehr benötigst, antworte auf diese Mail, um deine Reservierung zu stornieren.~~

Falls du ihn nicht mehr benötigst, kannst du deine Reservierung in deiner [Reservierungsübersicht](https://katalog.leihbar-koeln.org/reservations) stornieren.

Danke, dass du dich für Leihen statt Kaufen entscheidest!

Liebe Grüße
Dein LeihBar-Team
author_association: COLLABORATOR
user: login: maxverlaufen
created_at: 2025-05-16T11:32:19Z
body: > @maxverlaufen und Reservierungsabholung Erinnerung:
> 
> Hi,
> 
> du hast bei **der** Leihbar Köln-Mülheim den Gegenstand 'XXX' reserviert. 
Rest passt :)


author_association: NONE
user: login: ArcoMul
created_at: 2025-05-16T11:32:53Z
body: @maxverlaufen und Reservierungszurückbringen Errinerung:

Hi,

wir hoffen, mit dem Gegenstand XXX hat alles gut funktioniert!
Deine Leihfrist endet morgen (16.5.2025), also bring uns den Gegenstand bitte zurück zur Leihbar Köln-Mülheim.

Leihbar Köln-Mülheim ist geöffnet von 17:00 bis 19:00.

_Falls du den Gegenstand langer behalten möchtest, kannst du deine Reservierung verlängern über deiner Reservierungsübersicht](https://katalog.leihbar-koeln.org/reservations)._

Danke, und bis morgen!

Liebe Grüße
dein LeihBar-Team
author_association: COLLABORATOR
user: login: ArcoMul
created_at: 2025-05-16T11:34:45Z
body: > > @maxverlaufen und Reservierungsabholung Erinnerung:
> > Hi,
> > du hast bei **der** Leihbar Köln-Mülheim den Gegenstand 'XXX' reserviert.
> > Rest passt :)

Das geht leider nicht, da der Name des 'Location' "Leihbar Köln-Mülheim" ist, könnte auch "Dennis' Leihladen" sein. Dann würde "der Dennis' Leihladen" komisch sein. Aber vielleicht gibt es eine allgemeinere Ansatz? Hast du ein Idee?
author_association: COLLABORATOR
user: login: maxverlaufen
created_at: 2025-05-16T11:38:34Z
body: > 
> _Falls du den Gegenstand länger behalten möchtest, kannst du die Reservierung in deiner Reservierungsübersicht](https://katalog.leihbar-koeln.org/reservations) verlängern._
> 
> Danke, und bis morgen!
> 
> Liebe Grüße dein LeihBar-Team


author_association: NONE
user: login: maxverlaufen
created_at: 2025-05-16T11:39:58Z
body: > > > @maxverlaufen und Reservierungsabholung Erinnerung:
> > > Hi,
> > > du hast bei **der** Leihbar Köln-Mülheim den Gegenstand 'XXX' reserviert.
> > > Rest passt :)
> 
> Das geht leider nicht, da der Name des 'Location' "Leihbar Köln-Mülheim" ist, könnte auch "Dennis' Leihladen" sein. Dann würde "der Dennis' Leihladen" komisch sein. Aber vielleicht gibt es eine allgemeinere Ansatz? Hast du ein Idee?

Ah ich verstehe... Ne, dann kann man da nichts machen. Da ist die deutsche Sprache mit ihren Geschlechterspezifischen Präpositionen echt hinderlich :D Aber dann ist das halt so.
author_association: NONE
user: login: ArcoMul
created_at: 2025-05-19T17:39:00Z
body: @maxverlaufen Als Teil dieses Feature möchte ich eine Stornierung-Bestätigung E-Mail implementieren, sodass wenn man storniert mittels eines Buttons, man nochmal eine 'offizielle' Ruckmeldung bekommt, dass es jetzt echt storniert ist. Ich glaube auch nützlich, wenn man eine Bestätigung E-Mail bekommt, wenn eine Reservierung über die Admin Oberflache storniert wird. Was denkst du?

Hier ein Konzept zum Text kannst du gerne erweitern/verbessern/umschreiben:

---

Hi XXX,

Hiermit bestätigen wir die Stornierung deine Reservierung für den Gegenstand 'XXX'.

Danke fürs Weitergeben, der Gegenstand ist jetzt wieder verfügbar für andere Nachbar:innen!

Gerne bis zum nächste Mal!

Liebe Grüße
dein LeihBar-Team


author_association: COLLABORATOR
user: login: ArcoMul
created_at: 2025-05-19T18:12:02Z
body: @maxverlaufen Und noch ein Text zum Korrigieren/Verbessern/Umschreiben 😁 🙏 

>Machst du den Gegenstand nicht mehr ausleihen? Mache den Gegenstand wieder verfügbar duch das Stornieren deine Reservierung. Bitte beachte dass diese Aktion nicht rückgängig gemacht werden kann.

<img width="676" alt="image" src="https://github.com/user-attachments/assets/ddcd7bf9-befc-4c43-8833-69da4fa05af6" />

author_association: COLLABORATOR
user: login: maxverlaufen
created_at: 2025-05-23T08:41:03Z
body: > @maxverlaufen Als Teil dieses Feature möchte ich eine Stornierung-Bestätigung E-Mail implementieren, sodass wenn man storniert mittels eines Buttons, man nochmal eine 'offizielle' Ruckmeldung bekommt, dass es jetzt echt storniert ist. Ich glaube auch nützlich, wenn man eine Bestätigung E-Mail bekommt, wenn eine Reservierung über die Admin Oberflache storniert wird. Was denkst du?
> 
> Hier ein Konzept zum Text kannst du gerne erweitern/verbessern/umschreiben:
> 
> Hi XXX,
> 
> Hiermit bestätigen wir die Stornierung **deiner** Reservierung für den Gegenstand 'XXX'.
> 
> Danke fürs Weitergeben, der Gegenstand ist jetzt wieder verfügbar für andere Nachbar:innen!
> 
> Gerne bis zum nächste Mal!
> 
> Liebe Grüße dein LeihBar-Team

Nur eine Anpassung nötig :)
author_association: NONE
user: login: maxverlaufen
created_at: 2025-05-23T08:42:30Z
body: > @maxverlaufen Und noch ein Text zum Korrigieren/Verbessern/Umschreiben 😁 🙏
> 
> > Möchtest du den Gegenstand nicht mehr ausleihen? Mache den Gegenstand duch die Stornierung deiner Reservierung wieder verfügbar. Bitte beachte dass diese Aktion nicht rückgängig gemacht werden kann.

Satz bisschen umgestellt.
author_association: NONE