also looking at this
LSU's own coaching surface, and what auditing the site turned up
#12
86 files
+3943
-1190
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -88,6 +88,8 @@ | ||
| 88 | 88 | .coaching-surface .coach-empty {margin:0 0 clamp(40px,5vw,72px);padding:clamp(30px,5vw,56px);border:var(--coach-rule);background:var(--lsu-card)} |
| 89 | 89 | .coaching-surface .coach-empty h2 {margin:0;font:400 clamp(24px,3vw,34px)/1.1 var(--lsu-display);text-transform:uppercase} |
| 90 | 90 | .coaching-surface .coach-empty p {margin:16px 0 0;max-width:58ch;color:var(--lsu-dim-1);font:400 16px/1.7 var(--lsu-body)} |
| 91 | .coaching-surface .coach-empty-back a {color:var(--lsu-white);text-decoration:none;border-bottom:1px solid var(--lsu-red);padding-bottom:2px} | |
| 92 | .coaching-surface .coach-empty-back a:hover {color:var(--lsu-red)} | |
| 91 | 93 | |
| 92 | 94 | /* Focus has to clear the black. A red ring on #000 is not reliably visible at |
| 93 | 95 | small sizes, so the ring is white with a red cast behind it. */ |
| @@ -88,6 +88,8 @@ | ||
| 88 | 88 | .coaching-surface .coach-empty {margin:0 0 clamp(40px,5vw,72px);padding:clamp(30px,5vw,56px);border:var(--coach-rule);background:var(--lsu-card)} |
| 89 | 89 | .coaching-surface .coach-empty h2 {margin:0;font:400 clamp(24px,3vw,34px)/1.1 var(--lsu-display);text-transform:uppercase} |
| 90 | 90 | .coaching-surface .coach-empty p {margin:16px 0 0;max-width:58ch;color:var(--lsu-dim-1);font:400 16px/1.7 var(--lsu-body)} |
| 91 | .coaching-surface .coach-empty-back a {color:var(--lsu-white);text-decoration:none;border-bottom:1px solid var(--lsu-red);padding-bottom:2px} | |
| 92 | .coaching-surface .coach-empty-back a:hover {color:var(--lsu-red)} | |
| 91 | 93 | |
| 92 | 94 | /* Focus has to clear the black. A red ring on #000 is not reliably visible at |
| 93 | 95 | small sizes, so the ring is white with a red cast behind it. */ |
| @@ -4,13 +4,22 @@ const { slug } = defineProps<Props>() | ||
| 4 | 4 | </script> |
| 5 | 5 | <script server> |
| 6 | 6 | import { coachingModule } from '../data/coaching' |
| 7 | import { portalHost, portalUrl } from '../functions/coaching-source' | |
| 7 | import { loadBrand, portalHost, portalUrl } from '../functions/coaching-source' | |
| 8 | 8 | |
| 9 | 9 | /* The registry is the only place a module's words live, so a page cannot |
| 10 | 10 | disagree with the nav about what it is called. */ |
| 11 | 11 | const panel = coachingModule(slug) |
| 12 | 12 | if (!panel) throw new Error(`unknown coaching module: ${slug}`) |
| 13 | 13 | |
| 14 | /* Whether the brand has published this module, read from the brand itself | |
| 15 | rather than from what the nav chooses to show. Without it the page framed | |
| 16 | a panel that answers 404, under a notice promising an account would open | |
| 17 | it. The storefront keeps its own empty state, so it is excluded here | |
| 18 | rather than carrying two. */ | |
| 19 | const panelBrand = await loadBrand() | |
| 20 | const panelLive = Boolean(panelBrand?.modules.includes(panel.slug)) | |
| 21 | const panelStorefront = panel.slug === 'plans' | |
| 22 | ||
| 14 | 23 | /* The panel is a cross-origin frame, and a frame can decline to load for |
| 15 | 24 | reasons this page cannot see: an origin the brand has not allowed, a |
| 16 | 25 | browser blocking third-party frames, an extension. When that happens the |
| @@ -28,20 +37,27 @@ const panelHost = portalHost() | ||
| 28 | 37 | </header> |
| 29 | 38 | <CoachingNav current="" /> |
| 30 | 39 | <div class="coach-shell"> |
| 31 | @if(panel.scope === 'private') | |
| 40 | @if(panelLive && panel.scope === 'private') | |
| 32 | 41 | <div class="coach-scope"> |
| 33 | 42 | <strong>Only you see this</strong> |
| 34 | 43 | <p>This panel reads your own training account, not anything this site holds. Last Soul Ultra cannot see it, and neither can the coach whose method you are following.</p> |
| 35 | 44 | </div> |
| 36 | @elseif(panel.scope === 'athlete') | |
| 45 | @elseif(panelLive && panel.scope === 'athlete') | |
| 37 | 46 | <div class="coach-scope"> |
| 38 | 47 | <strong>Needs a training account</strong> |
| 39 | 48 | <p>Sign in happens inside the panel, with the account that holds your subscription. That is a separate account from the one you use to request a race entry.</p> |
| 40 | 49 | </div> |
| 41 | 50 | @endif |
| 42 | @if(panel.slug === 'plans') | |
| 51 | @if(panelStorefront) | |
| 43 | 52 | <CoachingOffers /> |
| 53 | @elseif(!panelLive) | |
| 54 | <div class="coach-empty"> | |
| 55 | <h2>Not open yet</h2> | |
| 56 | <p>No coach has published a method here, so there is nothing for this panel to show. It fills in once the first tier opens, under the coach's own name and the coach's own price.</p> | |
| 57 | <p class="coach-empty-back"><a href="/coaching">Read what coaching here will be</a></p> | |
| 58 | </div> | |
| 44 | 59 | @endif |
| 60 | @if(panelLive) | |
| 45 | 61 | <div class="coach-panel-bar"> |
| 46 | 62 | <p>Loaded from {{ panelHost }}. Your training account lives there, not here.</p> |
| 47 | 63 | <a href="" target="_blank" rel="noopener">Open it there<span class="coach-sr"> (opens in a new tab)</span></a> |
| @@ -49,5 +65,6 @@ const panelHost = portalHost() | ||
| 49 | 65 | <div class="coach-panel"> |
| 50 | 66 | <CoachingPortal section="" /> |
| 51 | 67 | </div> |
| 68 | @endif | |
| 52 | 69 | </div> |
| 53 | 70 | </div> |
| @@ -97,6 +97,14 @@ describe('a module route, with a brand published', () => { | ||
| 97 | 97 | expect(plans).toContain('€19.99 a month') |
| 98 | 98 | expect(await render('calendar')).not.toContain('coach-offers') |
| 99 | 99 | }) |
| 100 | ||
| 101 | it('frames the panel on every published module', async () => { | |
| 102 | for (const module of coachingModules) { | |
| 103 | const html = await render(module.slug) | |
| 104 | expect(html).toContain(`<hq-coaching brand="last-soul-ultra"`) | |
| 105 | expect(html).not.toContain('Not open yet') | |
| 106 | } | |
| 107 | }) | |
| 100 | 108 | }) |
| 101 | 109 | |
| 102 | 110 | describe('a module route, with nothing published', () => { |
| @@ -120,6 +128,27 @@ describe('a module route, with nothing published', () => { | ||
| 120 | 128 | const calendar = await render('calendar') |
| 121 | 129 | |
| 122 | 130 | expect(calendar).toContain('<title>Your week - Last Soul Ultra coaching</title>') |
| 123 | expect(calendar).toContain('coach-panel') | |
| 131 | expect(calendar).toContain('Not open yet') | |
| 132 | }) | |
| 133 | ||
| 134 | it('frames no panel, and loads no remote script, for a module nobody published', async () => { | |
| 135 | const calendar = await render('calendar') | |
| 136 | ||
| 137 | expect(calendar).not.toContain('<hq-coaching') | |
| 138 | expect(calendar).not.toContain('hq-coaching.js') | |
| 139 | }) | |
| 140 | ||
| 141 | it('drops the notice that promised an account would open the panel', async () => { | |
| 142 | // With nothing to sign in to, "needs a training account" is an excuse, | |
| 143 | // not an explanation. | |
| 144 | expect(await render('calendar')).not.toContain('Needs a training account') | |
| 145 | expect(await render('health')).not.toContain('Only you see this') | |
| 146 | }) | |
| 147 | ||
| 148 | it('leaves the storefront one empty state, not two', async () => { | |
| 149 | const plans = await render('plans') | |
| 150 | ||
| 151 | expect([...plans.matchAll(/class="coach-empty"/g)]).toHaveLength(1) | |
| 152 | expect(plans).toContain('Nothing is open yet') | |
| 124 | 153 | }) |
| 125 | 154 | }) |