@endora-commerce/mod-comparisons 0.0.0-stage → 0.100.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/LICENSE +21 -0
- package/README.md +61 -2
- package/dist/admin/api/comparisons-client.d.ts +24 -0
- package/dist/admin/api/comparisons-client.d.ts.map +1 -0
- package/dist/admin/api/comparisons-client.js +15 -0
- package/dist/admin/api/comparisons-client.js.map +1 -0
- package/dist/admin/index.d.ts +37 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +49 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/pages/ComparisonDetailPage.d.ts +19 -0
- package/dist/admin/pages/ComparisonDetailPage.d.ts.map +1 -0
- package/dist/admin/pages/ComparisonDetailPage.js +62 -0
- package/dist/admin/pages/ComparisonDetailPage.js.map +1 -0
- package/dist/admin/pages/ComparisonsListPage.d.ts +10 -0
- package/dist/admin/pages/ComparisonsListPage.d.ts.map +1 -0
- package/dist/admin/pages/ComparisonsListPage.js +71 -0
- package/dist/admin/pages/ComparisonsListPage.js.map +1 -0
- package/dist/backend/entities/comparison-product.entity.d.ts +22 -0
- package/dist/backend/entities/comparison-product.entity.d.ts.map +1 -0
- package/dist/backend/entities/comparison-product.entity.js +54 -0
- package/dist/backend/entities/comparison-product.entity.js.map +1 -0
- package/dist/backend/entities/comparison.entity.d.ts +61 -0
- package/dist/backend/entities/comparison.entity.d.ts.map +1 -0
- package/dist/backend/entities/comparison.entity.js +115 -0
- package/dist/backend/entities/comparison.entity.js.map +1 -0
- package/dist/backend/index.d.ts +61 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +117 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/request-actor.d.ts +22 -0
- package/dist/backend/request-actor.d.ts.map +1 -0
- package/dist/backend/request-actor.js +24 -0
- package/dist/backend/request-actor.js.map +1 -0
- package/dist/backend/routes.admin.d.ts +22 -0
- package/dist/backend/routes.admin.d.ts.map +1 -0
- package/dist/backend/routes.admin.js +41 -0
- package/dist/backend/routes.admin.js.map +1 -0
- package/dist/backend/routes.public.d.ts +33 -0
- package/dist/backend/routes.public.d.ts.map +1 -0
- package/dist/backend/routes.public.js +215 -0
- package/dist/backend/routes.public.js.map +1 -0
- package/dist/backend/routes.share.d.ts +27 -0
- package/dist/backend/routes.share.d.ts.map +1 -0
- package/dist/backend/routes.share.js +49 -0
- package/dist/backend/routes.share.js.map +1 -0
- package/dist/backend/services/anonymous-token-cookie.d.ts +23 -0
- package/dist/backend/services/anonymous-token-cookie.d.ts.map +1 -0
- package/dist/backend/services/anonymous-token-cookie.js +33 -0
- package/dist/backend/services/anonymous-token-cookie.js.map +1 -0
- package/dist/backend/services/asset-byte-fetcher.d.ts +37 -0
- package/dist/backend/services/asset-byte-fetcher.d.ts.map +1 -0
- package/dist/backend/services/asset-byte-fetcher.js +70 -0
- package/dist/backend/services/asset-byte-fetcher.js.map +1 -0
- package/dist/backend/services/comparable-attribute-projection.d.ts +57 -0
- package/dist/backend/services/comparable-attribute-projection.d.ts.map +1 -0
- package/dist/backend/services/comparable-attribute-projection.js +136 -0
- package/dist/backend/services/comparable-attribute-projection.js.map +1 -0
- package/dist/backend/services/comparison-admin.service.d.ts +31 -0
- package/dist/backend/services/comparison-admin.service.d.ts.map +1 -0
- package/dist/backend/services/comparison-admin.service.js +184 -0
- package/dist/backend/services/comparison-admin.service.js.map +1 -0
- package/dist/backend/services/comparison-pdf-renderer.d.ts +57 -0
- package/dist/backend/services/comparison-pdf-renderer.d.ts.map +1 -0
- package/dist/backend/services/comparison-pdf-renderer.js +211 -0
- package/dist/backend/services/comparison-pdf-renderer.js.map +1 -0
- package/dist/backend/services/comparison-service.d.ts +339 -0
- package/dist/backend/services/comparison-service.d.ts.map +1 -0
- package/dist/backend/services/comparison-service.js +588 -0
- package/dist/backend/services/comparison-service.js.map +1 -0
- package/dist/backend/services/share-token-generator.d.ts +20 -0
- package/dist/backend/services/share-token-generator.d.ts.map +1 -0
- package/dist/backend/services/share-token-generator.js +23 -0
- package/dist/backend/services/share-token-generator.js.map +1 -0
- package/dist/manifest.d.ts +199 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +179 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrations/20260501T185834_comparisons_init.d.ts +32 -0
- package/dist/migrations/20260501T185834_comparisons_init.d.ts.map +1 -0
- package/dist/migrations/20260501T185834_comparisons_init.js +102 -0
- package/dist/migrations/20260501T185834_comparisons_init.js.map +1 -0
- package/dist/migrations/20260830T163143_comparisons_organization_attribution.d.ts +84 -0
- package/dist/migrations/20260830T163143_comparisons_organization_attribution.d.ts.map +1 -0
- package/dist/migrations/20260830T163143_comparisons_organization_attribution.js +147 -0
- package/dist/migrations/20260830T163143_comparisons_organization_attribution.js.map +1 -0
- package/dist/migrations/index.d.ts +28 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +31 -0
- package/dist/migrations/index.js.map +1 -0
- package/docs/comparisons.md +278 -0
- package/i18n/en.json +51 -0
- package/i18n/pl.json +51 -0
- package/package.json +102 -3
- package/tailwind.css +14 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
/**
|
|
3
|
+
* D-187 — `comparisons` gains its organisation, and a row that names a customer
|
|
4
|
+
* account carries one.
|
|
5
|
+
*
|
|
6
|
+
* Feature 087 Group B, class 1 of 4. `Comparison` is `@CustomerScoped`, and
|
|
7
|
+
* until this migration the `allowed-set` arm of `customerFilterCond` had no
|
|
8
|
+
* column to grant on: a sales representative assigned to the buyer's own
|
|
9
|
+
* organisation was shown **none** of their comparisons and told so, through the
|
|
10
|
+
* `ORGANIZATION_ATTRIBUTION_PENDING` notice the refusing arm records. The
|
|
11
|
+
* column is what turns that into an answer.
|
|
12
|
+
*
|
|
13
|
+
* ## The column and the read arrive together, on purpose
|
|
14
|
+
*
|
|
15
|
+
* `customerOrganizationColumn` asks the ORM's own metadata whether the filtered
|
|
16
|
+
* entity carries `organizationId`, **per query**, so the entity property in
|
|
17
|
+
* this same merge request is what switches the grant on — there is no third
|
|
18
|
+
* artefact, no flag and no staging. At the same instant the notice retires
|
|
19
|
+
* itself: it is keyed on the column's *absence*.
|
|
20
|
+
*
|
|
21
|
+
* That pairing is why this migration is not "column and backfill". From the
|
|
22
|
+
* moment the grant is live, a row inserted without an organisation is invisible
|
|
23
|
+
* to the representative who serves that organisation, on a screen that has just
|
|
24
|
+
* stopped explaining itself — and MikroORM applies no filter to `INSERT`
|
|
25
|
+
* (`r1-spike.md` §4, measured), so the filter cannot refuse it. The `CHECK`
|
|
26
|
+
* below is the only refusal an `INSERT` has.
|
|
27
|
+
*
|
|
28
|
+
* ## An implication, not an equivalence
|
|
29
|
+
*
|
|
30
|
+
* `customer_account_id is null or organization_id is not null` is FR-010 and
|
|
31
|
+
* FR-011 together and nothing wider: an **owned** row has an organisation, an
|
|
32
|
+
* **ownerless** row need not. Half of this table is ownerless by construction —
|
|
33
|
+
* `comparisons_owner_xor_chk` makes every row that is not owned an anonymous
|
|
34
|
+
* one — and who an anonymous comparison belongs to is R-6's open question. The
|
|
35
|
+
* equivalence would answer it in the schema, which D-187 declines to do.
|
|
36
|
+
*
|
|
37
|
+
* ## No foreign key
|
|
38
|
+
*
|
|
39
|
+
* Matching `Cart`, which carries none. D-187 withdraws `r1-spike.md` §8's
|
|
40
|
+
* `on delete set null` recommendation as contradicting FR-011: it produces a
|
|
41
|
+
* row that names an account and no organisation, which is precisely the state
|
|
42
|
+
* this migration makes unreachable, and under the constraint below it would
|
|
43
|
+
* abort an unrelated organisation delete with a message about a table the
|
|
44
|
+
* operator was not touching. If one is ever taken here it must be `restrict`,
|
|
45
|
+
* and it is not this migration's decision.
|
|
46
|
+
*
|
|
47
|
+
* ## Derive, then count, then refuse — never delete
|
|
48
|
+
*
|
|
49
|
+
* The derivation is the owning account's own organisation, and on **this**
|
|
50
|
+
* table it is total by proof rather than by hope.
|
|
51
|
+
* `comparisons_customer_account_fk` is `on delete cascade`, so a row naming an
|
|
52
|
+
* account that is gone cannot exist; `comparisons_owner_xor_chk` makes every
|
|
53
|
+
* other row anonymous; and D-178 made `customer_accounts.organization_id`
|
|
54
|
+
* `NOT NULL`. *Derivable ⟺ has an account* is a theorem here, and the refusal
|
|
55
|
+
* branch is dead code the day it is written.
|
|
56
|
+
*
|
|
57
|
+
* It is written anyway, and that is the point of writing it: a proof that stops
|
|
58
|
+
* being true stops **silently**. Drop the foreign key, relax the XOR, or
|
|
59
|
+
* re-open D-178, and the count below is the only thing that would notice. It
|
|
60
|
+
* raises with the count and up to twenty ids and deletes nothing (D-184), in
|
|
61
|
+
* the shape `20260825T141659_customer_accounts_organization_required.ts`
|
|
62
|
+
* established and `20260830T112911_carts_organization_attribution_check.ts`
|
|
63
|
+
* repeated.
|
|
64
|
+
*
|
|
65
|
+
* ## Nothing to declare in the manifest
|
|
66
|
+
*
|
|
67
|
+
* `comparisons` already declares `organizations`, whose own
|
|
68
|
+
* `20260424T205317_organizations_init.ts` creates `customer_accounts` — far
|
|
69
|
+
* below `BASELINE_THROUGH`, so the table this reads exists whatever this
|
|
70
|
+
* module's position in the dependency order. No foreign key is added, so
|
|
71
|
+
* `fk-dependency-drift` has nothing to say either, and a migration naming
|
|
72
|
+
* another module's table is outside `check:module-boundary`'s population by
|
|
73
|
+
* that check's own rule.
|
|
74
|
+
*
|
|
75
|
+
* The standing guard is
|
|
76
|
+
* `backend/test/integration/tenancy/customer-scoped-organization-completeness.test.ts`,
|
|
77
|
+
* which derives its population from the ORM's metadata rather than from a list
|
|
78
|
+
* — so this class is covered by gaining the column, with no edit to that file.
|
|
79
|
+
*/
|
|
80
|
+
export class Migration20260830T163143ComparisonsOrganizationAttribution extends Migration {
|
|
81
|
+
async up() {
|
|
82
|
+
// 1. The column. Nullable, because an anonymous comparison legitimately has
|
|
83
|
+
// none (FR-011) and this table is half anonymous by construction.
|
|
84
|
+
this.addSql(`alter table "comparisons" add column "organization_id" uuid null;`);
|
|
85
|
+
// 2. Derive the organisation from the account that owns the comparison.
|
|
86
|
+
// Total on this table: the foreign key cascades, so an account named
|
|
87
|
+
// here exists, and its own organisation is NOT NULL since D-178.
|
|
88
|
+
this.addSql(`
|
|
89
|
+
update "comparisons" c
|
|
90
|
+
set "organization_id" = ca."organization_id"
|
|
91
|
+
from "customer_accounts" ca
|
|
92
|
+
where ca."id" = c."customer_account_id"
|
|
93
|
+
and c."organization_id" is null;
|
|
94
|
+
`);
|
|
95
|
+
// 3. Count what is left and refuse. Provably empty today — see the block
|
|
96
|
+
// comment. It runs because the proof rests on three facts none of which
|
|
97
|
+
// this migration owns, and a proof that stops being true stops without
|
|
98
|
+
// saying so.
|
|
99
|
+
this.addSql(`
|
|
100
|
+
do $$
|
|
101
|
+
declare
|
|
102
|
+
remaining bigint;
|
|
103
|
+
sample text;
|
|
104
|
+
begin
|
|
105
|
+
select count(*) into remaining
|
|
106
|
+
from "comparisons"
|
|
107
|
+
where "customer_account_id" is not null
|
|
108
|
+
and "organization_id" is null;
|
|
109
|
+
if remaining > 0 then
|
|
110
|
+
select string_agg(id::text, ', ') into sample from (
|
|
111
|
+
select "id" from "comparisons"
|
|
112
|
+
where "customer_account_id" is not null
|
|
113
|
+
and "organization_id" is null
|
|
114
|
+
order by "id" limit 20
|
|
115
|
+
) s;
|
|
116
|
+
raise exception
|
|
117
|
+
'D-187: % comparisons row(s) still name a customer account with organization_id IS NULL after derivation. First ids: %. Every customer account has an organization (D-178) and this table cascades on account delete, so these rows contradict the schema. Do not delete them - find out how they got here.',
|
|
118
|
+
remaining, sample;
|
|
119
|
+
end if;
|
|
120
|
+
end $$;
|
|
121
|
+
`);
|
|
122
|
+
// 4. The constraint.
|
|
123
|
+
this.addSql(`
|
|
124
|
+
alter table "comparisons"
|
|
125
|
+
add constraint "comparisons_organization_attribution_chk"
|
|
126
|
+
check ("customer_account_id" is null or "organization_id" is not null);
|
|
127
|
+
`);
|
|
128
|
+
// 5. The index the entity property declares. Partial, like the two owner
|
|
129
|
+
// indexes the init migration writes: the column is null on every
|
|
130
|
+
// anonymous row, and the reader is an organisation-scoped list.
|
|
131
|
+
this.addSql(`
|
|
132
|
+
create index "idx_comparisons_organization"
|
|
133
|
+
on "comparisons" ("organization_id")
|
|
134
|
+
where "organization_id" is not null;
|
|
135
|
+
`);
|
|
136
|
+
}
|
|
137
|
+
async down() {
|
|
138
|
+
// The constraint, the index and the column. The organisations step 2
|
|
139
|
+
// derived go with the column they were written into — there is nothing to
|
|
140
|
+
// preserve, because each of them was already implied by the account its row
|
|
141
|
+
// names.
|
|
142
|
+
this.addSql(`alter table "comparisons" drop constraint if exists "comparisons_organization_attribution_chk";`);
|
|
143
|
+
this.addSql(`drop index if exists "idx_comparisons_organization";`);
|
|
144
|
+
this.addSql(`alter table "comparisons" drop column if exists "organization_id";`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
//# sourceMappingURL=20260830T163143_comparisons_organization_attribution.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"20260830T163143_comparisons_organization_attribution.js","sourceRoot":"","sources":["../../src/migrations/20260830T163143_comparisons_organization_attribution.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6EG;AACH,MAAM,OAAO,0DAA2D,SAAQ,SAAS;IAC9E,KAAK,CAAC,EAAE;QACf,4EAA4E;QAC5E,qEAAqE;QACrE,IAAI,CAAC,MAAM,CAAC,mEAAmE,CAAC,CAAC;QAEjF,wEAAwE;QACxE,wEAAwE;QACxE,oEAAoE;QACpE,IAAI,CAAC,MAAM,CAAC;;;;;;KAMX,CAAC,CAAC;QAEH,yEAAyE;QACzE,2EAA2E;QAC3E,0EAA0E;QAC1E,gBAAgB;QAChB,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;KAsBX,CAAC,CAAC;QAEH,qBAAqB;QACrB,IAAI,CAAC,MAAM,CAAC;;;;KAIX,CAAC,CAAC;QAEH,yEAAyE;QACzE,oEAAoE;QACpE,mEAAmE;QACnE,IAAI,CAAC,MAAM,CAAC;;;;KAIX,CAAC,CAAC;IACL,CAAC;IAEQ,KAAK,CAAC,IAAI;QACjB,qEAAqE;QACrE,0EAA0E;QAC1E,4EAA4E;QAC5E,SAAS;QACT,IAAI,CAAC,MAAM,CACT,iGAAiG,CAClG,CAAC;QACF,IAAI,CAAC,MAAM,CAAC,sDAAsD,CAAC,CAAC;QACpE,IAAI,CAAC,MAAM,CAAC,oEAAoE,CAAC,CAAC;IACpF,CAAC;CACF"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `./migrations` subpath — every migration class this module owns, as one
|
|
3
|
+
* ordered `migrations` array.
|
|
4
|
+
*
|
|
5
|
+
* The array is what the platform reads when this module is **installed**:
|
|
6
|
+
* `src/packages/package-runtime.ts` takes `exported['migrations']` and refuses
|
|
7
|
+
* the package outright when it is absent (D-168).
|
|
8
|
+
*
|
|
9
|
+
* Listed in ascending timestamp, which is the order of this module's own
|
|
10
|
+
* migrations and of nothing else (feature 081): a manifest `dependencies` array
|
|
11
|
+
* is the only thing ordering this block against another module's.
|
|
12
|
+
*
|
|
13
|
+
* The **named** exports stay beside the array, and the asymmetry with
|
|
14
|
+
* `./backend` — which publishes an array and no named class (D-168) — is
|
|
15
|
+
* deliberate. `db/migrations-registry.generated.ts` imports each class by name
|
|
16
|
+
* from this specifier, and a migration class name is contract in a way an entity
|
|
17
|
+
* class name is not: `mikro_orm_migrations` persists it, so it is a string every
|
|
18
|
+
* already-migrated database holds.
|
|
19
|
+
*
|
|
20
|
+
* A class that is in neither the array nor the barrel is a migration that does
|
|
21
|
+
* not run: `migration:pending` reports nothing pending and the first symptom is
|
|
22
|
+
* a query against a table nobody created.
|
|
23
|
+
*/
|
|
24
|
+
import { Migration20260501T185834ComparisonsInit } from './20260501T185834_comparisons_init.js';
|
|
25
|
+
import { Migration20260830T163143ComparisonsOrganizationAttribution } from './20260830T163143_comparisons_organization_attribution.js';
|
|
26
|
+
export declare const migrations: (typeof Migration20260501T185834ComparisonsInit)[];
|
|
27
|
+
export { Migration20260501T185834ComparisonsInit, Migration20260830T163143ComparisonsOrganizationAttribution, };
|
|
28
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,uCAAuC,EAAE,MAAM,uCAAuC,CAAC;AAChG,OAAO,EAAE,0DAA0D,EAAE,MAAM,2DAA2D,CAAC;AAEvI,eAAO,MAAM,UAAU,oDAGtB,CAAC;AAEF,OAAO,EACL,uCAAuC,EACvC,0DAA0D,GAC3D,CAAC"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `./migrations` subpath — every migration class this module owns, as one
|
|
3
|
+
* ordered `migrations` array.
|
|
4
|
+
*
|
|
5
|
+
* The array is what the platform reads when this module is **installed**:
|
|
6
|
+
* `src/packages/package-runtime.ts` takes `exported['migrations']` and refuses
|
|
7
|
+
* the package outright when it is absent (D-168).
|
|
8
|
+
*
|
|
9
|
+
* Listed in ascending timestamp, which is the order of this module's own
|
|
10
|
+
* migrations and of nothing else (feature 081): a manifest `dependencies` array
|
|
11
|
+
* is the only thing ordering this block against another module's.
|
|
12
|
+
*
|
|
13
|
+
* The **named** exports stay beside the array, and the asymmetry with
|
|
14
|
+
* `./backend` — which publishes an array and no named class (D-168) — is
|
|
15
|
+
* deliberate. `db/migrations-registry.generated.ts` imports each class by name
|
|
16
|
+
* from this specifier, and a migration class name is contract in a way an entity
|
|
17
|
+
* class name is not: `mikro_orm_migrations` persists it, so it is a string every
|
|
18
|
+
* already-migrated database holds.
|
|
19
|
+
*
|
|
20
|
+
* A class that is in neither the array nor the barrel is a migration that does
|
|
21
|
+
* not run: `migration:pending` reports nothing pending and the first symptom is
|
|
22
|
+
* a query against a table nobody created.
|
|
23
|
+
*/
|
|
24
|
+
import { Migration20260501T185834ComparisonsInit } from './20260501T185834_comparisons_init.js';
|
|
25
|
+
import { Migration20260830T163143ComparisonsOrganizationAttribution } from './20260830T163143_comparisons_organization_attribution.js';
|
|
26
|
+
export const migrations = [
|
|
27
|
+
Migration20260501T185834ComparisonsInit,
|
|
28
|
+
Migration20260830T163143ComparisonsOrganizationAttribution,
|
|
29
|
+
];
|
|
30
|
+
export { Migration20260501T185834ComparisonsInit, Migration20260830T163143ComparisonsOrganizationAttribution, };
|
|
31
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,uCAAuC,EAAE,MAAM,uCAAuC,CAAC;AAChG,OAAO,EAAE,0DAA0D,EAAE,MAAM,2DAA2D,CAAC;AAEvI,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,uCAAuC;IACvC,0DAA0D;CAC3D,CAAC;AAEF,OAAO,EACL,uCAAuC,EACvC,0DAA0D,GAC3D,CAAC"}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Compare Products
|
|
3
|
+
description: 'Compare Products: customer-curated set with display modes, share link, and PDF export'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Compare Products
|
|
7
|
+
|
|
8
|
+
Customer-facing product-comparison module. Owns a first-class
|
|
9
|
+
`Comparison` resource (a customer-curated set of products with a chosen
|
|
10
|
+
display mode and a stable shareable link), an admin observability
|
|
11
|
+
surface, and the Node-side PDF export. The module deliberately holds no
|
|
12
|
+
catalog data; it consumes products and the `is_comparable` attribute
|
|
13
|
+
flag through the `CatalogQueryService` port.
|
|
14
|
+
|
|
15
|
+
This page is written for two audiences:
|
|
16
|
+
|
|
17
|
+
- **Buyers and platform administrators** who want to see *what* the
|
|
18
|
+
feature does (sections "What a buyer does", "What an administrator
|
|
19
|
+
sees", "What an administrator configures").
|
|
20
|
+
- **Developers** who need to extend or operate the module (every
|
|
21
|
+
section after "Public surface").
|
|
22
|
+
|
|
23
|
+
## What a buyer does
|
|
24
|
+
|
|
25
|
+
### 1. Pick products to compare
|
|
26
|
+
|
|
27
|
+
On any catalog list page or product detail page, click **Compare** on
|
|
28
|
+
the product card. The button toggles — click it again to remove the
|
|
29
|
+
product from the comparison set. A header pill (**Compare (N)**) shows
|
|
30
|
+
how many products you have queued.
|
|
31
|
+
|
|
32
|
+
The first time you mark a product, the storefront opens a session for
|
|
33
|
+
your comparison. You don't need to be signed in.
|
|
34
|
+
|
|
35
|
+
### 2. Open the comparison page
|
|
36
|
+
|
|
37
|
+
Click the header pill, or navigate to `/compare`. You'll see a
|
|
38
|
+
side-by-side table:
|
|
39
|
+
|
|
40
|
+
- The header row always shows each product's **name**, **price** in
|
|
41
|
+
your sales-channel currency, and **base image**.
|
|
42
|
+
- The body shows every comparable attribute the catalog administrator
|
|
43
|
+
has flagged for comparison (weight, material, dimensions, etc. —
|
|
44
|
+
varies per category).
|
|
45
|
+
|
|
46
|
+
### 3. Switch the view
|
|
47
|
+
|
|
48
|
+
The toolbar above the table has three buttons:
|
|
49
|
+
|
|
50
|
+
- **All attributes** — shows everything; rows that are identical
|
|
51
|
+
across products are highlighted with a calmer styling, rows that
|
|
52
|
+
differ stand out.
|
|
53
|
+
- **Common attributes only** — shows only the rows where every product
|
|
54
|
+
agrees, so you can confirm the baseline.
|
|
55
|
+
- **Differences only** — shows only the rows where at least one
|
|
56
|
+
product disagrees, so the decision-relevant signal stands out.
|
|
57
|
+
|
|
58
|
+
Switching is instantaneous — no reload.
|
|
59
|
+
|
|
60
|
+
### 4. Share with a colleague
|
|
61
|
+
|
|
62
|
+
Click **Copy share link**. The storefront writes a URL of the form
|
|
63
|
+
`https://your-store/compare/share/<token>` to your clipboard. Anyone
|
|
64
|
+
who opens that link sees the same comparison — without needing an
|
|
65
|
+
account, and without being able to change anything. They can switch
|
|
66
|
+
modes themselves (locally), but they cannot remove products, delete the
|
|
67
|
+
comparison, or use *Add to cart*.
|
|
68
|
+
|
|
69
|
+
The link works as long as the comparison exists. If you delete the
|
|
70
|
+
comparison, the link stops working for everyone.
|
|
71
|
+
|
|
72
|
+
**What the recipient sees is decided by who the recipient is**, not by
|
|
73
|
+
who sent the link. A share link grants access to the comparison, never
|
|
74
|
+
to anything in it:
|
|
75
|
+
|
|
76
|
+
- **Prices** — a signed-in recipient sees the prices agreed with *their*
|
|
77
|
+
organization, and a recipient who is not signed in sees the store's
|
|
78
|
+
standard prices. Your negotiated prices are never disclosed by a link
|
|
79
|
+
you send, and the page says whose prices it is showing.
|
|
80
|
+
- **Products** — if your comparison holds a product that is restricted
|
|
81
|
+
to your organization, a recipient outside it does not see that column.
|
|
82
|
+
The page tells them that something is not available to them rather
|
|
83
|
+
than quietly showing a shorter table. A recipient whose own
|
|
84
|
+
organization is allowed to see the product sees it normally.
|
|
85
|
+
|
|
86
|
+
### 5. Add a chosen product to the cart
|
|
87
|
+
|
|
88
|
+
Once you've decided, click **Add to cart** on the chosen product's
|
|
89
|
+
column. The cart picks up the product subject to the same rules as if
|
|
90
|
+
you'd added it from the product page (channel availability, stock).
|
|
91
|
+
The comparison itself stays intact.
|
|
92
|
+
|
|
93
|
+
### 6. Export to PDF
|
|
94
|
+
|
|
95
|
+
Click **Export to PDF**. The storefront downloads a PDF named
|
|
96
|
+
`comparison-<token>.pdf` that mirrors what's on screen — same
|
|
97
|
+
products, same display mode, same column order. The PDF is generated
|
|
98
|
+
fresh each time; switching the display mode and re-exporting produces
|
|
99
|
+
a new file with the new mode.
|
|
100
|
+
|
|
101
|
+
PDFs of three or more products land in landscape orientation; one or
|
|
102
|
+
two products land in portrait.
|
|
103
|
+
|
|
104
|
+
### 7. Tidy up
|
|
105
|
+
|
|
106
|
+
When you're done, click **Delete comparison**. The set is cleared and
|
|
107
|
+
any shared links you sent stop resolving.
|
|
108
|
+
|
|
109
|
+
## Limits and edge cases
|
|
110
|
+
|
|
111
|
+
- **Maximum products per comparison** — defaults to **4**. Your
|
|
112
|
+
platform operator can raise or lower this per sales channel via
|
|
113
|
+
Settings (`compare.max_products`). The eleventh add — or whichever
|
|
114
|
+
one exceeds the configured cap — is refused with a message; the
|
|
115
|
+
existing comparison stays unchanged.
|
|
116
|
+
- **Removing a product** — drops a column. The remaining columns
|
|
117
|
+
recompute which rows count as common vs. different.
|
|
118
|
+
- **One product in the set** — the page renders, but suggests adding
|
|
119
|
+
at least one more product to draw a meaningful comparison.
|
|
120
|
+
- **Empty set** — the page invites you to add products from the
|
|
121
|
+
catalog.
|
|
122
|
+
- **Product disappears from the catalog** — if a product is unpublished
|
|
123
|
+
while it is in your comparison, the column stays but is marked
|
|
124
|
+
unavailable; *Add to cart* is disabled for that column.
|
|
125
|
+
- **Shared link in a different sales channel** — recipients see prices
|
|
126
|
+
in their own channel currency, and any product that isn't sold in
|
|
127
|
+
their channel still appears but is marked unavailable.
|
|
128
|
+
- **Shared link opened by a buyer from another organization** — the
|
|
129
|
+
columns are re-priced for that buyer's own agreements, and any product
|
|
130
|
+
they are not entitled to see is left out with a note saying so.
|
|
131
|
+
- **Multi-value attributes** — two products are treated as agreeing on
|
|
132
|
+
a multi-value attribute (e.g. a list of certifications) only when
|
|
133
|
+
their full sets of values match.
|
|
134
|
+
- **Missing values** — a product without a value for a row renders an
|
|
135
|
+
`—`, and the row counts as a difference.
|
|
136
|
+
|
|
137
|
+
## What an administrator sees
|
|
138
|
+
|
|
139
|
+
Open **Comparisons** in the admin sidebar. The list shows every
|
|
140
|
+
comparison every customer has built, with the customer's email (or
|
|
141
|
+
*Anonymous* for unsigned-in builders), the sales channel, the active
|
|
142
|
+
display mode, the product count, and the creation time. Filter by
|
|
143
|
+
channel, owner kind, or time range; sort is newest-first by default.
|
|
144
|
+
|
|
145
|
+
Click a row to open the detail view. You'll see every product and
|
|
146
|
+
attribute row the customer put in the comparison — including products
|
|
147
|
+
restricted to organizations other than theirs — projected through that
|
|
148
|
+
customer's sales channel. **The prices here are the channel's standard
|
|
149
|
+
prices, not the customer's negotiated ones**: the comparison prices for
|
|
150
|
+
whoever is looking at it, and an administrator has no buying
|
|
151
|
+
organization to price against. The screen says so, so a figure quoted
|
|
152
|
+
back to a customer is never mistaken for the figure they were shown.
|
|
153
|
+
There are no edit, delete, or share buttons on the admin view by design
|
|
154
|
+
(it is read-only audit, not a tool to alter customer state).
|
|
155
|
+
|
|
156
|
+
If a customer deletes their comparison on the storefront, the row
|
|
157
|
+
disappears from the admin list on the next refresh.
|
|
158
|
+
|
|
159
|
+
## What an administrator configures
|
|
160
|
+
|
|
161
|
+
| Where | What |
|
|
162
|
+
| --- | --- |
|
|
163
|
+
| **Catalog → Attributes**, the `Comparable` checkbox | Picks which attributes appear as rows on the comparison page. Independent of `Searchable` / `Filterable`. |
|
|
164
|
+
| **Settings → Compare**, the `compare.max_products` setting | The per-channel cap on how many products a single comparison can hold. |
|
|
165
|
+
|
|
166
|
+
## Why this exists
|
|
167
|
+
|
|
168
|
+
In B2B procurement, a buyer rarely chooses alone — engineers, finance
|
|
169
|
+
folk, and managers all weigh in. Building a comparison once, sharing
|
|
170
|
+
the link, and exporting a PDF for archival is what the feature is for.
|
|
171
|
+
The admin view exists so the platform team can investigate support
|
|
172
|
+
tickets that quote a shared link.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Public surface
|
|
177
|
+
|
|
178
|
+
| Verb + Path | Audience | Purpose |
|
|
179
|
+
| --- | --- | --- |
|
|
180
|
+
| `GET /api/v1/comparisons/me` | storefront (anonymous or customer) | Read the caller's Comparison; `204 No Content` when none |
|
|
181
|
+
| `POST /api/v1/comparisons/me/products` | storefront | Add a product (creates the Comparison + `compare_token` cookie on first call); refuses with `409 COMPARISON_FULL` over the channel-resolved cap |
|
|
182
|
+
| `DELETE /api/v1/comparisons/me/products/:productId` | storefront | Remove a product; `404 PRODUCT_NOT_IN_COMPARISON` if absent |
|
|
183
|
+
| `PATCH /api/v1/comparisons/me` | storefront | Update the persisted display mode (`all` / `common` / `differences`) |
|
|
184
|
+
| `DELETE /api/v1/comparisons/me` | storefront | Hard-delete the Comparison; the share token stops resolving for everyone |
|
|
185
|
+
| `GET /api/v1/comparisons/me/pdf` | storefront (owner only) | Owner PDF export; PDF mirrors the active mode and embeds product base images; `409 COMPARISON_EMPTY` for a zero-product comparison |
|
|
186
|
+
| `GET /api/v1/comparisons/share/:token` | public, no auth | Recipient view; same shape as the owner read minus `maxProducts`, plus `meta.viewerIsOwner`; priced and filtered for the *recipient* (`data.pricedFor`, `data.hiddenProductCount`); `404 COMPARISON_NOT_FOUND` for a deleted/never-existed token |
|
|
187
|
+
| `GET /api/v1/admin/comparisons` | admin (`comparisons:read`) | List every Comparison with filters (channel, owner kind, time range) and cursor pagination |
|
|
188
|
+
| `GET /api/v1/admin/comparisons/:id` | admin (`comparisons:read`) | Read-only detail; rendered through the comparison's recorded sales channel so the view matches what the customer reported |
|
|
189
|
+
|
|
190
|
+
There is **no** comparisons-side cart proxy. The storefront's
|
|
191
|
+
*Add to cart* button on the comparison page calls the existing
|
|
192
|
+
`POST /api/v1/cart/items` directly.
|
|
193
|
+
|
|
194
|
+
## Settings
|
|
195
|
+
|
|
196
|
+
One knob under the `compare` group, registered by
|
|
197
|
+
`packages/modules/comparisons/src/manifest.ts`:
|
|
198
|
+
|
|
199
|
+
| Code | Type | Default | Purpose |
|
|
200
|
+
| --- | --- | --- | --- |
|
|
201
|
+
| `compare.max_products` | `number` | `4` | Upper bound on a single Comparison; the store-front refuses to add the (max+1)-th product per channel. Sane range `1..16`. |
|
|
202
|
+
|
|
203
|
+
The bound is read at request time inside
|
|
204
|
+
`ComparisonService.addProduct(...)`. Lowering the cap mid-session does
|
|
205
|
+
**not** retroactively trim existing comparisons; the next add is the
|
|
206
|
+
first request that picks up the new value.
|
|
207
|
+
|
|
208
|
+
## Catalog flag
|
|
209
|
+
|
|
210
|
+
The module relies on a new `is_comparable` boolean column on
|
|
211
|
+
`product_attributes`. Catalog owns the column (migration `028` lives
|
|
212
|
+
under `catalog/migrations/`); the comparisons module reads it through
|
|
213
|
+
`CatalogQueryService.comparableAttributeKeys()`.
|
|
214
|
+
|
|
215
|
+
The Catalog admin UI's `<AttributesManager>` exposes a `Comparable`
|
|
216
|
+
checkbox alongside `Searchable` and `Filterable`; toggling it has no
|
|
217
|
+
side effect (no event emission, no reindex) — the next comparison-page
|
|
218
|
+
render picks up the change directly from Postgres.
|
|
219
|
+
|
|
220
|
+
## Storage
|
|
221
|
+
|
|
222
|
+
Two tables, both owned by the comparisons module
|
|
223
|
+
(`027_comparisons_init.ts`):
|
|
224
|
+
|
|
225
|
+
- `comparisons` — primary key, 22-char base64url `share_token`
|
|
226
|
+
(`UNIQUE`), exclusive owner column (`customer_account_id` *or*
|
|
227
|
+
`anonymous_token`; DB CHECK enforces XOR), `sales_channel_id` (FK
|
|
228
|
+
with `ON DELETE RESTRICT`), `display_mode`, `created_at`,
|
|
229
|
+
`updated_at`. Partial indexes on each owner column; a separate index
|
|
230
|
+
on `(sales_channel_id, created_at desc)` supports the admin overview
|
|
231
|
+
channel filter.
|
|
232
|
+
- `comparison_products` — composite PK on
|
|
233
|
+
`(comparison_id, product_id)`, `position` (smallint), `added_at`.
|
|
234
|
+
Both FKs cascade. Reverse index on `product_id` for the admin list's
|
|
235
|
+
product-count aggregate.
|
|
236
|
+
|
|
237
|
+
No soft-delete. A deleted comparison must resolve to a
|
|
238
|
+
clear "no longer exists" state for shared-link recipients — a missing
|
|
239
|
+
row + `404 COMPARISON_NOT_FOUND` already satisfies that without a
|
|
240
|
+
soft-delete bit.
|
|
241
|
+
|
|
242
|
+
## Anonymous → authenticated identity
|
|
243
|
+
|
|
244
|
+
Anonymous customers carry the `compare_token` cookie (HttpOnly,
|
|
245
|
+
SameSite=Lax, Path=/, Max-Age = 1 year). At sign-in the existing
|
|
246
|
+
`onLogin` hook in `organizationsModule` extracts the cookie and calls
|
|
247
|
+
`ComparisonService.adoptAnonymousComparison(...)`:
|
|
248
|
+
|
|
249
|
+
- Customer with no Comparison → the anonymous one is reassigned
|
|
250
|
+
(`customer_account_id` set, `anonymous_token` cleared).
|
|
251
|
+
- Customer with an existing Comparison → the anonymous one is hard-
|
|
252
|
+
deleted; the customer's curated set wins.
|
|
253
|
+
|
|
254
|
+
## PDF export
|
|
255
|
+
|
|
256
|
+
Generated server-side with `pdfmake` — the module's only new runtime
|
|
257
|
+
dependency. Document definition is built declaratively from `ComparisonOwnerView`; product
|
|
258
|
+
base images are pre-fetched by `AssetByteFetcher` (per-request cache,
|
|
259
|
+
1×1 transparent PNG fallback) and embedded as data URIs. Page
|
|
260
|
+
orientation is landscape when product count ≥ 3, portrait otherwise.
|
|
261
|
+
|
|
262
|
+
A strict deny-all `setUrlAccessPolicy` pins the contract that pdfmake
|
|
263
|
+
never opens its own network sockets — every image reaches the document
|
|
264
|
+
through the AssetByteFetcher.
|
|
265
|
+
|
|
266
|
+
## Module isolation
|
|
267
|
+
|
|
268
|
+
| Direction | What we depend on | How |
|
|
269
|
+
| --- | --- | --- |
|
|
270
|
+
| Reads | Catalog products and `is_comparable` flag | `CatalogQueryService.comparableAttributeKeys()` + entity reads via the EM |
|
|
271
|
+
| Reads | `compare.max_products` | `SettingsService.get(...)` — same shape Search uses |
|
|
272
|
+
| Reads | Sales channel context (currency, public flag) | `getResolvedChannel()` — the channel the resolver middleware put on the request scope |
|
|
273
|
+
| Reads | Customer email for the admin list | `CustomerAccount` entity — read-only join |
|
|
274
|
+
| Writes | None outside its own two tables | — |
|
|
275
|
+
|
|
276
|
+
The comparisons module has zero compile-time dependencies on the
|
|
277
|
+
`carts` module. Removing the comparisons module leaves catalog,
|
|
278
|
+
settings, sales_channels, and carts working — no dangling references.
|
package/i18n/en.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"errors.COMPARISON_FULL": "Comparison Full.",
|
|
3
|
+
"errors.COMPARISON_NOT_FOUND": "Comparison Not Found.",
|
|
4
|
+
"errors.COMPARISON_EMPTY": "Comparison Empty.",
|
|
5
|
+
"errors.PDF_GENERATION_FAILED": "Pdf Generation Failed.",
|
|
6
|
+
"error.load": "Failed to load.",
|
|
7
|
+
"common.loading": "Loading…",
|
|
8
|
+
"list.page.title": "Comparisons",
|
|
9
|
+
"list.page.description": "Read-only audit of every comparison generated on the storefront.",
|
|
10
|
+
"list.filter.salesChannelId": "Sales channel id",
|
|
11
|
+
"list.filter.uuidOptional": "uuid (optional)",
|
|
12
|
+
"list.filter.owner": "Owner",
|
|
13
|
+
"list.filter.createdAfter": "Created after",
|
|
14
|
+
"list.filter.createdBefore": "Created before",
|
|
15
|
+
"list.empty": "No comparisons match the filters.",
|
|
16
|
+
"list.column.owner": "Owner",
|
|
17
|
+
"list.column.salesChannel": "Sales channel",
|
|
18
|
+
"list.column.mode": "Mode",
|
|
19
|
+
"list.column.products": "Products",
|
|
20
|
+
"list.column.created": "Created",
|
|
21
|
+
"list.column.open": "Open",
|
|
22
|
+
"list.loadMore": "Load more",
|
|
23
|
+
"detail.page.title": "Comparison detail",
|
|
24
|
+
"detail.page.description": "Read-only view — what the customer is currently seeing.",
|
|
25
|
+
"detail.pricesNote": "Prices here are the sales channel’s standard prices, not the customer’s negotiated ones — the customer sees their own. Products restricted to other organizations are listed here in full.",
|
|
26
|
+
"detail.backToList": "Back to list",
|
|
27
|
+
"detail.owner": "Owner",
|
|
28
|
+
"detail.salesChannel": "Sales channel",
|
|
29
|
+
"detail.displayMode": "Display mode",
|
|
30
|
+
"detail.shareToken": "Share token",
|
|
31
|
+
"detail.created": "Created",
|
|
32
|
+
"detail.updated": "updated",
|
|
33
|
+
"detail.attribute": "Attribute",
|
|
34
|
+
"detail.noComparableAttributes": "No comparable attributes defined for this catalog.",
|
|
35
|
+
"owner.any": "Any",
|
|
36
|
+
"owner.customer": "Customer",
|
|
37
|
+
"owner.anonymous": "Anonymous",
|
|
38
|
+
"owner.noEmail": "(no email)",
|
|
39
|
+
"owner.noEmailOnFile": "(no email on file)",
|
|
40
|
+
"owner.anonymousWithToken": "Anonymous ({token})",
|
|
41
|
+
"displayMode.full": "Full",
|
|
42
|
+
"displayMode.compact": "Compact",
|
|
43
|
+
"displayMode.differences_only": "Differences only",
|
|
44
|
+
"displayMode.differencesOnly": "Differences only",
|
|
45
|
+
"displayMode.all": "All attributes",
|
|
46
|
+
"displayMode.common": "Common attributes",
|
|
47
|
+
"displayMode.differences": "Differences",
|
|
48
|
+
"nav.comparisons.label": "Comparisons",
|
|
49
|
+
"actions.openComparisons.label": "Comparisons",
|
|
50
|
+
"actions.openComparisons.description": "Compare-feature audit"
|
|
51
|
+
}
|
package/i18n/pl.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"errors.COMPARISON_FULL": "Błąd: comparison full.",
|
|
3
|
+
"errors.COMPARISON_NOT_FOUND": "Błąd: comparison not found.",
|
|
4
|
+
"errors.COMPARISON_EMPTY": "Błąd: comparison empty.",
|
|
5
|
+
"errors.PDF_GENERATION_FAILED": "Błąd: pdf generation failed.",
|
|
6
|
+
"error.load": "Nie udało się wczytać.",
|
|
7
|
+
"common.loading": "Wczytywanie…",
|
|
8
|
+
"list.page.title": "Porównania",
|
|
9
|
+
"list.page.description": "Tylko-do-odczytu audyt wszystkich porównań wygenerowanych w sklepie.",
|
|
10
|
+
"list.filter.salesChannelId": "Id kanału sprzedaży",
|
|
11
|
+
"list.filter.uuidOptional": "uuid (opcjonalnie)",
|
|
12
|
+
"list.filter.owner": "Właściciel",
|
|
13
|
+
"list.filter.createdAfter": "Utworzone po",
|
|
14
|
+
"list.filter.createdBefore": "Utworzone przed",
|
|
15
|
+
"list.empty": "Żadne porównanie nie pasuje do filtrów.",
|
|
16
|
+
"list.column.owner": "Właściciel",
|
|
17
|
+
"list.column.salesChannel": "Kanał sprzedaży",
|
|
18
|
+
"list.column.mode": "Tryb",
|
|
19
|
+
"list.column.products": "Produkty",
|
|
20
|
+
"list.column.created": "Utworzono",
|
|
21
|
+
"list.column.open": "Otwórz",
|
|
22
|
+
"list.loadMore": "Wczytaj więcej",
|
|
23
|
+
"detail.page.title": "Szczegóły porównania",
|
|
24
|
+
"detail.page.description": "Widok tylko do odczytu — to, co aktualnie widzi klient.",
|
|
25
|
+
"detail.pricesNote": "Ceny w tym widoku to ceny standardowe kanału sprzedaży, a nie ceny wynegocjowane przez klienta — klient widzi swoje. Produkty ograniczone do innych organizacji są tutaj pokazane w całości.",
|
|
26
|
+
"detail.backToList": "Wróć do listy",
|
|
27
|
+
"detail.owner": "Właściciel",
|
|
28
|
+
"detail.salesChannel": "Kanał sprzedaży",
|
|
29
|
+
"detail.displayMode": "Tryb wyświetlania",
|
|
30
|
+
"detail.shareToken": "Token udostępniania",
|
|
31
|
+
"detail.created": "Utworzono",
|
|
32
|
+
"detail.updated": "zaktualizowano",
|
|
33
|
+
"detail.attribute": "Atrybut",
|
|
34
|
+
"detail.noComparableAttributes": "Brak porównywalnych atrybutów dla tego katalogu.",
|
|
35
|
+
"owner.any": "Dowolny",
|
|
36
|
+
"owner.customer": "Klient",
|
|
37
|
+
"owner.anonymous": "Anonimowy",
|
|
38
|
+
"owner.noEmail": "(brak e-maila)",
|
|
39
|
+
"owner.noEmailOnFile": "(brak e-maila w kartotece)",
|
|
40
|
+
"owner.anonymousWithToken": "Anonimowy ({token})",
|
|
41
|
+
"displayMode.full": "Pełny",
|
|
42
|
+
"displayMode.compact": "Kompaktowy",
|
|
43
|
+
"displayMode.differences_only": "Tylko różnice",
|
|
44
|
+
"displayMode.differencesOnly": "Tylko różnice",
|
|
45
|
+
"displayMode.all": "Wszystkie atrybuty",
|
|
46
|
+
"displayMode.common": "Wspólne atrybuty",
|
|
47
|
+
"displayMode.differences": "Różnice",
|
|
48
|
+
"nav.comparisons.label": "Porównania",
|
|
49
|
+
"actions.openComparisons.label": "Porównania",
|
|
50
|
+
"actions.openComparisons.description": "Audyt porównań"
|
|
51
|
+
}
|