Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions kompassi-v2-frontend/src/__generated__/gql.ts

Large diffs are not rendered by default.

158 changes: 156 additions & 2 deletions kompassi-v2-frontend/src/__generated__/graphql.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { confirmOrderCancellation } from "../actions";
import SubmitButton from "@/components/forms/SubmitButton";
import OrderCancellationView from "@/components/tickets/OrderCancellationView";
import { getOrder } from "@/services/tickets";
import { getTranslations } from "@/translations";

interface Props {
params: Promise<{
locale: string;
eventSlug: string;
orderId: string;
code: string;
}>;
searchParams: Promise<Record<string, string>>;
}

export const revalidate = 0;

/// Order cancellation confirmation page, reached via the link in the confirmation
/// email. See OrderCancellationView for PII concerns.
export default async function OrderCancellationConfirmationPage(props: Props) {
const { locale, eventSlug, orderId, code } = await props.params;
const searchParams = await props.searchParams;
const data = await getOrder(eventSlug, orderId);
const translations = getTranslations(locale);
const t = translations.Tickets.Order;
const confirmT = t.cancelPage.confirm;

return (
<OrderCancellationView
title={confirmT.title}
eventSlug={eventSlug}
orderId={orderId}
data={data}
messages={t}
searchParams={searchParams}
>
{confirmT.warning}

<form
action={confirmOrderCancellation.bind(
null,
locale,
eventSlug,
orderId,
code,
)}
>
<div className="d-grid gap-2 mb-4">
<SubmitButton className="btn btn-danger btn-lg">
{confirmT.actions.cancelOrder}
</SubmitButton>
</div>
</form>
</OrderCancellationView>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { graphql } from "@/__generated__/gql";
import { getClient } from "@/apolloClient";

const requestOrderCancellationMutation = graphql(`
mutation RequestOrderCancellation($input: RequestOrderCancellationInput!) {
requestOrderCancellation(input: $input) {
success
}
}
`);

export async function requestOrderCancellation(
locale: string,
eventSlug: string,
orderId: string,
) {
let success = false;
try {
const response = await getClient().mutate({
mutation: requestOrderCancellationMutation,
variables: {
input: {
eventSlug,
orderId,
},
},
});
success = !!response.data?.requestOrderCancellation?.success;
} catch (error) {
console.error("requestOrderCancellation failed", error);
}

if (success) {
return void redirect(
`/${eventSlug}/orders/${orderId}/cancel?success=cancellationRequested`,
);
} else {
return void redirect(
`/${eventSlug}/orders/${orderId}/cancel?error=cancellationRequestFailed`,
);
}
}

const confirmOrderCancellationMutation = graphql(`
mutation ConfirmOrderCancellation($input: ConfirmOrderCancellationInput!) {
confirmOrderCancellation(input: $input) {
success
}
}
`);

export async function confirmOrderCancellation(
locale: string,
eventSlug: string,
orderId: string,
code: string,
) {
// "cancelled": order cancelled, refund (if any) initiated
// "refundFailed": order cancelled, but the provider rejected the refund request
// "error": nothing happened (eg. invalid or expired code)
let outcome: "cancelled" | "refundFailed" | "error" = "error";
try {
const response = await getClient().mutate({
mutation: confirmOrderCancellationMutation,
variables: {
input: {
eventSlug,
orderId,
code,
},
},
});
if (response.data?.confirmOrderCancellation) {
outcome = response.data.confirmOrderCancellation.success
? "cancelled"
: "refundFailed";
}
} catch (error) {
console.error("confirmOrderCancellation failed", error);
}

switch (outcome) {
case "cancelled":
revalidatePath(`/${locale}/${eventSlug}/orders/${orderId}`);
return void redirect(`/${eventSlug}/orders/${orderId}?success=cancelled`);
case "refundFailed":
revalidatePath(`/${locale}/${eventSlug}/orders/${orderId}`);
return void redirect(
`/${eventSlug}/orders/${orderId}?error=refundFailed`,
);
default:
return void redirect(
`/${eventSlug}/orders/${orderId}/cancel/${code}?error=cancellationFailed`,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { requestOrderCancellation } from "./actions";
import FormattedDateTime from "@/components/FormattedDateTime";
import SubmitButton from "@/components/forms/SubmitButton";
import OrderCancellationView from "@/components/tickets/OrderCancellationView";
import { getOrder } from "@/services/tickets";
import { getTranslations } from "@/translations";

interface Props {
params: Promise<{
locale: string;
eventSlug: string;
orderId: string;
}>;
searchParams: Promise<Record<string, string>>;
}

export const revalidate = 0;

/// Order cancellation request page. See OrderCancellationView for PII concerns.
export default async function OrderCancellationPage(props: Props) {
const { locale, eventSlug, orderId } = await props.params;
const searchParams = await props.searchParams;
const data = await getOrder(eventSlug, orderId);
const translations = getTranslations(locale);
const t = translations.Tickets.Order;
const cancelT = t.cancelPage;

// A link was just sent. Requesting another one immediately is throttled by
// design, so disable the button to avoid an inevitable failure.
const cancellationRequested =
searchParams.success === "cancellationRequested";

return (
<OrderCancellationView
title={cancelT.title}
eventSlug={eventSlug}
orderId={orderId}
data={data}
messages={t}
searchParams={searchParams}
>
{cancelT.explanation}

{data.order.cancellationDeadline && (
<p>
{cancelT.deadline(
<FormattedDateTime
value={data.order.cancellationDeadline}
locale={locale}
scope={undefined}
session={undefined}
/>,
)}
</p>
)}

<form
action={requestOrderCancellation.bind(null, locale, eventSlug, orderId)}
>
<div className="d-grid gap-2 mb-4">
<SubmitButton
className={
cancellationRequested
? "btn btn-secondary btn-lg"
: "btn btn-danger btn-lg"
}
disabled={cancellationRequested}
>
{cancellationRequested
? cancelT.actions.cancellationLinkSent
: cancelT.actions.sendConfirmationEmail}
</SubmitButton>
</div>
</form>
</OrderCancellationView>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { ReactNode } from "react";

import { payOrder } from "./actions";
import { PaymentStatus } from "@/__generated__/graphql";
import Messages from "@/components/errors/Messages";
import Section from "@/components/Section";
import OrderHeader from "@/components/tickets/OrderHeader";
import ProductsTable from "@/components/tickets/ProductsTable";
import RequestCancellationSection from "@/components/tickets/RequestCancellationSection";
import SellerSection from "@/components/tickets/SellerSection";
import ViewContainer from "@/components/ViewContainer";
import { getOrder } from "@/services/tickets";
Expand All @@ -17,6 +19,7 @@ interface Props {
eventSlug: string;
orderId: string;
}>;
searchParams: Promise<Record<string, string>>;
}

export const revalidate = 0;
Expand All @@ -26,6 +29,7 @@ export const revalidate = 0;
/// so absolutely no PII.
export default async function OrderPage(props: Props) {
const params = await props.params;
const searchParams = await props.searchParams;
const { locale, eventSlug, orderId } = params;
const { order, event, seller } = await getOrder(eventSlug, orderId);
const translations = getTranslations(locale);
Expand All @@ -50,6 +54,8 @@ export default async function OrderPage(props: Props) {
<ViewContainer>
<OrderHeader order={order} messages={t} locale={locale} event={event} />

<Messages messages={t.Order.cancelMessages} searchParams={searchParams} />

<ProductsTable order={order} locale={locale} messages={t} />

<SellerSection seller={seller} messages={t.Order.attributes.seller} />
Expand All @@ -66,6 +72,13 @@ export default async function OrderPage(props: Props) {
</Section>
)}

<RequestCancellationSection
order={order}
cancelHref={`/${eventSlug}/orders/${orderId}/cancel`}
contactEmail={seller.email}
messages={t.Order.actions.requestCancellation}
/>

{showProfileMessage && <p>{t.Order.profileMessage(ProfileLink)}</p>}
</ViewContainer>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"use server";

import { revalidatePath } from "next/cache";
import { graphql } from "@/__generated__";
import { getClient } from "@/apolloClient";

const mutation = graphql(`
mutation UpdateTicketsPreferences($input: UpdateTicketsPreferencesInput!) {
updateTicketsPreferences(input: $input) {
preferences {
contactEmail
termsAndConditionsUrlEn
termsAndConditionsUrlFi
termsAndConditionsUrlSv
cancellationPeriodDays
}
}
}
`);

export async function updateTicketsPreferences(
locale: string,
eventSlug: string,
formData: FormData,
) {
const cancellationPeriodDays =
parseInt("" + formData.get("cancellationPeriodDays"), 10) || 0;

await getClient().mutate({
mutation,
variables: {
input: {
eventSlug,
contactEmail: "" + (formData.get("contactEmail") ?? ""),
termsAndConditionsUrlEn:
"" + (formData.get("termsAndConditionsUrlEn") ?? ""),
termsAndConditionsUrlFi:
"" + (formData.get("termsAndConditionsUrlFi") ?? ""),
termsAndConditionsUrlSv:
"" + (formData.get("termsAndConditionsUrlSv") ?? ""),
cancellationPeriodDays,
},
},
});

revalidatePath(`/${locale}/${eventSlug}/tickets-preferences`);
}
Loading
Loading