mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Support: add a feedback email, rebrand from Donate, tighten privacy copy
This commit is contained in:
@@ -36,6 +36,26 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Inline accent link. Restated as DonateModal's `.email` — the two are the same
|
||||
recipe; a shared link primitive would be the home if a third site appears. */
|
||||
/* TODO: if inline accent links recur, lift this recipe to a base.css element
|
||||
baseline or a small primitive rather than copying it a third time. */
|
||||
.link {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.link:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Shortcuts table */
|
||||
.shortcuts {
|
||||
border-collapse: collapse;
|
||||
|
||||
@@ -55,12 +55,21 @@ describe('AboutModal', () => {
|
||||
expect(text).toMatch(/offline/i);
|
||||
});
|
||||
|
||||
test('has three labelled sections: identity, shortcuts, privacy', () => {
|
||||
test('has labelled sections: identity, shortcuts, privacy, feedback', () => {
|
||||
const headings = Array.from(container.querySelectorAll('h3')).map(
|
||||
(h) => h.textContent?.toLowerCase() ?? '',
|
||||
);
|
||||
expect(headings.some((h) => h.includes('astrolabe'))).toBe(true);
|
||||
expect(headings.some((h) => h.includes('keyboard'))).toBe(true);
|
||||
expect(headings.some((h) => h.includes('privacy'))).toBe(true);
|
||||
expect(headings.some((h) => h.includes('feedback'))).toBe(true);
|
||||
});
|
||||
|
||||
test('offers the feedback address as a mailto link', () => {
|
||||
const mailto = Array.from(container.querySelectorAll('a')).find((a) =>
|
||||
a.getAttribute('href')?.startsWith('mailto:'),
|
||||
);
|
||||
expect(mailto).toBeTruthy();
|
||||
expect(mailto!.getAttribute('href')).toContain('feedback@astrolabe-viz.com');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* (SOUL.md — local-only, no accounts, no telemetry).
|
||||
*/
|
||||
|
||||
import { FEEDBACK_EMAIL, feedbackMailtoHref } from '../feedback';
|
||||
import styles from './AboutModal.module.css';
|
||||
|
||||
/** True when the user agent is macOS / iOS — drives the Cmd vs. Ctrl label. */
|
||||
@@ -64,7 +65,7 @@ export function AboutModal() {
|
||||
</p>
|
||||
<ul className={styles.list}>
|
||||
<li>No account, no sign-in, no server-side storage.</li>
|
||||
<li>No telemetry, no analytics, no tracking of any kind.</li>
|
||||
<li>The app runs no analytics or tracking — no cookies, no telemetry, no profiling.</li>
|
||||
<li>
|
||||
The only outbound network requests are ones you create: URL-sourced datasets you add
|
||||
yourself.
|
||||
@@ -75,6 +76,18 @@ export function AboutModal() {
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Feedback */}
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.heading}>Feedback</h3>
|
||||
<p className={styles.body}>
|
||||
Found a bug or have an idea? Email{' '}
|
||||
<a className={styles.link} href={feedbackMailtoHref()}>
|
||||
{FEEDBACK_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Donate modal body — minimal, sincere, one action. */
|
||||
/* Support modal body — two ways to give back: feedback, or a donation to Ukraine. */
|
||||
|
||||
.donate {
|
||||
display: flex;
|
||||
@@ -8,6 +8,12 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.body {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
@@ -15,9 +21,33 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Feedback row: the address (as a mailto link) beside a copy button. */
|
||||
.contact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.email {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.email:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.email:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
/* Primary CTA — styled as a button even though it's an <a> so it matches
|
||||
|
||||
@@ -20,32 +20,36 @@ afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
describe('DonateModal', () => {
|
||||
test('renders a sincere message referencing the project', () => {
|
||||
const links = () => Array.from(container.querySelectorAll('a'));
|
||||
|
||||
describe('DonateModal (Support)', () => {
|
||||
test('renders a sincere message referencing the project and feedback', () => {
|
||||
const text = container.textContent ?? '';
|
||||
expect(text).toMatch(/astrolabe/i);
|
||||
// Some mention of contribution / support
|
||||
expect(text).toMatch(/contribut|support/i);
|
||||
expect(text).toMatch(/feedback/i);
|
||||
});
|
||||
|
||||
test('renders exactly one primary CTA link', () => {
|
||||
const links = container.querySelectorAll('a');
|
||||
expect(links).toHaveLength(1);
|
||||
test('offers the feedback address as a mailto link', () => {
|
||||
const mailto = links().find((a) => a.getAttribute('href')?.startsWith('mailto:'));
|
||||
expect(mailto).toBeTruthy();
|
||||
expect(mailto!.getAttribute('href')).toContain('feedback@astrolabe-viz.com');
|
||||
expect(mailto!.textContent).toContain('feedback@astrolabe-viz.com');
|
||||
});
|
||||
|
||||
test('the CTA link opens in a new tab with rel noopener', () => {
|
||||
const link = container.querySelector('a')!;
|
||||
expect(link.getAttribute('target')).toBe('_blank');
|
||||
expect(link.getAttribute('rel')).toContain('noopener');
|
||||
test('provides a copy-address button', () => {
|
||||
const copy = Array.from(container.querySelectorAll('button')).find((b) =>
|
||||
/copy/i.test(b.textContent ?? ''),
|
||||
);
|
||||
expect(copy).toBeTruthy();
|
||||
});
|
||||
|
||||
test('the CTA link has visible text (not icon-only)', () => {
|
||||
const link = container.querySelector('a')!;
|
||||
expect((link.textContent ?? '').trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('the CTA points to the Ukraine-defense donation, not a placeholder', () => {
|
||||
const link = container.querySelector('a')!;
|
||||
expect(link.getAttribute('href')).toBe('https://savelife.in.ua/en/donate-en/');
|
||||
test('the donation CTA points to Ukraine-defense and opens safely in a new tab', () => {
|
||||
const donate = links().find(
|
||||
(a) => a.getAttribute('href') === 'https://savelife.in.ua/en/donate-en/',
|
||||
);
|
||||
expect(donate).toBeTruthy();
|
||||
expect(donate!.getAttribute('target')).toBe('_blank');
|
||||
expect(donate!.getAttribute('rel')).toContain('noopener');
|
||||
expect((donate!.textContent ?? '').trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
/**
|
||||
* Donate modal body (spec §01B/§01C).
|
||||
* Support modal body (spec §01B/§01C).
|
||||
*
|
||||
* User-facing name is **Support**; the internal modal id stays `donate` (its
|
||||
* origin, and still its primary accent CTA). The project asks nothing for
|
||||
* itself, so this surface offers two ways to give back: feedback to the author,
|
||||
* and a donation to Ukraine's defense — where any project donations would go
|
||||
* anyway.
|
||||
*
|
||||
* Rendered inside ModalShell — no backdrop, close button, or focus trap here;
|
||||
* the shell owns all of that (docs/architecture/03 → Layer 3). The project asks
|
||||
* for nothing for itself: the one solicitation redirects to Ukraine's defense,
|
||||
* where the author would forward any project donations anyway.
|
||||
* the shell owns all of that (docs/architecture/03 → Layer 3).
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from './Button';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { FEEDBACK_EMAIL, feedbackMailtoHref } from '../feedback';
|
||||
import styles from './DonateModal.module.css';
|
||||
|
||||
/**
|
||||
@@ -17,23 +25,60 @@ import styles from './DonateModal.module.css';
|
||||
const DONATE_URL = 'https://savelife.in.ua/en/donate-en/';
|
||||
|
||||
export function DonateModal() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(FEEDBACK_EMAIL);
|
||||
// The clipboard write is invisible, so the success is confirmed inline
|
||||
// ("Copied") rather than by a toast (docs/architecture/10 → Toast copy).
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: "Couldn't copy",
|
||||
message: 'Your browser blocked clipboard access. Select and copy the address manually.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.donate}>
|
||||
<p className={styles.body}>
|
||||
Astrolabe is free and open-source, built in spare time — there's nothing to buy and no one
|
||||
to pay.
|
||||
</p>
|
||||
<p className={styles.body}>
|
||||
If it has saved you time and you'd like to give something back, please send it where it
|
||||
matters far more than it would to me: Ukraine's defense against Russia's invasion. The Come
|
||||
Back Alive foundation supports the people defending the country.
|
||||
Astrolabe is free, built in spare time — there's nothing to buy and no one to pay.
|
||||
</p>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<a href={DONATE_URL} className={styles.primary} target="_blank" rel="noopener noreferrer">
|
||||
Donate to Ukraine's defense
|
||||
</a>
|
||||
</div>
|
||||
<section className={styles.section}>
|
||||
<p className={styles.body}>
|
||||
The most useful thing you can send back is feedback — what's broken, what's missing, what
|
||||
you'd want next.
|
||||
</p>
|
||||
<div className={styles.contact}>
|
||||
<a className={styles.email} href={feedbackMailtoHref()}>
|
||||
{FEEDBACK_EMAIL}
|
||||
</a>
|
||||
<Button onClick={() => void handleCopy()}>{copied ? 'Copied' : 'Copy'}</Button>
|
||||
{/* A polite live region announces the copy to assistive tech, which the
|
||||
button's label swap alone would not reliably do. */}
|
||||
<span role="status" className="visually-hidden">
|
||||
{copied ? 'Email address copied to clipboard' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<p className={styles.body}>
|
||||
And if you'd like to give something beyond words, please send it where it matters far more
|
||||
than it would to me: Ukraine's defense against Russia's invasion. The Come Back Alive
|
||||
foundation supports the people defending the country.
|
||||
</p>
|
||||
<div className={styles.actions}>
|
||||
<a href={DONATE_URL} className={styles.primary} target="_blank" rel="noopener noreferrer">
|
||||
Donate to Ukraine's defense
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user