• Added the two‑step Widerruf flow with an auth‑only CTA on the Widerrufsbelehrung page and a dedicated confirmation
screen where users pick an eligible end‑customer purchase and confirm. Eligibility is enforced server‑side
(endcustomer_event, within 14 days, no event package created after purchase), refund is issued via Paddle, the
purchase is marked refunded, the tenant package is deactivated, and a new confirmation email is sent using resources/
views/emails/partials/layout.blade.php.
Details
- New controller + form request for the confirm flow: app/Http/Controllers/WithdrawalController.php, app/Http/
Requests/Marketing/WithdrawalConfirmRequest.php
- New confirmation page + CTA: resources/js/pages/marketing/WithdrawalConfirm.tsx, resources/js/pages/legal/Show.tsx
- Routes + locale rewrites: routes/web.php, resources/js/lib/localizedPath.ts
- New email notification + template: app/Notifications/Customer/WithdrawalConfirmed.php, resources/views/emails/
withdrawal-confirmation.blade.php
- Translations added for marketing UI + backend flash + email copy: public/lang/de/marketing.json, public/lang/en/
marketing.json, resources/lang/de/marketing.php, resources/lang/en/marketing.php, resources/lang/de/emails.php,
resources/lang/en/emails.php
- Tests: tests/Feature/Marketing/WithdrawalConfirmationTest.php
This commit is contained in:
@@ -17,6 +17,8 @@ export const defaultLocaleRewrites: LocaleRewriteMap = {
|
||||
'/occasions/birthday': { de: '/anlaesse/geburtstag' },
|
||||
'/occasions/corporate-event': { de: '/anlaesse/firmenevent' },
|
||||
'/occasions/confirmation': { de: '/anlaesse/konfirmation' },
|
||||
'/widerruf': { en: '/withdrawal/confirm' },
|
||||
'/withdrawal/confirm': { de: '/widerruf' },
|
||||
};
|
||||
|
||||
const sanitizePath = (input: string): string => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MarketingLayout from '@/layouts/mainWebsite';
|
||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||
|
||||
type LegalShowProps = {
|
||||
seoTitle: string;
|
||||
@@ -12,7 +15,11 @@ type LegalShowProps = {
|
||||
};
|
||||
|
||||
const LegalShow: React.FC<LegalShowProps> = (props) => {
|
||||
const { seoTitle, title, content, effectiveFromLabel, versionLabel } = props;
|
||||
const { seoTitle, title, content, effectiveFromLabel, versionLabel, slug } = props;
|
||||
const { t } = useTranslation('marketing');
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const { auth } = usePage<{ auth?: { user?: { id?: number } | null } }>().props;
|
||||
const showWithdrawalAction = slug === 'widerrufsbelehrung' && Boolean(auth?.user);
|
||||
|
||||
return (
|
||||
<MarketingLayout title={seoTitle}>
|
||||
@@ -33,6 +40,23 @@ const LegalShow: React.FC<LegalShowProps> = (props) => {
|
||||
)}
|
||||
</header>
|
||||
|
||||
{showWithdrawalAction && (
|
||||
<div className="mb-10 rounded-2xl border border-pink-200/60 bg-pink-50 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{t('withdrawal.cta_title')}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
{t('withdrawal.cta_body')}
|
||||
</p>
|
||||
<Link
|
||||
href={localizedPath('/widerruf')}
|
||||
className="mt-4 inline-flex items-center justify-center rounded-full bg-gray-900 px-5 py-2 text-sm font-semibold text-white transition hover:bg-gray-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900"
|
||||
>
|
||||
{t('withdrawal.cta_button')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<article
|
||||
className="prose prose-slate max-w-none prose-headings:font-display"
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
|
||||
169
resources/js/pages/marketing/WithdrawalConfirm.tsx
Normal file
169
resources/js/pages/marketing/WithdrawalConfirm.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Head, Link, useForm, usePage } from '@inertiajs/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MarketingLayout from '@/layouts/mainWebsite';
|
||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type EligiblePurchase = {
|
||||
id: number;
|
||||
package_name: string;
|
||||
purchased_at: string;
|
||||
expires_at: string;
|
||||
price: number | string;
|
||||
currency: string;
|
||||
};
|
||||
|
||||
type WithdrawalConfirmProps = {
|
||||
eligiblePurchases: EligiblePurchase[];
|
||||
windowDays: number;
|
||||
};
|
||||
|
||||
const WithdrawalConfirm: React.FC<WithdrawalConfirmProps> = ({ eligiblePurchases, windowDays }) => {
|
||||
const { t, i18n } = useTranslation('marketing');
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const { flash } = usePage<{ flash?: { success?: string; error?: string } }>().props;
|
||||
const initialPurchaseId = eligiblePurchases[0]?.id ?? null;
|
||||
const { data, setData, post, processing, errors } = useForm<{ purchase_id: number | null }>({
|
||||
purchase_id: initialPurchaseId,
|
||||
});
|
||||
|
||||
const formatDate = (value: string) => new Date(value).toLocaleDateString(i18n.language);
|
||||
const formatDateTime = (value: string) => new Date(value).toLocaleString(i18n.language);
|
||||
const formattedPurchases = useMemo(
|
||||
() => eligiblePurchases.map((purchase) => ({
|
||||
...purchase,
|
||||
purchasedLabel: formatDateTime(purchase.purchased_at),
|
||||
expiresLabel: formatDate(purchase.expires_at),
|
||||
})),
|
||||
[eligiblePurchases, i18n.language],
|
||||
);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
post(localizedPath('/widerruf'), { preserveScroll: true });
|
||||
};
|
||||
|
||||
const hasEligible = formattedPurchases.length > 0;
|
||||
|
||||
return (
|
||||
<MarketingLayout title={t('withdrawal.title')}>
|
||||
<Head title={t('withdrawal.title')} />
|
||||
<section className="bg-white py-16">
|
||||
<div className="mx-auto max-w-4xl px-6">
|
||||
<header className="mb-10">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-gray-400">
|
||||
Fotospiel App
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold text-gray-900 md:text-4xl">
|
||||
{t('withdrawal.title')}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-gray-500">
|
||||
{t('withdrawal.subtitle', { days: windowDays })}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{flash?.success && (
|
||||
<div className="mb-6 rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-700">
|
||||
{flash.success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{flash?.error && (
|
||||
<div className="mb-6 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
{flash.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasEligible ? (
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{t('withdrawal.empty_title')}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
{t('withdrawal.empty_body')}
|
||||
</p>
|
||||
<Link
|
||||
href={localizedPath('/packages')}
|
||||
className="mt-6 inline-flex items-center text-sm font-semibold text-gray-900 underline underline-offset-4"
|
||||
>
|
||||
{t('withdrawal.empty_cta')}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{t('withdrawal.selection_title')}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
{t('withdrawal.selection_body')}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
{formattedPurchases.map((purchase) => (
|
||||
<label
|
||||
key={purchase.id}
|
||||
className="flex cursor-pointer items-start gap-4 rounded-xl border border-gray-200 p-4 transition hover:border-gray-300"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="purchase_id"
|
||||
value={purchase.id}
|
||||
checked={data.purchase_id === purchase.id}
|
||||
onChange={() => setData('purchase_id', purchase.id)}
|
||||
className="mt-1 h-4 w-4 text-gray-900"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{purchase.package_name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{t('withdrawal.purchase_date', { date: purchase.purchasedLabel })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-gray-900">
|
||||
{purchase.price} {purchase.currency}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
{t('withdrawal.expires_at', { date: purchase.expiresLabel })}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{errors.purchase_id && (
|
||||
<p className="mt-3 text-sm text-red-600">
|
||||
{errors.purchase_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-gray-200 bg-gray-50 p-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{t('withdrawal.confirm_title')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
{t('withdrawal.confirm_body')}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" size="lg" disabled={processing || !data.purchase_id}>
|
||||
{processing ? t('withdrawal.confirm_processing') : t('withdrawal.confirm_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</MarketingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
WithdrawalConfirm.layout = (page: React.ReactNode) => page;
|
||||
|
||||
export default WithdrawalConfirm;
|
||||
@@ -279,6 +279,19 @@ return [
|
||||
'reason' => 'Grund: :reason',
|
||||
'footer' => 'Die Rückerstattung wird vom Zahlungsanbieter verarbeitet und kann je nach Bank einige Tage dauern.',
|
||||
],
|
||||
'withdrawal_confirmation' => [
|
||||
'subject' => 'Widerruf bestätigt: :package',
|
||||
'preheader' => 'Dein Widerruf für :package wurde bestätigt.',
|
||||
'subtitle' => 'Widerruf für :package',
|
||||
'greeting' => 'Hallo :name,',
|
||||
'body' => 'Wir haben deinen Widerruf für :package dokumentiert und die Rückerstattung angestoßen.',
|
||||
'package_label' => 'Paket',
|
||||
'amount_label' => 'Betrag',
|
||||
'transaction_label' => 'Zahlungs-ID',
|
||||
'confirmed_label' => 'Bestätigt am',
|
||||
'processing_hint' => 'Die Rückerstattung wird vom Zahlungsanbieter verarbeitet und kann je nach Bank einige Tage dauern.',
|
||||
'footer' => 'Wenn du Fragen hast, antworte einfach auf diese E-Mail.',
|
||||
],
|
||||
|
||||
'ops' => [
|
||||
'purchase' => [
|
||||
|
||||
@@ -301,4 +301,12 @@ return [
|
||||
],
|
||||
'too_many_attempts' => 'Zu viele Versuche. Bitte kurz warten und erneut probieren.',
|
||||
],
|
||||
'withdrawal' => [
|
||||
'success' => 'Dein Widerruf wurde bestätigt. Eine Bestätigung ist per E-Mail unterwegs.',
|
||||
'errors' => [
|
||||
'not_eligible' => 'Dieses Paket kann aktuell nicht widerrufen werden.',
|
||||
'missing_transaction' => 'Für diesen Kauf fehlt eine gültige Zahlungsreferenz.',
|
||||
'refund_failed' => 'Die Rückerstattung konnte nicht gestartet werden. Bitte kontaktiere den Support.',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -253,6 +253,19 @@ return [
|
||||
'reason' => 'Reason: :reason',
|
||||
'footer' => 'The refund is processed by the payment provider and may take a few days depending on your bank.',
|
||||
],
|
||||
'withdrawal_confirmation' => [
|
||||
'subject' => 'Withdrawal confirmed: :package',
|
||||
'preheader' => 'Your withdrawal for :package has been confirmed.',
|
||||
'subtitle' => 'Withdrawal for :package',
|
||||
'greeting' => 'Hi :name,',
|
||||
'body' => 'We have documented your withdrawal for :package and started the refund.',
|
||||
'package_label' => 'Package',
|
||||
'amount_label' => 'Amount',
|
||||
'transaction_label' => 'Payment ID',
|
||||
'confirmed_label' => 'Confirmed on',
|
||||
'processing_hint' => 'The refund is processed by the payment provider and may take a few days depending on your bank.',
|
||||
'footer' => 'If you have any questions, just reply to this email.',
|
||||
],
|
||||
|
||||
'ops' => [
|
||||
'purchase' => [
|
||||
|
||||
@@ -301,4 +301,12 @@ return [
|
||||
],
|
||||
'too_many_attempts' => 'Too many attempts. Please wait a moment and try again.',
|
||||
],
|
||||
'withdrawal' => [
|
||||
'success' => 'Your withdrawal was confirmed. A confirmation email is on the way.',
|
||||
'errors' => [
|
||||
'not_eligible' => 'This package is currently not eligible for withdrawal.',
|
||||
'missing_transaction' => 'This purchase is missing a valid payment reference.',
|
||||
'refund_failed' => 'We could not start the refund. Please contact support.',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
39
resources/views/emails/withdrawal-confirmation.blade.php
Normal file
39
resources/views/emails/withdrawal-confirmation.blade.php
Normal file
@@ -0,0 +1,39 @@
|
||||
@extends('emails.partials.layout')
|
||||
|
||||
@section('title', $subject)
|
||||
@section('preheader', __('emails.withdrawal_confirmation.preheader', ['package' => $packageName]))
|
||||
@section('hero_title', $greeting)
|
||||
@section('hero_subtitle', __('emails.withdrawal_confirmation.subtitle', ['package' => $packageName]))
|
||||
|
||||
@section('content')
|
||||
<p style="margin:0 0 16px; font-size:15px; color:#0f172a;">
|
||||
{{ __('emails.withdrawal_confirmation.body', ['package' => $packageName]) }}
|
||||
</p>
|
||||
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse; margin:0 0 16px;">
|
||||
<tr>
|
||||
<td style="padding:10px 0; font-size:14px; color:#6b7280;">{{ __('emails.withdrawal_confirmation.package_label') }}</td>
|
||||
<td style="padding:10px 0; font-size:14px; color:#0f172a; text-align:right;">{{ $packageName }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 0; font-size:14px; color:#6b7280;">{{ __('emails.withdrawal_confirmation.amount_label') }}</td>
|
||||
<td style="padding:10px 0; font-size:14px; color:#0f172a; text-align:right;">{{ $amount }} {{ $currency }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 0; font-size:14px; color:#6b7280;">{{ __('emails.withdrawal_confirmation.transaction_label') }}</td>
|
||||
<td style="padding:10px 0; font-size:14px; color:#0f172a; text-align:right;">{{ $providerId }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 0; font-size:14px; color:#6b7280;">{{ __('emails.withdrawal_confirmation.confirmed_label') }}</td>
|
||||
<td style="padding:10px 0; font-size:14px; color:#0f172a; text-align:right;">{{ $confirmedAt->locale(app()->getLocale())->isoFormat('LLL') }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0; font-size:14px; color:#6b7280;">
|
||||
{{ __('emails.withdrawal_confirmation.processing_hint') }}
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
{!! __('emails.withdrawal_confirmation.footer') !!}
|
||||
@endsection
|
||||
Reference in New Issue
Block a user