diff --git a/.gitignore b/.gitignore index 2477554..7484388 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ playwright-report/ src/content/docs/spec/ src/content/docs/reference/ src/generated/ +public/woocommerce-checkout-postcode-field.png diff --git a/.gitmodules b/.gitmodules index 1385bc8..d37a2b5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "php"] path = php url = https://github.com/gatepost-dev/php +[submodule "woocommerce"] + path = woocommerce + url = https://github.com/gatepost-dev/woocommerce diff --git a/.prettierignore b/.prettierignore index 4ac74b1..f339a67 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,7 @@ pnpm-lock.yaml spec js php +woocommerce dist temp coverage diff --git a/README.md b/README.md index 41a8597..5157207 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ This repo holds the source of the site at ## What the site holds - A [playground](https://gatepost-dev.github.io/docs/playground/) where you check a postcode in your browser. -- Guides for the TypeScript core, the TypeScript client, the PHP package, the HTML field and the React field. +- Guides for the TypeScript core, the TypeScript client, the PHP package, the HTML field, the React field and the WooCommerce plugin. - An API reference that the build writes from the TSDoc comments and the PHP doc comments. - The spec pages: the grammar, the client contract, the field and the glossary. - A [readiness tracker](https://gatepost-dev.github.io/docs/readiness/) of common tools that reject the new postcode. @@ -44,7 +44,7 @@ pnpm sdk pnpm dev ``` -`pnpm sdk` builds the `js` and `php` submodules, because the site documents their source. `pnpm dev` serves the site at `http://localhost:4321/docs/`. +`pnpm sdk` builds the `js` and `php` submodules, because the site documents their source. The `woocommerce` submodule needs no build. The site copies one screenshot from it. `pnpm dev` serves the site at `http://localhost:4321/docs/`. ## Add a page diff --git a/astro.config.mjs b/astro.config.mjs index 17cc43f..954e2e5 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -5,6 +5,7 @@ import starlight from '@astrojs/starlight'; import { defineConfig } from 'astro/config'; import { createStarlightTypeDocPlugin } from 'starlight-typedoc'; import { writeClientSamples } from './scripts/client-samples.ts'; +import { copyPluginImage } from './scripts/plugin-assets.ts'; import { writeSpecPages } from './scripts/spec-pages.ts'; // GitHub Pages serves the org's project site under the repo name. A custom domain changes the @@ -26,6 +27,9 @@ function typeDocOf(name) { sidebar: { label: `@gatepost/${name}`, collapsed: true }, typeDoc: { excludeInternal: true, + // The field element inherits every member of HTMLElement. Without this, its page lists them + // all, grows past 1 MB, and the accessibility check of that page runs out of time. + excludeExternals: true, readme: 'none', disableSources: true, entryFileName: 'index', @@ -45,6 +49,7 @@ export default defineConfig({ // The preview serves the built site and reads none of the generated files. if (command === 'preview') return; writeSpecPages(import.meta.dirname); + copyPluginImage(import.meta.dirname); await writeClientSamples(import.meta.dirname); try { execFileSync( diff --git a/eslint.config.js b/eslint.config.js index f1456d1..8bd33a3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,6 +15,7 @@ export default defineConfig( 'spec/**', 'js/**', 'php/**', + 'woocommerce/**', 'test-results/**', 'playwright-report/**', ], diff --git a/scripts/plugin-assets.ts b/scripts/plugin-assets.ts new file mode 100644 index 0000000..0585497 --- /dev/null +++ b/scripts/plugin-assets.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// Copies the checkout screenshot of the WooCommerce plugin from its submodule, so that the guide +// shows the image that the plugin shows, from this site and from no other. +import { copyFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +/** The screenshot in the plugin submodule. It shows synthetic values. */ +export const PLUGIN_IMAGE = 'assets/checkout-postcode-field.png'; + +const COPY = 'public/woocommerce-checkout-postcode-field.png'; + +/** + * Copies the screenshot into the public folder of the site, which the build serves as it is. + * The build calls it before it reads the pages. + * + * @param root - The root folder of the docs repo, which holds the `woocommerce` submodule. + * @param out - The folder that receives the copy. It is the root folder by default. + */ +export function copyPluginImage(root: string, out: string = root): void { + const target = join(out, COPY); + mkdirSync(join(target, '..'), { recursive: true }); + copyFileSync(join(root, 'woocommerce', PLUGIN_IMAGE), target); +} diff --git a/src/content/docs/guides/woocommerce.md b/src/content/docs/guides/woocommerce.md new file mode 100644 index 0000000..198b359 --- /dev/null +++ b/src/content/docs/guides/woocommerce.md @@ -0,0 +1,106 @@ +--- +title: Add the postcode to a WooCommerce checkout +description: Install Gatepost Postcode for WooCommerce, and choose how it checks each postcode. +sidebar: + label: WooCommerce + order: 6 +--- + +Gatepost Postcode for WooCommerce adds a postcode field to the checkout for Nigerian addresses. WooCommerce hides that field for Nigeria. The plugin shows it again, and it checks what the customer types. + +The field shows only when the address is in Nigeria. It works in the classic checkout and in the checkout block. The plugin needs PHP 8.1, WordPress 6.7 and WooCommerce 10.0, or later versions of each. + +## Install + +The plugin is not published yet. It has no release, and it is not on WordPress.org. There is no zip file to download today. + +To try the plugin now, build the zip file from the source. You need Git, PHP 8.1 or later and Composer. + +```sh notrun +git clone https://github.com/gatepost-dev/woocommerce +cd woocommerce +composer install +scripts/build-zip +``` + +The script writes `dist/gatepost-postcode-for-woocommerce.zip`. Do not use the source archive that GitHub offers. It does not hold the bundled SDK, so the plugin cannot start. + +Install and activate WooCommerce first. Then, in WordPress, go to Plugins > Add New Plugin > Upload Plugin, upload the zip file, and activate it. + +## Choose the settings + +The settings are under WooCommerce > Settings > Advanced > Nigerian postcodes. The name of the section is `gatepost_postcode`. A store owner never types a name from the table. The names help a developer find each setting in the code. All are option names, except `gatepost_wc_remove_key`. It is a field of the form, and WooCommerce does not save it. + +
+ +| Setting | Name | Values | Default | +| --------------------- | ------------------------ | --------------------------------------------------------------------------------------- | -------- | +| Live secret key | `gatepost_wc_secret_key` | A key that starts with `nipost_live_` | empty | +| Remove the key | `gatepost_wc_remove_key` | A box that removes the saved key. It shows only when a key is saved. | off | +| Required | `gatepost_wc_required` | A box. A customer in Nigeria must enter a postcode. | off | +| Old 6-digit postcodes | `gatepost_wc_legacy` | `accept` ("Accept them") or `reject` ("Ask for the new postcode") | `accept` | +| Lookup | `gatepost_wc_confirm` | `level1` ("Ask NIPOST whether the postcode exists") or `none` ("Check the format only") | `level1` | + +
+ +The plugin never prints the saved key. The key field stays empty, and a line tells you that a key is saved. An empty field keeps the saved key. To remove the key, tick the box "Remove the key" and save. A new key that you type in the same save replaces the old key. + +The plugin saves only a live secret key. NIPOST's test keys work only on its staging gateway, which the plugin does not use. A key that does not start with `nipost_live_` gives an error, and the saved key stays as it was. Read [how to get a key](../../start/#get-a-key). The plugin does not issue keys. + +## What the customer sees + +A customer with a Nigerian address sees a Postcode field. The plugin checks the format while the customer fills in the form. It names the problem and suggests a fix for a common typo, such as the letter O in place of a zero. + +![The billing details of the classic checkout, with Nigeria as the country and the postcode FC 01 Z99 ZZ 01 in the Postcode field. The name, address and email are made-up examples.](/docs/woocommerce-checkout-postcode-field.png) + +The plugin stores the postcode in its standard form, such as `FC-01-Z99-ZZ-01`. It copies the postcode into the address of the order, so shipping plugins see it. The orders list shows the postcode and the result of the check. The plugin works with order tables (HPOS) and with orders stored as posts. + +The format check needs no key. It runs on your server and sends nothing to NIPOST. + +## Look up each postcode + +With a live secret key and the lookup on, the plugin asks NIPOST whether each postcode exists. It asks after the customer places the order, not while the customer types. It sends one request for each different postcode of the order. The billing and shipping addresses share one request when they hold the same postcode. + +A payment retry reuses the order. The order remembers the answer for each address. A retry sends only a postcode that has no answer yet, or a postcode that changed. The plugin never retries by itself. + +NIPOST receives these items: + +- The postcode in its standard form. +- Your secret key, in the `X-API-Key` header. +- The IP address of your shop server. NIPOST sees it with each request. + +The plugin sends no name, email address or other part of the address. It never sends an old 6-digit postcode. Read NIPOST's [terms of use](https://postcode.gov.ng/terms), [acceptable use policy](https://postcode.gov.ng/acceptable-use) and [privacy policy](https://postcode.gov.ng/privacy) before you enter a key. + +## Read the result of a check + +The order keeps the result in the order meta `_gatepost_postcode_check`. An order with two addresses gets the status that needs the most attention. + +
+ +| Status | Meaning | +| ----------- | ------------------------------------------------------------------------------------------- | +| `valid` | NIPOST knows the postcode. | +| `invalid` | NIPOST has no record of the postcode. An order note asks you to check it with the customer. | +| `unchecked` | The plugin did not ask NIPOST. The store has no key, or the lookup is off. | +| `error` | The lookup failed. An order note gives the cause. The orders list shows "Check failed". | + +
+ +An old 6-digit postcode that the store accepts also gets `unchecked`, because NIPOST never receives it. + +## When the lookup fails + +A failed lookup never stops an order. The plugin waits at most 3 seconds for each postcode. The order keeps the postcode that the customer typed, with the status `unchecked` or `error`. An order note names the true cause, and it asks you to check the postcode by hand. + +NIPOST's service can be slow or down, and a store must not lose a sale for that reason. A postcode that NIPOST does not know also does not stop the order. It gets the status `invalid` and a note. + +The notes and the WooCommerce log name a postcode with its last part hidden, such as `FC-01-Z99-ZZ-**`. + +## Export and erase requests + +The plugin answers the export and erase tools of WordPress. The export holds the postcodes of an order and of a customer's saved address. An erase request removes them. It keeps the check status, which is not personal data. The plugin also adds suggested text to the privacy policy page of your store. + +## Next steps + +- [Read the "External services" section of the plugin's `readme.txt`](https://github.com/gatepost-dev/woocommerce/blob/main/readme.txt) for the full text of what it sends to NIPOST. +- [Read the postcode grammar](../../spec/grammar/) that the format check follows. diff --git a/src/content/docs/start.md b/src/content/docs/start.md index 0767886..d3e499e 100644 --- a/src/content/docs/start.md +++ b/src/content/docs/start.md @@ -7,13 +7,14 @@ Each Gatepost tool follows the same spec, so each one reads a postcode in the sa
-| You want to | Use | Needs a key | -| -------------------------------------------------------- | -------------------------------------------------- | ---------------- | -| Check the form of a postcode in TypeScript or JavaScript | [`@gatepost/core`](../guides/typescript/) | no | -| Add a postcode field to an HTML form | [`@gatepost/field`](../guides/html/) | only to confirm | -| Add a postcode field to a React form | [`@gatepost/react`](../guides/react/) | only to confirm | -| Look up a postcode from a TypeScript server | [`@gatepost/client`](../guides/typescript-client/) | yes | -| Check or look up a postcode in PHP | [`gatepost/postcode`](../guides/php/) | only for lookups | +| You want to | Use | Needs a key | +| -------------------------------------------------------- | ----------------------------------------------------------- | ---------------- | +| Check the form of a postcode in TypeScript or JavaScript | [`@gatepost/core`](../guides/typescript/) | no | +| Add a postcode field to an HTML form | [`@gatepost/field`](../guides/html/) | only to confirm | +| Add a postcode field to a React form | [`@gatepost/react`](../guides/react/) | only to confirm | +| Look up a postcode from a TypeScript server | [`@gatepost/client`](../guides/typescript-client/) | yes | +| Check or look up a postcode in PHP | [`gatepost/postcode`](../guides/php/) | only for lookups | +| Check postcodes in a WooCommerce checkout | [Gatepost Postcode for WooCommerce](../guides/woocommerce/) | only for lookups |
diff --git a/src/release-state.json b/src/release-state.json index 7d24069..4cf8c34 100644 --- a/src/release-state.json +++ b/src/release-state.json @@ -1,3 +1,4 @@ { - "state": "not published" + "state": "not published", + "plugin": "not published" } diff --git a/test/unit/release-state.test.ts b/test/unit/release-state.test.ts index 9fdd337..ed22283 100644 --- a/test/unit/release-state.test.ts +++ b/test/unit/release-state.test.ts @@ -9,7 +9,15 @@ import { trackedPages } from '../../scripts/check-prose.ts'; // from each guide and sets the state in src/release-state.json to "published". const NOTICE = 'The first alpha is not published yet.'; -const { state } = JSON.parse(readFileSync('src/release-state.json', 'utf8')) as { state: string }; +// The sentence that the WooCommerce guide holds while the plugin has no release. The plugin has its +// own flag, because it ships on its own schedule. The guide gives no install line of a registry. +const PLUGIN_NOTICE = 'The plugin is not published yet.'; +const PLUGIN_PAGE = 'src/content/docs/guides/woocommerce.md'; + +const { state, plugin } = JSON.parse(readFileSync('src/release-state.json', 'utf8')) as { + state: string; + plugin: string; +}; const INSTALL_PAGES = [ ...new Set( @@ -46,3 +54,24 @@ describe('the release state', () => { expect(others.filter((page) => readFileSync(page, 'utf8').includes(NOTICE))).toEqual([]); }); }); + +describe('the release state of the plugin', () => { + it('is one of the two known values', () => { + expect(['published', 'not published']).toContain(plugin); + }); + + it('matches the notice of the WooCommerce guide', () => { + const text = readFileSync(PLUGIN_PAGE, 'utf8'); + expect( + text.includes(PLUGIN_NOTICE), + plugin === 'not published' + ? 'the guide must say that the plugin is not published' + : 'the guide still says that the plugin is not published', + ).toBe(plugin === 'not published'); + }); + + it('keeps the notice off every other page', () => { + const others = trackedPages().filter((page) => page !== PLUGIN_PAGE); + expect(others.filter((page) => readFileSync(page, 'utf8').includes(PLUGIN_NOTICE))).toEqual([]); + }); +}); diff --git a/test/unit/woocommerce-page.test.ts b/test/unit/woocommerce-page.test.ts new file mode 100644 index 0000000..29d4dcf --- /dev/null +++ b/test/unit/woocommerce-page.test.ts @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { PLUGIN_IMAGE, copyPluginImage } from '../../scripts/plugin-assets.ts'; +import { examplesOf } from '../support/examples.ts'; + +const PAGE_PATH = 'src/content/docs/guides/woocommerce.md'; +const PAGE = readFileSync(PAGE_PATH, 'utf8'); +const plugin = (path: string): string => readFileSync(join('woocommerce', path), 'utf8'); + +// The files of the plugin that run in a shop. The tests of the plugin are not part of its source. +const PHP_FILES = execFileSync('git', ['-C', 'woocommerce', 'ls-files', 'src', '*.php'], { + encoding: 'utf8', +}) + .split('\n') + .filter((path) => path.endsWith('.php') && !path.startsWith('tests/')); +const SOURCE = PHP_FILES.map(plugin).join('\n'); +const README_TXT = plugin('readme.txt'); +const README_MD = plugin('README.md'); +const PLUGIN_STATE = ( + JSON.parse(readFileSync('src/release-state.json', 'utf8')) as { plugin: string } +).plugin; + +const NAMES = Array.from(PAGE.matchAll(/`(_?gatepost_[a-z_]+)`/g), ([, name]) => name ?? ''); +const SETTINGS = [ + 'gatepost_postcode', + 'gatepost_wc_secret_key', + 'gatepost_wc_required', + 'gatepost_wc_legacy', + 'gatepost_wc_confirm', + 'gatepost_wc_remove_key', +]; + +describe('the WooCommerce page', () => { + it('names the section, each setting and the order meta', () => { + expect(new Set(NAMES)).toEqual(new Set([...SETTINGS, '_gatepost_postcode_check'])); + }); + + it.each([...new Set(NAMES)])('names %s, which the plugin uses', (name) => { + expect(SOURCE).toContain(`'${name}'`); + }); + + it.each(['accept', 'reject', 'level1', 'none'])( + 'names the value %s, which a setting takes', + (v) => { + expect(PAGE).toContain(`\`${v}\``); + expect(SOURCE).toContain(`'${v}'`); + }, + ); + + it.each(['valid', 'invalid', 'unchecked', 'error'])( + 'names the check status %s, which the plugin has', + (status) => { + expect(PAGE).toContain(`\`${status}\``); + expect(plugin('src/CheckStatus.php')).toContain(`= '${status}';`); + }, + ); + + it('names the menu path that the plugin gives', () => { + expect(PAGE).toContain('WooCommerce > Settings > Advanced > Nigerian postcodes'); + expect(README_TXT).toContain('WooCommerce > Settings > Advanced > Nigerian postcodes'); + expect(plugin('src/SettingsPage.php')).toContain("'Nigerian postcodes'"); + }); + + it('names the label of each setting as the settings page shows it', () => { + const page = plugin('src/SettingsPage.php'); + for (const label of ['Live secret key', 'Required', 'Old 6-digit postcodes', 'Lookup']) { + expect(page).toContain(`'${label}'`); + expect(PAGE).toContain(label); + } + expect(page).toContain("'Remove the key'"); + expect(PAGE).toContain('Remove the key'); + }); + + it('gives the floors of the plugin', () => { + const header = plugin('gatepost-postcode-for-woocommerce.php'); + expect(header).toContain('Requires PHP: 8.1'); + expect(header).toContain('Requires at least: 6.7'); + expect(header).toContain('WC requires at least: 10.0'); + expect(README_TXT).toContain('Requires PHP: 8.1'); + expect(PAGE).toMatch(/PHP 8\.1/); + expect(PAGE).toMatch(/WordPress 6\.7/); + expect(PAGE).toMatch(/WooCommerce 10\.0/); + }); + + it('gives the lookup limit that the plugin sets', () => { + expect(plugin('src/Plugin.php')).toContain('LOOKUP_TIMEOUT_MS = 3000;'); + expect(PAGE).toContain('3 seconds'); + }); + + it('says that the plugin accepts only a live secret key', () => { + expect(plugin('src/SettingsPage.php')).toContain('nipost_live_'); + expect(PAGE).toContain('`nipost_live_`'); + }); + + it('says that the plugin has no release, in step with the readme of the plugin', () => { + expect(README_MD).toContain('The plugin has no release yet'); + expect(README_TXT).toContain('This plugin is not on WordPress.org yet.'); + }); + + // The page holds the claims of an unpublished plugin until the release flag changes. + it.each(['not on WordPress.org', 'It has no release', 'no zip file to download'])( + 'holds the claim "%s" only while the plugin is not published', + (claim) => { + expect(PAGE.includes(claim)).toBe(PLUGIN_STATE === 'not published'); + }, + ); + + it('says what NIPOST receives, in the words of the readme of the plugin', () => { + expect(README_TXT).toContain("NIPOST also sees the IP address of the store's server"); + expect(README_TXT).toContain("the store's secret key in the X-API-Key header"); + expect(README_TXT).toContain('The plugin sends no name, email address or other part of the'); + expect(README_TXT).toContain('It never sends an old 6-digit postcode.'); + expect(PAGE).toContain('The IP address of your shop server. NIPOST sees it with each request.'); + expect(PAGE).toContain('in the `X-API-Key` header'); + expect(PAGE).toContain('The plugin sends no name, email address or other part of the address.'); + expect(PAGE).toContain('It never sends an old 6-digit postcode.'); + // The shop address, the domain and the URL are not what NIPOST sees. + expect(PAGE).not.toMatch(/\b(? { + expect(README_TXT).toContain('== External services =='); + expect(PAGE).toContain('woocommerce/blob/main/readme.txt'); + expect(PAGE).toContain('"External services"'); + }); + + it('says that the remove box is a field and not a saved option', () => { + expect(plugin('src/SettingsPage.php')).toContain('The box itself is not saved.'); + expect(PAGE).toContain('except `gatepost_wc_remove_key`'); + }); + + it('shows the build commands that the readme of the plugin shows', () => { + const blocks = examplesOf(PAGE_PATH, PAGE).filter(({ language }) => language === 'sh'); + expect(blocks).toHaveLength(1); + const [block] = blocks; + expect(block?.attributes, 'a build that needs Composer and network cannot run in a test').toBe( + 'notrun', + ); + const lines = (block?.source ?? '').split('\n').filter((line) => line !== ''); + expect(lines).toEqual([ + 'git clone https://github.com/gatepost-dev/woocommerce', + 'cd woocommerce', + 'composer install', + 'scripts/build-zip', + ]); + expect(README_MD).toContain('`composer install` and `scripts/build-zip`'); + expect(plugin('scripts/build-zip')).toContain('dist/$slug.zip'); + expect(plugin('scripts/build-zip')).toContain("slug='gatepost-postcode-for-woocommerce'"); + expect(PAGE).toContain('dist/gatepost-postcode-for-woocommerce.zip'); + }); + + it('wraps each table in the scroll region', () => { + const tables = PAGE.split('\n').filter((line, index, lines) => { + return line.startsWith('|') && !(lines[index - 1] ?? '').startsWith('|'); + }); + const regions = PAGE.match( + /
/g, + ); + expect(tables.length).toBeGreaterThan(0); + expect(regions).toHaveLength(tables.length); + }); + + it('shows the checkout image of the plugin with alt text and a local address', () => { + const image = /!\[([^\]]+)\]\(([^)]+)\)/.exec(PAGE); + expect(image?.[1]?.length).toBeGreaterThan(40); + expect(image?.[2]).not.toMatch(/^[a-z]+:/); + expect(image?.[2]).toMatch(/^\/docs\/woocommerce-checkout-postcode-field\.png$/); + }); +}); + +describe('copyPluginImage', () => { + const folder = mkdtempSync(join(tmpdir(), 'gatepost-plugin-')); + afterAll(() => { + rmSync(folder, { recursive: true, force: true }); + }); + + it('copies the screenshot of the plugin to the assets of the site', () => { + copyPluginImage('.', folder); + const copy = join(folder, 'public/woocommerce-checkout-postcode-field.png'); + expect(existsSync(copy)).toBe(true); + expect(readFileSync(copy).equals(readFileSync(join('woocommerce', PLUGIN_IMAGE)))).toBe(true); + }); +}); diff --git a/woocommerce b/woocommerce new file mode 160000 index 0000000..e634dd4 --- /dev/null +++ b/woocommerce @@ -0,0 +1 @@ +Subproject commit e634dd451fd726241f6cb767233ff85c13e5b682