@edvizion/astro-components 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +224 -0
- package/package.json +41 -0
- package/src/components/SchoolSearch/SchoolSearch.astro +98 -0
- package/src/components/SchoolSearch/api.ts +156 -0
- package/src/components/SchoolSearch/school-search-element.ts +993 -0
- package/src/components/SchoolSearch/types.ts +64 -0
- package/src/components/SchoolSearch/us-states.ts +63 -0
- package/src/index.ts +35 -0
- package/src/theme/EdvizionTheme.astro +32 -0
- package/src/theme/component-styles.ts +70 -0
- package/src/theme/tokens.ts +86 -0
package/README.md
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# @edvizion/astro-components
|
|
2
|
+
|
|
3
|
+
Shared Astro components for Edvizion apps.
|
|
4
|
+
|
|
5
|
+
- [`<SchoolSearch>`](#schoolsearch) — school picker backed by the Edvizion School Directory
|
|
6
|
+
- [`<EdvizionTheme>`](#theming) — shared design tokens for every component
|
|
7
|
+
|
|
8
|
+
## SchoolSearch
|
|
9
|
+
|
|
10
|
+
```astro
|
|
11
|
+
---
|
|
12
|
+
import { SchoolSearch } from '@edvizion/astro-components';
|
|
13
|
+
---
|
|
14
|
+
<form>
|
|
15
|
+
<SchoolSearch
|
|
16
|
+
name="schoolId"
|
|
17
|
+
required
|
|
18
|
+
defaultState="AZ"
|
|
19
|
+
stateLabel="School of Attendance State"
|
|
20
|
+
schoolLabel="School of Attendance Name"
|
|
21
|
+
/>
|
|
22
|
+
<button>Save</button>
|
|
23
|
+
</form>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The user picks a US state (50 + DC), types a school name or city, and presses
|
|
27
|
+
**Enter** or **Search**. Nothing is fetched while typing. Picking a result sets
|
|
28
|
+
the value to that school's `edvizionId`.
|
|
29
|
+
|
|
30
|
+
Requests go to `https://api.edvizion.com/schools/*` with no `Authorization`
|
|
31
|
+
header — Edvizion Auth injects the token.
|
|
32
|
+
|
|
33
|
+
### Recommending a missing school
|
|
34
|
+
|
|
35
|
+
After any search, the user sees **"Can't find your school? Recommend it"**. That
|
|
36
|
+
opens an inline panel (school name, prefilled from the search, plus optional
|
|
37
|
+
notes) which POSTs `{ name, state, notes }` to `/schools/recommendations`.
|
|
38
|
+
|
|
39
|
+
On a **202** the component's value becomes **`sch_00000000-0000-0000-0000-000000000000`** and the card
|
|
40
|
+
reads "Recommended — pending review". Recommendations are reviewed by a person
|
|
41
|
+
in a Google Space and never enter the directory, so there is no real
|
|
42
|
+
`edvizionId` yet. The submitted name and state are on
|
|
43
|
+
`picker.selectedSchool.recommendation`:
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
picker.value; // "sch_00000000-0000-0000-0000-000000000000"
|
|
47
|
+
picker.selectedSchool.recommendation; // { name, state, notes, requestId, submittedAt }
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
A native form post only carries `sch_00000000-0000-0000-0000-000000000000`, so if your backend needs the
|
|
51
|
+
recommended name, read it from `selectedSchool` (or the `edv-recommend` event)
|
|
52
|
+
and send it along.
|
|
53
|
+
|
|
54
|
+
Any failure keeps the panel open with the user's text so they can retry. A 502
|
|
55
|
+
means the recommendation reached nobody.
|
|
56
|
+
|
|
57
|
+
Hide the feature with `recommend={false}`. `RECOMMENDED_SCHOOL_ID` is exported
|
|
58
|
+
for comparisons.
|
|
59
|
+
|
|
60
|
+
### Props
|
|
61
|
+
|
|
62
|
+
| prop | default | |
|
|
63
|
+
| --- | --- | --- |
|
|
64
|
+
| `name` | — | Form field name the `edvizionId` is submitted under |
|
|
65
|
+
| `required` | `false` | Requires both a state and a school |
|
|
66
|
+
| `disabled` | `false` | Also disabled by a disabled `<fieldset>` |
|
|
67
|
+
| `defaultState` | none | USPS code, case-insensitive. Unsupported codes are ignored with a warning |
|
|
68
|
+
| `stateLabel` | `"State"` | Label for the state select |
|
|
69
|
+
| `schoolLabel` | `"School"` | Label for the search box |
|
|
70
|
+
| `placeholder` | `"School name or city"` | |
|
|
71
|
+
| `hint` | `"Search by school name or city, then press Enter."` | |
|
|
72
|
+
| `limit` | `20` | Results per page (max 100). "Show more results" pages beyond this |
|
|
73
|
+
| `value` / `valueName` | — | Pre-selected school for edit forms (`valueName` is the display name). `sch_00000000-0000-0000-0000-000000000000` shows as pending review |
|
|
74
|
+
| `endpoint` | production API | Override the search endpoint (e.g. staging) |
|
|
75
|
+
| `recommend` | `true` | Offer "Recommend it" when a school can't be found |
|
|
76
|
+
| `recommendEndpoint` | production API | Override the recommendation endpoint |
|
|
77
|
+
| `form` | — | Associate with a form by id when not nested inside it |
|
|
78
|
+
|
|
79
|
+
Any other attribute (`id`, `class`, `data-*`, …) is passed to the element.
|
|
80
|
+
|
|
81
|
+
### Reading value and validity
|
|
82
|
+
|
|
83
|
+
`<SchoolSearch>` renders an `<edv-school-search>` element that is a real
|
|
84
|
+
form-associated control, so you read it exactly like an `<input>`.
|
|
85
|
+
|
|
86
|
+
**Through the form** — nothing special:
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
const form = document.querySelector('form');
|
|
90
|
+
new FormData(form).get('schoolId'); // "sch_0a19…" or null
|
|
91
|
+
form.checkValidity(); // false until a state and school are chosen
|
|
92
|
+
form.elements.schoolId; // the <edv-school-search> element
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The internal state select and search box are never submitted; only the
|
|
96
|
+
`edvizionId` is. Native form submission is blocked while it is invalid, and the
|
|
97
|
+
component shows inline errors.
|
|
98
|
+
|
|
99
|
+
**From the element directly:**
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
const picker = document.querySelector('edv-school-search');
|
|
103
|
+
|
|
104
|
+
picker.value; // "sch_…" or ""
|
|
105
|
+
picker.selectedSchool; // { edvizionId, name, city, state } | null
|
|
106
|
+
picker.state; // "AZ" or ""
|
|
107
|
+
picker.validity; // ValidityState — .valid, .valueMissing, .customError
|
|
108
|
+
picker.validationMessage; // "Select a state." | "Search for and select a school." | custom
|
|
109
|
+
picker.checkValidity();
|
|
110
|
+
picker.reportValidity();
|
|
111
|
+
picker.setCustomValidity('That school is not eligible.'); // '' to clear
|
|
112
|
+
|
|
113
|
+
picker.state = 'NV'; // programmatic; clears the selection
|
|
114
|
+
picker.selectSchool({ edvizionId, name, city, state });
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Events** (all bubble from `<edv-school-search>`):
|
|
118
|
+
|
|
119
|
+
| event | when |
|
|
120
|
+
| --- | --- |
|
|
121
|
+
| `input`, `change` | the selected school changes (chosen, cleared, or cleared by a state change). Read `event.target.value` |
|
|
122
|
+
| `edv-search` | results loaded. `detail: { query, state, offset, results }` |
|
|
123
|
+
| `edv-search-error` | a search failed. `detail: { status, code, requestId, message }` |
|
|
124
|
+
| `edv-recommend` | a recommendation was accepted (202). `detail: { name, state, notes, requestId, submittedAt }` |
|
|
125
|
+
| `edv-recommend-error` | a recommendation failed. Same `detail` shape as `edv-search-error` |
|
|
126
|
+
|
|
127
|
+
Typing in the search box does **not** fire `input` on the component; its value
|
|
128
|
+
only changes when a school is selected.
|
|
129
|
+
|
|
130
|
+
In TypeScript, `document.querySelector('edv-school-search')` is typed as
|
|
131
|
+
`EdvSchoolSearchElement`.
|
|
132
|
+
|
|
133
|
+
### Accessibility
|
|
134
|
+
|
|
135
|
+
Labelled controls, a polite live region for search status, results as an ARIA
|
|
136
|
+
listbox (↓ from the search box, ↑/↓/Home/End, Enter or Space to choose, Esc to
|
|
137
|
+
return), focus moved to the "Change school" button after selecting, and inline
|
|
138
|
+
errors tied to the controls with `aria-invalid` / `aria-describedby`. Axe runs
|
|
139
|
+
in the e2e suite.
|
|
140
|
+
|
|
141
|
+
## Theming
|
|
142
|
+
|
|
143
|
+
Every component reads `--edv-*` CSS custom properties, with built-in defaults,
|
|
144
|
+
so they look right with no setup. Custom properties inherit through shadow DOM,
|
|
145
|
+
so one theme restyles every component.
|
|
146
|
+
|
|
147
|
+
```astro
|
|
148
|
+
---
|
|
149
|
+
import { EdvizionTheme } from '@edvizion/astro-components';
|
|
150
|
+
---
|
|
151
|
+
<!-- Whole page -->
|
|
152
|
+
<EdvizionTheme global theme={{ colorPrimary: '#0f766e', radiusSmall: '0' }} />
|
|
153
|
+
|
|
154
|
+
<!-- Or just a subtree -->
|
|
155
|
+
<EdvizionTheme theme={{ colorPrimary: '#7c3aed' }}>
|
|
156
|
+
<SchoolSearch name="school" />
|
|
157
|
+
</EdvizionTheme>
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Or set the properties in any stylesheet:
|
|
161
|
+
|
|
162
|
+
```css
|
|
163
|
+
:root { --edv-color-primary: #0f766e; --edv-font-family: Inter, sans-serif; }
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The token list is `TOKEN_PROPERTIES` in [src/theme/tokens.ts](src/theme/tokens.ts).
|
|
167
|
+
For one-off overrides beyond tokens, components expose `::part()`:
|
|
168
|
+
|
|
169
|
+
```css
|
|
170
|
+
edv-school-search::part(search-button) { text-transform: uppercase; }
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
SchoolSearch parts: `root`, `state-field`, `school-field`, `label`, `select`,
|
|
174
|
+
`input`, `button`, `search-button`, `change-button`, `more-button`, `hint`,
|
|
175
|
+
`error`, `status`, `results`, `option`, `option-name`, `option-meta`,
|
|
176
|
+
`selection`, `selection-name`, `selection-meta`, `recommend-prompt`,
|
|
177
|
+
`recommend-button`, `recommend`, `recommend-input`, `textarea`,
|
|
178
|
+
`recommend-notes`, `recommend-submit`, `recommend-cancel`.
|
|
179
|
+
|
|
180
|
+
New components should build their shadow styles on `baseComponentStyles` and
|
|
181
|
+
`cssVar()` from `src/theme/` so they pick up the same tokens.
|
|
182
|
+
|
|
183
|
+
## Development
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
npm run dev # playground at http://localhost:4391
|
|
187
|
+
npm run test:e2e # Playwright: Chromium, Firefox, WebKit
|
|
188
|
+
npm run check # astro check (TypeScript)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The e2e suite mocks both endpoints with `page.route`, so it needs no token and
|
|
192
|
+
never hits production. Fixture pages live in `playground/pages/fixtures/`. It
|
|
193
|
+
runs its own server on port 4399, so it can run alongside `npm run dev`.
|
|
194
|
+
|
|
195
|
+
### Trying it against the real API
|
|
196
|
+
|
|
197
|
+
`localhost` is on the API's CORS allow-list. Start `npm run dev`, open
|
|
198
|
+
http://localhost:4391, and paste this into the DevTools console. It stands in
|
|
199
|
+
for Edvizion Auth by adding your token to requests to `api.edvizion.com`, and it
|
|
200
|
+
prompts for the token so the token never lands in your console history:
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
(() => {
|
|
204
|
+
const token = prompt('Edvizion Cognito JWT (ID or access token)')?.trim().replace(/^Bearer\s+/i, '');
|
|
205
|
+
if (!token) return console.warn('No token entered; fetch left unchanged.');
|
|
206
|
+
const realFetch = (window.__edvRealFetch ??= window.fetch.bind(window));
|
|
207
|
+
window.fetch = (input, init = {}) => {
|
|
208
|
+
const url = new URL(input instanceof Request ? input.url : String(input), location.href);
|
|
209
|
+
if (url.origin !== 'https://api.edvizion.com') return realFetch(input, init);
|
|
210
|
+
const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
211
|
+
headers.set('authorization', `Bearer ${token}`);
|
|
212
|
+
return realFetch(input, { ...init, headers });
|
|
213
|
+
};
|
|
214
|
+
try {
|
|
215
|
+
const { exp } = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
|
|
216
|
+
console.info(`Edvizion token injected. Expires ${new Date(exp * 1000).toLocaleString()}. Reload the page to remove it.`);
|
|
217
|
+
} catch {
|
|
218
|
+
console.info('Edvizion token injected. Reload the page to remove it.');
|
|
219
|
+
}
|
|
220
|
+
})();
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Recommendations sent this way are **real**: they post to the review Google
|
|
224
|
+
Space.
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@edvizion/astro-components",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared Astro components for Edvizion apps.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./SchoolSearch.astro": "./src/components/SchoolSearch/SchoolSearch.astro",
|
|
10
|
+
"./EdvizionTheme.astro": "./src/theme/EdvizionTheme.astro",
|
|
11
|
+
"./school-search-element": "./src/components/SchoolSearch/school-search-element.ts"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"astro-component",
|
|
18
|
+
"withastro"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"dev": "astro dev",
|
|
22
|
+
"build": "astro build",
|
|
23
|
+
"check": "astro check",
|
|
24
|
+
"test:e2e": "playwright test",
|
|
25
|
+
"test:e2e:ui": "playwright test --ui"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"astro": ">=5"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@astrojs/check": "^0.9.10",
|
|
32
|
+
"@axe-core/playwright": "^4.13.0",
|
|
33
|
+
"@playwright/test": "^1.63.0",
|
|
34
|
+
"@types/node": "^26.6.3",
|
|
35
|
+
"astro": "^7.3.5",
|
|
36
|
+
"typescript": "^6.0.3"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* School picker backed by the Edvizion School Directory. Behaves like a
|
|
4
|
+
* native form control: submits the chosen school's `edvizionId` under `name`,
|
|
5
|
+
* participates in form validation, reset, and disabled fieldsets.
|
|
6
|
+
*/
|
|
7
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
8
|
+
import { normalizeStateCode } from './us-states';
|
|
9
|
+
|
|
10
|
+
interface Props extends Omit<HTMLAttributes<'div'>, 'slot'> {
|
|
11
|
+
/** Form field name the `edvizionId` is submitted under. */
|
|
12
|
+
name?: string;
|
|
13
|
+
required?: boolean;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
/** Pre-selected USPS state code, e.g. "AZ". Case-insensitive. */
|
|
16
|
+
defaultState?: string;
|
|
17
|
+
/** Label for the state select. Default: "State". e.g. "School of Attendance State". */
|
|
18
|
+
stateLabel?: string;
|
|
19
|
+
/** Label for the school search field. Default: "School". e.g. "School of Attendance Name". */
|
|
20
|
+
schoolLabel?: string;
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
/** Helper text under the search field. */
|
|
23
|
+
hint?: string;
|
|
24
|
+
/** Results per page (max 100). Default: 20. */
|
|
25
|
+
limit?: number;
|
|
26
|
+
/** Override the search endpoint (e.g. a staging API). */
|
|
27
|
+
endpoint?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Let users recommend a school that search can't find. On success the value
|
|
30
|
+
* becomes `sch_00000000-0000-0000-0000-000000000000`. Default: true.
|
|
31
|
+
*/
|
|
32
|
+
recommend?: boolean;
|
|
33
|
+
/** Override the recommendation endpoint. */
|
|
34
|
+
recommendEndpoint?: string;
|
|
35
|
+
/** Pre-selected school `edvizionId` (for edit forms; may be `sch_00000000-0000-0000-0000-000000000000`). */
|
|
36
|
+
value?: string;
|
|
37
|
+
/** Display name for a pre-selected `value`. */
|
|
38
|
+
valueName?: string;
|
|
39
|
+
/** Associate with a form by id when not nested inside it. */
|
|
40
|
+
form?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const {
|
|
44
|
+
name,
|
|
45
|
+
required,
|
|
46
|
+
disabled,
|
|
47
|
+
defaultState,
|
|
48
|
+
stateLabel,
|
|
49
|
+
schoolLabel,
|
|
50
|
+
placeholder,
|
|
51
|
+
hint,
|
|
52
|
+
limit,
|
|
53
|
+
endpoint,
|
|
54
|
+
recommend = true,
|
|
55
|
+
recommendEndpoint,
|
|
56
|
+
value,
|
|
57
|
+
valueName,
|
|
58
|
+
...rest
|
|
59
|
+
} = Astro.props;
|
|
60
|
+
|
|
61
|
+
// Astro stringifies booleans on custom elements (`required="false"`), and any
|
|
62
|
+
// present attribute counts as on — so omit false flags entirely.
|
|
63
|
+
const flag = (on: boolean | undefined) => (on ? '' : undefined);
|
|
64
|
+
|
|
65
|
+
const state = normalizeStateCode(defaultState);
|
|
66
|
+
if (defaultState && !state) {
|
|
67
|
+
console.warn(`<SchoolSearch>: ignoring unsupported defaultState "${defaultState}".`);
|
|
68
|
+
}
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
<edv-school-search
|
|
72
|
+
{...rest}
|
|
73
|
+
name={name}
|
|
74
|
+
required={flag(required)}
|
|
75
|
+
disabled={flag(disabled)}
|
|
76
|
+
default-state={state ?? undefined}
|
|
77
|
+
state-label={stateLabel}
|
|
78
|
+
school-label={schoolLabel}
|
|
79
|
+
placeholder={placeholder}
|
|
80
|
+
hint={hint}
|
|
81
|
+
limit={limit}
|
|
82
|
+
endpoint={endpoint}
|
|
83
|
+
no-recommend={flag(!recommend)}
|
|
84
|
+
recommend-endpoint={recommendEndpoint}
|
|
85
|
+
value={value}
|
|
86
|
+
value-name={valueName}
|
|
87
|
+
></edv-school-search>
|
|
88
|
+
|
|
89
|
+
<script>
|
|
90
|
+
import './school-search-element';
|
|
91
|
+
</script>
|
|
92
|
+
|
|
93
|
+
<style is:global>
|
|
94
|
+
edv-school-search:not(:defined) {
|
|
95
|
+
display: block;
|
|
96
|
+
min-height: 9.5rem;
|
|
97
|
+
}
|
|
98
|
+
</style>
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { SchoolRecommendationResponse, SchoolSearchResponse } from './types';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_SEARCH_ENDPOINT = 'https://api.edvizion.com/schools/search';
|
|
4
|
+
export const DEFAULT_RECOMMEND_ENDPOINT = 'https://api.edvizion.com/schools/recommendations';
|
|
5
|
+
/** The value a form receives when the user recommended a missing school. */
|
|
6
|
+
export const RECOMMENDED_SCHOOL_ID = 'sch_00000000-0000-0000-0000-000000000000';
|
|
7
|
+
export const MAX_QUERY_LENGTH = 200;
|
|
8
|
+
export const DEFAULT_LIMIT = 20;
|
|
9
|
+
export const MAX_LIMIT = 100;
|
|
10
|
+
export const MAX_OFFSET = 1000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Mirrors the API's own tokenizer (anything that is not a letter, digit,
|
|
14
|
+
* apostrophe or ampersand splits terms) so obviously-invalid queries are
|
|
15
|
+
* caught before a round trip. The API remains the authority.
|
|
16
|
+
*/
|
|
17
|
+
export function validateQuery(raw: string): string | null {
|
|
18
|
+
const query = raw.trim();
|
|
19
|
+
if (!query) return 'Enter a school name or city to search.';
|
|
20
|
+
if (query.length > MAX_QUERY_LENGTH) return `Search must be ${MAX_QUERY_LENGTH} characters or fewer.`;
|
|
21
|
+
const terms = query.split(/[^\p{L}\p{N}'&]+/u).filter(Boolean);
|
|
22
|
+
if (terms.length === 0) return 'Enter letters or numbers to search.';
|
|
23
|
+
if (!terms.some((term) => term.length >= 2)) return 'Enter at least 2 characters to search.';
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class SchoolSearchError extends Error {
|
|
28
|
+
constructor(
|
|
29
|
+
message: string,
|
|
30
|
+
readonly status: number | null,
|
|
31
|
+
readonly code: string | null,
|
|
32
|
+
readonly requestId: string | null,
|
|
33
|
+
) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'SchoolSearchError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SearchSchoolsOptions {
|
|
40
|
+
query: string;
|
|
41
|
+
state: string;
|
|
42
|
+
limit?: number;
|
|
43
|
+
offset?: number;
|
|
44
|
+
endpoint?: string;
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Calls the school search API. */
|
|
49
|
+
export async function searchSchools({
|
|
50
|
+
query,
|
|
51
|
+
state,
|
|
52
|
+
limit = DEFAULT_LIMIT,
|
|
53
|
+
offset = 0,
|
|
54
|
+
endpoint = DEFAULT_SEARCH_ENDPOINT,
|
|
55
|
+
signal,
|
|
56
|
+
}: SearchSchoolsOptions): Promise<SchoolSearchResponse> {
|
|
57
|
+
const url = new URL(endpoint, globalThis.location?.href);
|
|
58
|
+
url.searchParams.set('q', query.trim());
|
|
59
|
+
url.searchParams.set('state', state);
|
|
60
|
+
url.searchParams.set('limit', String(limit));
|
|
61
|
+
if (offset > 0) url.searchParams.set('offset', String(offset));
|
|
62
|
+
|
|
63
|
+
const { response, body } = await send(url, { method: 'GET', signal }, 'search');
|
|
64
|
+
if (!body || !Array.isArray((body as SchoolSearchResponse).results)) {
|
|
65
|
+
throw new SchoolSearchError(unavailable.search, response.status, null, response.headers.get('x-request-id'));
|
|
66
|
+
}
|
|
67
|
+
return body as SchoolSearchResponse;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface RecommendSchoolOptions {
|
|
71
|
+
name: string;
|
|
72
|
+
state: string;
|
|
73
|
+
notes?: string;
|
|
74
|
+
endpoint?: string;
|
|
75
|
+
signal?: AbortSignal;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Reports a school missing from the directory. It is posted to a Google Space
|
|
80
|
+
* for a person to review — it never enters the directory or search results.
|
|
81
|
+
* Success is 202. A 502 means the recommendation reached nobody and must be
|
|
82
|
+
* retried. The recommender is taken from the injected token, not the body.
|
|
83
|
+
*/
|
|
84
|
+
export async function recommendSchool({
|
|
85
|
+
name,
|
|
86
|
+
state,
|
|
87
|
+
notes,
|
|
88
|
+
endpoint = DEFAULT_RECOMMEND_ENDPOINT,
|
|
89
|
+
signal,
|
|
90
|
+
}: RecommendSchoolOptions): Promise<SchoolRecommendationResponse> {
|
|
91
|
+
const payload: Record<string, string> = { name: name.trim(), state };
|
|
92
|
+
if (notes?.trim()) payload.notes = notes.trim();
|
|
93
|
+
|
|
94
|
+
const { body } = await send(
|
|
95
|
+
new URL(endpoint, globalThis.location?.href),
|
|
96
|
+
{ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), signal },
|
|
97
|
+
'recommend',
|
|
98
|
+
);
|
|
99
|
+
return body as SchoolRecommendationResponse;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type Operation = 'search' | 'recommend';
|
|
103
|
+
|
|
104
|
+
const unavailable: Record<Operation, string> = {
|
|
105
|
+
search: 'School search is unavailable right now. Please try again in a moment.',
|
|
106
|
+
recommend: 'Your recommendation could not be sent, so it was not received. Please try again.',
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Sends a request. No Authorization header is set here: Edvizion Auth
|
|
111
|
+
* injects the bearer token into outgoing requests.
|
|
112
|
+
*/
|
|
113
|
+
async function send(url: URL, init: RequestInit, operation: Operation) {
|
|
114
|
+
let response: Response;
|
|
115
|
+
try {
|
|
116
|
+
response = await fetch(url, { ...init, headers: { accept: 'application/json', ...init.headers } });
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if ((error as Error)?.name === 'AbortError') throw error;
|
|
119
|
+
throw new SchoolSearchError(unavailable[operation], null, null, null);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const requestId = response.headers.get('x-request-id');
|
|
123
|
+
const body: unknown = await response.json().catch(() => null);
|
|
124
|
+
|
|
125
|
+
if (!response.ok) {
|
|
126
|
+
const { code, message } = readError(body);
|
|
127
|
+
throw new SchoolSearchError(messageFor(operation, response.status, message), response.status, code, requestId);
|
|
128
|
+
}
|
|
129
|
+
return { response, body };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function messageFor(operation: Operation, status: number, serverMessage: string | null): string {
|
|
133
|
+
if (status === 400) {
|
|
134
|
+
return serverMessage ?? (operation === 'search' ? 'That search could not be run. Try different words.' : 'Check the school name and try again.');
|
|
135
|
+
}
|
|
136
|
+
if (status === 401 || status === 403) {
|
|
137
|
+
return operation === 'search'
|
|
138
|
+
? 'You need to be signed in to search for schools. Sign in and try again.'
|
|
139
|
+
: 'You need to be signed in to recommend a school. Sign in and try again.';
|
|
140
|
+
}
|
|
141
|
+
if (operation === 'recommend' && status === 500) {
|
|
142
|
+
return 'Recommending schools is unavailable right now. Please try again later.';
|
|
143
|
+
}
|
|
144
|
+
return unavailable[operation];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The API's error envelope is `{ error: { code, message } }`; tolerate a flat one too.
|
|
148
|
+
function readError(body: unknown): { code: string | null; message: string | null } {
|
|
149
|
+
const source = (body as { error?: unknown })?.error ?? body;
|
|
150
|
+
if (!source || typeof source !== 'object') return { code: null, message: null };
|
|
151
|
+
const { code, message } = source as { code?: unknown; message?: unknown };
|
|
152
|
+
return {
|
|
153
|
+
code: typeof code === 'string' ? code : null,
|
|
154
|
+
message: typeof message === 'string' ? message : null,
|
|
155
|
+
};
|
|
156
|
+
}
|