mirror of
https://github.com/apophenia-news/apophenia.news.git
synced 2026-10-17 01:17:53 +00:00
Auto-unsubscribe via ?email= with animated green check
Visiting /newsletter/unsubscribe?email=... now unsubscribes on load and shows an animated success check; the manual form shares the same state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+65
-38
@@ -204,46 +204,73 @@ ${shellHead({
|
||||
})}
|
||||
${nav}
|
||||
<main class="shell py-10">
|
||||
<section class="card max-w-md mx-auto p-6 sm:p-8">
|
||||
<h1 class="text-3xl font-bold">Unsubscribe</h1>
|
||||
<p class="mt-3 text-zinc-600">Enter your email to stop receiving our newsletter.</p>
|
||||
<form class="mt-6" novalidate x-data="{
|
||||
email: '', loading: false, ok: false, error: '',
|
||||
async submit() {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
this.ok = false;
|
||||
this.error = '';
|
||||
try {
|
||||
const res = await fetch('https://newsletter.planetrenox.com/api/unsub', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pool: 'apophenia', email: this.email.trim() })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.ok) {
|
||||
this.ok = true;
|
||||
this.email = '';
|
||||
} else {
|
||||
this.error = data.error === 'invalid email' ? 'Please enter a valid email address.' : 'Something went wrong. Please try again.';
|
||||
}
|
||||
} catch {
|
||||
this.error = 'Could not connect. Please try again.';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
<section class="card max-w-md mx-auto p-6 sm:p-8" x-data="{
|
||||
email: '', done: '', auto: false, loading: false, ok: false, error: '',
|
||||
init() {
|
||||
// ?email=a+b@x.com -> URLSearchParams turns '+' into ' ', and emails never contain spaces
|
||||
const e = new URLSearchParams(location.search).get('email')?.trim().replace(/ /g, '+');
|
||||
if (e) (this.email = e, this.auto = true, this.submit());
|
||||
},
|
||||
async submit() {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
this.ok = false;
|
||||
this.error = '';
|
||||
try {
|
||||
const res = await fetch('https://newsletter.planetrenox.com/api/unsub', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pool: 'apophenia', email: this.email.trim() })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.ok) {
|
||||
this.done = data.email;
|
||||
this.ok = true;
|
||||
this.email = '';
|
||||
history.replaceState(null, '', location.pathname);
|
||||
} else {
|
||||
this.error = data.error === 'invalid email' ? 'Please enter a valid email address.' : 'Something went wrong. Please try again.';
|
||||
}
|
||||
} catch {
|
||||
this.error = 'Could not connect. Please try again.';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.auto = false;
|
||||
}
|
||||
}" @submit.prevent="submit">
|
||||
<label for="unsubscribe-email" class="block text-sm font-medium">Email address</label>
|
||||
<input id="unsubscribe-email" name="email" type="email" autocomplete="email" required x-model="email" :disabled="loading"
|
||||
placeholder="you@example.com" class="mt-2 w-full rounded-xl border border-zinc-300 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||
<button type="submit" :disabled="loading"
|
||||
class="mt-3 w-full rounded-xl bg-accent px-4 py-3 font-medium text-white transition hover:bg-indigo-700 disabled:opacity-60"
|
||||
x-text="loading ? 'Unsubscribing…' : 'Unsubscribe'">Unsubscribe</button>
|
||||
<p x-show="ok" x-cloak role="status" class="mt-4 text-sm text-emerald-700">You're unsubscribed from the Apophenia newsletter.</p>
|
||||
<p x-show="error" x-cloak role="alert" x-text="error" class="mt-4 text-sm text-rose-700"></p>
|
||||
</form>
|
||||
<a href="/newsletter/" class="mt-6 inline-block text-sm">Back to newsletter</a>
|
||||
}
|
||||
}">
|
||||
<div x-show="ok" x-cloak role="status" class="text-center py-2">
|
||||
<svg class="unsub-check mx-auto h-32 w-32" viewBox="0 0 120 120" aria-hidden="true">
|
||||
<circle class="uc-wave" cx="60" cy="60" r="44"/>
|
||||
<g class="uc-burst">${[0, 45, 90, 135, 180, 225, 270, 315].map((a) => `<line x1="60" y1="9" x2="60" y2="2" transform="rotate(${a} 60 60)"/>`).join("")}</g>
|
||||
<circle class="uc-disc" cx="60" cy="60" r="44"/>
|
||||
<circle class="uc-ring" cx="60" cy="60" r="44" pathLength="1"/>
|
||||
<path class="uc-tick" d="M39 61.5l14 14 28-29" pathLength="1"/>
|
||||
</svg>
|
||||
<h1 class="uc-text mt-5 text-3xl font-bold">You're unsubscribed</h1>
|
||||
<p class="uc-text mt-3 text-zinc-600"><b class="text-zinc-900 break-all" x-text="done"></b> won't receive Apophenia emails anymore.</p>
|
||||
<a href="/newsletter/" class="uc-text mt-6 inline-block text-sm">Changed your mind? Resubscribe</a>
|
||||
</div>
|
||||
|
||||
<div x-show="auto && !ok" x-cloak class="py-10 text-center text-zinc-600">
|
||||
<span class="mx-auto mb-4 block h-10 w-10 animate-spin rounded-full border-4 border-emerald-100 border-t-emerald-500"></span>
|
||||
Unsubscribing <b class="text-zinc-900 break-all" x-text="email"></b>…
|
||||
</div>
|
||||
|
||||
<div x-show="!ok && !auto">
|
||||
<h1 class="text-3xl font-bold">Unsubscribe</h1>
|
||||
<p class="mt-3 text-zinc-600">Enter your email to stop receiving our newsletter.</p>
|
||||
<form class="mt-6" novalidate @submit.prevent="submit">
|
||||
<label for="unsubscribe-email" class="block text-sm font-medium">Email address</label>
|
||||
<input id="unsubscribe-email" name="email" type="email" autocomplete="email" required x-model="email" :disabled="loading"
|
||||
placeholder="you@example.com" class="mt-2 w-full rounded-xl border border-zinc-300 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||
<button type="submit" :disabled="loading"
|
||||
class="mt-3 w-full rounded-xl bg-accent px-4 py-3 font-medium text-white transition hover:bg-indigo-700 disabled:opacity-60"
|
||||
x-text="loading ? 'Unsubscribing…' : 'Unsubscribe'">Unsubscribe</button>
|
||||
<p x-show="error" x-cloak role="alert" x-text="error" class="mt-4 text-sm text-rose-700"></p>
|
||||
</form>
|
||||
<a href="/newsletter/" class="mt-6 inline-block text-sm">Back to newsletter</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
${footer}
|
||||
|
||||
@@ -58,3 +58,77 @@
|
||||
@apply inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-full border border-indigo-200 bg-soft text-indigo-700;
|
||||
}
|
||||
}
|
||||
|
||||
/* Unsubscribe success check: ring draws -> disc pops -> tick draws -> wave + burst */
|
||||
.unsub-check {
|
||||
overflow: visible;
|
||||
filter: drop-shadow(0 12px 28px rgba(16, 185, 129, 0.35));
|
||||
}
|
||||
.unsub-check > * {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
.uc-ring {
|
||||
fill: none;
|
||||
stroke: #10b981;
|
||||
stroke-width: 4;
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
transform: rotate(-90deg);
|
||||
animation: uc-draw 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards;
|
||||
}
|
||||
.uc-disc {
|
||||
fill: #10b981;
|
||||
transform: scale(0);
|
||||
animation: uc-pop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) 0.45s forwards;
|
||||
}
|
||||
.uc-tick {
|
||||
fill: none;
|
||||
stroke: #fff;
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
animation: uc-draw 0.35s cubic-bezier(0.65, 0, 0.45, 1) 0.8s forwards;
|
||||
}
|
||||
.uc-wave {
|
||||
fill: none;
|
||||
stroke: #34d399;
|
||||
stroke-width: 3;
|
||||
opacity: 0;
|
||||
animation: uc-wave 1s ease-out 0.85s forwards;
|
||||
}
|
||||
.uc-burst {
|
||||
stroke: #34d399;
|
||||
stroke-width: 4;
|
||||
stroke-linecap: round;
|
||||
opacity: 0;
|
||||
transform-box: view-box;
|
||||
animation: uc-burst 0.7s ease-out 0.95s forwards;
|
||||
}
|
||||
.uc-text {
|
||||
opacity: 0;
|
||||
animation: uc-rise 0.5s ease-out 1s forwards;
|
||||
}
|
||||
p.uc-text { animation-delay: 1.1s; }
|
||||
a.uc-text { animation-delay: 1.2s; }
|
||||
|
||||
@keyframes uc-draw { to { stroke-dashoffset: 0; } }
|
||||
@keyframes uc-pop { to { transform: scale(1); } }
|
||||
@keyframes uc-wave {
|
||||
0% { opacity: 0.8; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(1.35); }
|
||||
}
|
||||
@keyframes uc-burst {
|
||||
0% { opacity: 0; transform: scale(0.75); }
|
||||
35% { opacity: 1; }
|
||||
100% { opacity: 0; transform: scale(1.12); }
|
||||
}
|
||||
@keyframes uc-rise {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.unsub-check *, .uc-text { animation-duration: 1ms !important; animation-delay: 0s !important; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user