continentept-mcp 0.3.1 → 0.5.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/.env.example CHANGED
@@ -9,6 +9,9 @@ CONTINENTE_PASSWORD=replace-with-your-password
9
9
  # Close the browser after this many idle seconds; 0 keeps it open. Default: 300
10
10
  # CONTINENTE_BROWSER_IDLE_SECONDS=300
11
11
 
12
+ # Set to 0 to stop the daily check for a newer release on npm.
13
+ # CONTINENTE_UPDATE_CHECK=1
14
+
12
15
  # These two only work as environment variables in the MCP client configuration:
13
16
  # CONTINENTE_CREDENTIALS_FILE=/private/path/credentials.env
14
17
  # CONTINENTE_DATA_DIR=/private/path/continentept-mcp
package/CHANGELOG.md CHANGED
@@ -6,6 +6,36 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-10-15
10
+
11
+ ### Fixed
12
+
13
+ - Searching for produce, meat and other products priced per kilogram failed with "Invalid money amount". Prices now record what they are for: per item, per piece, per kg or per litre.
14
+ - One unreadable search result no longer fails the whole search; the other results are returned as a partial result, and the unreadable one is described.
15
+ - Search results keep the website's relevance order. Products whose names merely resembled a favorite no longer jump ahead of better matches; exact favorites still come first.
16
+ - Favorites priced per kilogram are read correctly, and an unrecognised favorite price is reported as unknown instead of failing the refresh.
17
+
18
+ ### Changed
19
+
20
+ - **Breaking:** catalogue prices (`price` in search, favorites and `get_product`) are now `{ amount, per }`. `unitPriceText` is replaced by `secondaryPrice`, and the search field `similarity` is removed. Saved favorites are fetched again once after updating.
21
+ - Search results include `packageText`, the pack size as shown on the website.
22
+ - Sign-in errors say whether no credentials are configured or Continente did not accept them, with the next step for each case. The instructions present the `basket-planner` prompt as optional, for clients that support prompts.
23
+ - Browser engine updated to Playwright 1.64 (Chrome 156). After updating, the new Chromium downloads automatically on first use (about 150 MB, once).
24
+
25
+ ## [0.4.0] - 2026-10-15
26
+
27
+ ### Added
28
+
29
+ - Update notice: once a day the server checks npm for a newer release and, if there is one, tells the assistant and reports it in `get_session_status`. Offline-safe; turn it off with `CONTINENTE_UPDATE_CHECK=0`.
30
+
31
+ ### Fixed
32
+
33
+ - Documentation: how the home folder is chosen on Windows, what "readable only by your user" covers, when the favorites snapshot is refetched, and what deleting the data directory removes.
34
+
35
+ ### Security
36
+
37
+ - A failed sign-in could write the account password or session cookies to the server log, because browser automation errors include what was typed and the request headers. Sign-in errors are now reported as fixed messages naming only the failed step.
38
+
9
39
  ## [0.3.1] - 2026-10-15
10
40
 
11
41
  ### Added
@@ -41,7 +71,9 @@ All notable changes to this project are documented here. The format follows
41
71
  - Automatic sign-in from environment variables or a private credentials file, and a `login` command.
42
72
  - The `basket-planner` skill for planning a shopping list and updating the basket.
43
73
 
44
- [Unreleased]: https://github.com/andredelgado/continente-mcp/compare/v0.3.1...HEAD
74
+ [Unreleased]: https://github.com/andredelgado/continente-mcp/compare/v0.5.0...HEAD
75
+ [0.5.0]: https://github.com/andredelgado/continente-mcp/compare/v0.4.0...v0.5.0
76
+ [0.4.0]: https://github.com/andredelgado/continente-mcp/compare/v0.3.1...v0.4.0
45
77
  [0.3.1]: https://github.com/andredelgado/continente-mcp/compare/v0.3.0...v0.3.1
46
78
  [0.3.0]: https://github.com/andredelgado/continente-mcp/compare/v0.2.0...v0.3.0
47
79
  [0.2.0]: https://github.com/andredelgado/continente-mcp/compare/v0.1.0...v0.2.0
package/README.md CHANGED
@@ -22,7 +22,8 @@ on the website.
22
22
  cannot be confirmed, the result says so and tells the assistant to check the cart instead
23
23
  of retrying.
24
24
  - **Your account**: favorites, order history and a ranking of the products you buy most.
25
- - **Runs locally** over stdio. Credentials and cookies stay on your machine.
25
+ - **Runs locally** over stdio. Your credentials and session are stored on your computer and
26
+ sent only to Continente.pt.
26
27
  - **Built-in guidance.** The server tells the assistant the cart rules as soon as it
27
28
  connects, and offers a `basket-planner` prompt with the full shopping workflow. Nothing
28
29
  extra to install; the same guide also ships as a [skill](skills/basket-planner/SKILL.md).
@@ -44,11 +45,22 @@ it needs it (about 150 MB, once).
44
45
  3. Enter your Continente email and password when asked. Claude Desktop keeps the password
45
46
  in its secure settings storage.
46
47
 
47
- That's it. The extension updates when you install a newer `.mcpb`. The server is also listed
48
+ That's it. The server is also listed
48
49
  in the [MCP Registry](https://registry.modelcontextprotocol.io) as
49
50
  `io.github.andredelgado/continente-mcp`, so clients that browse the registry can find it. To use 1Password instead
50
51
  of storing the password in Claude Desktop, use the manual setup below.
51
52
 
53
+ ### Updates
54
+
55
+ The server checks npm once a day for a newer release. When there is one, it tells the
56
+ assistant, so Claude can mention it, and `get_session_status` shows it.
57
+
58
+ - **Extension:** download the new `continente-mcp.mcpb` and open it. Claude Desktop updates the
59
+ extension in place and keeps your email and password.
60
+ - **npx setups:** using `continentept-mcp@latest` picks up new versions when the client restarts.
61
+ - **Release emails:** on [GitHub](https://github.com/andredelgado/continente-mcp), choose
62
+ **Watch → Custom → Releases**.
63
+
52
64
  ## Manual setup (any MCP client)
53
65
 
54
66
  **1. Save your credentials** in `~/.continentept-mcp/credentials.env` (see
package/dist/cli.js CHANGED
@@ -5,6 +5,9 @@ import { createShoppingApplication } from './runtime.js';
5
5
  import { runAutoLogin } from './auto-login.js';
6
6
  import { isMainModule } from './main-module.js';
7
7
  import { runChromiumInstall } from './session/chromium.js';
8
+ import { safeLoginMessage } from './session/login-errors.js';
9
+ import { checkForUpdate } from './services/updates.js';
10
+ import { loadSettings } from './config.js';
8
11
  const USAGE = `Usage: continentept-mcp [command]
9
12
 
10
13
  Commands:
@@ -17,16 +20,20 @@ Options:
17
20
  -h, --help Show this help
18
21
  -v, --version Show the version
19
22
  `;
20
- function packageVersion() {
23
+ function packageMetadata() {
21
24
  const metadata = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
22
- if (typeof metadata !== 'object' || metadata === null || !('version' in metadata) ||
23
- typeof metadata.version !== 'string')
24
- throw new Error('Missing package version');
25
- return metadata.version;
25
+ if (typeof metadata !== 'object' || metadata === null || !('version' in metadata) || !('name' in metadata) ||
26
+ typeof metadata.version !== 'string' || typeof metadata.name !== 'string')
27
+ throw new Error('Missing package metadata');
28
+ return { name: metadata.name, version: metadata.version };
26
29
  }
27
30
  export async function startStdioServer() {
28
- const version = packageVersion();
29
- const application = createShoppingApplication({ version });
31
+ const { name, version } = packageMetadata();
32
+ const settings = loadSettings();
33
+ const update = await checkForUpdate({ currentVersion: version, packageName: name, dataDir: settings.dataDir, enabled: settings.updateCheck });
34
+ if (update.available)
35
+ console.error(`${name} ${update.latestVersion} is available (running ${version}).`);
36
+ const application = createShoppingApplication({ version, update });
30
37
  await connectStdioApplication(application);
31
38
  console.error(`continentept-mcp ${version} ready on stdio`);
32
39
  return application;
@@ -67,14 +74,14 @@ export async function main(args) {
67
74
  return (await runAutoLogin({ showBrowser: rest.includes('--show-browser') })) ? 0 : 1;
68
75
  }
69
76
  catch (error) {
70
- console.error(`Login failed: ${error instanceof Error ? error.message : 'unknown error'}`);
77
+ console.error(safeLoginMessage(error));
71
78
  return 1;
72
79
  }
73
80
  case 'install-browser':
74
81
  return runChromiumInstall(rest);
75
82
  case '-v':
76
83
  case '--version':
77
- console.log(packageVersion());
84
+ console.log(packageMetadata().version);
78
85
  return 0;
79
86
  case '-h':
80
87
  case '--help':
@@ -5,6 +5,7 @@ import { STOREFRONT_ORIGIN } from '../parsers/catalog.js';
5
5
  import { isLoginUrl, responseStatus } from './policy.js';
6
6
  import { CART_SUMMARY_URL } from './storefront.js';
7
7
  import { requestIO, acquireResource, checkRequest } from '../session/request.js';
8
+ import { LoginFailed, loginFailure } from '../session/login-errors.js';
8
9
  const LOGIN_ORIGIN = 'https://login.continente.pt';
9
10
  const EMAIL_INPUT = '#userNameCC';
10
11
  const PASSWORD_INPUT = '#password_input';
@@ -59,32 +60,43 @@ export class AuthenticationClient {
59
60
  this.logger('Login skipped: CONTINENTE_EMAIL and CONTINENTE_PASSWORD are required.');
60
61
  return false;
61
62
  }
62
- const browser = await acquireResource(() => this.launch({ headless: !this.options.showBrowser }), browser => browser.close());
63
+ let step = 'starting the browser';
64
+ let browser;
63
65
  let closing;
64
- const close = () => closing ??= browser.close();
66
+ const close = () => closing ??= browser?.close() ?? Promise.resolve();
65
67
  try {
68
+ const launched = browser = await acquireResource(() => this.launch({ headless: !this.options.showBrowser }), opened => opened.close());
66
69
  return await requestIO(async () => {
67
- const context = await acquireResource(() => browser.newContext({ userAgent: browserUserAgent(browser.version()), locale: 'pt-PT' }), context => context.close());
70
+ const context = await acquireResource(() => launched.newContext({ userAgent: browserUserAgent(launched.version()), locale: 'pt-PT' }), context => context.close());
68
71
  const page = await acquireResource(() => context.newPage(), page => page.close());
69
72
  this.logger('Signing in.');
73
+ step = 'opening the login page';
70
74
  await page.goto(`${STOREFRONT_ORIGIN}/login/`, { waitUntil: 'domcontentloaded', timeout: 30000 });
71
75
  await page.getByRole('button', { name: CONSENT_BUTTON }).click({ timeout: 5000 }).catch(() => { });
72
76
  const frame = await this.loginFrame(page);
77
+ step = 'entering the email';
73
78
  await this.submit(frame, EMAIL_INPUT, email);
74
79
  const leftLogin = page.waitForURL(url => url.origin === STOREFRONT_ORIGIN && !isLoginUrl(url.href), { timeout: 20000 }).catch(() => { });
80
+ step = 'entering the password';
75
81
  await this.submit(frame, PASSWORD_INPUT, password);
76
82
  await leftLogin;
77
83
  checkRequest();
84
+ step = 'checking the new session';
78
85
  if (!(await this.isSignedIn(context)))
79
- throw new Error('The website did not accept the login');
86
+ throw new LoginFailed('Sign-in failed: the website did not accept the login.');
87
+ step = 'saving the session';
80
88
  const cookies = SessionCookiesSchema.safeParse(await context.cookies());
81
89
  if (!cookies.success)
82
- throw new Error('The website returned an unexpected session');
90
+ throw new LoginFailed('Sign-in failed: the website returned an unexpected session.');
83
91
  await this.cookies.save(cookies.data);
84
92
  this.logger('Signed in; session saved.');
85
93
  return true;
86
94
  }, close);
87
95
  }
96
+ catch (error) {
97
+ // Playwright messages from these steps can contain the typed password or Cookie headers.
98
+ throw loginFailure(step, error);
99
+ }
88
100
  finally {
89
101
  await close();
90
102
  }
@@ -93,7 +93,7 @@ export class CatalogClient {
93
93
  break;
94
94
  }
95
95
  const previousCount = ids.size;
96
- parsed.value.forEach(product => ids.add(product.productId));
96
+ parsed.value.products.forEach(product => ids.add(product.productId));
97
97
  pages.push(html);
98
98
  reportProgress(pages.length, 5, 'Reading catalogue pages');
99
99
  const continuation = parseSearchPagination(html);
package/dist/config.d.ts CHANGED
@@ -9,6 +9,8 @@ export interface Settings {
9
9
  readonly favoritesFile: string;
10
10
  /** Undefined keeps the session default. */
11
11
  readonly browserIdleMs: number | undefined;
12
+ /** Check npm once a day for a newer release. */
13
+ readonly updateCheck: boolean;
12
14
  }
13
15
  /** Read `KEY=value` lines. Surrounding quotes are removed; nothing is expanded or unescaped. */
14
16
  export declare function readDotEnv(text: string): Record<string, string>;
package/dist/config.js CHANGED
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { createHash } from 'node:crypto';
5
- const FILE_KEYS = new Set(['CONTINENTE_EMAIL', 'CONTINENTE_PASSWORD', 'CONTINENTE_BROWSER_IDLE_SECONDS']);
5
+ const FILE_KEYS = new Set(['CONTINENTE_EMAIL', 'CONTINENTE_PASSWORD', 'CONTINENTE_BROWSER_IDLE_SECONDS', 'CONTINENTE_UPDATE_CHECK']);
6
6
  const ASSIGNMENT = /^(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/;
7
7
  /** Read `KEY=value` lines. Surrounding quotes are removed; nothing is expanded or unescaped. */
8
8
  export function readDotEnv(text) {
@@ -52,6 +52,7 @@ export function loadSettings(env = process.env) {
52
52
  cookieFile: join(dataDir, 'session.json'),
53
53
  favoritesFile: join(dataDir, 'favorites.json'),
54
54
  browserIdleMs: Number.isFinite(idleSeconds) && idleSeconds >= 0 ? idleSeconds * 1000 : undefined,
55
+ updateCheck: !['0', 'false', 'off', 'no'].includes(value('CONTINENTE_UPDATE_CHECK')?.trim().toLowerCase() ?? ''),
55
56
  };
56
57
  }
57
58
  export function hasCredentials(settings) {
@@ -4,8 +4,16 @@ export declare const FavoriteProductSchema: z.ZodObject<{
4
4
  productSlug: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ProductSlug", "out">>;
5
5
  name: z.ZodString;
6
6
  price: z.ZodNullable<z.ZodObject<{
7
- currency: z.ZodLiteral<"EUR">;
8
- minorUnits: z.ZodNumber;
7
+ amount: z.ZodObject<{
8
+ currency: z.ZodLiteral<"EUR">;
9
+ minorUnits: z.ZodNumber;
10
+ }, z.core.$strict>;
11
+ per: z.ZodEnum<{
12
+ item: "item";
13
+ kg: "kg";
14
+ l: "l";
15
+ piece: "piece";
16
+ }>;
9
17
  }, z.core.$strict>>;
10
18
  url: z.ZodURL;
11
19
  }, z.core.$strict>;
@@ -15,11 +23,31 @@ export declare const ProductSchema: z.ZodObject<{
15
23
  productSlug: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ProductSlug", "out">>;
16
24
  name: z.ZodString;
17
25
  price: z.ZodNullable<z.ZodObject<{
18
- currency: z.ZodLiteral<"EUR">;
19
- minorUnits: z.ZodNumber;
26
+ amount: z.ZodObject<{
27
+ currency: z.ZodLiteral<"EUR">;
28
+ minorUnits: z.ZodNumber;
29
+ }, z.core.$strict>;
30
+ per: z.ZodEnum<{
31
+ item: "item";
32
+ kg: "kg";
33
+ l: "l";
34
+ piece: "piece";
35
+ }>;
20
36
  }, z.core.$strict>>;
21
37
  url: z.ZodURL;
22
- unitPriceText: z.ZodNullable<z.ZodString>;
38
+ secondaryPrice: z.ZodNullable<z.ZodObject<{
39
+ amount: z.ZodObject<{
40
+ currency: z.ZodLiteral<"EUR">;
41
+ minorUnits: z.ZodNumber;
42
+ }, z.core.$strict>;
43
+ per: z.ZodEnum<{
44
+ item: "item";
45
+ kg: "kg";
46
+ l: "l";
47
+ piece: "piece";
48
+ }>;
49
+ }, z.core.$strict>>;
50
+ packageText: z.ZodNullable<z.ZodString>;
23
51
  minimumText: z.ZodNullable<z.ZodString>;
24
52
  }, z.core.$strict>;
25
53
  export type Product = z.infer<typeof ProductSchema>;
@@ -227,7 +255,7 @@ export declare const PurchaseFrequencySchema: z.ZodObject<{
227
255
  }, z.core.$strict>;
228
256
  export type PurchaseFrequency = z.infer<typeof PurchaseFrequencySchema>;
229
257
  export declare const FavoritesCacheSchema: z.ZodObject<{
230
- schemaVersion: z.ZodLiteral<1>;
258
+ schemaVersion: z.ZodLiteral<2>;
231
259
  accountScope: z.ZodString;
232
260
  fetchedAt: z.ZodISODateTime;
233
261
  favorites: z.ZodArray<z.ZodObject<{
@@ -235,8 +263,16 @@ export declare const FavoritesCacheSchema: z.ZodObject<{
235
263
  productSlug: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ProductSlug", "out">>;
236
264
  name: z.ZodString;
237
265
  price: z.ZodNullable<z.ZodObject<{
238
- currency: z.ZodLiteral<"EUR">;
239
- minorUnits: z.ZodNumber;
266
+ amount: z.ZodObject<{
267
+ currency: z.ZodLiteral<"EUR">;
268
+ minorUnits: z.ZodNumber;
269
+ }, z.core.$strict>;
270
+ per: z.ZodEnum<{
271
+ item: "item";
272
+ kg: "kg";
273
+ l: "l";
274
+ piece: "piece";
275
+ }>;
240
276
  }, z.core.$strict>>;
241
277
  url: z.ZodURL;
242
278
  }, z.core.$strict>>;
@@ -248,18 +284,30 @@ export declare const ProductDetailsSchema: z.ZodObject<{
248
284
  productSlug: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ProductSlug", "out">>;
249
285
  name: z.ZodString;
250
286
  url: z.ZodURL;
251
- price: z.ZodObject<{
252
- amount: z.ZodNullable<z.ZodObject<{
287
+ price: z.ZodNullable<z.ZodObject<{
288
+ amount: z.ZodObject<{
253
289
  currency: z.ZodLiteral<"EUR">;
254
290
  minorUnits: z.ZodNumber;
255
- }, z.core.$strict>>;
256
- basis: z.ZodEnum<{
257
- line: "line";
258
- unit: "unit";
259
- unknown: "unknown";
291
+ }, z.core.$strict>;
292
+ per: z.ZodEnum<{
293
+ item: "item";
294
+ kg: "kg";
295
+ l: "l";
296
+ piece: "piece";
260
297
  }>;
261
- }, z.core.$strict>;
262
- unitPriceText: z.ZodNullable<z.ZodString>;
298
+ }, z.core.$strict>>;
299
+ secondaryPrice: z.ZodNullable<z.ZodObject<{
300
+ amount: z.ZodObject<{
301
+ currency: z.ZodLiteral<"EUR">;
302
+ minorUnits: z.ZodNumber;
303
+ }, z.core.$strict>;
304
+ per: z.ZodEnum<{
305
+ item: "item";
306
+ kg: "kg";
307
+ l: "l";
308
+ piece: "piece";
309
+ }>;
310
+ }, z.core.$strict>>;
263
311
  availability: z.ZodEnum<{
264
312
  available: "available";
265
313
  unavailable: "unavailable";
@@ -1,16 +1,20 @@
1
1
  import { z } from 'zod';
2
2
  import { ToolErrorSchema } from './errors.js';
3
3
  import { CartItemIdSchema, OrderIdSchema, OrderNumberSchema, ProductIdSchema, ProductReferenceSchema } from './identity.js';
4
- import { MoneySchema, QuotedMoneySchema } from './money.js';
4
+ import { MoneySchema, PriceTagSchema, QuotedMoneySchema } from './money.js';
5
5
  import { ObservedQuantitySchema, QuantitySchema, ProductSaleRulesSchema } from './quantity.js';
6
6
  const productDetails = {
7
7
  name: z.string().trim().min(1).max(250),
8
- price: MoneySchema.nullable(),
8
+ /** The main price as displayed; null when absent or unrecognised. */
9
+ price: PriceTagSchema.nullable(),
9
10
  url: z.url(),
10
11
  };
11
12
  export const FavoriteProductSchema = ProductReferenceSchema.safeExtend(productDetails);
12
13
  export const ProductSchema = FavoriteProductSchema.safeExtend({
13
- unitPriceText: z.string().nullable(),
14
+ /** The comparison price shown next to the main one, such as per kg or per piece. */
15
+ secondaryPrice: PriceTagSchema.nullable(),
16
+ /** Pack size as displayed, such as `940 gr`, `2 x 52 gr` or `1,2 kg (aprox.)`. */
17
+ packageText: z.string().nullable(),
14
18
  minimumText: z.string().nullable(),
15
19
  });
16
20
  export const CartItemSchema = z.strictObject({
@@ -51,15 +55,15 @@ export const PurchaseFrequencySchema = z.strictObject({
51
55
  }).refine(frequency => (frequency.identityBasis === 'product') === (frequency.productId !== null), { message: 'Identity basis must agree with the available product ID' })
52
56
  .refine(frequency => new Set(frequency.quantities.map(quantity => quantity.unit)).size === frequency.quantities.length, { message: 'Purchase totals must contain each unit only once' });
53
57
  export const FavoritesCacheSchema = z.strictObject({
54
- schemaVersion: z.literal(1),
58
+ schemaVersion: z.literal(2),
55
59
  accountScope: z.string().trim().min(1).max(128),
56
60
  fetchedAt: z.iso.datetime({ offset: true }),
57
61
  favorites: z.array(FavoriteProductSchema),
58
62
  completeness: z.literal('complete').optional(),
59
63
  });
60
64
  export const ProductDetailsSchema = ProductReferenceSchema.safeExtend({
61
- name: productDetails.name, url: productDetails.url, price: QuotedMoneySchema,
62
- unitPriceText: z.string().nullable(), availability: z.enum(['available', 'unavailable', 'unknown']),
65
+ name: productDetails.name, url: productDetails.url, price: PriceTagSchema.nullable(),
66
+ secondaryPrice: PriceTagSchema.nullable(), availability: z.enum(['available', 'unavailable', 'unknown']),
63
67
  saleRules: z.discriminatedUnion('status', [
64
68
  z.strictObject({ status: z.literal('available'), rules: ProductSaleRulesSchema }),
65
69
  z.strictObject({ status: z.literal('unavailable'), error: ToolErrorSchema }),
@@ -21,3 +21,22 @@ export declare const QuotedMoneySchema: z.ZodObject<{
21
21
  export type QuotedMoney = z.infer<typeof QuotedMoneySchema>;
22
22
  /** Convert decimal text through integer arithmetic; never round an unrecognized precision. */
23
23
  export declare function parseMoneyAmount(input: unknown): Result<Money | null, ToolError>;
24
+ /**
25
+ * What a displayed price is for. The storefront shows packaged goods per `item` (one pack or
26
+ * article), produce and meat per `kg`, drinks per `l`, and an approximate price per `piece`.
27
+ */
28
+ export declare const PriceTagSchema: z.ZodObject<{
29
+ amount: z.ZodObject<{
30
+ currency: z.ZodLiteral<"EUR">;
31
+ minorUnits: z.ZodNumber;
32
+ }, z.core.$strict>;
33
+ per: z.ZodEnum<{
34
+ item: "item";
35
+ kg: "kg";
36
+ l: "l";
37
+ piece: "piece";
38
+ }>;
39
+ }, z.core.$strict>;
40
+ export type PriceTag = z.infer<typeof PriceTagSchema>;
41
+ /** Parse labels such as `1,29€`, `1,19€/kg` or `0,24€/un`. An empty label is an unknown price. */
42
+ export declare function parsePriceTag(input: unknown): Result<PriceTag | null, ToolError>;
@@ -28,3 +28,28 @@ export function parseMoneyAmount(input) {
28
28
  }
29
29
  return { ok: true, value: { currency: 'EUR', minorUnits: Number(minorUnits) } };
30
30
  }
31
+ /**
32
+ * What a displayed price is for. The storefront shows packaged goods per `item` (one pack or
33
+ * article), produce and meat per `kg`, drinks per `l`, and an approximate price per `piece`.
34
+ */
35
+ export const PriceTagSchema = z.strictObject({
36
+ amount: MoneySchema,
37
+ per: z.enum(['item', 'piece', 'kg', 'l']),
38
+ });
39
+ const PRICE_UNITS = { kg: 'kg', l: 'l', lt: 'l', un: 'piece' };
40
+ /** Parse labels such as `1,29€`, `1,19€/kg` or `0,24€/un`. An empty label is an unknown price. */
41
+ export function parsePriceTag(input) {
42
+ if (input === null || input === undefined)
43
+ return { ok: true, value: null };
44
+ if (typeof input !== 'string')
45
+ return { ok: false, error: invalidData('price') };
46
+ const text = input.replace(/\s+/g, '');
47
+ if (!text)
48
+ return { ok: true, value: null };
49
+ const match = /^(\d+(?:[.,]\d{1,2})?)€(?:\/([a-z]+))?$/i.exec(text);
50
+ const per = match ? PRICE_UNITS[(match[2] ?? '').toLowerCase()] ?? (match[2] === undefined ? 'item' : undefined) : undefined;
51
+ const amount = match && per ? parseMoneyAmount(match[1]) : null;
52
+ if (!amount?.ok || !amount.value || !per)
53
+ return { ok: false, error: { ...invalidData('price'), message: 'Unrecognised price format' } };
54
+ return { ok: true, value: { amount: amount.value, per } };
55
+ }
@@ -38,3 +38,9 @@ export declare const FavoritesStatusSchema: z.ZodObject<{
38
38
  empty: z.ZodNullable<z.ZodBoolean>;
39
39
  }, z.core.$strict>;
40
40
  export type FavoritesStatus = z.infer<typeof FavoritesStatusSchema>;
41
+ export declare const UpdateStatusSchema: z.ZodObject<{
42
+ currentVersion: z.ZodNullable<z.ZodString>;
43
+ latestVersion: z.ZodNullable<z.ZodString>;
44
+ available: z.ZodBoolean;
45
+ }, z.core.$strict>;
46
+ export type UpdateStatus = z.infer<typeof UpdateStatusSchema>;
@@ -9,3 +9,6 @@ export const FavoritesStatusSchema = z.strictObject({
9
9
  state: z.enum(['missing', 'fresh', 'stale', 'unavailable']), fetchedAt: z.iso.datetime({ offset: true }).nullable(),
10
10
  expiresAt: z.iso.datetime({ offset: true }).nullable(), empty: z.boolean().nullable(),
11
11
  });
12
+ export const UpdateStatusSchema = z.strictObject({
13
+ currentVersion: z.string().nullable(), latestVersion: z.string().nullable(), available: z.boolean(),
14
+ });
@@ -205,14 +205,33 @@ export declare const SearchData: z.ZodObject<{
205
205
  productSlug: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ProductSlug", "out">>;
206
206
  name: z.ZodString;
207
207
  price: z.ZodNullable<z.ZodObject<{
208
- currency: z.ZodLiteral<"EUR">;
209
- minorUnits: z.ZodNumber;
208
+ amount: z.ZodObject<{
209
+ currency: z.ZodLiteral<"EUR">;
210
+ minorUnits: z.ZodNumber;
211
+ }, z.core.$strict>;
212
+ per: z.ZodEnum<{
213
+ item: "item";
214
+ kg: "kg";
215
+ l: "l";
216
+ piece: "piece";
217
+ }>;
210
218
  }, z.core.$strict>>;
211
219
  url: z.ZodURL;
212
- unitPriceText: z.ZodNullable<z.ZodString>;
220
+ secondaryPrice: z.ZodNullable<z.ZodObject<{
221
+ amount: z.ZodObject<{
222
+ currency: z.ZodLiteral<"EUR">;
223
+ minorUnits: z.ZodNumber;
224
+ }, z.core.$strict>;
225
+ per: z.ZodEnum<{
226
+ item: "item";
227
+ kg: "kg";
228
+ l: "l";
229
+ piece: "piece";
230
+ }>;
231
+ }, z.core.$strict>>;
232
+ packageText: z.ZodNullable<z.ZodString>;
213
233
  minimumText: z.ZodNullable<z.ZodString>;
214
234
  favorite: z.ZodBoolean;
215
- similarity: z.ZodNumber;
216
235
  }, z.core.$strict>>;
217
236
  scanned: z.ZodNumber;
218
237
  returned: z.ZodNumber;
@@ -256,8 +275,16 @@ export declare const FavoritesData: z.ZodObject<{
256
275
  productSlug: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ProductSlug", "out">>;
257
276
  name: z.ZodString;
258
277
  price: z.ZodNullable<z.ZodObject<{
259
- currency: z.ZodLiteral<"EUR">;
260
- minorUnits: z.ZodNumber;
278
+ amount: z.ZodObject<{
279
+ currency: z.ZodLiteral<"EUR">;
280
+ minorUnits: z.ZodNumber;
281
+ }, z.core.$strict>;
282
+ per: z.ZodEnum<{
283
+ item: "item";
284
+ kg: "kg";
285
+ l: "l";
286
+ piece: "piece";
287
+ }>;
261
288
  }, z.core.$strict>>;
262
289
  url: z.ZodURL;
263
290
  }, z.core.$strict>>;
@@ -498,14 +525,33 @@ export declare const SearchOutput: z.ZodObject<{
498
525
  productSlug: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ProductSlug", "out">>;
499
526
  name: z.ZodString;
500
527
  price: z.ZodNullable<z.ZodObject<{
501
- currency: z.ZodLiteral<"EUR">;
502
- minorUnits: z.ZodNumber;
528
+ amount: z.ZodObject<{
529
+ currency: z.ZodLiteral<"EUR">;
530
+ minorUnits: z.ZodNumber;
531
+ }, z.core.$strict>;
532
+ per: z.ZodEnum<{
533
+ item: "item";
534
+ kg: "kg";
535
+ l: "l";
536
+ piece: "piece";
537
+ }>;
503
538
  }, z.core.$strict>>;
504
539
  url: z.ZodURL;
505
- unitPriceText: z.ZodNullable<z.ZodString>;
540
+ secondaryPrice: z.ZodNullable<z.ZodObject<{
541
+ amount: z.ZodObject<{
542
+ currency: z.ZodLiteral<"EUR">;
543
+ minorUnits: z.ZodNumber;
544
+ }, z.core.$strict>;
545
+ per: z.ZodEnum<{
546
+ item: "item";
547
+ kg: "kg";
548
+ l: "l";
549
+ piece: "piece";
550
+ }>;
551
+ }, z.core.$strict>>;
552
+ packageText: z.ZodNullable<z.ZodString>;
506
553
  minimumText: z.ZodNullable<z.ZodString>;
507
554
  favorite: z.ZodBoolean;
508
- similarity: z.ZodNumber;
509
555
  }, z.core.$strict>>;
510
556
  scanned: z.ZodNumber;
511
557
  returned: z.ZodNumber;
@@ -598,8 +644,16 @@ export declare const FavoritesOutput: z.ZodObject<{
598
644
  productSlug: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ProductSlug", "out">>;
599
645
  name: z.ZodString;
600
646
  price: z.ZodNullable<z.ZodObject<{
601
- currency: z.ZodLiteral<"EUR">;
602
- minorUnits: z.ZodNumber;
647
+ amount: z.ZodObject<{
648
+ currency: z.ZodLiteral<"EUR">;
649
+ minorUnits: z.ZodNumber;
650
+ }, z.core.$strict>;
651
+ per: z.ZodEnum<{
652
+ item: "item";
653
+ kg: "kg";
654
+ l: "l";
655
+ piece: "piece";
656
+ }>;
603
657
  }, z.core.$strict>>;
604
658
  url: z.ZodURL;
605
659
  }, z.core.$strict>>;
@@ -1301,6 +1355,11 @@ export declare const SessionStatusOutput: z.ZodObject<{
1301
1355
  expiresAt: z.ZodNullable<z.ZodISODateTime>;
1302
1356
  empty: z.ZodNullable<z.ZodBoolean>;
1303
1357
  }, z.core.$strict>;
1358
+ update: z.ZodObject<{
1359
+ currentVersion: z.ZodNullable<z.ZodString>;
1360
+ latestVersion: z.ZodNullable<z.ZodString>;
1361
+ available: z.ZodBoolean;
1362
+ }, z.core.$strict>;
1304
1363
  }, z.core.$strict>;
1305
1364
  }, z.core.$strict>, z.ZodObject<{
1306
1365
  status: z.ZodLiteral<"error">;
@@ -1359,18 +1418,30 @@ export declare const ProductDetailsOutput: z.ZodObject<{
1359
1418
  productSlug: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ProductSlug", "out">>;
1360
1419
  name: z.ZodString;
1361
1420
  url: z.ZodURL;
1362
- price: z.ZodObject<{
1363
- amount: z.ZodNullable<z.ZodObject<{
1421
+ price: z.ZodNullable<z.ZodObject<{
1422
+ amount: z.ZodObject<{
1364
1423
  currency: z.ZodLiteral<"EUR">;
1365
1424
  minorUnits: z.ZodNumber;
1366
- }, z.core.$strict>>;
1367
- basis: z.ZodEnum<{
1368
- line: "line";
1369
- unit: "unit";
1370
- unknown: "unknown";
1425
+ }, z.core.$strict>;
1426
+ per: z.ZodEnum<{
1427
+ item: "item";
1428
+ kg: "kg";
1429
+ l: "l";
1430
+ piece: "piece";
1371
1431
  }>;
1372
- }, z.core.$strict>;
1373
- unitPriceText: z.ZodNullable<z.ZodString>;
1432
+ }, z.core.$strict>>;
1433
+ secondaryPrice: z.ZodNullable<z.ZodObject<{
1434
+ amount: z.ZodObject<{
1435
+ currency: z.ZodLiteral<"EUR">;
1436
+ minorUnits: z.ZodNumber;
1437
+ }, z.core.$strict>;
1438
+ per: z.ZodEnum<{
1439
+ item: "item";
1440
+ kg: "kg";
1441
+ l: "l";
1442
+ piece: "piece";
1443
+ }>;
1444
+ }, z.core.$strict>>;
1374
1445
  availability: z.ZodEnum<{
1375
1446
  available: "available";
1376
1447
  unavailable: "unavailable";