mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Procurement: draft Enterprise Agreement + signature, legal pages & consent, quote/agreement split (#7021)
Consolidates the enterprise procurement and legal work into one PR off `main`. Supersedes #7020 (closed; every commit from it is contained here). Sits on top of PAYG prepaid bundles (#7032) and the `--color-*` → `--c-*` portal token rename. ## Why Enterprise procurement was a mock. The stage screens read from a fake state machine, the "agreement" was prose hardcoded in a component, and nothing a buyer did was recorded anywhere. To actually sell to an enterprise we need three things it didn't have: a real document they can read and sign, a record that proves they signed that exact version, and a licence that flips when they pay. ## What **The agreement is a real versioned document** - Registry at `resources/legal/manifest.json` + `legal/<id>/<version>/*.md`. Publishing a new version is a markdown file and a manifest bump, no code change. `@`-prefixed parts are generated sections. - `AgreementAssembler` builds MSA (Part A) + generated Order Form (Part B) + DPA (Part C) as one document. Only the Order Form varies per deal. - `AgreementPdfRenderer` goes through our own pipeline (commonmark → `FileToPdf`/WeasyPrint), so we dogfood it. - Immutable signature record pinning document id and version, a SHA-256 of the exact rendered markdown, the variable snapshot, typed signatory details, timestamp and IP. **Legal document pages and consent logging** - `GET /api/v1/legal/{docId}` serves any registry document; a viewer modal renders it with a draft badge. The SLA exhibit is viewable for the first time. - `legal_consent` + `POST /api/v1/legal/consent`. EULA clickwrap is recorded once: at trial start, or at the quote step only if there was no trial. **Quote and Agreement are separate steps** The quote step is a plain itemised review (figures, renewal, PO) with download and "Accept quote". Accepting advances to the agreement and does not charge Stripe. Signing the agreement is still the commitment point. **One quote number** We no longer mint our own reference. The Stripe quote number is the identifier everywhere, so the UI and the memo can't disagree. `quote_number` is nullable until Stripe assigns it at finalisation (`20260808000000`). **Payment takes the deal live** `invoice.paid` on the stripe-webhook moves the deal to live and the UI reflects it. Nothing watched for payment before, so a paid customer sat in "payment" forever. Needs `invoice.paid` enabled on the webhook endpoint in the Stripe dashboard. **Security** Any signup could self-issue a $0 enterprise licence, from three things compounding: leader-on-signup, no entitlement gate, and no ACV floor. So: `startTrial` now has a stage guard (it was replacing committed licences), the offline `.lic` is gated on entitlement, the ACV floor is enforced before the quote persists, and the air-gap check reads the quote's deployment rather than the deal's. Invitee emails are redacted in logs. Dev and Storybook were hitting real Stripe; both now route through `resolveDemoResponse`. **Removed the dead procurement island** The original stage-by-stage page survived the rebuild with no route and no consumer, so it was invisible to review but still cost a reader's time. 16 unreferenced files, 182 lines of superseded API, 53 orphaned en-US keys, and `Procurement.css` from 1665 to 968 lines. Nothing deleted had a live consumer. ## Screenshots Home, deal underway (hero card footer): <!-- home-in-procurement.png --> Quote builder, step 1: <!-- quote-builder.png --> Agreement, ready to sign: <!-- agreement-signing.png --> Payment and live: <!-- stage-payment.png / stage-live.png --> ## How to test **Storybook** covers every state without a backend: ```bash cd frontend && npm run storybook ``` Then `Portal/Procurement/*`: | Story | What to look at | | --- | --- | | `DealStatusHero` — Trial / Quote / Agreement / Payment / Live | One hero per stage: progress band, one-line status, stage CTA | | `QuoteBuilder` — Default | 4 steps. Users + volume drive the price; Governance and PDF size are multipliers; step 4 is the itemised review | | `ProcurementAgreement` — Default / Signing | Header actions, always-visible scrollbar on the paper, one-line signature row | | `ProcurementStages` — Payment / Live / License | "View & pay invoice" opens Stripe directly; licence key and `.lic` download | | `Views/Home` — Subscribed In Procurement | The hero in real page context | Note: `ProcurementAgreement` renders "Could not load the agreement" in Storybook because it fetches the document from the backend. The chrome is accurate, the paper body needs the app. **Full flow** needs SaaS running and a linked team: 1. Home → **Explore enterprise** → trial setup (deployment + seats). EULA is recorded here. 2. **Build your quote** → 4 steps → Generate. Buyer details are required first. 3. Review the itemised quote → **Accept quote**. Confirm Stripe was *not* charged. 4. Agreement → tick, fill signatory, **Sign agreement**. Check `procurement_signature` for the version and content hash. 5. **View & pay invoice** → pay in Stripe test mode → deal should move to live on the `invoice.paid` webhook. Worth reviewing specifically: the licence cannot be issued without entitlement (step 3 before payment), and `startTrial` on an already-committed deal is rejected rather than overwriting. ## Verification - `:saas compileJava` + `spotlessJavaCheck` - `task frontend:check:all` green end to end: 9 typecheck variants, eslint at zero warnings, `theme-lint`, `lint:css`, prettier, build, **1656 tests across 188 files** - 7 deno tests on the `invoice.paid` handler, covering all four shapes Stripe uses for the subscription reference ## Open, not addressed here - **The commercial model contradicts itself in three places.** The Order Form says annual-in-advance, the MSA §2.3/§3.2 implies otherwise, the quote engine computes `tcv = annualNet × termYears` flat, and Stripe only invoices one year. Needs a decision before this is customer-facing. - The 25 MB data-processing increments vs the ×1.4/×2.4 size multiplier, deferred pending Matt. - All legal text is **draft**. It renders with a draft badge and is not presented as executed; counsel's read is still a publish gate. - `{{subprocessor_url}}` / `{{eula_url}}` awaiting marketing's final links. - `frontend-a11y` is red on pre-existing portal contrast debt, deferred by decision. ## Schema notes Two migrations land on the SaaS side (`v3`), both applied by that repo's PR CI: - `20260808000000` drops the NOT NULL on `procurement_quote.quote_number`, which is required rather than cosmetic — the number now comes from Stripe at finalisation, so a draft holds NULL, and `ddl-auto` cannot drop an existing NOT NULL itself. - `20260809000000` adds `procurement_deal.last_paid_invoice_id`, nullable. Nothing here needs a migration in this repo: Flyway is not on the classpath, so the Java side only ever adds via `ddl-auto`, and Postgres migrations run ahead of the app deploy.
This commit is contained in:
@@ -6,6 +6,11 @@ dependencies {
|
||||
implementation project(':common')
|
||||
implementation project(':proprietary')
|
||||
|
||||
// Markdown -> HTML for rendering versioned legal documents (agreement) to PDF via the
|
||||
// shared FileToPdf/WeasyPrint path in :common. Same library the core Markdown-to-PDF tool uses.
|
||||
implementation "org.commonmark:commonmark:$commonmarkVersion"
|
||||
implementation "org.commonmark:commonmark-ext-gfm-tables:$commonmarkVersion"
|
||||
|
||||
api 'org.springframework.boot:spring-boot-starter-security'
|
||||
api 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
api 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
|
||||
|
||||
@@ -20,7 +20,8 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
"stirling.software.saas.ai.repository",
|
||||
"stirling.software.saas.payg.repository",
|
||||
"stirling.software.saas.payg.bundle",
|
||||
"stirling.software.saas.procurement.repository"
|
||||
"stirling.software.saas.procurement.repository",
|
||||
"stirling.software.saas.legal"
|
||||
})
|
||||
@EntityScan({
|
||||
"stirling.software.saas.accountlink",
|
||||
@@ -28,6 +29,7 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
"stirling.software.saas.billing.model",
|
||||
"stirling.software.saas.ai.model",
|
||||
"stirling.software.saas.payg",
|
||||
"stirling.software.saas.procurement.model"
|
||||
"stirling.software.saas.procurement.model",
|
||||
"stirling.software.saas.legal"
|
||||
})
|
||||
public class SaasJpaConfig {}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package stirling.software.saas.legal;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* An append-only record that a user accepted a versioned legal document at a particular moment in
|
||||
* the product. Distinct from a signed agreement (which is a negotiated, signature-bearing artifact,
|
||||
* see {@code ProcurementAgreementSignature}); this captures the lighter clickwrap consents — the
|
||||
* EULA accepted at trial start and at quote generation — with the exact document version, so what
|
||||
* was agreed is auditable even after the document versions up.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "legal_consent")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class LegalConsent implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "consent_id")
|
||||
private Long consentId;
|
||||
|
||||
@Column(name = "team_id")
|
||||
private Long teamId;
|
||||
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
@Column(name = "document_id", nullable = false, length = 64)
|
||||
private String documentId;
|
||||
|
||||
@Column(name = "document_version", nullable = false, length = 32)
|
||||
private String documentVersion;
|
||||
|
||||
// Where in the product the consent was given: "trial", "quote", etc.
|
||||
@Column(name = "context", nullable = false, length = 32)
|
||||
private String context;
|
||||
|
||||
@Column(name = "signer_ip", length = 64)
|
||||
private String signerIp;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "consented_at", nullable = false, updatable = false)
|
||||
private LocalDateTime consentedAt;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package stirling.software.saas.legal;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface LegalConsentRepository extends JpaRepository<LegalConsent, Long> {}
|
||||
@@ -0,0 +1,47 @@
|
||||
package stirling.software.saas.legal;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/** Records clickwrap consents to versioned legal documents (see {@link LegalConsent}). */
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
public class LegalConsentService {
|
||||
|
||||
private final LegalDocumentRegistry registry;
|
||||
private final LegalConsentRepository consents;
|
||||
|
||||
/**
|
||||
* Record that the given user accepted the current version of {@code documentId} in {@code
|
||||
* context} (e.g. "trial", "quote"). No-op for an unknown document. Best-effort: callers treat a
|
||||
* failure as non-fatal so it never blocks the flow the consent accompanies.
|
||||
*/
|
||||
@Transactional
|
||||
public void record(Long teamId, Long userId, String documentId, String context, String ip) {
|
||||
LegalDocumentMeta meta = registry.meta(documentId).orElse(null);
|
||||
if (meta == null) {
|
||||
log.warn("[legal] consent for unknown document '{}' ignored", documentId);
|
||||
return;
|
||||
}
|
||||
LegalConsent consent = new LegalConsent();
|
||||
consent.setTeamId(teamId);
|
||||
consent.setUserId(userId);
|
||||
consent.setDocumentId(meta.id());
|
||||
consent.setDocumentVersion(meta.version());
|
||||
consent.setContext(context);
|
||||
consent.setSignerIp(ip);
|
||||
consents.save(consent);
|
||||
log.info(
|
||||
"[legal] consent recorded team={} doc={} v{} context={}",
|
||||
teamId,
|
||||
meta.id(),
|
||||
meta.version(),
|
||||
context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package stirling.software.saas.legal;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.TeamMembership;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
|
||||
/**
|
||||
* Serves the versioned legal documents (EULA, SLA exhibit, subprocessors) for in-product viewing,
|
||||
* and records the lighter clickwrap consents. The enterprise agreement itself is served + signed
|
||||
* through the procurement controller, since it needs a quote to fill its Order Form.
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/legal")
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
public class LegalController {
|
||||
|
||||
private final LegalDocumentRegistry registry;
|
||||
private final LegalConsentService consents;
|
||||
private final TeamMembershipRepository memberRepo;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/** A legal document rendered for viewing: registry metadata + the static markdown body. */
|
||||
public record LegalDocumentResponse(
|
||||
String docId,
|
||||
String version,
|
||||
String versionLabel,
|
||||
String displayName,
|
||||
String effectiveDate,
|
||||
String status,
|
||||
String markdown) {}
|
||||
|
||||
public record ConsentRequest(String documentId, String context) {}
|
||||
|
||||
/** Fetch a legal document's current version as markdown. 404 for an unknown document. */
|
||||
@GetMapping("/{docId}")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<LegalDocumentResponse> document(@PathVariable String docId) {
|
||||
return registry.meta(docId)
|
||||
.<ResponseEntity<LegalDocumentResponse>>map(
|
||||
meta ->
|
||||
ResponseEntity.ok(
|
||||
new LegalDocumentResponse(
|
||||
meta.id(),
|
||||
meta.version(),
|
||||
meta.versionLabel(),
|
||||
meta.displayName(),
|
||||
meta.effectiveDate(),
|
||||
meta.status(),
|
||||
registry.staticMarkdown(docId))))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a clickwrap consent (e.g. the EULA accepted at trial start or quote generation).
|
||||
* Best-effort — a teamless caller still returns 200 so the accompanying flow is never blocked.
|
||||
*/
|
||||
@PostMapping("/consent")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<Void> consent(
|
||||
@RequestBody ConsentRequest request, Authentication auth, HttpServletRequest http) {
|
||||
if (request == null || request.documentId() == null || request.context() == null) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
Optional<TeamMembership> membership = primaryMembership(auth);
|
||||
Long teamId = membership.map(m -> m.getTeam().getId()).orElse(null);
|
||||
Long userId = membership.map(m -> m.getUser().getId()).orElse(null);
|
||||
// Best-effort for real: consent is audit metadata, not an authorisation gate, so a failed
|
||||
// write must not fail the trial start or quote generation this call accompanies. Previously
|
||||
// that only held because the caller happened to swallow the 500.
|
||||
try {
|
||||
consents.record(
|
||||
teamId, userId, request.documentId(), request.context(), clientIp(http));
|
||||
} catch (RuntimeException e) {
|
||||
log.warn(
|
||||
"[legal] consent not recorded doc={} context={}: {}",
|
||||
request.documentId(),
|
||||
request.context(),
|
||||
e.getMessage());
|
||||
}
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
private Optional<TeamMembership> primaryMembership(Authentication auth) {
|
||||
User user;
|
||||
try {
|
||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
} catch (SecurityException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return memberRepo.findPrimaryMembership(user.getId()).stream().findFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Best guess at the caller's address, for the audit record.
|
||||
*
|
||||
* <p>Informational only, and must stay that way: the first {@code X-Forwarded-For} hop is set
|
||||
* by the client, so a stored address is trivially spoofable and is not evidence of where a
|
||||
* consent or signature came from. Treat it as a hint when reconstructing events, never as
|
||||
* proof.
|
||||
*/
|
||||
private static String clientIp(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package stirling.software.saas.legal;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* One legal document's registry entry, as declared in {@code legal/manifest.json}. Immutable
|
||||
* snapshot loaded at startup by {@link LegalDocumentRegistry}.
|
||||
*
|
||||
* <p>{@code parts} lists the pieces, in render order, that make up the document. A plain entry
|
||||
* (e.g. {@code "msa.md"}) is a static markdown file under {@code legal/<id>/<version>/}; an entry
|
||||
* prefixed with {@code "@"} (e.g. {@code "@order-form"}) is a dynamic section that a document
|
||||
* assembler generates at render time.
|
||||
*/
|
||||
public record LegalDocumentMeta(
|
||||
String id,
|
||||
String label,
|
||||
String displayName,
|
||||
String version,
|
||||
String effectiveDate,
|
||||
String status,
|
||||
List<String> parts) {
|
||||
|
||||
/** Fully-qualified version label shown to users and stored on signatures, e.g. "SEA v0.9.1". */
|
||||
public String versionLabel() {
|
||||
return label + " v" + version;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package stirling.software.saas.legal;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Loads the versioned legal-document registry from {@code legal/manifest.json} on startup and
|
||||
* serves document metadata + rendered markdown from the classpath.
|
||||
*
|
||||
* <p>Publishing a new version of any document is a content-only change: drop the markdown under
|
||||
* {@code legal/<id>/<newVersion>/} and bump that document's {@code version} in the manifest — no
|
||||
* code change. Signatures pin the exact {@code {id, version, contentHash}} they were signed against
|
||||
* (see the procurement agreement flow), so historical documents stay reproducible.
|
||||
*
|
||||
* <p>Token slots of the form <code>{{name}}</code> in the markdown are filled at render time. This
|
||||
* registry fills the document-level common tokens ({@code version}, {@code version_date}, {@code
|
||||
* subprocessor_url}, {@code eula_url}); callers that need per-quote tokens (the enterprise
|
||||
* agreement's Order Form) fill the rest.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class LegalDocumentRegistry {
|
||||
|
||||
private static final String MANIFEST = "legal/manifest.json";
|
||||
private static final Pattern TOKEN = Pattern.compile("\\{\\{\\s*([a-zA-Z0-9_]+)\\s*}}");
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private final Map<String, LegalDocumentMeta> documents = new LinkedHashMap<>();
|
||||
private String subprocessorUrl = "";
|
||||
private String eulaUrl = "";
|
||||
|
||||
@PostConstruct
|
||||
void load() throws IOException {
|
||||
JsonNode root;
|
||||
try (InputStream in = new ClassPathResource(MANIFEST).getInputStream()) {
|
||||
root = objectMapper.readTree(in);
|
||||
}
|
||||
subprocessorUrl = root.path("subprocessorUrl").asText("");
|
||||
eulaUrl = root.path("eulaUrl").asText("");
|
||||
JsonNode docs = root.path("documents");
|
||||
docs.fieldNames()
|
||||
.forEachRemaining(
|
||||
id -> {
|
||||
JsonNode d = docs.get(id);
|
||||
List<String> parts =
|
||||
objectMapper.convertValue(
|
||||
d.path("parts"),
|
||||
objectMapper
|
||||
.getTypeFactory()
|
||||
.constructCollectionType(
|
||||
List.class, String.class));
|
||||
documents.put(
|
||||
id,
|
||||
new LegalDocumentMeta(
|
||||
id,
|
||||
d.path("label").asText(id),
|
||||
d.path("displayName").asText(id),
|
||||
d.path("version").asText("0"),
|
||||
d.path("effectiveDate").asText(""),
|
||||
d.path("status").asText("draft"),
|
||||
parts == null ? List.of() : parts));
|
||||
});
|
||||
log.info("[legal] loaded {} document(s) from {}", documents.size(), MANIFEST);
|
||||
}
|
||||
|
||||
public Optional<LegalDocumentMeta> meta(String docId) {
|
||||
return Optional.ofNullable(documents.get(docId));
|
||||
}
|
||||
|
||||
public String subprocessorUrl() {
|
||||
return subprocessorUrl;
|
||||
}
|
||||
|
||||
public String eulaUrl() {
|
||||
return eulaUrl;
|
||||
}
|
||||
|
||||
/** Document-level tokens available to every document (before any per-quote tokens). */
|
||||
public Map<String, String> commonTokens(LegalDocumentMeta meta) {
|
||||
Map<String, String> t = new LinkedHashMap<>();
|
||||
t.put("version", meta.version());
|
||||
t.put("version_date", meta.effectiveDate());
|
||||
t.put("subprocessor_url", subprocessorUrl);
|
||||
t.put("eula_url", eulaUrl);
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Read one static markdown part of a document from the classpath. */
|
||||
public String readPart(LegalDocumentMeta meta, String partFile) {
|
||||
String path = "legal/" + meta.id() + "/" + meta.version() + "/" + partFile;
|
||||
try (InputStream in = new ClassPathResource(path).getInputStream()) {
|
||||
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Missing legal document part: " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The concatenated static parts of a document (dynamic {@code @}-parts skipped), with only the
|
||||
* common tokens filled. Use for fully-static documents (EULA, SLA, subprocessors).
|
||||
*/
|
||||
public String staticMarkdown(String docId) {
|
||||
LegalDocumentMeta meta =
|
||||
meta(docId)
|
||||
.orElseThrow(
|
||||
() -> new IllegalArgumentException("Unknown document: " + docId));
|
||||
Map<String, String> tokens = commonTokens(meta);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String part : meta.parts()) {
|
||||
if (part.startsWith("@")) continue; // dynamic section — not part of the static body
|
||||
if (sb.length() > 0) sb.append("\n\n");
|
||||
sb.append(fill(readPart(meta, part), tokens));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** Replace {@code {{token}}} slots; unknown tokens are left intact so gaps are visible. */
|
||||
public static String fill(String markdown, Map<String, String> tokens) {
|
||||
Matcher m = TOKEN.matcher(markdown);
|
||||
StringBuilder out = new StringBuilder();
|
||||
while (m.find()) {
|
||||
String key = m.group(1);
|
||||
String value = tokens.get(key);
|
||||
m.appendReplacement(
|
||||
out,
|
||||
value == null
|
||||
? Matcher.quoteReplacement(m.group(0))
|
||||
: Matcher.quoteReplacement(value));
|
||||
}
|
||||
m.appendTail(out);
|
||||
return out.toString();
|
||||
}
|
||||
}
|
||||
@@ -34,17 +34,22 @@ import lombok.Setter;
|
||||
@Entity
|
||||
@Table(
|
||||
name = "payg_prepaid_bundle",
|
||||
// Declared here for ddl-auto (fresh schemas) and to document intent. The authoritative creator
|
||||
// in production is the Supabase CLI migration 20260720000000_payg_prepaid_bundle, which builds
|
||||
// Declared here for ddl-auto (fresh schemas) and to document intent. The authoritative
|
||||
// creator
|
||||
// in production is the Supabase CLI migration 20260720000000_payg_prepaid_bundle, which
|
||||
// builds
|
||||
// the partial forms (WHERE units_remaining > 0 / WHERE stripe_ref IS NOT NULL). Flyway was
|
||||
// retired for SaaS (#7100), so there is no migration twin — names match the CLI migration.
|
||||
indexes = {
|
||||
// Hot-path FIFO draw lookup — findDrawableForUpdate runs a locked read on every billable
|
||||
// charge past the free grant; without it that degrades to a locked scan as the table grows.
|
||||
// Hot-path FIFO draw lookup — findDrawableForUpdate runs a locked read on every
|
||||
// billable
|
||||
// charge past the free grant; without it that degrades to a locked scan as the table
|
||||
// grows.
|
||||
@Index(
|
||||
name = "idx_payg_prepaid_bundle_team_expiry",
|
||||
columnList = "team_id, expires_at"),
|
||||
// One pool per Stripe payment — the idempotency guard so a redelivered invoice.paid can't
|
||||
// One pool per Stripe payment — the idempotency guard so a redelivered invoice.paid
|
||||
// can't
|
||||
// credit the same purchase twice.
|
||||
@Index(
|
||||
name = "uq_payg_prepaid_bundle_stripe_ref",
|
||||
|
||||
+240
-7
@@ -22,6 +22,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
@@ -30,6 +32,8 @@ import stirling.software.proprietary.security.database.repository.UserRepository
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
|
||||
import stirling.software.saas.procurement.legal.AgreementSigning;
|
||||
import stirling.software.saas.procurement.model.ProcurementAgreementSignature;
|
||||
import stirling.software.saas.procurement.model.ProcurementDeal;
|
||||
import stirling.software.saas.procurement.model.ProcurementQuote;
|
||||
import stirling.software.saas.procurement.model.QuoteDetails;
|
||||
@@ -178,7 +182,17 @@ public class ProcurementController {
|
||||
String taxId) {}
|
||||
|
||||
/** Trial setup captured before the trial starts: deployment target + seat count. */
|
||||
public record StartTrialRequest(String deployment, int users) {}
|
||||
/**
|
||||
* Setup step 2 collects the buying entity; all of it is optional so an older client still
|
||||
* starts.
|
||||
*/
|
||||
public record StartTrialRequest(
|
||||
String deployment,
|
||||
int users,
|
||||
String businessName,
|
||||
String contactName,
|
||||
String contactEmail,
|
||||
String inviteEmails) {}
|
||||
|
||||
public record SnapshotResponse(
|
||||
Long dealId,
|
||||
@@ -190,8 +204,33 @@ public class ProcurementController {
|
||||
int trialExtensionsUsed,
|
||||
boolean licensed,
|
||||
String licenseKey,
|
||||
// Version label of the signed agreement PDF available for download, else null.
|
||||
String agreementSignedVersion,
|
||||
// Buying entity captured at trial setup; null on deals started before that step.
|
||||
String businessName,
|
||||
String contactName,
|
||||
String contactEmail,
|
||||
QuoteResponse latestQuote) {}
|
||||
|
||||
/** The filled agreement for review: registry metadata + the rendered markdown body. */
|
||||
public record AgreementDocumentResponse(
|
||||
String docId,
|
||||
String version,
|
||||
String versionLabel,
|
||||
String displayName,
|
||||
String effectiveDate,
|
||||
String status,
|
||||
String markdown) {}
|
||||
|
||||
/** Buyer-supplied signing inputs from the agreement stage. */
|
||||
public record SignAgreementRequest(
|
||||
String customerLegalName,
|
||||
String signatoryName,
|
||||
String signatoryTitle,
|
||||
boolean authorityConfirmed) {}
|
||||
|
||||
public record SignAgreementResponse(Long signatureId, String versionLabel, boolean pdfStored) {}
|
||||
|
||||
// ---- endpoints ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -213,7 +252,8 @@ public class ProcurementController {
|
||||
}
|
||||
|
||||
private static final SnapshotResponse EMPTY_SNAPSHOT =
|
||||
new SnapshotResponse(null, null, null, 0, null, null, 0, false, null, null);
|
||||
new SnapshotResponse(
|
||||
null, null, null, 0, null, null, 0, false, null, null, null, null, null, null);
|
||||
|
||||
/**
|
||||
* Download the offline / air-gapped licence file (.lic) for the team — available for an
|
||||
@@ -239,6 +279,15 @@ public class ProcurementController {
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/** Mark the account as looking at enterprise. Idempotent; never disturbs an existing deal. */
|
||||
@PostMapping("/interest")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> recordInterest(Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
return ResponseEntity.ok(toSnapshot(procurement.recordInterest(teamId), true));
|
||||
}
|
||||
|
||||
@PostMapping("/trial/start")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> startTrial(
|
||||
@@ -248,8 +297,30 @@ public class ProcurementController {
|
||||
// Body is optional so an older client (no setup step) still starts a cloud trial.
|
||||
String deployment = request != null ? request.deployment() : null;
|
||||
int seats = request != null ? request.users() : 0;
|
||||
return ResponseEntity.ok(
|
||||
toSnapshot(procurement.startTrial(teamId, deployment, seats), true));
|
||||
ProcurementDeal deal;
|
||||
try {
|
||||
deal =
|
||||
procurement.startTrial(
|
||||
teamId,
|
||||
deployment,
|
||||
seats,
|
||||
request != null ? request.businessName() : null,
|
||||
request != null ? request.contactName() : null,
|
||||
request != null ? request.contactEmail() : null,
|
||||
request != null ? request.inviteEmails() : null);
|
||||
} catch (IllegalStateException e) {
|
||||
// Past the trial the deal holds a committed licence; restarting would replace it.
|
||||
log.warn("[procurement] trial start rejected team={}: {}", teamId, e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
// After the trial exists, so a rejected invite can never stop it starting.
|
||||
if (request != null) {
|
||||
procurement.sendTrialInvites(
|
||||
teamId,
|
||||
primaryMembership(auth).map(TeamMembership::getUser).orElse(null),
|
||||
request.inviteEmails());
|
||||
}
|
||||
return ResponseEntity.ok(toSnapshot(deal, true));
|
||||
}
|
||||
|
||||
@PostMapping("/trial/extend")
|
||||
@@ -270,8 +341,17 @@ public class ProcurementController {
|
||||
@RequestBody QuoteRequest request, Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
return ResponseEntity.ok(
|
||||
toQuote(procurement.buildQuote(teamId, request.toConfig(), request.toDetails())));
|
||||
try {
|
||||
return ResponseEntity.ok(
|
||||
toQuote(
|
||||
procurement.buildQuote(
|
||||
teamId, request.toConfig(), request.toDetails())));
|
||||
} catch (IllegalStateException e) {
|
||||
// Below the minimum deal size, or the deal is already live. A client error, not a
|
||||
// fault.
|
||||
log.warn("[procurement] quote rejected team={}: {}", teamId, e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
}
|
||||
|
||||
// Issue + accept are Supabase edge functions (they own Stripe): issue-procurement-quote turns a
|
||||
@@ -293,6 +373,111 @@ public class ProcurementController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The filled Stirling Enterprise Agreement (MSA + Order Form + DPA) for the team's current
|
||||
* quote, as markdown, for the buyer to review before signing. 404 when there's no quote yet.
|
||||
*/
|
||||
@GetMapping("/agreement/document")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<AgreementDocumentResponse> agreementDocument(Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
return procurement
|
||||
.agreementDocument(teamId)
|
||||
.<ResponseEntity<AgreementDocumentResponse>>map(
|
||||
a ->
|
||||
ResponseEntity.ok(
|
||||
new AgreementDocumentResponse(
|
||||
a.docId(),
|
||||
a.version(),
|
||||
a.versionLabel(),
|
||||
a.displayName(),
|
||||
a.effectiveDate(),
|
||||
a.status(),
|
||||
a.markdown())))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a signed agreement: capture the typed legal name / signatory / title / authority, pin
|
||||
* the exact document version + content hash + variable snapshot, and store the rendered PDF
|
||||
* (best-effort). The caller then proceeds to accept the quote as before.
|
||||
*/
|
||||
@PostMapping("/agreement/sign")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SignAgreementResponse> signAgreement(
|
||||
@RequestBody SignAgreementRequest request,
|
||||
Authentication auth,
|
||||
HttpServletRequest http) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
if (request == null
|
||||
|| request.signatoryName() == null
|
||||
|| request.signatoryName().isBlank()
|
||||
|| !request.authorityConfirmed()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
try {
|
||||
ProcurementAgreementSignature sig =
|
||||
procurement.signAgreement(
|
||||
teamId,
|
||||
new AgreementSigning(
|
||||
request.customerLegalName(),
|
||||
request.signatoryName(),
|
||||
request.signatoryTitle(),
|
||||
request.authorityConfirmed()),
|
||||
clientIp(http));
|
||||
return ResponseEntity.ok(
|
||||
new SignAgreementResponse(
|
||||
sig.getSignatureId(), sig.getDocumentLabel(), sig.getPdf() != null));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
}
|
||||
|
||||
/** Download the stored signed-agreement PDF for the team. 404 if none was rendered/stored. */
|
||||
@GetMapping("/agreement/signature/pdf")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<byte[]> signaturePdf(Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
return procurement
|
||||
.signedAgreementPdf(teamId)
|
||||
.<ResponseEntity<byte[]>>map(
|
||||
pdf ->
|
||||
ResponseEntity.ok()
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment;"
|
||||
+ " filename=\"stirling-enterprise-agreement.pdf\"")
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.body(pdf))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the current (unsigned) agreement as a PDF — the document shown at the sign step. 404
|
||||
* when there's no quote yet or the render runtime is unavailable.
|
||||
*/
|
||||
@GetMapping("/agreement/document/pdf")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<byte[]> agreementDocumentPdf(Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
return procurement
|
||||
.agreementDocumentPdf(teamId)
|
||||
.<ResponseEntity<byte[]>>map(
|
||||
pdf ->
|
||||
ResponseEntity.ok()
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment;"
|
||||
+ " filename=\"stirling-enterprise-agreement.pdf\"")
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.body(pdf))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision on accept: upgrade the team's licence to the committed annual term, valid
|
||||
* immediately. Called server-side by the accept edge function (ROLE_ADMIN via X-API-Key) once
|
||||
@@ -311,9 +496,38 @@ public class ProcurementController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go live once payment settles: advance the deal to active and re-affirm the annual licence.
|
||||
* Called server-side by the {@code invoice.paid} webhook (ROLE_ADMIN via X-API-Key), alongside
|
||||
* {@code /provision}, which runs earlier at accept and deliberately leaves the stage alone.
|
||||
*
|
||||
* <p>Answers 200 when the team has no deal at all, rather than erroring: a committed
|
||||
* subscription can be closed directly in Stripe by sales with no portal deal behind it, and a
|
||||
* non-2xx would have Stripe retry a webhook that can never succeed.
|
||||
*
|
||||
* <p>{@code invoiceId} is what makes this idempotent without swallowing renewals: the same
|
||||
* invoice twice is a redelivery, a different one is next year's payment and has to re-issue the
|
||||
* licence. Optional so an older caller still works, at the cost of that distinction.
|
||||
*/
|
||||
@PostMapping("/activate")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<Void> activate(
|
||||
@RequestParam("teamId") long teamId,
|
||||
@RequestParam(value = "invoiceId", required = false) String invoiceId) {
|
||||
try {
|
||||
procurement.markLive(teamId, invoiceId);
|
||||
} catch (IllegalStateException e) {
|
||||
log.info(
|
||||
"[procurement] activate skipped, no deal for team={}: {}",
|
||||
teamId,
|
||||
e.getMessage());
|
||||
}
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo/manual stand-in for the {@code invoice.paid} webhook: mark the deal live (issue the
|
||||
* annual licence, advance to active). The real go-live is webhook-driven once payment settles.
|
||||
* annual licence, advance to active). Production go-live runs through {@code /activate}.
|
||||
*/
|
||||
@PostMapping("/go-live")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@@ -360,6 +574,21 @@ public class ProcurementController {
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort client IP for the signature record: first X-Forwarded-For hop, else the peer.
|
||||
*
|
||||
* <p>Informational only. That header is client-set, so {@code signer_ip} is spoofable and is
|
||||
* not evidence of where a signature came from — the document hash and version are what make the
|
||||
* record trustworthy. Treat the address as a hint, never as proof.
|
||||
*/
|
||||
private static String clientIp(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the snapshot for a deal. {@code includeLicenseKey} is true only for the team leader; a
|
||||
* member sees {@code licensed} but not the key itself (see {@link #snapshot}). Mutation
|
||||
@@ -381,6 +610,10 @@ public class ProcurementController {
|
||||
deal.getTrialExtensionsUsed(),
|
||||
deal.getLicenseRef() != null,
|
||||
includeLicenseKey ? deal.getLicenseRef() : null,
|
||||
procurement.signedAgreementLabel(deal.getDealId()).orElse(null),
|
||||
deal.getBusinessName(),
|
||||
deal.getContactName(),
|
||||
deal.getContactEmail(),
|
||||
latest);
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -30,4 +30,17 @@ public class ProcurementConfigurationProperties {
|
||||
* /go-live is a stand-in for the invoice.paid webhook and would let a leader activate unpaid.
|
||||
*/
|
||||
private boolean demoControlsEnabled = false;
|
||||
|
||||
/**
|
||||
* Smallest annual fee, in minor units, that may be quoted. The pricing curve has no natural
|
||||
* floor — a small enough committed volume rounds the meter to zero — and every registered user
|
||||
* is the leader of their own team, so without this any signup could price a $0 enterprise
|
||||
* quote, accept it, and be provisioned a committed licence. 12_000_00 is USD 12,000/yr, the
|
||||
* self-hosted deploy fee, chosen so the floor cannot sit below a line item the quote itself can
|
||||
* contain.
|
||||
*
|
||||
* <p>This is a commercial number, not a technical one: set it to whatever the smallest
|
||||
* enterprise deal you will actually sign is. Zero disables the check.
|
||||
*/
|
||||
private long minAnnualNetMinor = 12_000_00L;
|
||||
}
|
||||
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
package stirling.software.saas.procurement.legal;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.saas.legal.LegalDocumentMeta;
|
||||
import stirling.software.saas.legal.LegalDocumentRegistry;
|
||||
import stirling.software.saas.procurement.model.ProcurementQuote;
|
||||
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
|
||||
import stirling.software.saas.procurement.pricing.QuoteConfig;
|
||||
import stirling.software.saas.procurement.pricing.QuoteLineItem;
|
||||
|
||||
/**
|
||||
* Builds the full Stirling Enterprise Agreement for a specific quote: the static MSA (Part A) and
|
||||
* DPA (Part C) from the {@link LegalDocumentRegistry}, with the dynamic Order Form (Part B)
|
||||
* generated from the quote and slotted where the manifest's {@code @order-form} part sits.
|
||||
*
|
||||
* <p>Only the Order Form varies per deal; the MSA and DPA bodies are rendered verbatim with token
|
||||
* substitution. The set of values used is returned as {@code variablesJson} so a signature can pin
|
||||
* exactly what was rendered.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AgreementAssembler {
|
||||
|
||||
public static final String DOC_ID = "enterprise-agreement";
|
||||
|
||||
private static final DateTimeFormatter DATE =
|
||||
DateTimeFormatter.ofPattern("MMMM d, yyyy", Locale.US);
|
||||
private static final String BLANK = "\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_";
|
||||
|
||||
private final LegalDocumentRegistry registry;
|
||||
private final ProcurementPricingService pricing;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* Render the agreement for a quote. {@code signing} is null for a preview (before signing) —
|
||||
* the effective date and signature block then read as blanks / "On signature".
|
||||
*/
|
||||
public AssembledAgreement assemble(ProcurementQuote quote, AgreementSigning signing) {
|
||||
LegalDocumentMeta meta =
|
||||
registry.meta(DOC_ID)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalStateException(
|
||||
"Enterprise agreement not registered"));
|
||||
|
||||
Map<String, String> tokens = tokens(quote, signing, meta);
|
||||
|
||||
StringBuilder md = new StringBuilder();
|
||||
for (String part : meta.parts()) {
|
||||
if (md.length() > 0) md.append("\n\n");
|
||||
if ("@order-form".equals(part)) {
|
||||
md.append(LegalDocumentRegistry.fill(orderForm(quote, tokens), tokens));
|
||||
} else {
|
||||
md.append(LegalDocumentRegistry.fill(registry.readPart(meta, part), tokens));
|
||||
}
|
||||
}
|
||||
|
||||
String variablesJson;
|
||||
try {
|
||||
variablesJson = objectMapper.writeValueAsString(tokens);
|
||||
} catch (Exception e) {
|
||||
variablesJson = "{}";
|
||||
}
|
||||
|
||||
return new AssembledAgreement(
|
||||
meta.id(),
|
||||
meta.version(),
|
||||
meta.versionLabel(),
|
||||
meta.displayName(),
|
||||
meta.effectiveDate(),
|
||||
meta.status(),
|
||||
md.toString(),
|
||||
variablesJson);
|
||||
}
|
||||
|
||||
private Map<String, String> tokens(
|
||||
ProcurementQuote quote, AgreementSigning signing, LegalDocumentMeta meta) {
|
||||
QuoteConfig cfg = toConfig(quote);
|
||||
boolean signed = signing != null;
|
||||
|
||||
String legalName =
|
||||
signed && notBlank(signing.customerLegalName())
|
||||
? signing.customerLegalName().trim()
|
||||
: (notBlank(quote.getBusinessName())
|
||||
? quote.getBusinessName().trim()
|
||||
: "Customer");
|
||||
|
||||
Map<String, String> t = new LinkedHashMap<>(registry.commonTokens(meta));
|
||||
t.put("effective_date", signed ? LocalDate.now().format(DATE) : "On signature");
|
||||
t.put("customer_legal_name", cell(legalName));
|
||||
t.put("quote_ref", nz(quote.getQuoteNumber()));
|
||||
t.put("deployment", ProcurementPricingService.deploymentName(quote.getDeployment()));
|
||||
t.put("committed_pdfs_yr", String.format(Locale.US, "%,d", Math.max(0, quote.getVolume())));
|
||||
t.put("posture", ProcurementPricingService.postureName(quote.getIntensity()));
|
||||
t.put("processes_per_pdf", String.valueOf(Math.max(1, quote.getIntensity())));
|
||||
t.put("rate_per_pdf", String.format(Locale.US, "$%.4f", pricing.effectiveRatePerPdf(cfg)));
|
||||
t.put("term_years", String.valueOf(quote.getTermYears()));
|
||||
t.put("term_discount_pct", pricing.termDiscountPct(quote.getTermYears()) + "%");
|
||||
t.put("sla_tier", slaTier(quote.getServiceLevel()));
|
||||
t.put("annual_fee_y1", money(quote.getAnnualNetMinor()));
|
||||
t.put("contract_total", money(quote.getTcvMinor()));
|
||||
t.put("elected_or_not", quote.isIndemnification() ? "Elected" : "Not elected");
|
||||
t.put("po_number", notBlank(quote.getPoNumber()) ? cell(quote.getPoNumber()) : "—");
|
||||
t.put(
|
||||
"customer_signatory",
|
||||
signed && notBlank(signing.signatoryName())
|
||||
? cell(signing.signatoryName())
|
||||
: BLANK);
|
||||
t.put(
|
||||
"customer_signatory_title",
|
||||
signed && notBlank(signing.signatoryTitle())
|
||||
? cell(signing.signatoryTitle())
|
||||
: BLANK);
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a buyer-supplied value safe to slot into a markdown table cell.
|
||||
*
|
||||
* <p>An unescaped {@code |} or newline splits the cell and breaks the Order Form's table. That
|
||||
* matters beyond appearance: this markdown is what gets hashed into the signature record, so a
|
||||
* value that restructures the table means the SHA-256 we keep as proof covers a document
|
||||
* reading differently from the one the signatory saw.
|
||||
*/
|
||||
private static String cell(String raw) {
|
||||
return raw.trim().replace("|", "\\|").replaceAll("\\s*\\R+\\s*", " ");
|
||||
}
|
||||
|
||||
/** Part B — the Order Form. Generated from the quote; the only per-deal section. */
|
||||
private String orderForm(ProcurementQuote quote, Map<String, String> t) {
|
||||
String date = t.get("effective_date");
|
||||
String signatory = t.get("customer_signatory");
|
||||
String signatoryTitle = t.get("customer_signatory_title");
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("## Part B — Order Form · {{quote_ref}}\n\n");
|
||||
sb.append("| Term | Value |\n| --- | --- |\n");
|
||||
row(sb, "Customer", "{{customer_legal_name}}");
|
||||
row(sb, "Subscription", "Enterprise · {{deployment}}");
|
||||
row(sb, "Purchase order", "{{po_number}}");
|
||||
row(sb, "Committed Volume", "{{committed_pdfs_yr}} PDFs / year at the {{posture}} posture");
|
||||
row(sb, "Committed rate", "{{rate_per_pdf}} per PDF");
|
||||
row(sb, "Service level", "{{sla_tier}} (per SLA Exhibit)");
|
||||
row(
|
||||
sb,
|
||||
"Term",
|
||||
"{{term_years}} year(s) · term discount {{term_discount_pct}} on committed processing");
|
||||
row(sb, "Itemized services", itemizedServices(quote));
|
||||
row(sb, "Annual Fee (year 1)", "{{annual_fee_y1}}");
|
||||
row(sb, "Total (paid in advance)", "{{contract_total}}");
|
||||
row(sb, "Escalator", "+3% at each anniversary during the Term");
|
||||
row(
|
||||
sb,
|
||||
"Payment",
|
||||
"Full {{term_years}}-year term invoiced in advance on acceptance · net 30 · ACH,"
|
||||
+ " wire, or check");
|
||||
row(sb, "Overage", "Committed rate, billed quarterly in arrears");
|
||||
row(
|
||||
sb,
|
||||
"Data schedule",
|
||||
"First 25 MB per file included; each additional 25 MB or part thereof (decimal MB,"
|
||||
+ " rounded up per file, measured once at ingestion) draws down 1 PDF Process."
|
||||
+ " Frozen for the Term (MSA §3.5).");
|
||||
row(
|
||||
sb,
|
||||
"Drawdown schedule",
|
||||
"{{posture}}: {{processes_per_pdf}} PDF Processes per PDF (MSA §3.3, frozen for the Term)");
|
||||
row(
|
||||
sb,
|
||||
"Enhanced IP Protection",
|
||||
"{{elected_or_not}} — extends §7.3 to patent claims at the §8.2 super-cap");
|
||||
row(sb, "Standard terms", "SSO, SCIM, RBAC, and audit logs included.");
|
||||
|
||||
sb.append(
|
||||
"\n**Itemized services menu (include as elected):** Self-hosted deployment $12,000/yr"
|
||||
+ " · Air-gapped deployment $36,000/yr · Dedicated SE/CSM $30,000/yr · Enhanced IP"
|
||||
+ " Protection (patent coverage, Section 7.3) 5% of committed processing fees ·"
|
||||
+ " Onboarding & training $7,500 one-time · Quarterly business reviews $8,000/yr."
|
||||
+ " Baseline IP indemnification (copyright, trademark, trade secret) is included at"
|
||||
+ " no charge.\n\n");
|
||||
sb.append(
|
||||
"**Signatures.** By signing, each signatory represents they have authority to bind"
|
||||
+ " their Party. Signatures delivered electronically or in counterparts are"
|
||||
+ " effective as originals.\n\n");
|
||||
sb.append("| Provider | Customer |\n| --- | --- |\n");
|
||||
sb.append("| Stirling PDF, Inc. | {{customer_legal_name}} |\n");
|
||||
sb.append("| Name: Matt Joseph | Name: ").append(signatory).append(" |\n");
|
||||
sb.append("| Title: CEO | Title: ").append(signatoryTitle).append(" |\n");
|
||||
sb.append("| Date: ").append(date).append(" | Date: ").append(date).append(" |\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* The elected add-on lines, taken from the quote's stored breakdown (excludes the base meter).
|
||||
*/
|
||||
private String itemizedServices(ProcurementQuote quote) {
|
||||
List<QuoteLineItem> lines = parseLineItems(quote.getLineItemsJson());
|
||||
List<String> elected = new ArrayList<>();
|
||||
for (QuoteLineItem li : lines) {
|
||||
if (li.key().equals("usage")
|
||||
|| li.key().equals("seats")
|
||||
|| li.key().equals("multi-year")) {
|
||||
continue;
|
||||
}
|
||||
String suffix = li.kind() == QuoteLineItem.Kind.ONE_TIME ? " (one-time)" : "/yr";
|
||||
elected.add(li.label() + " " + money(li.amountMinor()) + suffix);
|
||||
}
|
||||
return elected.isEmpty() ? "None elected" : String.join(" · ", elected);
|
||||
}
|
||||
|
||||
private List<QuoteLineItem> parseLineItems(String json) {
|
||||
if (json == null || json.isBlank()) return List.of();
|
||||
try {
|
||||
return objectMapper.readValue(
|
||||
json,
|
||||
objectMapper
|
||||
.getTypeFactory()
|
||||
.constructCollectionType(List.class, QuoteLineItem.class));
|
||||
} catch (Exception e) {
|
||||
log.warn("[legal] could not parse quote line items for the order form", e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static QuoteConfig toConfig(ProcurementQuote q) {
|
||||
int users = q.getSeats() == null ? 0 : q.getSeats();
|
||||
return new QuoteConfig(
|
||||
q.getVolume(),
|
||||
users,
|
||||
q.getIntensity(),
|
||||
q.getSizeMult(),
|
||||
q.getDeployment(),
|
||||
q.getTermYears(),
|
||||
q.getServiceLevel(),
|
||||
q.isIndemnification(),
|
||||
q.isTraining(),
|
||||
q.isQbr(),
|
||||
q.getCurrency());
|
||||
}
|
||||
|
||||
private static void row(StringBuilder sb, String term, String value) {
|
||||
sb.append("| ").append(term).append(" | ").append(value).append(" |\n");
|
||||
}
|
||||
|
||||
private static String slaTier(String serviceLevel) {
|
||||
if ("dedicated".equalsIgnoreCase(serviceLevel)) return "Dedicated";
|
||||
if ("priority".equalsIgnoreCase(serviceLevel)) return "Priority";
|
||||
return "Standard";
|
||||
}
|
||||
|
||||
/** Minor units (cents) → whole-dollar display; the quote figures are whole dollars. */
|
||||
private static String money(long minor) {
|
||||
return String.format(Locale.US, "$%,d", minor / 100L);
|
||||
}
|
||||
|
||||
private static boolean notBlank(String s) {
|
||||
return s != null && !s.isBlank();
|
||||
}
|
||||
|
||||
private static String nz(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package stirling.software.saas.procurement.legal;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import org.commonmark.Extension;
|
||||
import org.commonmark.ext.gfm.tables.TablesExtension;
|
||||
import org.commonmark.node.Node;
|
||||
import org.commonmark.parser.Parser;
|
||||
import org.commonmark.renderer.html.HtmlRenderer;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.CustomHtmlSanitizer;
|
||||
import stirling.software.common.util.FileToPdf;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
/**
|
||||
* Renders an assembled agreement's markdown to a PDF, dogfooding Stirling's own conversion path:
|
||||
* commonmark (markdown → HTML) then {@link FileToPdf#convertHtmlToPdf} (HTML → PDF via WeasyPrint),
|
||||
* the same pipeline as the product's Markdown-to-PDF tool.
|
||||
*
|
||||
* <p>The signed PDF is a stored artifact, but it must never block signing: {@link #tryRender}
|
||||
* returns {@code null} if the conversion runtime (WeasyPrint) is unavailable, so the signature is
|
||||
* still recorded and the buyer keeps the on-the-fly download.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AgreementPdfRenderer {
|
||||
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final CustomHtmlSanitizer customHtmlSanitizer;
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
private static final List<Extension> EXTENSIONS = List.of(TablesExtension.create());
|
||||
|
||||
/** Render to PDF, or return null if the conversion runtime isn't available. */
|
||||
public byte[] tryRender(String markdown) {
|
||||
try {
|
||||
return render(markdown);
|
||||
} catch (Exception e) {
|
||||
org.slf4j.LoggerFactory.getLogger(AgreementPdfRenderer.class)
|
||||
.warn(
|
||||
"[legal] agreement PDF render unavailable; recording signature without a"
|
||||
+ " stored PDF: {}",
|
||||
e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] render(String markdown) throws Exception {
|
||||
Parser parser = Parser.builder().extensions(EXTENSIONS).build();
|
||||
Node document = parser.parse(markdown);
|
||||
HtmlRenderer renderer = HtmlRenderer.builder().extensions(EXTENSIONS).build();
|
||||
String html = renderer.render(document);
|
||||
|
||||
byte[] pdfBytes =
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
runtimePathConfig.getWeasyPrintPath(),
|
||||
null,
|
||||
html.getBytes(StandardCharsets.UTF_8),
|
||||
"agreement.html",
|
||||
tempFileManager,
|
||||
customHtmlSanitizer);
|
||||
return pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package stirling.software.saas.procurement.legal;
|
||||
|
||||
/**
|
||||
* The buyer-supplied inputs captured at the moment of signing the enterprise agreement: the legal
|
||||
* entity name, the signatory's typed name and title, and their representation of authority to bind.
|
||||
* Null when the agreement is rendered for preview (before signing).
|
||||
*/
|
||||
public record AgreementSigning(
|
||||
String customerLegalName,
|
||||
String signatoryName,
|
||||
String signatoryTitle,
|
||||
boolean authorityConfirmed) {}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package stirling.software.saas.procurement.legal;
|
||||
|
||||
/**
|
||||
* A rendered enterprise agreement: the full markdown the buyer sees (MSA + Order Form + DPA, tokens
|
||||
* filled), plus the registry metadata that pins it. {@code variablesJson} is the exact set of
|
||||
* Order-Form values as rendered, stored alongside a signature so the document is reproducible.
|
||||
*/
|
||||
public record AssembledAgreement(
|
||||
String docId,
|
||||
String version,
|
||||
String versionLabel,
|
||||
String displayName,
|
||||
String effectiveDate,
|
||||
String status,
|
||||
String markdown,
|
||||
String variablesJson) {}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package stirling.software.saas.procurement.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* An immutable record of a signed enterprise agreement. Each signature pins the exact legal
|
||||
* document it was signed against — {@code documentId} + {@code documentVersion} + a SHA-256 {@code
|
||||
* contentHash} of the rendered markdown — plus the Order-Form variable snapshot and the typed
|
||||
* signatory details, so the agreement stays reproducible even after the templates version up. The
|
||||
* rendered PDF is stored when the conversion runtime is available.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "procurement_agreement_signature")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class ProcurementAgreementSignature implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "signature_id")
|
||||
private Long signatureId;
|
||||
|
||||
@Column(name = "deal_id", nullable = false)
|
||||
private Long dealId;
|
||||
|
||||
@Column(name = "quote_id", nullable = false)
|
||||
private Long quoteId;
|
||||
|
||||
// Which legal document, and which version of it, was signed.
|
||||
@Column(name = "document_id", nullable = false, length = 64)
|
||||
private String documentId;
|
||||
|
||||
@Column(name = "document_version", nullable = false, length = 32)
|
||||
private String documentVersion;
|
||||
|
||||
@Column(name = "document_label", length = 64)
|
||||
private String documentLabel;
|
||||
|
||||
// SHA-256 (hex) of the exact rendered agreement markdown the buyer accepted.
|
||||
@Column(name = "content_hash", nullable = false, length = 64)
|
||||
private String contentHash;
|
||||
|
||||
// The Order-Form variable values as rendered, so the document can be reproduced.
|
||||
@Column(name = "variables_json", columnDefinition = "text")
|
||||
private String variablesJson;
|
||||
|
||||
@Column(name = "customer_legal_name", length = 255)
|
||||
private String customerLegalName;
|
||||
|
||||
@Column(name = "signatory_name", nullable = false, length = 255)
|
||||
private String signatoryName;
|
||||
|
||||
@Column(name = "signatory_title", length = 255)
|
||||
private String signatoryTitle;
|
||||
|
||||
@Column(name = "authority_confirmed", nullable = false)
|
||||
private boolean authorityConfirmed;
|
||||
|
||||
@Column(name = "signer_ip", length = 64)
|
||||
private String signerIp;
|
||||
|
||||
// The rendered PDF artifact; null when the conversion runtime was unavailable at signing.
|
||||
@Column(name = "pdf")
|
||||
private byte[] pdf;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "signed_at", nullable = false, updatable = false)
|
||||
private LocalDateTime signedAt;
|
||||
}
|
||||
@@ -34,6 +34,13 @@ public class ProcurementDeal implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* Interest, before any commitment: the account asked about enterprise but has not started a
|
||||
* trial. Kept as a real stage so intent survives a refresh, so the enterprise surface is only
|
||||
* shown to accounts that asked for it, and so drop-off at the cheapest step is measurable.
|
||||
*/
|
||||
public static final String STAGE_EXPLORING = "exploring";
|
||||
|
||||
public static final String STAGE_TRIAL = "trial";
|
||||
public static final String STAGE_QUOTE = "quote";
|
||||
public static final String STAGE_AGREEMENT = "security";
|
||||
@@ -69,12 +76,38 @@ public class ProcurementDeal implements Serializable {
|
||||
@Column(name = "trial_extensions_used", nullable = false)
|
||||
private int trialExtensionsUsed;
|
||||
|
||||
// Captured at trial setup, so the buying entity is known before any quote exists — the quote's
|
||||
// own copies seed from these and may then diverge (a deal can change hands mid-cycle).
|
||||
// Nullable: trials started before this step, and older clients, supply none.
|
||||
@Column(name = "business_name", length = 255)
|
||||
private String businessName;
|
||||
|
||||
@Column(name = "contact_name", length = 255)
|
||||
private String contactName;
|
||||
|
||||
@Column(name = "contact_email", length = 320)
|
||||
private String contactEmail;
|
||||
|
||||
// Addresses the buyer named at setup. Kept as the record of what was asked for; the invitations
|
||||
// themselves go out through the team-invite path when the trial starts.
|
||||
@Column(name = "invite_emails", length = 2000)
|
||||
private String inviteEmails;
|
||||
|
||||
@Column(name = "license_ref", length = 128)
|
||||
private String licenseRef;
|
||||
|
||||
@Column(name = "subscription_id", length = 255)
|
||||
private String subscriptionId;
|
||||
|
||||
/**
|
||||
* The last Stripe invoice whose payment was applied to this deal. Distinguishes a redelivered
|
||||
* {@code invoice.paid} for a payment already handled from a genuine renewal, which has to
|
||||
* re-issue: the committed licence expires term years from issue, so a renewal that doesn't
|
||||
* re-issue leaves the licence lapsing after the customer has paid.
|
||||
*/
|
||||
@Column(name = "last_paid_invoice_id", length = 255)
|
||||
private String lastPaidInvoiceId;
|
||||
|
||||
@Column(name = "accepted_quote_id")
|
||||
private Long acceptedQuoteId;
|
||||
|
||||
|
||||
+5
-1
@@ -47,7 +47,11 @@ public class ProcurementQuote implements Serializable {
|
||||
@Column(name = "deal_id", nullable = false)
|
||||
private Long dealId;
|
||||
|
||||
@Column(name = "quote_number", nullable = false, length = 64)
|
||||
/**
|
||||
* Stripe's quote number, the deal's one buyer-facing reference. Null until the quote is issued:
|
||||
* Stripe assigns it at finalisation, and the issue edge function writes it back then.
|
||||
*/
|
||||
@Column(name = "quote_number", length = 64)
|
||||
private String quoteNumber;
|
||||
|
||||
@Column(name = "status", nullable = false, length = 24)
|
||||
|
||||
+43
-6
@@ -60,12 +60,7 @@ public class ProcurementPricingService {
|
||||
rates.discountPerDoubling()
|
||||
* (Math.log(runVol / (double) RUN_CURVE_KNEE) / LOG2))
|
||||
: 0.0;
|
||||
double rate = Math.max(rates.floorRatePerRun(), rates.listRatePerRun() * (1.0 - volDisc));
|
||||
// File-size multiplier (D93): larger, image-heavy PDFs cost more OCR/compute/storage. Folds
|
||||
// into the per-run rate after the floor, so it flows through the meter, TCV and renewal.
|
||||
// QuoteConfig has already snapped it to a known tier, so a tampered request can't sneak a
|
||||
// cheaper factor in.
|
||||
rate *= cfg.sizeMult();
|
||||
double rate = perRunRate(cfg, rates);
|
||||
double termDisc = rates.termDiscount(cfg.termYears());
|
||||
|
||||
// The meter is a whole-dollar figure (the quote reads in dollars), then minor units.
|
||||
@@ -160,6 +155,48 @@ public class ProcurementPricingService {
|
||||
return new QuoteBreakdown(lines, annualNet, tcv, renewalAnnual, cfg.currency());
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-run rate after the committed-volume curve, the half-cent floor, and the file-size
|
||||
* multiplier — the same value {@link #price} meters against. Extracted so read-only callers
|
||||
* (the Order Form) can quote it without re-deriving the curve.
|
||||
*/
|
||||
private static double perRunRate(QuoteConfig cfg, PricingRates rates) {
|
||||
long runVol = Math.max(0, cfg.volume()) * (long) Math.max(1, cfg.intensity());
|
||||
double volDisc =
|
||||
runVol > RUN_CURVE_KNEE
|
||||
? Math.min(
|
||||
0.5,
|
||||
rates.discountPerDoubling()
|
||||
* (Math.log(runVol / (double) RUN_CURVE_KNEE) / LOG2))
|
||||
: 0.0;
|
||||
return Math.max(rates.floorRatePerRun(), rates.listRatePerRun() * (1.0 - volDisc))
|
||||
* cfg.sizeMult();
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective per-PDF rate at the chosen posture, in dollars (4-decimal quote figure). This
|
||||
* is what the Order Form and quote copy speak in — never the per-run rate. Read-only; does not
|
||||
* affect billing.
|
||||
*/
|
||||
public double effectiveRatePerPdf(QuoteConfig cfg) {
|
||||
return perRunRate(cfg, PricingRates.defaults()) * Math.max(1, cfg.intensity());
|
||||
}
|
||||
|
||||
/** The multi-year term discount as a whole-percent figure for the Order Form (0.05 → 5). */
|
||||
public int termDiscountPct(int termYears) {
|
||||
return (int) Math.round(PricingRates.defaults().termDiscount(termYears) * 100.0);
|
||||
}
|
||||
|
||||
/** Buyer-facing posture name (Essentials / Governed / Regulated) for the given intensity. */
|
||||
public static String postureName(int intensity) {
|
||||
return postureLabel(intensity);
|
||||
}
|
||||
|
||||
/** Buyer-facing deployment name (Stirling Cloud / Self-hosted / Air-gapped). */
|
||||
public static String deploymentName(String deployment) {
|
||||
return deploymentLabel(deployment);
|
||||
}
|
||||
|
||||
/** The default CPI escalator (fraction) applied to the annual fee on each post-term renewal. */
|
||||
public double cpiEscalator() {
|
||||
return PricingRates.defaults().cpiEscalator();
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package stirling.software.saas.procurement.repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import stirling.software.saas.procurement.model.ProcurementAgreementSignature;
|
||||
|
||||
public interface ProcurementAgreementSignatureRepository
|
||||
extends JpaRepository<ProcurementAgreementSignature, Long> {
|
||||
|
||||
Optional<ProcurementAgreementSignature> findFirstByDealIdOrderBySignedAtDesc(Long dealId);
|
||||
|
||||
Optional<ProcurementAgreementSignature> findFirstByQuoteIdOrderBySignedAtDesc(Long quoteId);
|
||||
|
||||
/**
|
||||
* Version labels of a deal's signatures, newest first. Projects just the label column so the
|
||||
* frequently-polled snapshot never loads the PDF bytes. A signature means the agreement is
|
||||
* signed; the PDF is resolved (stored or re-rendered) at download time.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT s.documentLabel FROM ProcurementAgreementSignature s"
|
||||
+ " WHERE s.dealId = :dealId"
|
||||
+ " ORDER BY s.signedAt DESC")
|
||||
List<String> findSignedLabels(@Param("dealId") Long dealId);
|
||||
}
|
||||
+371
-19
@@ -5,7 +5,6 @@ import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -20,16 +19,24 @@ import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.proprietary.model.TeamMembership;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
|
||||
import stirling.software.saas.procurement.legal.AgreementAssembler;
|
||||
import stirling.software.saas.procurement.legal.AgreementPdfRenderer;
|
||||
import stirling.software.saas.procurement.legal.AgreementSigning;
|
||||
import stirling.software.saas.procurement.legal.AssembledAgreement;
|
||||
import stirling.software.saas.procurement.license.EnterpriseLicenseService;
|
||||
import stirling.software.saas.procurement.license.LicenseEntitlements;
|
||||
import stirling.software.saas.procurement.model.ProcurementAgreementSignature;
|
||||
import stirling.software.saas.procurement.model.ProcurementDeal;
|
||||
import stirling.software.saas.procurement.model.ProcurementQuote;
|
||||
import stirling.software.saas.procurement.model.QuoteDetails;
|
||||
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
|
||||
import stirling.software.saas.procurement.pricing.QuoteBreakdown;
|
||||
import stirling.software.saas.procurement.pricing.QuoteConfig;
|
||||
import stirling.software.saas.procurement.repository.ProcurementAgreementSignatureRepository;
|
||||
import stirling.software.saas.procurement.repository.ProcurementDealRepository;
|
||||
import stirling.software.saas.procurement.repository.ProcurementQuoteRepository;
|
||||
import stirling.software.saas.service.SaasTeamService;
|
||||
import stirling.software.saas.util.LogRedactionUtils;
|
||||
|
||||
/**
|
||||
* Orchestrates a linked team's procurement journey: start a (mock-licensed) trial, build a
|
||||
@@ -52,6 +59,12 @@ public class ProcurementService {
|
||||
private final EnterpriseLicenseService licenses;
|
||||
private final ProcurementConfigurationProperties config;
|
||||
private final TeamMembershipRepository memberRepo;
|
||||
private final AgreementAssembler agreementAssembler;
|
||||
private final AgreementPdfRenderer agreementPdfRenderer;
|
||||
private final ProcurementAgreementSignatureRepository signatureRepo;
|
||||
// Trial-setup invitations run through the team-invite path, with its seat, role and
|
||||
// rate-limit rules rather than a second implementation here.
|
||||
private final SaasTeamService teams;
|
||||
|
||||
public ProcurementService(
|
||||
ProcurementDealRepository dealRepo,
|
||||
@@ -59,13 +72,21 @@ public class ProcurementService {
|
||||
ProcurementPricingService pricing,
|
||||
EnterpriseLicenseService licenses,
|
||||
ProcurementConfigurationProperties config,
|
||||
TeamMembershipRepository memberRepo) {
|
||||
TeamMembershipRepository memberRepo,
|
||||
AgreementAssembler agreementAssembler,
|
||||
AgreementPdfRenderer agreementPdfRenderer,
|
||||
ProcurementAgreementSignatureRepository signatureRepo,
|
||||
SaasTeamService teams) {
|
||||
this.dealRepo = dealRepo;
|
||||
this.quoteRepo = quoteRepo;
|
||||
this.pricing = pricing;
|
||||
this.licenses = licenses;
|
||||
this.config = config;
|
||||
this.memberRepo = memberRepo;
|
||||
this.agreementAssembler = agreementAssembler;
|
||||
this.agreementPdfRenderer = agreementPdfRenderer;
|
||||
this.signatureRepo = signatureRepo;
|
||||
this.teams = teams;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,11 +110,45 @@ public class ProcurementService {
|
||||
return dealRepo.findByTeamId(teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting a trial is only legitimate before one exists, while the buyer is still exploring, or
|
||||
* to restart within the trial itself. A null stage is a deal that has just been constructed.
|
||||
*
|
||||
* <p>Package-private so the policy can be tested without the service's ten dependencies. Adding
|
||||
* a later stage here would let a leader replace a paying customer's committed licence.
|
||||
*/
|
||||
static boolean canStartTrial(String stage) {
|
||||
return stage == null
|
||||
|| ProcurementDeal.STAGE_EXPLORING.equals(stage)
|
||||
|| ProcurementDeal.STAGE_TRIAL.equals(stage);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ProcurementQuote> quotesForDeal(Long dealId) {
|
||||
return quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that the account is looking at enterprise. Creates the deal at {@code exploring} when
|
||||
* there is none; an existing deal is returned untouched, so this can never walk a live deal
|
||||
* backwards or restart a trial.
|
||||
*/
|
||||
@Transactional
|
||||
public ProcurementDeal recordInterest(Long teamId) {
|
||||
return dealRepo.findByTeamId(teamId)
|
||||
.orElseGet(
|
||||
() -> {
|
||||
ProcurementDeal deal = new ProcurementDeal(teamId);
|
||||
deal.setStage(ProcurementDeal.STAGE_EXPLORING);
|
||||
ProcurementDeal saved = dealRepo.save(deal);
|
||||
log.info(
|
||||
"[procurement] interest recorded team={} deal={}",
|
||||
teamId,
|
||||
saved.getDealId());
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start (or restart) the free trial for a team: issue a mock trial licence and stamp the trial
|
||||
* window on the deal. No Stripe: a no-card trial has no subscription; the entitlement is the
|
||||
@@ -101,10 +156,40 @@ public class ProcurementService {
|
||||
* ({@code cloud}/{@code selfhost}/{@code airgap}) and seat count are captured here so the quote
|
||||
* builder opens seeded to their environment; both are still editable when the quote is built.
|
||||
*/
|
||||
@Transactional
|
||||
public ProcurementDeal startTrial(Long teamId, String deployment, int seats) {
|
||||
return startTrial(teamId, deployment, seats, null, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start (or restart) the trial, capturing the buying entity if the setup step collected it.
|
||||
* Blank details are ignored rather than written, so a re-run without them keeps what is there.
|
||||
*
|
||||
* <p>Only from before the trial or during it. Past that, {@code licenseRef} points at the
|
||||
* committed annual licence, and this method would replace it with a fresh 14-day trial key
|
||||
* while Stripe kept billing — the same hazard {@link #extendTrial} guards against, one step
|
||||
* worse because it re-issues rather than re-dates. It would also rewind the stage and reset the
|
||||
* extension counter.
|
||||
*
|
||||
* <p>The transaction is declared here rather than on the 3-arg overload: that one only
|
||||
* delegates, and Spring's proxy cannot intercept a self-invocation, so an annotation there does
|
||||
* nothing for either path. This is the method the controller calls, and it reaches out to
|
||||
* Keygen between the read and the write.
|
||||
*/
|
||||
@Transactional
|
||||
public ProcurementDeal startTrial(
|
||||
Long teamId,
|
||||
String deployment,
|
||||
int seats,
|
||||
String businessName,
|
||||
String contactName,
|
||||
String contactEmail,
|
||||
String inviteEmails) {
|
||||
ProcurementDeal deal =
|
||||
dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
|
||||
if (!canStartTrial(deal.getStage())) {
|
||||
throw new IllegalStateException(
|
||||
"Trial cannot be started from stage " + deal.getStage());
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime ends = now.plusDays(config.getTrialDurationDays());
|
||||
deal.setStage(ProcurementDeal.STAGE_TRIAL);
|
||||
@@ -113,6 +198,10 @@ public class ProcurementService {
|
||||
deal.setTrialStartedAt(now);
|
||||
deal.setTrialEndsAt(ends);
|
||||
deal.setTrialExtensionsUsed(0);
|
||||
if (isNotBlank(businessName)) deal.setBusinessName(businessName.trim());
|
||||
if (isNotBlank(contactName)) deal.setContactName(contactName.trim());
|
||||
if (isNotBlank(contactEmail)) deal.setContactEmail(contactEmail.trim());
|
||||
if (isNotBlank(inviteEmails)) deal.setInviteEmails(inviteEmails.trim());
|
||||
deal.setLicenseRef(licenses.issueTrialLicense(teamId, leaderEmail(teamId), ends));
|
||||
deal = dealRepo.save(deal);
|
||||
log.info(
|
||||
@@ -128,6 +217,45 @@ public class ProcurementService {
|
||||
/**
|
||||
* Constrain a caller-supplied deployment to the known set; anything else falls back to cloud.
|
||||
*/
|
||||
/**
|
||||
* Send the invitations named at trial setup. Best-effort per address: a rejection (already a
|
||||
* member, an invitee with their own paid plan, the hourly rate limit) must not fail the trial,
|
||||
* so each is logged and skipped rather than propagated.
|
||||
*
|
||||
* <p>Note the first accepted invitation converts a personal team into a shared one with
|
||||
* unlimited seats — that is {@code inviteUserToTeam}'s own rule, and naming teammates here is
|
||||
* the buyer asking for exactly that.
|
||||
*/
|
||||
public void sendTrialInvites(
|
||||
Long teamId, stirling.software.proprietary.security.model.User inviter, String emails) {
|
||||
if (inviter == null || !isNotBlank(emails)) return;
|
||||
for (String raw : emails.split("[,;\s]+")) {
|
||||
String email = raw.trim();
|
||||
if (email.isEmpty()) continue;
|
||||
try {
|
||||
teams.inviteUserToTeam(teamId, email, inviter);
|
||||
// Redacted: an invitee list is third-party PII, and these logs are the one place it
|
||||
// would otherwise be written in full. LogRedactionUtils is what the rest of the
|
||||
// SaaS
|
||||
// module uses for the same reason.
|
||||
log.info(
|
||||
"[procurement] trial invite sent team={} to={}",
|
||||
teamId,
|
||||
LogRedactionUtils.redactEmail(email));
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"[procurement] trial invite skipped team={} to={}: {}",
|
||||
teamId,
|
||||
LogRedactionUtils.redactEmail(email),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isNotBlank(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
private static String normalizeDeployment(String deployment) {
|
||||
if (deployment == null) return "cloud";
|
||||
String d = deployment.trim().toLowerCase(Locale.ROOT);
|
||||
@@ -172,16 +300,32 @@ public class ProcurementService {
|
||||
}
|
||||
// (Re)building a quote returns the deal to the quote stage and drops any prior acceptance,
|
||||
// so a rebuild from security/payment can't leave a stale stage or accepted-quote pointer.
|
||||
QuoteBreakdown breakdown = pricing.price(cfg);
|
||||
// Enforced before anything is persisted, and server-side rather than in the builder: the
|
||||
// pricing curve has no natural floor (a small enough committed volume rounds the meter to
|
||||
// zero) and every registered user leads their own team, so without this any signup could
|
||||
// price a $0 enterprise quote, accept it, and be provisioned a committed licence.
|
||||
long floor = config.getMinAnnualNetMinor();
|
||||
if (floor > 0 && breakdown.annualNetMinor() < floor) {
|
||||
throw new IllegalStateException(
|
||||
"Quoted annual fee "
|
||||
+ breakdown.annualNetMinor()
|
||||
+ " is below the minimum enterprise deal size "
|
||||
+ floor);
|
||||
}
|
||||
|
||||
deal.setStage(ProcurementDeal.STAGE_QUOTE);
|
||||
deal.setAcceptedQuoteId(null);
|
||||
deal = dealRepo.save(deal);
|
||||
|
||||
QuoteBreakdown breakdown = pricing.price(cfg);
|
||||
|
||||
ProcurementQuote quote = new ProcurementQuote();
|
||||
quote.setDealId(deal.getDealId());
|
||||
quote.setQuoteNumber(nextQuoteNumber(deal.getDealId()));
|
||||
// Priced but not yet issued: the edge fn creates the Stripe Quote and flips this to SENT.
|
||||
// No quote number here: the deal's one reference is Stripe's, and Stripe does not assign it
|
||||
// until the quote is finalised. The edge fn creates the Stripe Quote, flips this to SENT,
|
||||
// and
|
||||
// writes the number back. Nothing displays a reference in between — the builder only shows
|
||||
// one
|
||||
// for an issued quote, and the agreement is not assembled until after issue.
|
||||
quote.setStatus(ProcurementQuote.STATUS_DRAFT);
|
||||
quote.setCurrency(cfg.currency());
|
||||
quote.setVolume(cfg.volume());
|
||||
@@ -210,10 +354,11 @@ public class ProcurementService {
|
||||
quote.setLineItemsJson(writeLineItems(breakdown));
|
||||
quote.setValidUntil(LocalDate.now().plusDays(30));
|
||||
quote = quoteRepo.save(quote);
|
||||
// Logged by id, not reference: a draft has no reference until Stripe issues it.
|
||||
log.info(
|
||||
"[procurement] quote built team={} quote={} annualNet={} tcv={}",
|
||||
teamId,
|
||||
quote.getQuoteNumber(),
|
||||
quote.getQuoteId(),
|
||||
quote.getAnnualNetMinor(),
|
||||
quote.getTcvMinor());
|
||||
return quote;
|
||||
@@ -241,6 +386,164 @@ public class ProcurementService {
|
||||
return deal;
|
||||
}
|
||||
|
||||
/** The quote a team is currently transacting on: its accepted quote, else the most recent. */
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<ProcurementQuote> currentQuote(Long teamId) {
|
||||
return dealRepo.findByTeamId(teamId)
|
||||
.flatMap(
|
||||
deal -> {
|
||||
if (deal.getAcceptedQuoteId() != null) {
|
||||
Optional<ProcurementQuote> accepted =
|
||||
quoteRepo.findById(deal.getAcceptedQuoteId());
|
||||
if (accepted.isPresent()) return accepted;
|
||||
}
|
||||
return quoteRepo
|
||||
.findByDealIdOrderByCreatedAtDesc(deal.getDealId())
|
||||
.stream()
|
||||
.findFirst();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The filled enterprise agreement for a team's current quote, rendered for review (unsigned).
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<AssembledAgreement> agreementDocument(Long teamId) {
|
||||
return currentQuote(teamId).map(q -> agreementAssembler.assemble(q, null));
|
||||
}
|
||||
|
||||
/**
|
||||
* The current (unsigned) agreement rendered to PDF, for download at the sign step. Empty when
|
||||
* there's no quote yet or the render runtime is unavailable. The signed PDF (with the signature
|
||||
* block filled) is a separate artifact recorded at signing (see {@link #latestSignature}).
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<byte[]> agreementDocumentPdf(Long teamId) {
|
||||
return currentQuote(teamId)
|
||||
.map(q -> agreementAssembler.assemble(q, null))
|
||||
.map(a -> agreementPdfRenderer.tryRender(a.markdown()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a signed enterprise agreement: assemble the final document, hash it, render + store
|
||||
* the PDF (best-effort), and persist an immutable signature pinned to the exact document
|
||||
* version. Does not itself accept the quote into a subscription — the caller proceeds to accept
|
||||
* as before.
|
||||
*
|
||||
* <p>Deliberately not {@code @Transactional}: rendering the PDF shells out to WeasyPrint, and
|
||||
* holding the deal's row lock across an external process buys nothing here. The only write is a
|
||||
* single insert, which {@code save} makes atomic on its own.
|
||||
*/
|
||||
public ProcurementAgreementSignature signAgreement(
|
||||
Long teamId, AgreementSigning signing, String signerIp) {
|
||||
ProcurementDeal deal =
|
||||
dealRepo.findByTeamId(teamId)
|
||||
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
|
||||
ProcurementQuote quote =
|
||||
currentQuote(teamId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalStateException(
|
||||
"No quote to sign for team " + teamId));
|
||||
|
||||
// Signing is only meaningful against an issued quote at the agreement stage. Without this a
|
||||
// direct API call could record a signature over a draft (whose quote_ref is still empty),
|
||||
// or
|
||||
// re-sign a deal that has already moved on.
|
||||
if (!ProcurementDeal.STAGE_AGREEMENT.equals(deal.getStage())) {
|
||||
throw new IllegalStateException(
|
||||
"Deal is not at the agreement stage for team " + teamId);
|
||||
}
|
||||
if (!ProcurementQuote.STATUS_SENT.equals(quote.getStatus())) {
|
||||
throw new IllegalStateException("Quote is not issued for team " + teamId);
|
||||
}
|
||||
|
||||
AssembledAgreement assembled = agreementAssembler.assemble(quote, signing);
|
||||
|
||||
ProcurementAgreementSignature sig = new ProcurementAgreementSignature();
|
||||
sig.setDealId(deal.getDealId());
|
||||
sig.setQuoteId(quote.getQuoteId());
|
||||
sig.setDocumentId(assembled.docId());
|
||||
sig.setDocumentVersion(assembled.version());
|
||||
sig.setDocumentLabel(assembled.versionLabel());
|
||||
sig.setContentHash(sha256(assembled.markdown()));
|
||||
sig.setVariablesJson(assembled.variablesJson());
|
||||
sig.setCustomerLegalName(signing.customerLegalName());
|
||||
sig.setSignatoryName(signing.signatoryName());
|
||||
sig.setSignatoryTitle(signing.signatoryTitle());
|
||||
sig.setAuthorityConfirmed(signing.authorityConfirmed());
|
||||
sig.setSignerIp(signerIp);
|
||||
sig.setPdf(agreementPdfRenderer.tryRender(assembled.markdown()));
|
||||
sig = signatureRepo.save(sig);
|
||||
log.info(
|
||||
"[procurement] agreement signed team={} quote={} doc={} pdf={}",
|
||||
teamId,
|
||||
quote.getQuoteId(),
|
||||
assembled.versionLabel(),
|
||||
sig.getPdf() != null);
|
||||
return sig;
|
||||
}
|
||||
|
||||
/** The latest recorded signature for a team's deal, if any (for the signed-PDF download). */
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<ProcurementAgreementSignature> latestSignature(Long teamId) {
|
||||
return dealRepo.findByTeamId(teamId)
|
||||
.flatMap(
|
||||
deal ->
|
||||
signatureRepo.findFirstByDealIdOrderBySignedAtDesc(
|
||||
deal.getDealId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The version label of the deal's latest signed agreement, if any. Used to surface the
|
||||
* "download signed agreement" action once a signature exists; the snapshot polls this, so it
|
||||
* deliberately avoids loading the PDF bytes.
|
||||
*
|
||||
* <p>Deliberately not conditional on a stored PDF: download re-renders from the pinned document
|
||||
* version on demand, so the action works whether or not the render succeeded at signing time.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<String> signedAgreementLabel(Long dealId) {
|
||||
return signatureRepo.findSignedLabels(dealId).stream().findFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed agreement as a PDF for download: the artifact stored at signing, or — if the
|
||||
* render runtime was unavailable then — re-rendered now from the signature's details. Empty
|
||||
* when the team has no signature or the render runtime is still unavailable.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<byte[]> signedAgreementPdf(Long teamId) {
|
||||
return latestSignature(teamId)
|
||||
.flatMap(
|
||||
sig -> {
|
||||
if (sig.getPdf() != null) return Optional.of(sig.getPdf());
|
||||
return quoteRepo
|
||||
.findById(sig.getQuoteId())
|
||||
.map(
|
||||
q ->
|
||||
agreementAssembler.assemble(
|
||||
q,
|
||||
new AgreementSigning(
|
||||
sig.getCustomerLegalName(),
|
||||
sig.getSignatoryName(),
|
||||
sig.getSignatoryTitle(),
|
||||
sig.isAuthorityConfirmed())))
|
||||
.map(a -> agreementPdfRenderer.tryRender(a.markdown()));
|
||||
});
|
||||
}
|
||||
|
||||
private static String sha256(String s) {
|
||||
try {
|
||||
byte[] digest =
|
||||
java.security.MessageDigest.getInstance("SHA-256")
|
||||
.digest(s.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
return java.util.HexFormat.of().formatHex(digest);
|
||||
} catch (java.security.NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision on accept: upgrade the team's licence to the committed annual term (valid
|
||||
* immediately), so the buyer can get going the moment they accept — before the invoice is paid.
|
||||
@@ -260,22 +563,58 @@ public class ProcurementService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the deal fully live (advance to the active stage) once payment settles. In production
|
||||
* this is the {@code invoice.paid} webhook; here it's the demo/manual stand-in. Re-affirms the
|
||||
* Mark the deal fully live (advance to the active stage) once payment settles — driven by the
|
||||
* {@code invoice.paid} webhook, and by the demo control when those are enabled. Re-affirms the
|
||||
* annual licence in case provisioning didn't run at accept.
|
||||
*
|
||||
* <p>Idempotent per invoice rather than per stage, which matters because {@code invoice.paid}
|
||||
* carries two different meanings. Stripe redelivers events, so the <em>same</em> invoice
|
||||
* arriving twice must do nothing. But the renewal payment a year later is also an {@code
|
||||
* invoice.paid}, and the committed licence expires term years from issue — so a
|
||||
* <em>different</em> invoice has to re-issue, moving the expiry out, or the customer's licence
|
||||
* lapses after they have paid. Keying on the stage alone couldn't tell those apart and treated
|
||||
* every renewal as a duplicate.
|
||||
*
|
||||
* @param paidInvoiceId the Stripe invoice that was paid, or null when the caller has no invoice
|
||||
* to identify the payment by (the demo control). Null keeps the old conservative behaviour:
|
||||
* a live deal short-circuits, since there is nothing to tell a renewal from a repeat.
|
||||
*/
|
||||
@Transactional
|
||||
public ProcurementDeal markLive(Long teamId) {
|
||||
public ProcurementDeal markLive(Long teamId, String paidInvoiceId) {
|
||||
ProcurementDeal deal =
|
||||
dealRepo.findByTeamId(teamId)
|
||||
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
|
||||
if (ProcurementDeal.STAGE_LIVE.equals(deal.getStage())
|
||||
&& (paidInvoiceId == null || paidInvoiceId.equals(deal.getLastPaidInvoiceId()))) {
|
||||
log.debug(
|
||||
"[procurement] invoice.paid already applied team={} deal={} invoice={}",
|
||||
teamId,
|
||||
deal.getDealId(),
|
||||
paidInvoiceId);
|
||||
return deal;
|
||||
}
|
||||
boolean renewal = ProcurementDeal.STAGE_LIVE.equals(deal.getStage());
|
||||
deal.setLicenseRef(issueOrUpgradeAnnual(deal));
|
||||
if (paidInvoiceId != null) deal.setLastPaidInvoiceId(paidInvoiceId);
|
||||
deal.setStage(ProcurementDeal.STAGE_LIVE);
|
||||
deal = dealRepo.save(deal);
|
||||
log.info("[procurement] deal live team={} deal={}", teamId, deal.getDealId());
|
||||
log.info(
|
||||
"[procurement] deal live team={} deal={} renewal={} invoice={}",
|
||||
teamId,
|
||||
deal.getDealId(),
|
||||
renewal,
|
||||
paidInvoiceId);
|
||||
return deal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Go live with no invoice reference — the demo control. See {@link #markLive(Long, String)}.
|
||||
*/
|
||||
@Transactional
|
||||
public ProcurementDeal markLive(Long teamId) {
|
||||
return markLive(teamId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue or upgrade the committed annual licence from the deal's accepted (else latest) quote,
|
||||
* stamping the full entitlement snapshot onto it and upgrading the trial licence in place when
|
||||
@@ -327,15 +666,34 @@ public class ProcurementService {
|
||||
* before paying — that's bounded: the trial licence carries {@code expiry = trialEndsAt}, so
|
||||
* the file the verifier accepts self-expires at trial end. The buyer must re-download after
|
||||
* provisioning to get the committed-term file (the portal warns about this).
|
||||
*
|
||||
* <p>Once a quote exists, the quote's deployment decides — not the deal's. They are two
|
||||
* different values: the deal's is chosen free at trial setup, the quote's is the one carrying
|
||||
* the air-gap deploy fee. Reading the deal's here meant selecting air-gapped in the trial and
|
||||
* then buying a cloud quote still yielded the offline file, and after provisioning it was
|
||||
* checked out against the committed annual licence — so the self-expiry above no longer bounded
|
||||
* it.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<String> offlineLicenseFile(Long teamId) {
|
||||
ProcurementDeal deal = dealRepo.findByTeamId(teamId).orElse(null);
|
||||
if (deal == null || deal.getLicenseRef() == null) return Optional.empty();
|
||||
if (!"airgap".equalsIgnoreCase(deal.getDeployment())) return Optional.empty();
|
||||
if (!"airgap".equalsIgnoreCase(entitledDeployment(deal))) return Optional.empty();
|
||||
return Optional.of(licenses.checkOutLicenseFile(deal.getLicenseRef()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The deployment the team is actually entitled to: the priced quote's once one exists,
|
||||
* otherwise the trial's self-selected target. Paid entitlements must follow what was quoted.
|
||||
*/
|
||||
private String entitledDeployment(ProcurementDeal deal) {
|
||||
ProcurementQuote quote = currentQuote(deal.getTeamId()).orElse(null);
|
||||
if (quote != null && quote.getDeployment() != null && !quote.getDeployment().isBlank()) {
|
||||
return quote.getDeployment();
|
||||
}
|
||||
return deal.getDeployment();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a team's procurement: delete the deal (quotes + activity cascade). For
|
||||
* re-demos/testing.
|
||||
@@ -346,12 +704,6 @@ public class ProcurementService {
|
||||
log.info("[procurement] deal reset team={}", teamId);
|
||||
}
|
||||
|
||||
private String nextQuoteNumber(Long dealId) {
|
||||
int seq = quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId).size() + 1;
|
||||
String token = UUID.randomUUID().toString().substring(0, 4).toUpperCase(Locale.ROOT);
|
||||
return String.format(Locale.ROOT, "QT-%s-%04d", token, seq);
|
||||
}
|
||||
|
||||
private String writeLineItems(QuoteBreakdown breakdown) {
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsString(breakdown.lineItems());
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
## Part C — Data Processing Addendum
|
||||
|
||||
This DPA forms part of the Agreement and applies where Provider processes Personal Data on Customer's behalf.
|
||||
|
||||
### C1. Roles; scope; instructions
|
||||
|
||||
Customer is the controller (or a processor on behalf of its own controllers); Provider is a processor (or subprocessor, as applicable). Provider processes Personal Data only on Customer's documented instructions — including processing initiated by Customer's users, policies, pipelines, and API calls — unless required by law (in which case Provider informs Customer unless legally prohibited). **Provider will inform Customer without undue delay if, in Provider's opinion, an instruction infringes the GDPR, UK GDPR, or other applicable data-protection law.** Customer is responsible for the lawfulness of the Personal Data it submits and the instructions it gives; Customer's rights under this DPA include instruction, audit (C9), objection to subprocessors (C5), assistance (C6), and return or deletion of data (C10).
|
||||
|
||||
### C2. Details of processing
|
||||
|
||||
**Subject matter/nature:** PDF processing and governance (classification, redaction, routing, retention, conversion, signing, extraction, AI-assisted analysis). **Duration:** the Term plus the deletion period. **Categories of data:** any Personal Data contained in Customer files and metadata (names, contact details, identifiers, financial or health data if present in Customer files), account data of Customer users. **Data subjects:** Customer's employees, users, customers, and other persons appearing in Customer files. **Sensitive data:** may be present in Customer files at Customer's discretion; Customer is responsible for the lawful basis.
|
||||
|
||||
### C3. Confidentiality; personnel
|
||||
|
||||
Provider ensures persons authorized to process Personal Data are bound by confidentiality and receive security training. Zero-standing-access applies: content access is just-in-time, logged, and audited (MSA Section 4.2).
|
||||
|
||||
### C4. Security measures (Annex II summary)
|
||||
|
||||
Encryption in transit (TLS 1.2+) and at rest (AES-256); zero-standing-access with audited JIT elevation; role-based access control; SSO/SCIM; tenant isolation; vulnerability management and penetration testing; audit logging of processing events (including file name, hash, size, and operations); backup and recovery. For Self-hosted and Air-gapped deployments, Customer operates the runtime environment and is responsible for infrastructure-level controls; Provider's measures apply to license/metering services and support access.
|
||||
|
||||
### C5. Subprocessors
|
||||
|
||||
Customer generally authorizes the subprocessors listed at {{subprocessor_url}}: cloud infrastructure (Amazon Web Services), payment processing (Stripe, as independent controller for payment data), email delivery (Google), account infrastructure (Supabase), product telemetry (PostHog, EU-hosted; pseudonymous usage events, never file content), and AI model providers: **Anthropic** (Claude models — receives prompts and the document text or excerpts needed for the requested AI feature) and **Voyage AI** (embedding models — receives extracted text excerpts solely to generate embeddings where Customer enables Ingestion/RAG features). AI features are optional and may be disabled; where used, AI providers receive only the content needed for the requested feature. **Whole Customer files are never transmitted to any AI provider.** Neither AI provider trains on Customer data (verified against the signed provider agreements, Jul 10, 2026). Provider gives thirty (30) days' notice of new subprocessors; Customer may object on reasonable data-protection grounds, and if unresolved, may terminate the affected Services with a pro-rata refund. **Provider imposes data-protection obligations on each subprocessor by written contract that are at least as protective as this DPA, and remains fully responsible to Customer for each subprocessor's performance.**
|
||||
|
||||
### C6. Data subject requests; assistance
|
||||
|
||||
Taking into account the nature of the processing, Provider provides reasonable assistance (including through the Processor's search, redaction, and audit tools) for Customer's obligations under GDPR Articles 32–36: security of processing, breach notification to authorities and data subjects, data protection impact assessments, and prior consultations with supervisory authorities, as well as responses to data subject requests. Provider forwards requests received directly to Customer and does not respond except as legally required. **Provider makes available to Customer all information necessary to demonstrate compliance with this DPA and allows for and contributes to audits, including inspections, per Section C9.**
|
||||
|
||||
### C7. Breach notification
|
||||
|
||||
Per MSA Section 5.3: without undue delay after becoming aware of a Personal Data Breach, and in any event within forty-eight (48) hours of awareness, with information provided in phases as available — including the nature of the breach, categories and approximate volumes affected, likely consequences, and measures taken or proposed.
|
||||
|
||||
### C8. International transfers
|
||||
|
||||
Where Personal Data subject to GDPR/UK GDPR is transferred to countries without adequacy, the Parties incorporate the EU Standard Contractual Clauses (Commission Decision 2021/914): **Module 2** (controller-to-processor) where Customer is a controller, and **Module 3** (processor-to-processor) where Customer acts as a processor, with the following selections — Clause 7 (docking): included; Clause 9(a): Option 2 (general written authorization, 30 days' notice per C5); Clause 11(a) optional language: not used; Clause 17: the law of Ireland; Clause 18: the courts of Ireland; competent supervisory authority: the Irish Data Protection Commission (per Annex I.C). Annex I (parties, description of transfer: as per Section C2), Annex II (technical and organizational measures: as per Section C4), and Annex III (subprocessors: as per Section C5 and {{subprocessor_url}}) are completed by reference to this DPA. For UK transfers, the UK International Data Transfer Addendum applies with its Tables completed by reference to the foregoing. Provider is not certified under the EU-U.S. Data Privacy Framework; the SCCs are the transfer mechanism.
|
||||
|
||||
**Note:** Provider does not currently offer contractual EU data residency for Stirling Cloud; residency is achieved via Self-hosted or Air-gapped deployment.
|
||||
|
||||
### C9. Audits
|
||||
|
||||
Provider's security reports and documentation (Section 5.1) are the ordinary means of demonstrating compliance. Customer may additionally audit — by itself or a mandated auditor — once per year on thirty (30) days' notice, and at any time where: (a) a security incident affecting Customer Personal Data has occurred; (b) provided documentation reveals a material deficiency; (c) a competent supervisory authority requires it; or (d) Customer reasonably suspects material noncompliance with this DPA. Audits are conducted during business hours, under confidentiality, at Customer's cost, with reasonable notice, without unreasonable interference with Provider's operations, and without access to other customers' data.
|
||||
|
||||
### C10. Return & deletion
|
||||
|
||||
On termination, at Customer's choice, Provider returns Customer file content and Personal Data (export of Customer files and the governed-record metadata) and/or deletes them — from live systems within thirty (30) days and from backups within ninety (90) days — except as retention is required by law, and certifies deletion on request. Where Customer uses HYOK, key destruction by Customer renders content cryptographically inaccessible immediately.
|
||||
|
||||
### C11. CCPA/CPRA
|
||||
|
||||
Provider is a "service provider" under the CCPA/CPRA. Provider: (a) processes Personal Information only for the business purposes specified in this Agreement — providing, securing, metering, and supporting the Services described in Section C2; (b) shall not sell or share Personal Information; (c) shall not retain, use, or disclose it for any purpose other than those business purposes, or outside the direct business relationship between the Parties; (d) shall not combine it with Personal Information received from other sources, except as permitted by CCPA regulations for the business purposes; (e) provides the same level of privacy protection required of businesses by the CCPA; (f) will notify Customer if it determines it can no longer meet its CCPA obligations; (g) grants Customer the right, upon reasonable notice, to take reasonable and appropriate steps to ensure Provider's use of Personal Information is consistent with Customer's obligations, and to stop and remediate any unauthorized use; and (h) flows these requirements down to its subprocessors per Section C5. Provider certifies that it understands these restrictions and will comply with them.
|
||||
|
||||
### C12. Liability
|
||||
|
||||
Liability under this DPA is subject to the MSA's limitations (Section 8).
|
||||
@@ -0,0 +1,119 @@
|
||||
# Stirling Enterprise Agreement
|
||||
|
||||
One signature executes all three parts: the Master Services Agreement (Part A), the Order Form (Part B), and the Data Processing Addendum (Part C). The Stirling EULA & Commercial Terms is incorporated by reference.
|
||||
|
||||
## Part A — Master Services Agreement
|
||||
|
||||
This Master Services Agreement (the "Agreement") is entered into as of {{effective_date}} (the "Effective Date") by and between **Stirling PDF, Inc.**, a Delaware corporation with offices at 548 Market Street PMB 887643, San Francisco, CA 94104 ("Provider"), and **{{customer_legal_name}}** ("Customer"). Each a "Party," together the "Parties."
|
||||
|
||||
### 1. Services & License
|
||||
|
||||
1.1 **The Services.** Provider will provide the Stirling PDF Processor (the "Processor") — the hosted or customer-deployed platform for distributing PDF editors and governing PDF processing, including policies, pipelines, the Stirling Agent, API access, and the administrative console — and the Stirling PDF Editor (the "Editor"), as described in the Order Form.
|
||||
|
||||
1.2 **License grants.** Provider grants Customer, for the Term: (a) a non-exclusive, non-transferable right to access and use the Processor for Customer's internal business operations, up to the Committed Volume; and (b) a non-exclusive right to deploy and distribute the Editor to Customer's authorized users without limit on user count. Open-source components of the Editor remain governed by their own licenses, which control for those components.
|
||||
|
||||
1.3 **Deployment.** The Services are delivered via the deployment stated in the Order Form (Stirling Cloud, Self-hosted, or Air-gapped). Self-hosted deployments validate their license and report metering data online; Air-gapped deployments verify a signed activation bundle offline and reconcile usage periodically as described in the Documentation. Customer shall not disable, circumvent, or falsify license validation or usage metering. **The metered rate does not vary by deployment**; deployment-specific services are priced as line items in the Order Form.
|
||||
|
||||
1.4 **Restrictions.** Customer shall not: resell or provide the Services to third parties as a service bureau; reverse engineer non-open-source components; use the Services to violate law; or exceed the scope of the Order Form other than through Overage (Section 3.4).
|
||||
|
||||
### 2. Term & Renewal
|
||||
|
||||
2.1 **Initial Term.** {{term_years}} year(s) from the Effective Date.
|
||||
|
||||
2.2 **Renewal.** The Agreement auto-renews for successive periods equal to the Initial Term unless either Party gives sixty (60) days' written notice of non-renewal before the end of the then-current term. The Annual Fee for each renewal year equals the immediately preceding year's Annual Fee increased by three percent (3%) — the same formula as Section 2.3. Itemized services escalate at the same rate unless restated in a superseding Order Form.
|
||||
|
||||
2.3 **In-term escalator.** The Annual Fee (including itemized services) increases by a fixed three percent (3%) at each anniversary of the Effective Date during the Term.
|
||||
|
||||
### 3. Fees & Payment
|
||||
|
||||
3.1 **Annual Fee.** Customer shall pay the Annual Fee stated in the Order Form, calculated as the Committed Volume ({{committed_pdfs_yr}} PDFs per year) at {{rate_per_pdf}} per PDF at the {{posture}} governance posture, plus the itemized services in the Order Form, less the term discount stated there.
|
||||
|
||||
3.2 **Invoicing.** Fees are invoiced annually in advance, due net thirty (30) days. Late amounts accrue interest at 1.5% per month or the maximum permitted by law, whichever is less. Fees are exclusive of taxes; Customer is responsible for all taxes other than Provider's income taxes.
|
||||
|
||||
3.3 **Committed Volume; measurement.** The Committed Volume is denominated in PDFs processed per year at the stated posture, and converts to a drawdown allowance in PDF Processes at the fixed conversion schedule below, which is frozen for the Term:
|
||||
|
||||
| Posture | PDF Processes per PDF |
|
||||
| --- | --- |
|
||||
| Essentials | 2 |
|
||||
| Governed | 4 |
|
||||
| Regulated | 7 |
|
||||
|
||||
A **"PDF Process"** is one policy execution, one pipeline run, or one Stirling Agent returned artifact, applied to one file, plus Data Processing increments under Section 3.5. For clarity: a pipeline run counts as one PDF Process regardless of the number of operations in its chain; a Stirling Agent artifact counts as one regardless of the number of messages that produced it; failed processes (those that do not complete) are not counted; reprocessing the same file and duplicate submissions are counted; counts are whole numbers (no rounding). The Processor's audit log records each PDF Process and is the system of record, subject to Section 3.7. Provider will make a per-file usage statement (file identifier, size, processes, drawdown) available for audit.
|
||||
|
||||
**Worked example.** At the Governed posture, a commitment of 90,000,000 PDFs/year provides a drawdown allowance of 360,000,000 PDF Processes. A 60 MB file that runs the four Governed policies draws down 4 PDF Processes plus 2 Data Processing increments (Section 3.5) = 6 PDF Processes. The allowance is a purchased quantity, not a feature limit: Customer may run any number of policies or pipelines; actual consumption simply draws the allowance down faster, and consumption beyond it bills as Overage (Section 3.4).
|
||||
|
||||
3.4 **Overage.** Consumption beyond the Committed Volume in a contract year is billed quarterly in arrears at the committed rate stated in the Order Form. Overage does not increase subsequent years' Committed Volume.
|
||||
|
||||
3.5 **Data Processing.** Each file includes its first twenty-five (25) megabytes (decimal, 1 MB = 1,000,000 bytes) at no additional drawdown. Each additional twenty-five (25) megabytes or part thereof (rounded up per file) draws down one (1) additional PDF Process. File size is measured once per file at ingestion, on the file as submitted. This schedule is stated here in full, is frozen for the Term, and is not subject to alteration through the Documentation.
|
||||
|
||||
3.6 **No refunds.** Except as expressly stated (Sections 7.1, 7.3, 10.3, and DPA Section C5), fees are non-refundable and Committed Volume does not roll over between contract years.
|
||||
|
||||
3.7 **Billing disputes.** Customer may dispute any invoice or metering record in good faith within sixty (60) days of the invoice date. Provider will investigate promptly, provide the relevant audit-log extracts and usage statements, and correct confirmed errors by credit or refund. The audit log is presumptively accurate but not conclusive; Customer may rebut it with reasonable evidence. Undisputed amounts remain payable when due.
|
||||
|
||||
### 4. Data Protection
|
||||
|
||||
4.1 The Data Processing Addendum at Part C (the "DPA") is incorporated into this Agreement and governs Provider's processing of Customer Personal Data, in compliance with the GDPR, UK GDPR, and CCPA/CPRA to the extent applicable.
|
||||
|
||||
4.2 **Zero-standing-access.** Customer file content is encrypted in transit and at rest. Provider personnel have no standing access to Customer file content; access is granted just-in-time under audited elevation, solely as necessary to provide the Services or as instructed by Customer. Document metadata is maintained to operate the governed record. Where the Order Form includes BYOK or HYOK key management, the key terms in the Documentation apply.
|
||||
|
||||
### 5. Security & Availability
|
||||
|
||||
5.1 **Security program.** Provider maintains a written information security program including access controls, encryption (TLS 1.2+ in transit, AES-256 at rest), audit logging, vulnerability management, and personnel security. Provider will provide its available security documentation (including its security program overview and penetration-test attestation) upon request under confidentiality.
|
||||
|
||||
5.2 **Availability.** For Stirling Cloud deployments, Provider targets 99.9% monthly uptime, excluding scheduled maintenance announced at least 48 hours in advance. The uptime figure is a target, not a credited commitment, and no service credits apply. Support response commitments for the {{sla_tier}} tier are set out in the SLA Exhibit referenced by the Order Form.
|
||||
|
||||
5.3 **Breach notice.** Provider will notify Customer without undue delay after becoming aware of a Personal Data Breach affecting Customer Personal Data, and in any event within forty-eight (48) hours of awareness. Provider may provide information in phases as it becomes available and will supplement its notice as investigation proceeds.
|
||||
|
||||
5.4 **Updates.** Provider will make security patches and product upgrades available to Customer at no additional charge for supported versions.
|
||||
|
||||
### 6. Confidentiality
|
||||
|
||||
6.1 Each Party shall protect the other's Confidential Information with at least the care it uses for its own similar information and no less than reasonable care, use it solely to perform under this Agreement, and disclose it only to personnel and advisors with a need to know who are bound by confidentiality obligations at least as protective. Confidential Information excludes information that is public without breach, independently developed, or rightfully received from a third party.
|
||||
|
||||
6.2 Compelled disclosure is permitted with prompt notice (where lawful) and reasonable cooperation to seek protective treatment.
|
||||
|
||||
6.3 Obligations survive three (3) years after termination; trade secrets survive as long as they remain trade secrets.
|
||||
|
||||
### 7. Warranties & Indemnification
|
||||
|
||||
7.1 **Performance warranty.** Provider warrants the Services will perform materially in accordance with the Documentation. Customer's exclusive remedy for breach is re-performance or, if Provider cannot re-perform within thirty (30) days, termination of the affected Services and a pro-rata refund of prepaid, unused fees for those Services.
|
||||
|
||||
7.2 **Mutual warranties.** Each Party warrants it has the authority to enter this Agreement and will comply with applicable law in its performance.
|
||||
|
||||
7.3 **IP indemnification.** Provider shall defend Customer against third-party claims that the Services, as provided and used per this Agreement, infringe a copyright or trademark or misappropriate a trade secret, and shall indemnify Customer for resulting damages finally awarded or agreed in settlement. Where **Enhanced IP Protection** is elected on the Order Form, this obligation extends to patent claims and carries the enhanced cap stated in Section 8.2. Exclusions: combinations with non-Provider materials, Customer content, modifications not made by Provider, and use after notice to stop. Provider may procure rights, modify, or replace the Services; if none is practicable, Provider may terminate the affected Services and refund prepaid, unused fees. This section states Customer's exclusive remedy for IP claims.
|
||||
|
||||
7.4 **Customer indemnification.** Customer shall defend and indemnify Provider against third-party claims arising from Customer content, Customer's breach of Section 1.4, or Customer's violation of law.
|
||||
|
||||
7.5 **Disclaimer.** EXCEPT AS EXPRESSLY STATED, THE SERVICES ARE PROVIDED WITHOUT OTHER WARRANTIES, EXPRESS OR IMPLIED, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. AI-ASSISTED OUTPUTS (INCLUDING CLASSIFICATION, EXTRACTION, AND AGENT ARTIFACTS) ARE PROBABILISTIC; CUSTOMER IS RESPONSIBLE FOR HUMAN REVIEW WHERE OUTPUTS HAVE LEGAL OR REGULATORY EFFECT.
|
||||
|
||||
### 8. Limitation of Liability
|
||||
|
||||
8.1 NEITHER PARTY IS LIABLE FOR INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR LOST PROFITS OR REVENUE.
|
||||
|
||||
8.2 **General cap.** EACH PARTY'S AGGREGATE LIABILITY IS CAPPED AT THE FEES PAID OR PAYABLE UNDER THIS AGREEMENT IN THE TWELVE (12) MONTHS PRECEDING THE FIRST EVENT GIVING RISE TO LIABILITY. **Super-cap:** for breaches of Section 6 (Confidentiality), breaches of the DPA or Section 4–5 security obligations, and IP indemnification under Section 7.3, the cap is TWO TIMES (2x) such fees. **Uncapped:** fraud, willful misconduct, Customer's payment obligations, and Customer's indemnification under Section 7.4 for claims arising from Customer's willful violation of law.
|
||||
|
||||
8.3 All claims arising from the same event or series of connected events count as a single claim for the purposes of the caps in Section 8.2.
|
||||
|
||||
### 9. General
|
||||
|
||||
9.1 **Entire agreement; precedence.** This Agreement (Parts A–C, the Order Form, the SLA Exhibit, and the Standard Contractual Clauses where applicable) is the entire agreement and supersedes prior proposals and quotes, including {{quote_ref}}. Only the following provisions of the Stirling EULA & Commercial Terms are incorporated: Section 3 (Definitions), Section 8 (AI features), Section 10 (Self-hosted and desktop software), and Section 11 (Fair use). The website Terms of Service do not apply to this Agreement; without limitation, their arbitration and class-waiver provisions, online auto-renewal rules, unilateral-amendment provision, self-serve pricing, and clickwrap acceptance mechanism are expressly excluded. Precedence: Order Form → Standard Contractual Clauses (for international transfers) → DPA → MSA → SLA Exhibit → incorporated EULA sections → Documentation.
|
||||
|
||||
9.2 **Governing law; venue.** Delaware law, excluding conflicts rules. Exclusive jurisdiction and venue in the state or federal courts located in San Francisco County, California, and the Parties consent to personal jurisdiction there.
|
||||
|
||||
9.3 **Assignment.** Neither Party may assign without the other's consent, except to a successor in a merger, acquisition, or sale of substantially all assets, with notice.
|
||||
|
||||
9.4 **Notices.** Written notices to the addresses on the Order Form; email permitted with confirmation of receipt.
|
||||
|
||||
9.5 **Force majeure; independent contractors; waiver; severability.** Standard terms apply: neither Party is liable for delay caused by events beyond reasonable control; the Parties are independent contractors; failure to enforce is not waiver; unenforceable provisions are severed with the remainder in effect.
|
||||
|
||||
9.6 **Publicity.** Neither Party may use the other's name or marks publicly without prior written consent, except Provider may identify Customer as a customer with Customer's prior approval of the specific use.
|
||||
|
||||
9.7 **Suspension.** Provider may suspend the Services for material breach that threatens the security or integrity of the Services, with notice and opportunity to cure where practicable. Undisputed unpaid fees more than thirty (30) days late are grounds for suspension after ten (10) days' notice.
|
||||
|
||||
### 10. Termination
|
||||
|
||||
10.1 Either Party may terminate for material breach uncured thirty (30) days after written notice, or immediately upon the other's insolvency.
|
||||
|
||||
10.2 On termination: Customer's access ends (self-hosted licenses expire per the license mechanism); each Party returns or destroys the other's Confidential Information; the DPA's deletion terms govern Customer Personal Data; Sections 3 (accrued fees), 6, 7, 8, 9, and 10 survive.
|
||||
|
||||
10.3 If Customer terminates for Provider's uncured material breach, Provider refunds prepaid fees for the unused remainder of the then-current contract year.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Stirling EULA & Commercial Terms
|
||||
|
||||
This document fills the EULA slot the website Terms of Service §5 already contemplates ("If a separate end-user license (EULA) accompanies software, that license governs to the extent of any conflict"). It carries the commercial terms of the actual product: the PDF Process meter, spend limits, prepaid capacity, the free allotment, trials, self-hosted licensing, and AI features. For customers under a signed Stirling Enterprise Agreement, that agreement controls.
|
||||
|
||||
**Effective:** {{version_date}} · **Version:** {{version}}
|
||||
|
||||
## 1. Agreement; precedence
|
||||
|
||||
These EULA & Commercial Terms ("EULA") supplement the Stirling Terms of Service (stirling.com/legal/terms-of-service). If they conflict, this EULA controls for the software and the commercial terms below. Open-source components are governed by their own licenses, which control for those components. By clicking accept, creating a workspace, or using the software, you agree on behalf of yourself and, if applicable, the organization you represent ("you").
|
||||
|
||||
## 2. The products
|
||||
|
||||
**The Stirling PDF Editor** is free: manual editing, the tool catalog, and team administration (including SSO) carry no subscription fee or per-seat charge, whether used in the browser, as a desktop application, or self-hosted. Usage limits on automated processing, fair-use rules (Section 11), support levels, and the feature set may change over time, and third-party costs (such as your own hosting) are yours. The Editor's open-source components remain available under their own licenses independently of this EULA. **The Stirling PDF Processor** is the paid platform that processes PDFs automatically — policies, pipelines, the Stirling Agent, and API processing — billed on the meter below.
|
||||
|
||||
## 3. Definitions
|
||||
|
||||
**"PDF Process"** — one policy execution, one pipeline run (regardless of the number of operations in its chain), or one Stirling Agent returned artifact (a processed file or summary, regardless of the number of messages that produced it), applied to one file. **"Data Processing"** — the data-volume component of the meter: files carry their first 25 MB included per file; volume past that is billed per Section 4. **"file"** — a document processed by the Processor. Chatting with the Stirling Agent is free; only returned artifacts meter.
|
||||
|
||||
## 4. Metered billing (pay as you go)
|
||||
|
||||
4.1 **Rates.** 1¢ per PDF Process, plus Data Processing at 1¢ per 25 MB increment past the first 25 MB of each file. Megabytes are decimal (1 MB = 1,000,000 bytes); size is measured once per file as submitted; increments round up ("part thereof" counts — a 26 MB file incurs one Data Processing increment, a 60 MB file incurs two). Rates may change on thirty (30) days' notice; changes apply prospectively. **Example:** two policies on a 3 MB contract = 2¢; two policies on a 60 MB scan set = 2¢ + 2¢ data = 4¢.
|
||||
|
||||
4.2 **Free allotment.** New workspaces receive a one-time allotment of 500 PDF Processes. A file processed by two processes consumes two of the 500. When the allotment is exhausted, processing pauses until the Processor is switched on.
|
||||
|
||||
4.3 **Invoices.** Usage is invoiced monthly on the 1st for the prior cycle, charged to your payment method on file (card or ACH debit). You authorize these charges.
|
||||
|
||||
4.4 **Usage records.** The Processor's audit log is the system of record, subject to Section 4.5. Your Usage & Billing page shows consumption, and a per-file usage statement (name, size, processes, charge) is available for download.
|
||||
|
||||
4.5 **Billing disputes.** You may dispute a charge or metering record in good faith within sixty (60) days of the invoice or charge date. We will investigate, provide the relevant usage-statement detail, and correct confirmed errors by credit or refund. The audit log is presumptively accurate but not conclusive; reasonable contrary evidence will be considered. Undisputed amounts remain payable.
|
||||
|
||||
## 5. Spend limits
|
||||
|
||||
5.1 You may set a monthly spend limit. By default, processing pauses when usage reaches the limit; queued documents resume when you raise the limit or the cycle resets. Nothing already processed is lost.
|
||||
|
||||
5.2 If you enable **keep-processing** ("Keep processing if you hit your limit"), usage past the limit continues to accrue and be billed per Section 4; the limit then functions as a notification threshold. You can change the limit or the toggle at any time in Usage & Billing.
|
||||
|
||||
## 6. Cancellation; downgrade
|
||||
|
||||
You may revert to the free Editor plan at any time from Usage & Billing. Accrued usage remains payable. Your policies, configuration, and history are retained per the Terms of Service data-retention practices.
|
||||
|
||||
## 7. Prepaid capacity (self-serve annual)
|
||||
|
||||
7.1 **Offer.** You may prepay twelve (12) months of processing capacity for the price of ten (10) (the "12-for-10 rate"), sized at purchase. Payment by card, or by bank transfer against a generated invoice (net 30); prepaid capacity activates when payment clears.
|
||||
|
||||
7.2 **No renewal of prepaid capacity; automatic transition to pay-as-you-go.** Prepaid capacity does not renew for another prepaid term. At purchase, you affirmatively consent to the following transition, which is disclosed before you pay: when the term ends, metered billing (Section 4) applies automatically at then-current rates so processing does not pause. We remind you thirty (30) days before term end; the reminder states the metered rates that will apply and how to cancel or revert to the free Editor plan (one click in Usage & Billing).
|
||||
|
||||
7.3 **Consumption; overage; expiry.** Capacity draws down in PDF Processes. If you exhaust capacity mid-term, you may top up at the same 12-for-10 rate, or metered billing applies at list rates (with a card on file) or processing pauses (without one). Unused capacity expires at term end and is not refunded and does not roll over.
|
||||
|
||||
7.4 **Cap.** Self-serve prepaid capacity is limited to 1,000,000 PDF Processes per year; larger commitments are available under a Stirling Enterprise Agreement.
|
||||
|
||||
## 8. AI features
|
||||
|
||||
AI features — classification, extraction, redaction-assist, and the Stirling Agent — are optional. They run only when you invoke a feature that uses them, and an administrator can disable them for the workspace; the rest of the Processor works without them. When used, they call machine-learning models from the providers listed at {{subprocessor_url}}. Currently: **Anthropic** (Claude models), which receives prompts and the document text or excerpts needed to perform the requested task; and **Voyage AI** (embedding models), which receives extracted text excerpts solely to generate embeddings when you enable Ingestion/RAG features. Only the document text or excerpts needed for the requested feature are sent — not your whole files — and only when that feature runs. AI charges are included in the price of whatever runs — there is no separate AI surcharge. Your content is not used to train models, by us or by these providers (verified against our signed provider agreements). AI outputs are probabilistic; review outputs before relying on them where accuracy has legal effect.
|
||||
|
||||
## 9. Evaluations and trials
|
||||
|
||||
Enterprise trials run fourteen (14) days, require no payment method, and are provided for evaluation only, AS IS, without service level commitments. Either party may end an evaluation at any time; on expiry your workspace continues on the free Editor plan.
|
||||
|
||||
## 10. Self-hosted and desktop software
|
||||
|
||||
10.1 **License.** We grant you a non-exclusive, non-transferable license to install and run the Editor and, with an active plan, the self-hosted Processor, for your internal business use. Open-source components remain under their own licenses.
|
||||
|
||||
10.2 **License validation and metering.** Self-hosted Processor deployments validate their license online and transmit usage metering data (process counts, file sizes, and file hashes for billing integrity, and diagnostic data — never file content or file names) to Stirling. File names used for unique PDF identification remain on your server and are not transmitted. Air-gapped deployments verify a signed activation bundle offline and reconcile usage periodically. You will not disable, circumvent, or falsify validation or metering. **The meter is the same regardless of where the software runs.**
|
||||
|
||||
10.3 **Updates.** Security patches and upgrades are made available for supported versions; some updates may install automatically per Terms of Service §5.
|
||||
|
||||
10.4 **Authorized users and administration.** "Authorized Users" are your employees, and the employees of your affiliates and contractors working on your behalf, whom you provision through your workspace. You are responsible for your users' credentials, your administrators' actions, and your users' compliance with this EULA. One workspace serves one legal entity and its affiliates; serving unrelated third parties requires a separate agreement. You may not redistribute the Processor or offer it as a hosted service to others. On termination or downgrade, self-hosted Processor licenses expire per the license mechanism; installed Editor copies remain usable under the free plan. We may verify license compliance through the validation mechanism in Section 10.2.
|
||||
|
||||
## 11. Fair use
|
||||
|
||||
Free-tier and flat-price features are subject to fair use: we may throttle or decline usage patterns that abuse free processing (for example, automation disguised as manual editing) after notice where practicable.
|
||||
|
||||
## 12. Changes to this EULA
|
||||
|
||||
We may update this EULA. Material changes take effect thirty (30) days after notice. Changes that materially increase your price or reduce your rights take effect at your next billing cycle or prepaid term start, or upon your affirmative acceptance — whichever comes first — except changes strictly necessary for legal compliance or security, which may take effect sooner with notice. Continued use after the effective date is acceptance. Version history is available at {{eula_url}}.
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"subprocessorUrl": "https://www.stirlingpdf.com/legal/subprocessors",
|
||||
"eulaUrl": "https://www.stirlingpdf.com/legal/eula",
|
||||
"documents": {
|
||||
"enterprise-agreement": {
|
||||
"label": "SEA",
|
||||
"displayName": "Stirling Enterprise Agreement",
|
||||
"version": "0.9.1",
|
||||
"effectiveDate": "2026-07-10",
|
||||
"status": "draft",
|
||||
"parts": ["msa.md", "@order-form", "dpa.md"]
|
||||
},
|
||||
"eula": {
|
||||
"label": "EULA",
|
||||
"displayName": "Stirling EULA & Commercial Terms",
|
||||
"version": "1.0.0",
|
||||
"effectiveDate": "2026-07-10",
|
||||
"status": "draft",
|
||||
"parts": ["eula.md"]
|
||||
},
|
||||
"sla": {
|
||||
"label": "SLA",
|
||||
"displayName": "Stirling SLA Exhibit",
|
||||
"version": "1.0.0",
|
||||
"effectiveDate": "2026-07-10",
|
||||
"status": "draft",
|
||||
"parts": ["sla.md"]
|
||||
},
|
||||
"subprocessors": {
|
||||
"label": "SUBP",
|
||||
"displayName": "Stirling Subprocessors",
|
||||
"version": "1.0.0",
|
||||
"effectiveDate": "2026-07-10",
|
||||
"status": "draft",
|
||||
"parts": ["subprocessors.md"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# SLA Exhibit — Stirling Enterprise Agreement
|
||||
|
||||
Referenced by the Order Form's service-level row and MSA §5.2. One document, three tiers; the Order Form's tier selection determines the applicable column. Uptime is a target, not a credited commitment: no service credits apply at any tier. Tiers differentiate support response, channels, and people.
|
||||
|
||||
## 1. Availability
|
||||
|
||||
For Stirling Cloud deployments, Provider targets **99.9% monthly uptime**, measured at the API and console endpoints, excluding scheduled maintenance announced at least 48 hours in advance and events beyond Provider's reasonable control. Current and historical status is published at the system status page. No service credits apply; persistent material failure to meet the target is addressed through MSA §7.1 (performance warranty and remedies) and §10 (termination for material breach).
|
||||
|
||||
Self-hosted and Air-gapped deployments: availability of the runtime is Customer's responsibility; this Section applies to Provider's license, metering, and update services.
|
||||
|
||||
## 2. Support tiers
|
||||
|
||||
| | **Standard** | **Priority** | **Dedicated** |
|
||||
| --- | --- | --- | --- |
|
||||
| Included with | Every Enterprise Agreement | Every Enterprise Agreement | The Dedicated SE/CSM line item ($30,000/yr) |
|
||||
| Hours | Business hours (Mon–Fri, 9:00–18:00 US Eastern, excl. US holidays) | Business hours + extended (7:00–21:00 US Eastern) | 24×7 for Severity 1 |
|
||||
| Channels | Email, in-product | Email, in-product, private Slack/Teams channel | All Priority channels + named Solutions Engineer and CSM |
|
||||
| Severity 1 first response (production down / processing halted org-wide) | 8 business hours | 4 hours | 1 hour, 24×7 |
|
||||
| Severity 2 (major feature degraded, no workaround) | Next business day | 8 business hours | 4 hours |
|
||||
| Severity 3 (minor defect, workaround exists) | 3 business days | 2 business days | Next business day |
|
||||
| Severity 4 (question, cosmetic) | 5 business days | 3 business days | 2 business days |
|
||||
| Escalation path | Support queue | Support lead | Named SE → CSM → Provider executive |
|
||||
| Business reviews | — | — | Quarterly, where the QBR line item is elected |
|
||||
|
||||
First response = a qualified human engaging with the issue, not an acknowledgment autoresponder. Resolution times are not committed; Provider works Severity 1 issues continuously within the tier's hours until resolved or downgraded.
|
||||
|
||||
## 3. Severity is set by Customer, subject to reasonable reclassification
|
||||
|
||||
Customer designates severity at filing; Provider may reclassify with explanation. Severity 1 requires production impact in a live (non-evaluation) environment.
|
||||
|
||||
## 4. Maintenance and updates
|
||||
|
||||
Scheduled maintenance is announced at least 48 hours ahead and targeted at low-usage windows. Security patches for supported versions ship to all tiers at no charge (MSA §5.4). Trials and evaluations are provided AS IS and are outside this Exhibit (EULA §9).
|
||||
|
||||
## 5. Exclusions
|
||||
|
||||
This Exhibit does not apply to: issues caused by Customer's environment, modifications, or third-party systems; usage exceeding the fair-use provisions; Preview/Beta features; or Force Majeure events (MSA §9.5).
|
||||
@@ -0,0 +1,19 @@
|
||||
# Stirling PDF — Subprocessors
|
||||
|
||||
Referenced by DPA §C5 and Annex III, and by EULA §8. Changes to this list carry 30 days' notice per DPA §C5. Last updated: {{version_date}}.
|
||||
|
||||
Stirling PDF, Inc. uses the following subprocessors to provide the Services. Customer files are processed within Stirling's own infrastructure. AI features are optional and can be disabled; where they are used, AI providers receive only the document text or excerpts needed for the requested feature, never whole customer files.
|
||||
|
||||
| Subprocessor | Purpose | Data processed | Location |
|
||||
| --- | --- | --- | --- |
|
||||
| **Amazon Web Services (AWS)** | Cloud infrastructure and storage for Stirling Cloud | Customer files (encrypted at rest), account and usage data | United States (EU region availability per deployment — see DPA §C8 note) |
|
||||
| **Stripe** | Payment processing | Billing contact and transaction data. Payment card details go directly to Stripe, which acts as an independent controller for them | United States |
|
||||
| **Supabase** | Account and workspace data infrastructure | Account, workspace, and configuration data | United States |
|
||||
| **Google** | Transactional and operational email delivery | Names, email addresses, message content of service emails | United States |
|
||||
| **Anthropic** | AI models (Claude) powering the Stirling Agent and AI-assisted features | Prompts and the document text or excerpts needed for the requested feature — never whole files; only where AI features are used | United States |
|
||||
| **Voyage AI** | Embedding models for Ingestion/RAG features | Extracted text excerpts, only where the customer enables Ingestion/RAG, solely to generate embeddings — never customer files | United States |
|
||||
| **PostHog** | Product telemetry and usage analytics | Pseudonymous usage events and diagnostic data — never file content | European Union (EU-hosted) |
|
||||
|
||||
Neither AI provider uses customer data for model training (contractually confirmed).
|
||||
|
||||
Self-hosted and air-gapped deployments: customer files remain in the customer's environment; Stirling receives license-validation and metering data only (process counts, file sizes, file hashes — never file names or content).
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package stirling.software.saas.procurement.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.saas.procurement.model.ProcurementDeal;
|
||||
|
||||
/**
|
||||
* Which stages may (re)start a trial is a security policy, not a convenience.
|
||||
*
|
||||
* <p>From the agreement stage onward a deal's {@code licenseRef} points at the committed annual
|
||||
* licence. Starting a trial replaces it with a fresh 14-day key, rewinds the stage and resets the
|
||||
* extension counter — so an unguarded restart would downgrade a paying customer's entitlement while
|
||||
* Stripe kept billing them. This pins the allowed set so widening it has to be deliberate.
|
||||
*/
|
||||
class ProcurementTrialRestartPolicyTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("allowed before a deal exists, while exploring, and within the trial")
|
||||
void allowsOnlyPreCommitmentStages() {
|
||||
assertThat(ProcurementService.canStartTrial(null)).isTrue();
|
||||
assertThat(ProcurementService.canStartTrial(ProcurementDeal.STAGE_EXPLORING)).isTrue();
|
||||
assertThat(ProcurementService.canStartTrial(ProcurementDeal.STAGE_TRIAL)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("refused once the deal is quoting or beyond")
|
||||
void refusesCommittedStages() {
|
||||
assertThat(ProcurementService.canStartTrial(ProcurementDeal.STAGE_QUOTE)).isFalse();
|
||||
assertThat(ProcurementService.canStartTrial(ProcurementDeal.STAGE_AGREEMENT)).isFalse();
|
||||
assertThat(ProcurementService.canStartTrial(ProcurementDeal.STAGE_PAYMENT)).isFalse();
|
||||
assertThat(ProcurementService.canStartTrial(ProcurementDeal.STAGE_LIVE)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unrecognised stage is refused, not waved through")
|
||||
void refusesUnknownStages() {
|
||||
// A stage added later, or a hand-edited row, must fail closed.
|
||||
assertThat(ProcurementService.canStartTrial("renewal")).isFalse();
|
||||
assertThat(ProcurementService.canStartTrial("")).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -1707,6 +1707,9 @@
|
||||
"editor/src/portal/components/billing/InvoicesList.stories.tsx :: Default": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx :: Default": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Bundle Healthy": [
|
||||
"aria-progressbar-name",
|
||||
"color-contrast"
|
||||
@@ -2107,22 +2110,15 @@
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License": [
|
||||
"aria-dialog-name"
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License Trial": [
|
||||
"aria-dialog-name"
|
||||
],
|
||||
"editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Schedule Call": [
|
||||
"aria-dialog-name"
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Manage": [
|
||||
"aria-dialog-name"
|
||||
],
|
||||
"editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Manage Maxed": [
|
||||
"aria-dialog-name"
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Setup": [
|
||||
"aria-dialog-name",
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Default": [
|
||||
@@ -2135,6 +2131,9 @@
|
||||
"editor/src/portal/components/procurement/ProcurementHome.stories.tsx :: Default": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/procurement/ProcurementModal.stories.tsx :: Open": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License": [
|
||||
"color-contrast"
|
||||
],
|
||||
@@ -2144,6 +2143,9 @@
|
||||
"editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License Online Only": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: Payment": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/procurement/QuoteBuilder.stories.tsx :: Default": [
|
||||
"color-contrast"
|
||||
],
|
||||
|
||||
@@ -7331,13 +7331,16 @@ tagline = "Native app for Microsoft Windows"
|
||||
title = "Windows"
|
||||
|
||||
[portal.home.editor]
|
||||
activeUsers = "{{n}} active"
|
||||
install = "Install the editor"
|
||||
invite = "Invite teammates"
|
||||
name = "Stirling PDF Editor"
|
||||
activeOfDeployed = "{{active}} of {{total}} active this month"
|
||||
name = "PDF Editor"
|
||||
open = "Open in browser"
|
||||
updated = "updated {{time}}"
|
||||
|
||||
[portal.home.editor.deploy]
|
||||
finish = "Finish deployment"
|
||||
options = "View deployment options"
|
||||
start = "Deploy the Editor"
|
||||
|
||||
[portal.home.editor.target]
|
||||
cloud = "Managed Cloud"
|
||||
docker = "Self-hosted · Docker"
|
||||
@@ -7348,25 +7351,6 @@ afternoon = "Good afternoon"
|
||||
evening = "Good evening"
|
||||
morning = "Good morning"
|
||||
|
||||
[portal.home.onboarding.enterprise]
|
||||
body = "Org-wide SSO + SCIM + RBAC, committed volume pricing, and air-gapped deployment."
|
||||
cta = "Start Trial"
|
||||
ctaQuote = "Get Quote"
|
||||
lead = "For 250+ employees."
|
||||
tag = "Enterprise"
|
||||
|
||||
[portal.home.onboarding.steps.editor]
|
||||
blurb = "Install the desktop app or self-host"
|
||||
title = "Download the editor"
|
||||
|
||||
[portal.home.onboarding.steps.invite]
|
||||
blurb = "Bring your team into the secure workspace"
|
||||
title = "Invite teammates"
|
||||
|
||||
[portal.home.onboarding.steps.policies]
|
||||
blurb = "{{active}} active · {{recommended}} recommended"
|
||||
title = "Confirm your policies"
|
||||
|
||||
[portal.infrastructure]
|
||||
manageEditorDeployment = "Manage Editor deployment"
|
||||
sectionsAriaLabel = "Infrastructure sections"
|
||||
@@ -7767,6 +7751,12 @@ pipelines = "Pipelines"
|
||||
policies = "Policies"
|
||||
sources = "Sources"
|
||||
|
||||
[portal.legal]
|
||||
draft = "{{label}} · draft"
|
||||
loadError = "Could not load this document. Please try again."
|
||||
loading = "Loading…"
|
||||
title = "Legal document"
|
||||
|
||||
[portal.nav]
|
||||
agent-builder = "Agent Builder"
|
||||
components = "Components"
|
||||
@@ -8430,19 +8420,27 @@ email = "Email intake"
|
||||
subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place."
|
||||
title = "Procurement"
|
||||
|
||||
[portal.procurement.action]
|
||||
download = "Download"
|
||||
pay = "Pay now"
|
||||
request = "Request"
|
||||
sign = "Review & sign"
|
||||
upload = "Upload"
|
||||
|
||||
[portal.procurement.agreement]
|
||||
agreeCta = "Agree & subscribe"
|
||||
confirm = "I have read and agree to the Stirling Enterprise Agreement."
|
||||
eyebrow = "Agreement"
|
||||
intro = "One combined agreement covers your deal: Master Service Agreement, Order Form, EULA, and Data Processing Agreement. Review it, then agree to accept the quote into a committed subscription."
|
||||
title = "Review your enterprise agreement"
|
||||
agreeCta = "Sign agreement"
|
||||
confidential = "Confidential"
|
||||
confirm = "I have read and agree to this Agreement, and I represent that I am authorized to sign it on behalf of the organization named above."
|
||||
docName = "Stirling Enterprise Agreement"
|
||||
docSub = "MSA, Order Form, EULA and DPA, combined into one signature."
|
||||
download = "Download"
|
||||
downloadDraftError = "Could not generate the agreement PDF - the document renderer is unavailable. Please try again shortly or contact support."
|
||||
downloadError = "Could not download the signed agreement. Please try again."
|
||||
legalName = "Legal entity name"
|
||||
legalNamePlaceholder = "The legal entity that will sign"
|
||||
loadError = "Could not load the agreement. Please try again."
|
||||
loading = "Loading the agreement..."
|
||||
ref = "Ref {{ref}} · {{version}}"
|
||||
requestChanges = "Request changes"
|
||||
scrollHint = "Scroll to review"
|
||||
signatory = "Signatory name"
|
||||
signatoryPlaceholder = "Full name"
|
||||
signatoryTitle = "Signatory title"
|
||||
signatoryTitlePlaceholder = "e.g. General Counsel"
|
||||
signError = "Could not record your signature. Please try again."
|
||||
|
||||
[portal.procurement.builder]
|
||||
addons = "Add-ons"
|
||||
@@ -8455,16 +8453,19 @@ businessName = "Business name"
|
||||
businessNamePlaceholder = "Your company"
|
||||
city = "City"
|
||||
cityPlaceholder = "San Francisco"
|
||||
completeRequired = "Please complete the required fields (marked *) with a valid email before generating the quote."
|
||||
contactEmail = "Contact email"
|
||||
contactEmailPlaceholder = "jane@acme.com"
|
||||
contactName = "Contact name"
|
||||
contactNamePlaceholder = "Jane Doe"
|
||||
continue = "Continue"
|
||||
done = "Done"
|
||||
eula = "I have read and agree to the Stirling Enterprise EULA. It governs the agreement generated from this quote."
|
||||
generate = "Generate quote"
|
||||
included = "Included"
|
||||
indemnification = "IP indemnification"
|
||||
indemnificationSub = "We defend qualifying IP claims, per the EULA"
|
||||
indemnification = "Enhanced IP Protection"
|
||||
indemnificationSub = "Extends our IP defense to patent claims. Baseline copyright, trademark and trade-secret indemnification is included free."
|
||||
paperEyebrow = "Enterprise quote"
|
||||
paperFor = "Prepared for"
|
||||
pdfSize = "PDF size"
|
||||
poNumber = "PO number"
|
||||
poNumberPlaceholder = "Optional"
|
||||
@@ -8512,6 +8513,7 @@ training = "Onboarding & training"
|
||||
trainingSub = "Live sessions to get your team running"
|
||||
users = "Total users"
|
||||
usersPlaceholder = "e.g. 250"
|
||||
viewEula = "Read it"
|
||||
volEstimated = "Estimated from {{count}} users (~2,000 PDFs each, including automation). Edit if you know better."
|
||||
volManual = "Using your figure. Re-estimate from your team size any time."
|
||||
volNoUsers = "Not sure? Enter your team size and we'll estimate it."
|
||||
@@ -8520,54 +8522,55 @@ volumePlaceholder = "e.g. 1,000,000"
|
||||
years_one = "{{count}} year"
|
||||
years_other = "{{count}} years"
|
||||
|
||||
[portal.procurement.docs]
|
||||
count_one = "{{count}} doc"
|
||||
count_other = "{{count}} docs"
|
||||
done = "Done"
|
||||
here = "You're here"
|
||||
hide = "Hide"
|
||||
optional = "Optional"
|
||||
paidAddon = "Paid add-on"
|
||||
show = "Show"
|
||||
subtitle = "Everything you need at each step of the journey, surfaced as the deal moves through it."
|
||||
supportingSubtitle = "SOC 2, security reviews, tax forms and more, ready when your security or procurement team asks. Some carry a one-time fee."
|
||||
supportingTitle = "Supporting your evaluation"
|
||||
[portal.procurement.documents]
|
||||
agreement = "Enterprise Agreement"
|
||||
agreementSub = "MSA, Order Form and DPA in one signature."
|
||||
download = "Download"
|
||||
eula = "EULA & Commercial Terms"
|
||||
eulaSub = "The software and self-serve commercial terms."
|
||||
invoice = "Invoice"
|
||||
invoiceSub = "Your first subscription invoice."
|
||||
laterInvoice = "Available after you accept"
|
||||
laterQuote = "Available once your quote is issued"
|
||||
quote = "Quote"
|
||||
quoteSub = "Your itemised enterprise quote."
|
||||
sla = "SLA Exhibit"
|
||||
slaSub = "Support tiers and response targets."
|
||||
subprocessors = "Subprocessors"
|
||||
subprocessorsSub = "Third parties that process data on your behalf."
|
||||
subtitle = "Your deal paperwork, available any time."
|
||||
title = "Documents"
|
||||
upcoming = "Upcoming"
|
||||
view = "View"
|
||||
|
||||
[portal.procurement.error]
|
||||
title = "Something went wrong"
|
||||
|
||||
[portal.procurement.hero]
|
||||
company = "Your enterprise deal"
|
||||
ctaLive = "You're live"
|
||||
ctaPayment = "Add payment"
|
||||
barAria = "Stage {{current}} of {{total}}"
|
||||
ctaAgreement = "Review & sign agreement"
|
||||
ctaExploring = "Set up your trial"
|
||||
ctaQuote = "Review your quote"
|
||||
ctaReviewQuote = "Review quote"
|
||||
ctaTrial = "Build your quote"
|
||||
eyebrow = "Enterprise procurement"
|
||||
documents = "Documents"
|
||||
eyebrow = "Enterprise"
|
||||
eyebrowCompany = "{{company}} Enterprise"
|
||||
inviteTeammates = "Invite teammates"
|
||||
licenseKey = "Licence key"
|
||||
nextStep = "Next step: {{action}}"
|
||||
notStarted = "Not started"
|
||||
liveSub = "Your organization is provisioned and your licence is active."
|
||||
liveTitle = "You are live on Stirling Enterprise"
|
||||
next = "Next: {{stage}}"
|
||||
open = "Open procurement"
|
||||
scheduleCall = "Schedule a call"
|
||||
setup1Sub = "Invite your teammates"
|
||||
setup1Title = "Deploy the PDF Editor"
|
||||
setup2Sub = "Turn on processing across editors and other sources"
|
||||
setup2Title = "Connect the PDF Processor"
|
||||
setup3Sub = "Turn on Security, Compliance, Routing, or Retention when you need them"
|
||||
setup3Title = "Add recommended policies"
|
||||
sentenceAgreement = "review and sign the agreement"
|
||||
sentenceLive = "provisioning and rollout"
|
||||
sentencePayment = "purchase order and payment"
|
||||
sentenceQuote = "review and accept your quote"
|
||||
sentenceTrial = "set up your trial workspace"
|
||||
|
||||
[portal.procurement.journey]
|
||||
daysLeft_one = "{{count}} day left"
|
||||
daysLeft_other = "{{count}} days left"
|
||||
engineerLabel = "Your solutions engineer"
|
||||
eyebrow = "Your rollout"
|
||||
live = "You're live on Stirling Enterprise"
|
||||
nextStep = "Next step: {{action}}"
|
||||
subtitle = "Your solutions engineer is on every step. One next action at a time; the full checklist is below."
|
||||
title = "From trial to live, one guided path"
|
||||
trialTitle = "Enterprise trial"
|
||||
|
||||
[portal.procurement.journeySteps.agreement]
|
||||
blurb = "One signature covers MSA, order form, EULA and DPA."
|
||||
@@ -8616,45 +8619,26 @@ description = "Your subscription is active and your licence is issued. Your team
|
||||
eyebrow = "Live"
|
||||
title = "You're live on Stirling Enterprise"
|
||||
|
||||
[portal.procurement.locked]
|
||||
description = "Trial keys, committed-volume quotes, the one-signature agreement, payment, and your document ledger all live here once you start an enterprise evaluation."
|
||||
eyebrow = "Enterprise only"
|
||||
talkToSales = "Talk to sales"
|
||||
title = "The procurement track opens with Enterprise"
|
||||
|
||||
[portal.procurement.milestone]
|
||||
download = "Download PDF"
|
||||
downloadError = "Could not download the quote PDF just yet — please try again in a moment."
|
||||
edit = "Edit quote"
|
||||
|
||||
[portal.procurement.modal]
|
||||
cancel = "Cancel"
|
||||
chooseFile = "Choose file"
|
||||
close = "Close"
|
||||
downloadBody = "Your download will begin shortly."
|
||||
downloadCta = "Download"
|
||||
downloadTitle = "Download"
|
||||
noFile = "No file selected"
|
||||
payBody = "Pay your committed contract by card or bank transfer through Stripe. Your workspace provisions as soon as payment clears."
|
||||
payCta = "Continue to Stripe"
|
||||
payTitle = "Confirm payment"
|
||||
requestBodyFree = "We generate this on demand. Confirm and your solutions engineer will send it across shortly."
|
||||
requestBodyPaid = "This is a paid add-on. Confirm and your solutions engineer will scope it and send the paperwork."
|
||||
requestCta = "Request"
|
||||
requestTitle = "Request this document"
|
||||
signBody = "Opens the Stirling Enterprise Agreement for e-signature: one signature covers the MSA, order form, EULA and DPA. We countersign automatically and you advance to payment."
|
||||
signCta = "Open for signature"
|
||||
signTitle = "Review and sign your agreement"
|
||||
uploadBody = "Send us your PO and we invoice against it on your terms. Drag in the PDF or pick a file below."
|
||||
uploadCta = "Upload purchase order"
|
||||
uploadTitle = "Upload your purchase order"
|
||||
|
||||
[portal.procurement.payment]
|
||||
description = "Your quote is accepted and your licence is already active — your team can start right away. Pay the first invoice when you're ready; you can pay or download it here, no email needed."
|
||||
downloadAgreement = "Download signed agreement"
|
||||
downloadInvoice = "Download invoice"
|
||||
eyebrow = "Payment"
|
||||
title = "Subscription created"
|
||||
viewInvoice = "View & pay invoice"
|
||||
|
||||
[portal.procurement.review]
|
||||
acceptCta = "Accept quote"
|
||||
annual = "Annual fee (year 1)"
|
||||
downloadCta = "Download quote"
|
||||
poNumber = "Purchase order: {{po}}"
|
||||
renewal = "Renews at {{amount}}/year after the term (+{{pct}}% CPI)"
|
||||
tcv = "{{years}}-year term, paid in full up front: {{tcv}}"
|
||||
validUntil = "Valid until {{date}}"
|
||||
|
||||
[portal.procurement.schedule]
|
||||
fallback = "Couldn't load the scheduler."
|
||||
fallbackLink = "Open scheduling in a new tab"
|
||||
@@ -8664,25 +8648,33 @@ title = "Schedule a call"
|
||||
|
||||
[portal.procurement.setup]
|
||||
airgap = "Air-gapped"
|
||||
airgapSub = "Fully offline, isolated network. Includes a downloadable licence file."
|
||||
cloud = "Cloud"
|
||||
cloudSub = "Fully managed by Stirling. Nothing for you to run."
|
||||
deployment = "Where will you run Stirling?"
|
||||
seats = "Team size"
|
||||
seatsHint = "Roughly how many people will use it. You can refine this when you build your quote."
|
||||
seatsPlaceholder = "e.g. 250"
|
||||
airgapSub = "Sealed, no outbound"
|
||||
back = "Back"
|
||||
businessName = "Business name"
|
||||
businessNamePlaceholder = "Acme Corp"
|
||||
cloud = "Stirling Cloud"
|
||||
cloudSub = "Managed, fastest start"
|
||||
continue = "Continue"
|
||||
deployment = "Where should it deploy?"
|
||||
eula = "I agree to the Stirling EULA & Commercial Terms."
|
||||
fullName = "Full name"
|
||||
fullNamePlaceholder = "Your name"
|
||||
invites = "Invite teammates · optional"
|
||||
invitesPlaceholder = "sam@acme.com, lee@acme.com"
|
||||
scheduleCall = "Schedule a call"
|
||||
seats = "People testing"
|
||||
seatsPlaceholder = "e.g. 15"
|
||||
selfhost = "Self-hosted"
|
||||
selfhostSub = "Run it in your own cloud or data centre."
|
||||
selfhostSub = "Docker or Kubernetes"
|
||||
start = "Start trial"
|
||||
subtitle = "Tell us how you plan to run Stirling so we can tailor your trial and quote. No card required."
|
||||
stepOf = "Step {{n}} of {{total}}"
|
||||
subtitle = "Free for 14 days, no card required."
|
||||
subtitleDetails = "A few details for your quote and agreement."
|
||||
talkFirst = "Prefer to talk first?"
|
||||
title = "Set up your trial"
|
||||
|
||||
[portal.procurement.status]
|
||||
action = "Action needed"
|
||||
available = "Available"
|
||||
complete = "Complete"
|
||||
pending = "Pending"
|
||||
request = "On request"
|
||||
viewEula = "Read it"
|
||||
workEmail = "Work email"
|
||||
workEmailPlaceholder = "you@company.com"
|
||||
|
||||
[portal.procurement.trial]
|
||||
body = "Extending adds 7 days and notifies your solutions engineer."
|
||||
@@ -8693,12 +8685,6 @@ maxed = "Maxed out"
|
||||
subtitle = "Your free trial runs through {{date}}. No card required."
|
||||
title = "Enterprise trial"
|
||||
|
||||
[portal.procurement.upsell]
|
||||
homeBadge = "Enterprise"
|
||||
homeBody = "Committed volume pricing, org-wide SSO + SCIM + RBAC, 90-day immutable audit, and a dedicated SE."
|
||||
homeCta = "Start Trial →"
|
||||
homeHeadline = "Process millions of PDFs."
|
||||
|
||||
[portal.search]
|
||||
ariaLabel = "Search"
|
||||
placeholder = "Search Stirling — endpoints, pipelines, docs…"
|
||||
@@ -8923,6 +8909,9 @@ name = "Name"
|
||||
namePlaceholder = "e.g. Claims intake"
|
||||
type = "Type"
|
||||
|
||||
[portal.stepModal]
|
||||
close = "Close"
|
||||
|
||||
[portal.usage]
|
||||
managePayment = "Manage Payment"
|
||||
subtitle = "Consumption, invoices, and plan management for every PDF Stirling has billed, in one console."
|
||||
@@ -8985,14 +8974,6 @@ summary = "Owns a team — manages its members' resources and shared configs."
|
||||
limited = "{{used}} / {{limit}}"
|
||||
unlimited = "{{used}} · Unlimited"
|
||||
|
||||
[portal.welcome]
|
||||
ariaLabel = "Welcome to Stirling PDF"
|
||||
install = "Install the editor"
|
||||
invite = "Invite teammates"
|
||||
openInBrowser = "Open in browser"
|
||||
productName = "PDF Editor"
|
||||
stats = "30M downloads · 60+ PDF operations · Free forever"
|
||||
|
||||
[printFile]
|
||||
title = "Print File"
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Policies } from "@portal/views/Policies";
|
||||
import { EditorAdmin } from "@portal/views/EditorAdmin";
|
||||
import { Infrastructure } from "@portal/views/Infrastructure";
|
||||
import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate";
|
||||
import { Procurement } from "@portal/views/Procurement";
|
||||
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
|
||||
|
||||
// Lazy so the generated docs manifest (bundled JSON) lands in its own chunk.
|
||||
@@ -62,7 +61,6 @@ export function ViewRouter() {
|
||||
element={<Infrastructure />}
|
||||
/>
|
||||
<Route path={rel(VIEW_PATHS.usage)} element={<PortalBillingGate />} />
|
||||
<Route path={rel(VIEW_PATHS.procurement)} element={<Procurement />} />
|
||||
<Route
|
||||
path={rel(VIEW_PATHS.docs)}
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { openApiUrl } from "@portal/api/externalUrl";
|
||||
|
||||
function spyOpen() {
|
||||
return vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("openApiUrl", () => {
|
||||
it("opens an https URL in a new tab with noopener", () => {
|
||||
const open = spyOpen();
|
||||
openApiUrl("https://invoice.stripe.com/i/abc123");
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
"https://invoice.stripe.com/i/abc123",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a relative URL by resolving it against the origin", () => {
|
||||
const open = spyOpen();
|
||||
openApiUrl("/invoices/abc.pdf");
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
`${window.location.origin}/invoices/abc.pdf`,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
});
|
||||
|
||||
// The reason this module exists: a navigation sink must not evaluate script.
|
||||
it.each([
|
||||
"javascript:alert(document.domain)",
|
||||
"JavaScript:alert(1)",
|
||||
"data:text/html,<script>alert(1)</script>",
|
||||
"vbscript:msgbox(1)",
|
||||
"file:///etc/passwd",
|
||||
])("refuses %s", (hostile) => {
|
||||
const open = spyOpen();
|
||||
openApiUrl(hostile);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing for an empty or missing URL", () => {
|
||||
const open = spyOpen();
|
||||
openApiUrl(null);
|
||||
openApiUrl(undefined);
|
||||
openApiUrl("");
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Open a URL that arrived from the API — a Stripe hosted-invoice page or invoice
|
||||
* PDF — in a new tab, after checking its scheme.
|
||||
*
|
||||
* These values are relayed from Stripe through our own edge functions, so they
|
||||
* are not attacker-controlled today. But a `javascript:` or `data:` URL reaching
|
||||
* a navigation sink is an XSS primitive, and nothing between Stripe and this call
|
||||
* promises the field can only ever hold a web address. Anything that is not plain
|
||||
* http(s) is dropped instead of opened.
|
||||
*/
|
||||
export function openApiUrl(url: string | null | undefined): void {
|
||||
if (!url) return;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url, window.location.origin);
|
||||
} catch {
|
||||
console.warn("[portal] refusing to open an unparseable URL");
|
||||
return;
|
||||
}
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
console.warn(`[portal] refusing to open a ${parsed.protocol} URL`);
|
||||
return;
|
||||
}
|
||||
window.open(parsed.href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import { resolveDemoResponse } from "@portal/api/demoData";
|
||||
import { saasApiBase } from "@portal/api/saasApiBase";
|
||||
import { getSupabaseClient } from "@app/auth/supabase/supabaseClient";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
/*
|
||||
* Procurement models the enterprise commercial journey, trial → quote →
|
||||
* agreement → payment → implementation, plus the paperwork ledger that rides
|
||||
* alongside it. The journey is enterprise-only; free/pro tiers receive a
|
||||
* minimal locked payload the view renders as an upgrade prompt.
|
||||
* Procurement models the enterprise commercial journey: trial → quote → agreement → payment →
|
||||
* implementation. It is surfaced by the deal-status hero on Home and the takeover flow beside it;
|
||||
* there is no separate procurement route.
|
||||
*/
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
@@ -19,11 +19,8 @@ import type { Tier } from "@portal/contexts/TierContext";
|
||||
* Payment read more plainly than the internal `security` / `procurement`).
|
||||
*/
|
||||
export type DealStage =
|
||||
| "trial"
|
||||
| "quote"
|
||||
| "security"
|
||||
| "procurement"
|
||||
| "active";
|
||||
/** Asked about enterprise, nothing committed yet. Precedes the journey rather than joining it. */
|
||||
"exploring" | "trial" | "quote" | "security" | "procurement" | "active";
|
||||
|
||||
export interface JourneyStep {
|
||||
stage: DealStage;
|
||||
@@ -38,9 +35,14 @@ export interface JourneyStep {
|
||||
gatingAction: string;
|
||||
}
|
||||
|
||||
/** Ordered journey definition, the stepper renders this verbatim. */
|
||||
/** `label`/`blurb`/`gatingAction` values are i18n keys — render with t(). */
|
||||
export const JOURNEY: JourneyStep[] = [
|
||||
/**
|
||||
* The commercial flow's stages, rendered verbatim as the hero's progress band. Quote and Agreement
|
||||
* are distinct: the buyer accepts the quote first (no Stripe), then signs the agreement, which is
|
||||
* what accepts into a committed subscription.
|
||||
*
|
||||
* <p>`label`/`blurb`/`gatingAction` are i18n keys — render with t().
|
||||
*/
|
||||
export const FLOW_JOURNEY: JourneyStep[] = [
|
||||
{
|
||||
stage: "trial",
|
||||
label: "portal.procurement.journeySteps.trial.label",
|
||||
@@ -73,204 +75,9 @@ export const JOURNEY: JourneyStep[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The commercial flow's stepper stages. The real backend collapses quote + agreement into one
|
||||
* accept step (accepting the issued quote is accepting the agreement), so the flow shows one fewer
|
||||
* step than the mock ledger's {@link JOURNEY} — the separate "Agreement" step is dropped. Reuses
|
||||
* JOURNEY's i18n keys.
|
||||
*/
|
||||
export const FLOW_JOURNEY: JourneyStep[] = JOURNEY.filter(
|
||||
(s) => s.stage !== "security",
|
||||
);
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Deal header */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface SolutionsEngineer {
|
||||
name: string;
|
||||
title: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface TrialInfo {
|
||||
/** License key seeded for the evaluation. */
|
||||
key: string;
|
||||
/** ISO date the trial began. */
|
||||
startedOn: string;
|
||||
/** ISO date the trial expires. */
|
||||
endsOn: string;
|
||||
/** Whole days remaining (derived in the fixture for a stable demo number). */
|
||||
daysLeft: number;
|
||||
extensionsUsed: number;
|
||||
maxExtensions: number;
|
||||
}
|
||||
|
||||
export interface QuoteInfo {
|
||||
number: string;
|
||||
/** Annual contract value, in USD. */
|
||||
amount: number;
|
||||
/** Contract term, e.g. "12 months". */
|
||||
term: string;
|
||||
/** ISO date the quote expires. */
|
||||
validUntil: string;
|
||||
}
|
||||
|
||||
export interface Deal {
|
||||
company: string;
|
||||
currentStage: DealStage;
|
||||
engineer: SolutionsEngineer;
|
||||
trial: TrialInfo;
|
||||
quote: QuoteInfo;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Document ledger + supporting pool */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Lifecycle of a single document.
|
||||
* available: ready to grab now (download/sign/pay/upload as the action says)
|
||||
* action: waiting on the buyer to act (the gating paperwork of a stage)
|
||||
* pending: issued, awaiting the other side / a system step
|
||||
* request: not generated yet; the buyer asks for it (some carry a fee)
|
||||
* complete: done, kept for the record
|
||||
*/
|
||||
export type DocStatus =
|
||||
| "available"
|
||||
| "action"
|
||||
| "pending"
|
||||
| "request"
|
||||
| "complete";
|
||||
|
||||
/** What pressing the document's button does. */
|
||||
export type DocAction = "download" | "sign" | "pay" | "upload" | "request";
|
||||
|
||||
export interface LedgerDoc {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Sub-line describing what the document is / what it covers. */
|
||||
sub: string;
|
||||
status: DocStatus;
|
||||
action: DocAction;
|
||||
/** Buyer-skippable paperwork (e.g. paid onboarding). */
|
||||
optional?: boolean;
|
||||
/** One-off fee in USD when the document/service is a paid add-on. */
|
||||
fee?: number;
|
||||
}
|
||||
|
||||
/** Document ledger grouped by the journey stage the paperwork belongs to. */
|
||||
export interface LedgerGroup {
|
||||
stage: DealStage;
|
||||
/** Buyer-facing stage name (matches JourneyStep.label). */
|
||||
label: string;
|
||||
docs: LedgerDoc[];
|
||||
}
|
||||
|
||||
/** Categories the stage-agnostic supporting pool is grouped under. */
|
||||
export type SupportingCategory =
|
||||
| "security"
|
||||
| "legal"
|
||||
| "corporate"
|
||||
| "procurement";
|
||||
|
||||
export interface SupportingGroup {
|
||||
category: SupportingCategory;
|
||||
label: string;
|
||||
docs: LedgerDoc[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Full procurement payload */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface ProcurementResponse {
|
||||
tier: Tier;
|
||||
/** True only for enterprise, gates the whole journey + ledger. */
|
||||
unlocked: boolean;
|
||||
/** Present only when unlocked. */
|
||||
deal: Deal | null;
|
||||
journey: JourneyStep[];
|
||||
ledger: LedgerGroup[];
|
||||
supporting: SupportingGroup[];
|
||||
}
|
||||
|
||||
/** GET /v1/procurement?tier=…, the deal, journey, ledger and supporting pool. */
|
||||
export async function fetchProcurement(
|
||||
tier: Tier,
|
||||
): Promise<ProcurementResponse> {
|
||||
return apiClient.local.json<ProcurementResponse>(
|
||||
`/v1/procurement?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Commercial actions. Each mutates the deal server-side and returns the updated
|
||||
* ProcurementResponse, the new canonical state, which the view applies so the
|
||||
* journey progresses. The MSW layer answers these today; a real backend honours
|
||||
* the same contracts unchanged.
|
||||
*/
|
||||
|
||||
/** Advance the deal to the next stage (the journey's primary CTA). */
|
||||
export async function advanceStage(
|
||||
fromStage: DealStage,
|
||||
): Promise<ProcurementResponse> {
|
||||
return apiClient.local.json<ProcurementResponse>("/v1/procurement/advance", {
|
||||
method: "POST",
|
||||
body: { fromStage },
|
||||
});
|
||||
}
|
||||
|
||||
/** Sign the Stirling Enterprise Agreement (MSA + order form + EULA + DPA). */
|
||||
export async function signAgreement(
|
||||
docId: string,
|
||||
): Promise<ProcurementResponse> {
|
||||
// A real backend opens an e-signature envelope and completes on callback;
|
||||
// here it completes immediately and advances the deal.
|
||||
return apiClient.local.json<ProcurementResponse>("/v1/procurement/sign", {
|
||||
method: "POST",
|
||||
body: { docId },
|
||||
});
|
||||
}
|
||||
|
||||
/** Pay the contract online (card / bank transfer via Stripe). */
|
||||
export async function payOnline(): Promise<ProcurementResponse> {
|
||||
return apiClient.local.json<ProcurementResponse>("/v1/procurement/pay", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Upload a purchase order to invoice against (an alternate payment path). */
|
||||
export async function uploadPurchaseOrder(
|
||||
file: File,
|
||||
): Promise<ProcurementResponse> {
|
||||
// A real backend takes the PO as multipart; the mock only needs the name.
|
||||
return apiClient.local.json<ProcurementResponse>(
|
||||
"/v1/procurement/purchase-order",
|
||||
{
|
||||
method: "POST",
|
||||
body: { fileName: file.name },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Request a document that is generated on demand (some carry a one-off fee). */
|
||||
export async function requestDocument(
|
||||
docId: string,
|
||||
action: DocAction,
|
||||
): Promise<ProcurementResponse> {
|
||||
return apiClient.local.json<ProcurementResponse>(
|
||||
`/v1/procurement/documents/${encodeURIComponent(docId)}/request`,
|
||||
{ method: "POST", body: { action } },
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Enterprise procurement — real SaaS backend (/api/v1/procurement).
|
||||
//
|
||||
// The journey/ledger visuals above still ride the MSW mock; the commercial spine
|
||||
// below (trial, server-priced quote, accept -> Stripe checkout) is the real thing,
|
||||
// served by the saas Java backend and gated on a linked account.
|
||||
// Enterprise procurement — the real SaaS backend (/api/v1/procurement): trial,
|
||||
// server-priced quote, agreement, accept -> Stripe. Gated on a linked account.
|
||||
// ============================================================================
|
||||
|
||||
export type QuoteLineItemKind =
|
||||
@@ -288,7 +95,11 @@ export interface QuoteLineItem {
|
||||
|
||||
export interface QuoteResult {
|
||||
quoteId: number;
|
||||
quoteNumber: string;
|
||||
/**
|
||||
* Stripe's quote number — the deal's one reference, shown on the quote, the agreement and the
|
||||
* invoice. Null while the quote is a local draft: Stripe assigns it only at finalisation.
|
||||
*/
|
||||
quoteNumber: string | null;
|
||||
/** draft (priced, editable) | sent (issued Stripe quote — PDF + shareable) | accepted | expired. */
|
||||
status: string;
|
||||
currency: string;
|
||||
@@ -332,6 +143,12 @@ export interface ProcurementSnapshot {
|
||||
licensed: boolean;
|
||||
/** The team's Keygen licence key (present once licensed); shown in the portal to copy/install. */
|
||||
licenseKey: string | null;
|
||||
/** Version label of the signed agreement PDF available to download (e.g. "SEA v0.9.1"), else null. */
|
||||
agreementSignedVersion: string | null;
|
||||
/** Buying entity captured at trial setup; null on deals started before that step existed. */
|
||||
businessName: string | null;
|
||||
contactName: string | null;
|
||||
contactEmail: string | null;
|
||||
latestQuote: QuoteResult | null;
|
||||
}
|
||||
|
||||
@@ -385,13 +202,37 @@ export function fetchLicenseFile(): Promise<string> {
|
||||
* Start the trial with the buyer's chosen deployment target and seat count (captured in the setup
|
||||
* step). These seed the quote builder; both remain editable when the quote is built.
|
||||
*/
|
||||
/** Details collected by trial setup's second step; every field optional server-side. */
|
||||
export interface TrialSetupDetails {
|
||||
businessName?: string;
|
||||
contactName?: string;
|
||||
contactEmail?: string;
|
||||
/**
|
||||
* Raw comma/space/semicolon separated addresses. Recorded on the deal AND sent through the
|
||||
* team-invite path once the trial starts; a rejected address is skipped, never fatal.
|
||||
*/
|
||||
inviteEmails?: string;
|
||||
}
|
||||
|
||||
export function startTrial(
|
||||
deployment: string,
|
||||
seats: number,
|
||||
details?: TrialSetupDetails,
|
||||
): Promise<ProcurementSnapshot> {
|
||||
return apiClient.saas.json<ProcurementSnapshot>(
|
||||
"/api/v1/procurement/trial/start",
|
||||
{ method: "POST", body: { deployment, users: seats } },
|
||||
{ method: "POST", body: { deployment, users: seats, ...details } },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that this account is looking at enterprise. Idempotent, and never disturbs an existing
|
||||
* deal — so it is safe to call on every entry into the flow.
|
||||
*/
|
||||
export function recordInterest(): Promise<ProcurementSnapshot> {
|
||||
return apiClient.saas.json<ProcurementSnapshot>(
|
||||
"/api/v1/procurement/interest",
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -418,12 +259,114 @@ export function buildQuote(cfg: QuoteConfigInput): Promise<QuoteResult> {
|
||||
});
|
||||
}
|
||||
|
||||
/** The filled enterprise agreement (MSA + Order Form + DPA) for the current quote, as markdown. */
|
||||
export interface AgreementDocument {
|
||||
docId: string;
|
||||
version: string;
|
||||
/** e.g. "SEA v0.9.1" — the exact document version a signature will be pinned to. */
|
||||
versionLabel: string;
|
||||
displayName: string;
|
||||
effectiveDate: string;
|
||||
/** "draft" until counsel clears it — the UI badges drafts. */
|
||||
status: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
/** Buyer-supplied signing inputs captured on the agreement stage. */
|
||||
export interface SignAgreementInput {
|
||||
customerLegalName: string;
|
||||
signatoryName: string;
|
||||
signatoryTitle: string;
|
||||
authorityConfirmed: boolean;
|
||||
}
|
||||
|
||||
export interface SignAgreementResult {
|
||||
signatureId: number;
|
||||
versionLabel: string;
|
||||
/** Whether the signed PDF was rendered + stored (false when the render runtime was unavailable). */
|
||||
pdfStored: boolean;
|
||||
}
|
||||
|
||||
/** Fetch the filled agreement to review before signing. */
|
||||
export function fetchAgreementDocument(): Promise<AgreementDocument> {
|
||||
return apiClient.saas.json<AgreementDocument>(
|
||||
"/api/v1/procurement/agreement/document",
|
||||
);
|
||||
}
|
||||
|
||||
/** Record the signed agreement (pins version + hash + variable snapshot + signatory + PDF). */
|
||||
export function recordAgreementSignature(
|
||||
input: SignAgreementInput,
|
||||
): Promise<SignAgreementResult> {
|
||||
return apiClient.saas.json<SignAgreementResult>(
|
||||
"/api/v1/procurement/agreement/sign",
|
||||
{ method: "POST", body: input },
|
||||
);
|
||||
}
|
||||
|
||||
/** Fetch a static legal document (eula, sla, subprocessors) by id for in-product viewing. */
|
||||
export function fetchLegalDocument(docId: string): Promise<AgreementDocument> {
|
||||
return apiClient.saas.json<AgreementDocument>(`/api/v1/legal/${docId}`);
|
||||
}
|
||||
|
||||
/** Download the stored, signed enterprise-agreement PDF for the team (post-signing). */
|
||||
export function fetchSignedAgreementPdf(): Promise<Blob> {
|
||||
return apiClient.saas.blob("/api/v1/procurement/agreement/signature/pdf");
|
||||
}
|
||||
|
||||
/** Download the current (unsigned) enterprise-agreement PDF — the document shown at the sign step. */
|
||||
export function fetchAgreementPdf(): Promise<Blob> {
|
||||
return apiClient.saas.blob("/api/v1/procurement/agreement/document/pdf");
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a clickwrap consent to a legal document (e.g. the EULA at trial start / quote generation).
|
||||
* Best-effort — never block the flow it accompanies on a consent-logging failure.
|
||||
*/
|
||||
export function recordLegalConsent(
|
||||
documentId: string,
|
||||
context: string,
|
||||
): Promise<void> {
|
||||
return apiClient.saas
|
||||
.json<void>("/api/v1/legal/consent", {
|
||||
method: "POST",
|
||||
body: { documentId, context },
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
// ---- Stripe Quote operations (Supabase edge functions) ---------------------
|
||||
// Java has no Stripe SDK, so issuing/accepting the quote and fetching its PDF run in edge functions
|
||||
// that own Stripe; they persist results back through SECURITY DEFINER RPCs. The portal invokes them
|
||||
// directly (same pattern the PAYG checkout uses).
|
||||
|
||||
/**
|
||||
* Fixture response for an edge function while demo data is on.
|
||||
*
|
||||
* These calls go out through the Supabase client rather than {@code apiClient}, which is where demo
|
||||
* data is normally intercepted — so until this existed they were never mocked. The portal has no
|
||||
* service worker: the MSW handlers are matched by URL inside {@link resolveDemoResponse}, so the URL
|
||||
* has to be reconstructed here to match what mocks/handlers/procurementSaas.ts registers.
|
||||
*
|
||||
* This mattered rather more than a missing fixture: issue and accept create a real Stripe Quote and a
|
||||
* real committed subscription. With a live session — the normal state when developing against the
|
||||
* shared project — pressing "Generate quote" in dev billed nothing but left real objects behind.
|
||||
*/
|
||||
async function demoEdgeResponse(
|
||||
fn: string,
|
||||
quoteId: number,
|
||||
): Promise<Response | undefined> {
|
||||
const base = saasApiBase();
|
||||
if (!base) return undefined;
|
||||
return resolveDemoResponse(new URL(`${base}/functions/v1/${fn}`), {
|
||||
method: "POST",
|
||||
body: { quote_id: quoteId },
|
||||
});
|
||||
}
|
||||
|
||||
async function invokeEdge<T>(fn: string, quoteId: number): Promise<T> {
|
||||
const demo = await demoEdgeResponse(fn, quoteId);
|
||||
if (demo) return (await demo.json()) as T;
|
||||
const supabase = getSupabaseClient();
|
||||
if (!supabase) throw new Error("No SaaS session");
|
||||
const { data, error } = await supabase.functions.invoke<T>(fn, {
|
||||
@@ -446,6 +389,8 @@ export function acceptQuote(quoteId: number): Promise<AcceptResult> {
|
||||
|
||||
/** Fetch the Stripe-generated quote PDF as a blob (for download / share). */
|
||||
export async function fetchQuotePdf(quoteId: number): Promise<Blob> {
|
||||
const demo = await demoEdgeResponse("get-procurement-quote-pdf", quoteId);
|
||||
if (demo) return await demo.blob();
|
||||
const supabase = getSupabaseClient();
|
||||
if (!supabase) throw new Error("No SaaS session");
|
||||
const { data, error } = await supabase.functions.invoke<Blob>(
|
||||
|
||||
@@ -13,20 +13,18 @@
|
||||
.portal-editor-hero__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: color-mix(in srgb, var(--c-primary) 22%, var(--c-hero-dark));
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: var(--c-surface);
|
||||
}
|
||||
|
||||
.portal-editor-hero__logo {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 0.875rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.1),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.06);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.portal-editor-hero__mark {
|
||||
width: 100%;
|
||||
@@ -39,7 +37,7 @@
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.portal-editor-hero__title-row {
|
||||
@@ -50,31 +48,36 @@
|
||||
}
|
||||
|
||||
.portal-editor-hero__name {
|
||||
font-size: 1.125rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
color: var(--c-text);
|
||||
margin-right: 0.125rem;
|
||||
}
|
||||
|
||||
.portal-editor-hero__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3125rem;
|
||||
padding: 0.25rem 0.6875rem;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 0.71875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--motion-fast);
|
||||
/* The deployment host, sat inline with the name as the rail's subject. */
|
||||
.portal-editor-hero__host {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--c-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.portal-editor-hero__chip:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
|
||||
/* Quiet separator between the host and the adoption count. */
|
||||
.portal-editor-hero__dot {
|
||||
width: 0.3125rem;
|
||||
height: 0.3125rem;
|
||||
border-radius: 999px;
|
||||
background: var(--c-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.portal-editor-hero__chip svg {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
|
||||
.portal-editor-hero__actives {
|
||||
font-size: 0.75rem;
|
||||
color: var(--c-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.portal-editor-hero__meta {
|
||||
@@ -83,15 +86,10 @@
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.portal-editor-hero__host {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
.portal-editor-hero__meta-sep {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
color: var(--c-border);
|
||||
}
|
||||
|
||||
.portal-editor-hero__action {
|
||||
@@ -101,35 +99,6 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Icon-only actions (e.g. install) on the dark header. */
|
||||
.portal-editor-hero__icon-btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.portal-editor-hero__icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
/* White CTA on the dark header, matching the marketing card. */
|
||||
.portal-editor-hero__action .portal-editor-hero__cta.sui-btn {
|
||||
background: #ffffff;
|
||||
border-color: #ffffff;
|
||||
color: var(--c-hero-dark-cta-text);
|
||||
}
|
||||
.portal-editor-hero__action .portal-editor-hero__cta.sui-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border-color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.portal-editor-hero__row {
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { EditorStatusCard } from "@portal/components/EditorStatusCard";
|
||||
import { SetupChecklist } from "@portal/components/SetupChecklist";
|
||||
import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress";
|
||||
|
||||
const progress: OnboardingProgress = {
|
||||
loading: false,
|
||||
deployed: true,
|
||||
editorDone: true,
|
||||
policiesDone: true,
|
||||
inviteDone: false,
|
||||
policiesActive: 3,
|
||||
policiesRecommended: 4,
|
||||
allComplete: false,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof EditorStatusCard> = {
|
||||
title: "Portal/Home/EditorStatusCard",
|
||||
@@ -31,25 +18,14 @@ const meta: Meta<typeof EditorStatusCard> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EditorStatusCard>;
|
||||
|
||||
/** The deployed-Editor status card on its own. */
|
||||
/** The deployment rail, reporting a live deployment. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** As it renders on the subscribed home: the setup checklist attached as the footer. */
|
||||
export const WithSetupChecklist: Story = {
|
||||
args: {
|
||||
footer: <SetupChecklist progress={progress} />,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Backend without the editor-deployment endpoint (404): the status row is
|
||||
* skipped and the card falls back to just the footer (setup checklist). It
|
||||
* lights up automatically once /v1/editor/deployment is served.
|
||||
* Backend without the editor-deployment endpoint (404): the rail keeps its identity and the neutral
|
||||
* deploy ask, and states no host or adoption figure, since neither can be read.
|
||||
*/
|
||||
export const DeploymentUnavailable: Story = {
|
||||
args: {
|
||||
footer: <SetupChecklist progress={progress} />,
|
||||
},
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
|
||||
@@ -3,15 +3,12 @@ import { Fragment, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Skeleton } from "@app/ui";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import { useEditorDeployment } from "@portal/queries/infrastructure";
|
||||
import { type EditorInstance } from "@portal/api/editorDeploy";
|
||||
import { EDITOR_URL } from "@portal/auth/editorUrl";
|
||||
import {
|
||||
DownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
UsersIcon,
|
||||
UserPlusIcon,
|
||||
} from "@portal/components/icons";
|
||||
useEditorDeployment,
|
||||
useFleetStats,
|
||||
} from "@portal/queries/infrastructure";
|
||||
import { type EditorInstance } from "@portal/api/editorDeploy";
|
||||
import { DownloadEditorModal } from "@portal/components/DownloadEditorModal";
|
||||
import "@portal/components/EditorStatusCard.css";
|
||||
|
||||
@@ -48,41 +45,64 @@ function primaryInstance(instances: EditorInstance[]): EditorInstance | null {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rail's primary action matures with adoption rather than deployment alone (marketing note
|
||||
* D243): the loud ask only stands while the org is still one person on an undeployed editor. Once
|
||||
* a deployment is in flight, finishing it takes over; once teammates are in, the ask goes quiet.
|
||||
*/
|
||||
type DeployAsk = "finish" | "start" | "options";
|
||||
|
||||
function deployAsk(
|
||||
instances: EditorInstance[],
|
||||
activeUsers: number,
|
||||
): DeployAsk {
|
||||
const deployed = instances.some((i) => i.status === "healthy");
|
||||
if (!deployed && instances.some((i) => i.status === "pairing"))
|
||||
return "finish";
|
||||
if (!deployed && activeUsers <= 1) return "start";
|
||||
return "options";
|
||||
}
|
||||
|
||||
interface EditorStatusCardProps {
|
||||
/**
|
||||
* Rendered as an attached footer strip inside the card (e.g. the "Finish
|
||||
* setting up" checklist), matching the free-tier hero's footer seam.
|
||||
*/
|
||||
/** Attached footer strip inside the card — the deal-status hero while a deal is underway. */
|
||||
footer?: ReactNode;
|
||||
/**
|
||||
* Hide the active-users / invite chips. Used on enterprise, where the
|
||||
* attached procurement deal hero already owns the invite action.
|
||||
*/
|
||||
hideChips?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribed/enterprise home hero: a status card for the org's deployed PDF
|
||||
* Editor. Reads the same `/v1/editor/deployment` data as the Editor admin view
|
||||
* (host, version, live users, deployment shape) and headlines the busiest
|
||||
* instance, with a single "Open in browser" action to the workspace URL.
|
||||
* The Home hero: the deployment rail for the org's PDF Editor. Host and build meta come from the
|
||||
* same `/v1/editor/deployment` data as the Editor admin view, headlining the busiest instance; the
|
||||
* adoption count is the figure Usage & Billing reports. The rail always renders — it carries the
|
||||
* deploy ask itself — and each figure stands down independently when its source can't supply it.
|
||||
*/
|
||||
export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) {
|
||||
export function EditorStatusCard({ footer }: EditorStatusCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const { setActiveView } = useView();
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
const { data, loading } = useEditorDeployment(tier);
|
||||
// The adoption figure is the same one Usage & Billing reports, read through the shared cache so
|
||||
// the two agree and only one request is made. Either field is null when the backend can't compute
|
||||
// it (e.g. EE auditing off), in which case the rail states no figure rather than a misleading 0.
|
||||
const { data: fleet } = useFleetStats();
|
||||
const adoption =
|
||||
fleet?.activeThisMonth != null && fleet.editorsDeployed != null
|
||||
? { active: fleet.activeThisMonth, total: fleet.editorsDeployed }
|
||||
: null;
|
||||
|
||||
const view = useMemo(() => {
|
||||
if (!data) return null;
|
||||
const primary = primaryInstance(data.instances);
|
||||
if (!primary) return null;
|
||||
const activeUsers = data.instances.reduce((s, i) => s + i.activeUsers, 0);
|
||||
const ask = deployAsk(data.instances, activeUsers);
|
||||
const primary = primaryInstance(data.instances);
|
||||
// Nothing deployed yet: the rail still shows, carrying the deploy ask. Only the host and
|
||||
// build meta are instance-derived, so they simply stand down.
|
||||
if (!primary) {
|
||||
return { host: null, activeUsers, ask, meta: [] as string[] };
|
||||
}
|
||||
const targetLabel = t(`portal.home.editor.target.${primary.target}`);
|
||||
return {
|
||||
host: primary.host,
|
||||
activeUsers,
|
||||
ask,
|
||||
workspaceUrl: data.summary.workspaceUrl,
|
||||
meta: [
|
||||
// Skip the deployment label when it just repeats the host (e.g. the
|
||||
@@ -95,21 +115,8 @@ export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) {
|
||||
};
|
||||
}, [data, t]);
|
||||
|
||||
const ready = !loading && !!view;
|
||||
// The editor-deployment endpoint isn't implemented on every backend yet.
|
||||
// When it's unavailable (finished loading with no data — e.g. a 404), skip
|
||||
// the status row entirely and fall back to just the footer (the setup
|
||||
// checklist, which reads supported endpoints). It lights up automatically
|
||||
// once the backend serves /v1/editor/deployment.
|
||||
const unavailable = !loading && !view;
|
||||
|
||||
if (unavailable) {
|
||||
return footer ? (
|
||||
<section className="portal-editor-hero portal-editor-hero--footer-only">
|
||||
{footer}
|
||||
</section>
|
||||
) : null;
|
||||
}
|
||||
// Only the deploy ask can go loud; unknown deployment state keeps it quiet.
|
||||
const loudAsk = !!view && view.ask !== "options";
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -122,7 +129,10 @@ export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) {
|
||||
</div>
|
||||
|
||||
<div className="portal-editor-hero__info">
|
||||
{!ready || !view ? (
|
||||
{/* Each figure stands down on its own: the host and build meta need the deployment
|
||||
endpoint, the adoption count needs fleet stats. Neither absence blanks the rail, and
|
||||
nothing is asserted that its own source could not supply. */}
|
||||
{loading ? (
|
||||
<>
|
||||
<Skeleton width="12rem" height="1.25rem" />
|
||||
<Skeleton width="22rem" height="0.75rem" />
|
||||
@@ -133,65 +143,60 @@ export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) {
|
||||
<span className="portal-editor-hero__name">
|
||||
{t("portal.home.editor.name")}
|
||||
</span>
|
||||
{!hideChips && (
|
||||
<button
|
||||
type="button"
|
||||
className="portal-editor-hero__chip"
|
||||
onClick={() => setActiveView("users")}
|
||||
>
|
||||
<UsersIcon size={13} />
|
||||
{t("portal.home.editor.activeUsers", {
|
||||
n: view.activeUsers,
|
||||
})}
|
||||
</button>
|
||||
{view?.host && (
|
||||
<span className="portal-editor-hero__host">{view.host}</span>
|
||||
)}
|
||||
{adoption && (
|
||||
<>
|
||||
<span className="portal-editor-hero__dot" aria-hidden />
|
||||
<span className="portal-editor-hero__actives">
|
||||
{t("portal.home.editor.activeOfDeployed", adoption)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="portal-editor-hero__meta">
|
||||
<span className="portal-editor-hero__host">{view.host}</span>
|
||||
{view.meta.map((item, i) => (
|
||||
<Fragment key={i}>
|
||||
<span className="portal-editor-hero__meta-sep">·</span>
|
||||
<span>{item}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
{view && view.meta.length > 0 && (
|
||||
<div className="portal-editor-hero__meta">
|
||||
{view.meta.map((item, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && (
|
||||
<span className="portal-editor-hero__meta-sep">·</span>
|
||||
)}
|
||||
<span>{item}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open in browser is a left-seated secondary in every state, and carries no arrow
|
||||
(marketing note D244); the deploy ask beside it is the only button that can go loud.
|
||||
Reaching the editor never depends on deployment data — it falls back to the configured
|
||||
editor URL — so this stays live even when the deployment endpoint is unavailable. */}
|
||||
<div className="portal-editor-hero__action">
|
||||
{!hideChips && (
|
||||
<button
|
||||
type="button"
|
||||
className="portal-editor-hero__icon-btn"
|
||||
onClick={() => setActiveView("users")}
|
||||
aria-label={t("portal.home.editor.invite")}
|
||||
title={t("portal.home.editor.invite")}
|
||||
>
|
||||
<UserPlusIcon size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="portal-editor-hero__icon-btn"
|
||||
onClick={() => setInstallOpen(true)}
|
||||
aria-label={t("portal.home.editor.install")}
|
||||
title={t("portal.home.editor.install")}
|
||||
>
|
||||
<DownloadIcon size={16} />
|
||||
</button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="portal-editor-hero__cta"
|
||||
leftSection={<ExternalLinkIcon size={13} />}
|
||||
disabled={!ready || !view}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (view)
|
||||
window.open(view.workspaceUrl, "_blank", "noopener,noreferrer");
|
||||
window.open(
|
||||
view?.workspaceUrl || EDITOR_URL,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("portal.home.editor.open")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={loudAsk ? "primary" : "secondary"}
|
||||
accent={loudAsk ? "neutral" : "default"}
|
||||
size="sm"
|
||||
onClick={() => setInstallOpen(true)}
|
||||
>
|
||||
{t(`portal.home.editor.deploy.${view?.ask ?? "options"}`)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,17 +16,10 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Pay-as-you-go tier: welcome header + setup checklist until onboarding completes. */
|
||||
export const Default: Story = {
|
||||
args: { tier: "pro" },
|
||||
};
|
||||
|
||||
/** Free tier renders the same welcome-header composition as pro. */
|
||||
export const FreeTier: Story = {
|
||||
args: { tier: "free" },
|
||||
};
|
||||
|
||||
/** Enterprise tier hides the status chips — the procurement deal hero owns the invite step. */
|
||||
export const EnterpriseTier: Story = {
|
||||
args: { tier: "enterprise" },
|
||||
};
|
||||
/**
|
||||
* The hero is the Editor deployment rail on every tier and in both editions — it reports its own
|
||||
* deployment state and deploy ask, so there is nothing tier-specific left to compose. A live
|
||||
* procurement deal attaches the deal-status hero as the rail's footer; that comes from
|
||||
* useProcurement, so it follows the mocked backend rather than a story arg.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
@@ -1,60 +1,50 @@
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import { useEffect } from "react";
|
||||
import { Skeleton } from "@app/ui";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { WelcomeBanner } from "@portal/components/WelcomeBanner";
|
||||
import { EditorStatusCard } from "@portal/components/EditorStatusCard";
|
||||
import { SetupChecklist } from "@portal/components/SetupChecklist";
|
||||
import { useOnboardingProgress } from "@portal/hooks/useOnboardingProgress";
|
||||
import { ControlledDealStatusHero } from "@portal/components/procurement/ProcurementBanner";
|
||||
import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow";
|
||||
import { useProcurement } from "@portal/components/procurement/useProcurement";
|
||||
|
||||
/**
|
||||
* The Home hero, composed with a procurement-aware, progress-aware footer:
|
||||
*
|
||||
* - no live deployment → welcome header (+ setup steps until complete)
|
||||
* - deployment live → deployed-Editor status header (+ steps until complete)
|
||||
* - onboarding complete → header only; the setup steps collapse away
|
||||
* - enterprise → status header with chips hidden (the deal hero owns invite)
|
||||
*
|
||||
* The footer is the deal-status hero while a procurement deal is underway
|
||||
* (procurement is a bolt-on to any tier); otherwise the setup checklist, until
|
||||
* every step is done — then it collapses to just the header, matching the
|
||||
* deployed-status card. The procurement takeover modals render alongside.
|
||||
* The Home hero: always the Editor deployment rail, carrying the deal-status hero as its footer
|
||||
* while a procurement deal is underway (procurement is a bolt-on to any tier). The rail states its
|
||||
* own deployment status and deploy ask, so there is nothing for a tier to choose between. The
|
||||
* procurement takeover modals render alongside.
|
||||
*/
|
||||
export function HomeHero({ tier }: { tier: Tier }) {
|
||||
const { openLinkModal } = useUI();
|
||||
export function HomeHero() {
|
||||
const procurement = useProcurement();
|
||||
const progress = useOnboardingProgress();
|
||||
const { trialSetupRequested, clearTrialSetupRequest } = useUI();
|
||||
const dealActive =
|
||||
procurement.isLinked && procurement.started && !!procurement.data;
|
||||
|
||||
// Start the enterprise flow right here on Home: open the trial-setup modal when the account is
|
||||
// linked, otherwise prompt to link first — no navigating off to the procurement view.
|
||||
const onStartEnterprise = () => {
|
||||
if (procurement.isLinked) procurement.onStartTrial();
|
||||
else openLinkModal();
|
||||
};
|
||||
|
||||
// Steps collapse once onboarding is complete; a live deal always keeps its
|
||||
// hero. Otherwise the setup checklist carries the (progress-aware) steps.
|
||||
const footer = dealActive ? (
|
||||
<ControlledDealStatusHero controller={procurement} />
|
||||
) : progress.allComplete ? undefined : (
|
||||
<SetupChecklist progress={progress} onStartEnterprise={onStartEnterprise} />
|
||||
);
|
||||
|
||||
// The live-status header (EditorStatusCard) needs a real deployment to show;
|
||||
// without one it renders nothing, so route to it only when actually deployed.
|
||||
// Everything else — including a step completed via the local download flag —
|
||||
// keeps the always-present welcome header, so the card never vanishes.
|
||||
const showStatus = progress.deployed;
|
||||
// Someone said yes to enterprise elsewhere (the billing upsell, a sales link). Open trial setup
|
||||
// once the snapshot has landed, so a buyer who already has a deal is not asked to start another.
|
||||
useEffect(() => {
|
||||
if (!trialSetupRequested || procurement.loading) return;
|
||||
clearTrialSetupRequest();
|
||||
if (!procurement.started) procurement.onExploreEnterprise();
|
||||
}, [trialSetupRequested, procurement, clearTrialSetupRequest]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showStatus ? (
|
||||
<EditorStatusCard footer={footer} hideChips={tier === "enterprise"} />
|
||||
{procurement.loading ? (
|
||||
// Hold the rail's shape rather than committing to a footer: branching before the snapshot
|
||||
// lands paints the no-deal rail first, flashing on every refresh of an active deal.
|
||||
<section className="portal-editor-hero" aria-busy>
|
||||
<div className="portal-editor-hero__row">
|
||||
<Skeleton width="2rem" height="2rem" />
|
||||
<Skeleton width="12rem" height="1rem" />
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<WelcomeBanner footer={footer} />
|
||||
<EditorStatusCard
|
||||
footer={
|
||||
dealActive ? (
|
||||
<ControlledDealStatusHero controller={procurement} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ProcurementFlow controller={procurement} />
|
||||
</>
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Getting-started steps (home-hero body) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-setup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.portal-setup__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.portal-setup__row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
width: 100%;
|
||||
padding: 0.6875rem 1.25rem;
|
||||
border: none;
|
||||
border-top: 1px solid var(--c-border-subtle);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.portal-setup__item:first-child .portal-setup__row {
|
||||
border-top: none;
|
||||
}
|
||||
.portal-setup__row:hover {
|
||||
background: var(--c-hover);
|
||||
}
|
||||
|
||||
/* Numbered step marker */
|
||||
.portal-setup__num {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--c-border);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
/* Completed step: filled green check. */
|
||||
.portal-setup__num.is-done {
|
||||
border-color: var(--color-green);
|
||||
background: var(--color-green);
|
||||
color: #fff;
|
||||
}
|
||||
.portal-setup__row.is-done .portal-setup__text strong {
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.portal-setup__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.portal-setup__text strong {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--c-text);
|
||||
}
|
||||
.portal-setup__text span {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
/* ── Enterprise upsell rung ── */
|
||||
.portal-setup__enterprise {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-top: 1px solid var(--c-border-subtle);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--c-primary) 5%, transparent) 0%,
|
||||
transparent 55%
|
||||
);
|
||||
}
|
||||
|
||||
.portal-setup__enterprise-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.portal-setup__enterprise-tag {
|
||||
flex-shrink: 0;
|
||||
padding: 0.1875rem 0.5625rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.59375rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--c-primary-hover);
|
||||
background: var(--c-primary-tint);
|
||||
}
|
||||
|
||||
.portal-setup__enterprise-text {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
color: var(--c-text-subtle);
|
||||
min-width: 0;
|
||||
}
|
||||
.portal-setup__enterprise-text strong {
|
||||
color: var(--c-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SetupChecklist } from "@portal/components/SetupChecklist";
|
||||
import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress";
|
||||
|
||||
const base: OnboardingProgress = {
|
||||
loading: false,
|
||||
deployed: false,
|
||||
editorDone: false,
|
||||
policiesDone: false,
|
||||
inviteDone: false,
|
||||
policiesActive: 0,
|
||||
policiesRecommended: 6,
|
||||
allComplete: false,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof SetupChecklist> = {
|
||||
title: "Portal/Home/SetupChecklist",
|
||||
component: SetupChecklist,
|
||||
parameters: { layout: "padded" },
|
||||
args: { progress: base },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "60rem",
|
||||
border: "1px solid var(--c-border)",
|
||||
borderRadius: "var(--radius-xl)",
|
||||
overflow: "hidden",
|
||||
background: "var(--c-surface)",
|
||||
}}
|
||||
>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SetupChecklist>;
|
||||
|
||||
/** A fresh workspace — no step complete yet. */
|
||||
export const NotStarted: Story = {};
|
||||
|
||||
/** Policies confirmed; editor + invite still open. */
|
||||
export const InProgress: Story = {
|
||||
args: {
|
||||
progress: {
|
||||
...base,
|
||||
policiesDone: true,
|
||||
policiesActive: 2,
|
||||
policiesRecommended: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Editor deployed + policies on; only the invite step remains. */
|
||||
export const AlmostDone: Story = {
|
||||
args: {
|
||||
progress: {
|
||||
...base,
|
||||
editorDone: true,
|
||||
policiesDone: true,
|
||||
policiesActive: 4,
|
||||
policiesRecommended: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,150 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress";
|
||||
import { DownloadEditorModal } from "@portal/components/DownloadEditorModal";
|
||||
import CheckRounded from "@mui/icons-material/CheckRounded";
|
||||
import "@portal/components/SetupChecklist.css";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Enterprise upsell rung */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Enterprise on-ramp rung. The CTA differs by tier: free orgs start a guided
|
||||
* trial, subscribed (paying) orgs jump straight to a quote — both open the
|
||||
* procurement flow. When {@code onStart} is given the CTA opens the flow's setup
|
||||
* modal over Home; otherwise it falls back to navigating to the procurement view.
|
||||
*/
|
||||
function EnterpriseRung({
|
||||
paying,
|
||||
onStart,
|
||||
}: {
|
||||
paying: boolean;
|
||||
onStart?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useView();
|
||||
return (
|
||||
<div className="portal-setup__enterprise">
|
||||
<div className="portal-setup__enterprise-copy">
|
||||
<span className="portal-setup__enterprise-tag">
|
||||
{t("portal.home.onboarding.enterprise.tag")}
|
||||
</span>
|
||||
<p className="portal-setup__enterprise-text">
|
||||
<strong>{t("portal.home.onboarding.enterprise.lead")}</strong>{" "}
|
||||
{t("portal.home.onboarding.enterprise.body")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onStart ?? (() => setActiveView("procurement"))}
|
||||
rightSection={<span aria-hidden>→</span>}
|
||||
>
|
||||
{t(
|
||||
paying
|
||||
? "portal.home.onboarding.enterprise.ctaQuote"
|
||||
: "portal.home.onboarding.enterprise.cta",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Getting-started steps */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface Step {
|
||||
id: string;
|
||||
title: string;
|
||||
blurb: string;
|
||||
done: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Numbered getting-started steps, rendered as the body of the home hero. Each
|
||||
* row opens its in-app surface; a completed step (from {@link OnboardingProgress})
|
||||
* swaps its number for a check. When every step is done the parent collapses the
|
||||
* hero to the deployed-status header and stops rendering this list entirely.
|
||||
*/
|
||||
export function SetupChecklist({
|
||||
progress,
|
||||
onStartEnterprise,
|
||||
}: {
|
||||
progress: OnboardingProgress;
|
||||
/** Start the enterprise flow in place (opens the setup modal over Home). Falls back to
|
||||
* navigating to the procurement view when omitted (e.g. in isolated stories). */
|
||||
onStartEnterprise?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const { setActiveView } = useView();
|
||||
const [downloadOpen, setDownloadOpen] = useState(false);
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
id: "editor",
|
||||
title: t("portal.home.onboarding.steps.editor.title"),
|
||||
blurb: t("portal.home.onboarding.steps.editor.blurb"),
|
||||
done: progress.editorDone,
|
||||
// Downloads are per-OS, so open the install picker rather than route away.
|
||||
onClick: () => setDownloadOpen(true),
|
||||
},
|
||||
{
|
||||
id: "policies",
|
||||
title: t("portal.home.onboarding.steps.policies.title"),
|
||||
blurb: t("portal.home.onboarding.steps.policies.blurb", {
|
||||
active: progress.policiesActive,
|
||||
recommended: progress.policiesRecommended,
|
||||
}),
|
||||
done: progress.policiesDone,
|
||||
onClick: () => setActiveView("policies"),
|
||||
},
|
||||
{
|
||||
id: "invite",
|
||||
title: t("portal.home.onboarding.steps.invite.title"),
|
||||
blurb: t("portal.home.onboarding.steps.invite.blurb"),
|
||||
done: progress.inviteDone,
|
||||
onClick: () => setActiveView("users"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="portal-setup">
|
||||
<ol className="portal-setup__list">
|
||||
{steps.map((s, i) => (
|
||||
<li key={s.id} className="portal-setup__item">
|
||||
<button
|
||||
type="button"
|
||||
className={"portal-setup__row" + (s.done ? " is-done" : "")}
|
||||
onClick={s.onClick}
|
||||
>
|
||||
<span
|
||||
className={"portal-setup__num" + (s.done ? " is-done" : "")}
|
||||
aria-hidden
|
||||
>
|
||||
{s.done ? <CheckRounded sx={{ fontSize: 16 }} /> : i + 1}
|
||||
</span>
|
||||
<span className="portal-setup__text">
|
||||
<strong>{s.title}</strong>
|
||||
<span>{s.blurb}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<EnterpriseRung paying={tier !== "free"} onStart={onStartEnterprise} />
|
||||
|
||||
<DownloadEditorModal
|
||||
open={downloadOpen}
|
||||
onClose={() => setDownloadOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Free-tier welcome hero — compact product header + steps */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-welcome {
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
background: var(--c-surface);
|
||||
}
|
||||
|
||||
/* ── Dark product header strip ── */
|
||||
.portal-welcome__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: color-mix(in srgb, var(--c-primary) 22%, var(--c-hero-dark));
|
||||
}
|
||||
|
||||
.portal-welcome__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-welcome__mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.portal-welcome__mark img {
|
||||
display: block;
|
||||
height: 1.75rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.portal-welcome__brand-text {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.625rem;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.portal-welcome__product {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.portal-welcome__stats {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.8125rem;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
/* Header action group: icon buttons + the CTA. */
|
||||
.portal-welcome__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.portal-welcome__icon-btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.portal-welcome__icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
/* White CTA on the dark header, matching the marketing card. */
|
||||
.portal-welcome__header .portal-welcome__cta.sui-btn {
|
||||
background: #ffffff;
|
||||
border-color: #ffffff;
|
||||
color: var(--c-hero-dark-cta-text);
|
||||
}
|
||||
.portal-welcome__header .portal-welcome__cta.sui-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border-color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
/* ── Steps + enterprise (setup checklist) sit directly under the header ── */
|
||||
.portal-welcome__footer {
|
||||
background: var(--c-surface);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { WelcomeBanner } from "@portal/components/WelcomeBanner";
|
||||
import { SetupChecklist } from "@portal/components/SetupChecklist";
|
||||
import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress";
|
||||
|
||||
const progress: OnboardingProgress = {
|
||||
loading: false,
|
||||
deployed: false,
|
||||
editorDone: false,
|
||||
policiesDone: true,
|
||||
inviteDone: false,
|
||||
policiesActive: 2,
|
||||
policiesRecommended: 5,
|
||||
allComplete: false,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof WelcomeBanner> = {
|
||||
title: "Portal/Home/WelcomeBanner",
|
||||
component: WelcomeBanner,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "72rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WelcomeBanner>;
|
||||
|
||||
/** The hero on its own, no attached footer. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** The hero as it renders on the free-tier home: the "Finish setting up"
|
||||
* checklist attached as the footer strip. */
|
||||
export const WithSetupChecklist: Story = {
|
||||
args: {
|
||||
footer: <SetupChecklist progress={progress} />,
|
||||
},
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import { EDITOR_URL } from "@portal/auth/editorUrl";
|
||||
import {
|
||||
DownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
UserPlusIcon,
|
||||
} from "@portal/components/icons";
|
||||
import { DownloadEditorModal } from "@portal/components/DownloadEditorModal";
|
||||
import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
|
||||
import "@portal/components/WelcomeBanner.css";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Free-tier welcome hero */
|
||||
/* */
|
||||
/* A compact product header — brand mark, "PDF Editor" + social-proof */
|
||||
/* stats, and a single "Open in browser" CTA — over the getting-started */
|
||||
/* steps (passed in as {@code footer}). Deliberately lean: the onboarding */
|
||||
/* steps, not marketing copy, are the point of the card. */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface WelcomeBannerProps {
|
||||
/**
|
||||
* The getting-started steps + enterprise rung, rendered inside the card
|
||||
* below the header. Kept as a slot so the hero stays a presentational shell.
|
||||
*/
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
export function WelcomeBanner({ footer }: WelcomeBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useView();
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="portal-welcome"
|
||||
aria-label={t("portal.welcome.ariaLabel")}
|
||||
>
|
||||
<div className="portal-welcome__header">
|
||||
<div className="portal-welcome__brand">
|
||||
<span className="portal-welcome__mark" aria-hidden>
|
||||
<img src={markDark} alt="" />
|
||||
</span>
|
||||
<div className="portal-welcome__brand-text">
|
||||
<span className="portal-welcome__product">
|
||||
{t("portal.welcome.productName")}
|
||||
</span>
|
||||
<span className="portal-welcome__stats">
|
||||
{t("portal.welcome.stats")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="portal-welcome__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="portal-welcome__icon-btn"
|
||||
onClick={() => setActiveView("users")}
|
||||
aria-label={t("portal.welcome.invite")}
|
||||
title={t("portal.welcome.invite")}
|
||||
>
|
||||
<UserPlusIcon size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-welcome__icon-btn"
|
||||
onClick={() => setInstallOpen(true)}
|
||||
aria-label={t("portal.welcome.install")}
|
||||
title={t("portal.welcome.install")}
|
||||
>
|
||||
<DownloadIcon size={16} />
|
||||
</button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="portal-welcome__cta"
|
||||
leftSection={<ExternalLinkIcon size={15} />}
|
||||
onClick={() => {
|
||||
window.open(EDITOR_URL, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
{t("portal.welcome.openInBrowser")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{footer && <div className="portal-welcome__footer">{footer}</div>}
|
||||
|
||||
<DownloadEditorModal
|
||||
open={installOpen}
|
||||
onClose={() => setInstallOpen(false)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -665,7 +665,6 @@ export function BundleCheckoutModal({
|
||||
{t("portal.billing.prepaid.buy.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
accent="premium"
|
||||
disabled={!canContinue || pdfBusy}
|
||||
onClick={handleContinue}
|
||||
rightSection={<span aria-hidden>›</span>}
|
||||
@@ -686,7 +685,7 @@ export function BundleCheckoutModal({
|
||||
>
|
||||
{t("portal.billing.prepaid.buy.cancelPurchase", "Cancel purchase")}
|
||||
</Button>
|
||||
<Button accent="premium" disabled={busy || pdfBusy} onClick={payOnline}>
|
||||
<Button disabled={busy || pdfBusy} onClick={payOnline}>
|
||||
{t("portal.billing.prepaid.pay.payOnline", "Pay online")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -701,7 +700,6 @@ export function BundleCheckoutModal({
|
||||
{t("portal.billing.prepaid.buy.back", "Back")}
|
||||
</Button>
|
||||
<Button
|
||||
accent="premium"
|
||||
disabled={!canAccept || busy || pdfBusy}
|
||||
onClick={handleFinalise}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card } from "@app/ui";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
|
||||
interface Props {
|
||||
@@ -9,12 +10,13 @@ interface Props {
|
||||
|
||||
/**
|
||||
* Volume-discount / Enterprise upsell, shared by the free and subscribed billing
|
||||
* views. The CTA opens the procurement journey (/procurement auto-opens the quote
|
||||
* builder in the takeover modal).
|
||||
* views. The CTA lands the buyer on Home with the trial-setup step raised — the deal lives there,
|
||||
* so there is nowhere else to send them.
|
||||
*/
|
||||
export function EnterpriseUpsell({ bare = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useView();
|
||||
const { requestTrialSetup } = useUI();
|
||||
const body = (
|
||||
<>
|
||||
<span className="portal-billing__eyebrow">
|
||||
@@ -38,7 +40,12 @@ export function EnterpriseUpsell({ bare = false }: Props) {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setActiveView("procurement")}
|
||||
onClick={() => {
|
||||
// The deal lives on Home; raise the request there rather than sending the buyer to a
|
||||
// separate view that only mirrors it.
|
||||
requestTrialSetup();
|
||||
setActiveView("home");
|
||||
}}
|
||||
>
|
||||
{t("portal.billing.enterpriseUpsell.cta", "Explore Enterprise")}
|
||||
</Button>
|
||||
|
||||
@@ -85,7 +85,6 @@ export function FreePlanView({ wallet, unsynced, onSubscribed }: Props) {
|
||||
const switchOnAction = isLeader ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
accent="premium"
|
||||
onClick={flow.status === "none" ? openActivation : resumeBundle}
|
||||
disabled={wallet.teamId == null}
|
||||
>
|
||||
|
||||
@@ -23,11 +23,7 @@ export function LinkAccountPrompt() {
|
||||
"Manual PDF editing — view, sign, merge, split, watermark, compress, convert, manual OCR — is always free, linked or not. Link to claim 500 free PDFs of metered processing (automation, AI, and the API); when you need more, turn on the Processor plan and only pay for what you use.",
|
||||
)}
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
accent="premium"
|
||||
onClick={() => openLinkModal()}
|
||||
>
|
||||
<Button variant="primary" onClick={() => openLinkModal()}>
|
||||
{t("portal.billing.linkPrompt.cta", "Link Stirling account")}
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
// The trademarked Stirling wordmark — the font is baked into the SVG (no brand webfont is loaded), so
|
||||
// we render the same asset the portal nav uses rather than styled text. Theme-switched in CSS.
|
||||
import wordmarkLight from "@app/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg";
|
||||
import wordmarkDark from "@app/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg";
|
||||
import { StepModalHeader } from "@portal/components/shared/StepModalHeader";
|
||||
|
||||
/**
|
||||
* Shared header for the prepay-flow modals — the prepaid wizard (activation → calculator → pay, of 3)
|
||||
* and the metered checkout (spend limit → payment, of 2): Stirling brand + "Step N of M" badge + close,
|
||||
* an M-segment progress bar, and the step title. Pass {@code step=undefined} to hide the badge +
|
||||
* progress (e.g. a terminal confirmation).
|
||||
* The prepay flows' stepped header — the prepaid wizard (activation → calculator → pay, of 3) and
|
||||
* the metered checkout (spend limit → payment, of 2).
|
||||
*
|
||||
* Chrome and copy only: the layout is the shared {@link StepModalHeader}, so this flow reads the
|
||||
* same as every other stepped modal. Keeps the billing root class, which the framed-checkout rule
|
||||
* targets to own the header's padding. Pass {@code step=undefined} to hide the badge + progress
|
||||
* (e.g. a terminal confirmation).
|
||||
*/
|
||||
export function PrepayModalHeader({
|
||||
step,
|
||||
@@ -24,68 +23,27 @@ export function PrepayModalHeader({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const showSteps = step != null;
|
||||
const filled = step ?? 0;
|
||||
return (
|
||||
<div className="portal-billing__bundle-head">
|
||||
<div className="portal-billing__bundle-head-top">
|
||||
<div className="portal-billing__bundle-brand">
|
||||
<img
|
||||
src={wordmarkLight}
|
||||
alt="Stirling"
|
||||
className="portal-billing__bundle-wordmark wordmark-light-only"
|
||||
/>
|
||||
<img
|
||||
src={wordmarkDark}
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="portal-billing__bundle-wordmark wordmark-dark-only"
|
||||
/>
|
||||
</div>
|
||||
<div className="portal-billing__bundle-head-right">
|
||||
{showSteps && (
|
||||
<span className="portal-billing__bundle-step">
|
||||
{t(
|
||||
"portal.billing.prepaid.buy.step",
|
||||
"Step {{current}} of {{total}}",
|
||||
{ current: step, total },
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
shape="circle"
|
||||
onClick={onClose}
|
||||
aria-label={t("portal.billing.prepaid.buy.close", "Close")}
|
||||
leftSection={
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showSteps && (
|
||||
<div className="portal-billing__bundle-progress" aria-hidden>
|
||||
<span className={filled >= 1 ? "is-filled" : ""} />
|
||||
<span className={filled >= 2 ? "is-filled" : ""} />
|
||||
{total >= 3 && <span className={filled >= 3 ? "is-filled" : ""} />}
|
||||
</div>
|
||||
)}
|
||||
<div className="portal-billing__bundle-head-title">{title}</div>
|
||||
</div>
|
||||
<StepModalHeader
|
||||
brand
|
||||
className="portal-billing__bundle-head"
|
||||
title={title}
|
||||
step={step}
|
||||
total={total}
|
||||
stepLabel={
|
||||
step != null
|
||||
? t(
|
||||
"portal.billing.prepaid.buy.step",
|
||||
"Step {{current}} of {{total}}",
|
||||
{
|
||||
current: step,
|
||||
total,
|
||||
},
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
closeLabel={t("portal.billing.prepaid.buy.close", "Close")}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ export function SpendLimitCard({
|
||||
>
|
||||
{t("portal.billing.spendLimit.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button accent="premium" size="sm" loading={saving} onClick={save}>
|
||||
<Button size="sm" loading={saving} onClick={save}>
|
||||
{t("portal.billing.spendLimit.save", "Save limit")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -451,7 +451,6 @@ export function StripeCheckoutModal({
|
||||
{t("portal.billing.checkout.cap.back", "Back")}
|
||||
</Button>
|
||||
<Button
|
||||
accent="premium"
|
||||
loading={capBusy}
|
||||
disabled={!capValid}
|
||||
onClick={handleContinue}
|
||||
|
||||
@@ -1478,59 +1478,6 @@
|
||||
gap: 0.875rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.portal-billing__bundle-head-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.portal-billing__bundle-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--c-text);
|
||||
}
|
||||
/* Trademarked wordmark SVG (theme-switched via .wordmark-light-only/.wordmark-dark-only). Height
|
||||
matches the portal nav's 22px wordmark so the modal and app read as one brand. No `display` here —
|
||||
the theme-switch utilities own visibility. */
|
||||
.portal-billing__bundle-wordmark {
|
||||
height: 1.375rem;
|
||||
width: auto;
|
||||
}
|
||||
.portal-billing__bundle-head-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.portal-billing__bundle-step {
|
||||
padding: 0.1875rem 0.625rem;
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--c-text-subtle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.portal-billing__bundle-progress {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.portal-billing__bundle-progress > span {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--c-border-subtle);
|
||||
}
|
||||
.portal-billing__bundle-progress > span.is-filled {
|
||||
background: var(--c-primary);
|
||||
}
|
||||
.portal-billing__bundle-head-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
/* Payment step: quote receipt + recipient fields + consent. */
|
||||
.portal-billing__bundle-pay {
|
||||
display: flex;
|
||||
|
||||
@@ -300,3 +300,32 @@ export function IntegrationsIcon(props: IconProps) {
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<line x1="8" y1="3" x2="8" y2="7" />
|
||||
<line x1="16" y1="3" x2="16" y2="7" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<circle cx="8" cy="16" r="5" />
|
||||
<line x1="11.6" y1="12.4" x2="21" y2="3" />
|
||||
<line x1="17" y1="7" x2="20" y2="10" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<polyline points="4,12.5 9.5,18 20,6.5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ActionModal } from "@portal/components/procurement/ActionModal";
|
||||
import type { LedgerDoc } from "@portal/api/procurement";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
const meta: Meta<typeof ActionModal> = {
|
||||
title: "Portal/Procurement/ActionModal",
|
||||
component: ActionModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: { onClose: () => {}, onDone: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ActionModal>;
|
||||
|
||||
const sign: LedgerDoc = {
|
||||
id: "d1",
|
||||
name: "Stirling Enterprise Agreement",
|
||||
sub: "One signature: MSA + order form + EULA + DPA.",
|
||||
status: "action",
|
||||
action: "sign",
|
||||
};
|
||||
|
||||
const pay: LedgerDoc = {
|
||||
id: "d2",
|
||||
name: "Pay online",
|
||||
sub: "Card or bank transfer via Stripe.",
|
||||
status: "pending",
|
||||
action: "pay",
|
||||
};
|
||||
|
||||
const upload: LedgerDoc = {
|
||||
id: "d3",
|
||||
name: "Purchase order",
|
||||
sub: "Upload it and we invoice against it.",
|
||||
status: "request",
|
||||
action: "upload",
|
||||
};
|
||||
|
||||
const requestPaid: LedgerDoc = {
|
||||
id: "d4",
|
||||
name: "Custom security review",
|
||||
sub: "Dedicated session with our security team.",
|
||||
status: "request",
|
||||
action: "request",
|
||||
fee: 5_000,
|
||||
};
|
||||
|
||||
export const Sign: Story = { args: { doc: sign } };
|
||||
export const Pay: Story = { args: { doc: pay } };
|
||||
export const UploadPO: Story = { args: { doc: upload } };
|
||||
export const RequestPaid: Story = { args: { doc: requestPaid } };
|
||||
@@ -1,166 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Button, Modal } from "@app/ui";
|
||||
import type { LedgerDoc, ProcurementResponse } from "@portal/api/procurement";
|
||||
import {
|
||||
payOnline,
|
||||
requestDocument,
|
||||
signAgreement,
|
||||
uploadPurchaseOrder,
|
||||
} from "@portal/api/procurement";
|
||||
import { USD } from "@portal/components/procurement/format";
|
||||
|
||||
interface ActionCopy {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
body: string;
|
||||
cta: string;
|
||||
}
|
||||
|
||||
/** Per-action confirmation copy, with the fee folded into the CTA when present. */
|
||||
function actionCopy(doc: LedgerDoc, t: TFunction): ActionCopy {
|
||||
const fee = doc.fee !== undefined ? ` · ${USD.format(doc.fee)}` : "";
|
||||
switch (doc.action) {
|
||||
case "sign":
|
||||
return {
|
||||
title: t("portal.procurement.modal.signTitle"),
|
||||
subtitle: doc.name,
|
||||
body: t("portal.procurement.modal.signBody"),
|
||||
cta: t("portal.procurement.modal.signCta"),
|
||||
};
|
||||
case "pay":
|
||||
return {
|
||||
title: t("portal.procurement.modal.payTitle"),
|
||||
subtitle: doc.name,
|
||||
body: t("portal.procurement.modal.payBody"),
|
||||
cta: t("portal.procurement.modal.payCta"),
|
||||
};
|
||||
case "upload":
|
||||
return {
|
||||
title: t("portal.procurement.modal.uploadTitle"),
|
||||
subtitle: doc.name,
|
||||
body: t("portal.procurement.modal.uploadBody"),
|
||||
cta: t("portal.procurement.modal.uploadCta"),
|
||||
};
|
||||
case "request":
|
||||
return {
|
||||
title: t("portal.procurement.modal.requestTitle"),
|
||||
subtitle: doc.name,
|
||||
body: doc.fee
|
||||
? t("portal.procurement.modal.requestBodyPaid")
|
||||
: t("portal.procurement.modal.requestBodyFree"),
|
||||
cta: `${t("portal.procurement.modal.requestCta")}${fee}`,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: t("portal.procurement.modal.downloadTitle"),
|
||||
subtitle: doc.name,
|
||||
body: t("portal.procurement.modal.downloadBody"),
|
||||
cta: t("portal.procurement.modal.downloadCta"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation modal for a document's gating action. Owns its in-flight state;
|
||||
* on success it hands the updated deal back to the caller via `onDone` so the
|
||||
* journey re-renders. Downloads are client-side and just close the modal.
|
||||
*/
|
||||
export function ActionModal({
|
||||
doc,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
doc: LedgerDoc | null;
|
||||
onClose: () => void;
|
||||
onDone: (next: ProcurementResponse) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
if (!doc) return null;
|
||||
const copy = actionCopy(doc, t);
|
||||
const needsFile = doc.action === "upload";
|
||||
|
||||
async function submit() {
|
||||
if (!doc) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
let next: ProcurementResponse | null = null;
|
||||
switch (doc.action) {
|
||||
case "sign":
|
||||
next = await signAgreement(doc.id);
|
||||
break;
|
||||
case "pay":
|
||||
next = await payOnline();
|
||||
break;
|
||||
case "upload":
|
||||
if (file) next = await uploadPurchaseOrder(file);
|
||||
break;
|
||||
case "request":
|
||||
next = await requestDocument(doc.id, doc.action);
|
||||
break;
|
||||
default:
|
||||
// download is client-side; no state change.
|
||||
break;
|
||||
}
|
||||
setFile(null);
|
||||
if (next) onDone(next);
|
||||
else onClose();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
width="md"
|
||||
title={copy.title}
|
||||
subtitle={copy.subtitle}
|
||||
footer={
|
||||
<div className="portal-proc__modal-actions">
|
||||
<Button variant="tertiary" onClick={onClose}>
|
||||
{t("portal.procurement.modal.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
accent="premium"
|
||||
loading={submitting}
|
||||
disabled={needsFile && !file}
|
||||
onClick={submit}
|
||||
>
|
||||
{copy.cta}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className="portal-proc__modal-body">{copy.body}</p>
|
||||
{needsFile && (
|
||||
<div className="portal-proc__upload">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx"
|
||||
className="portal-proc__upload-input"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
{t("portal.procurement.modal.chooseFile")}
|
||||
</Button>
|
||||
<span className="portal-proc__upload-name">
|
||||
{file ? file.name : t("portal.procurement.modal.noFile")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DealJourney } from "@portal/components/procurement/DealJourney";
|
||||
import { buildProcurement } from "@portal/mocks/procurement";
|
||||
import type { Deal } from "@portal/api/procurement";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
const data = buildProcurement("enterprise");
|
||||
const deal = data.deal as Deal;
|
||||
|
||||
const meta: Meta<typeof DealJourney> = {
|
||||
title: "Portal/Procurement/DealJourney",
|
||||
component: DealJourney,
|
||||
parameters: { layout: "padded" },
|
||||
args: { deal, journey: data.journey, onAdvance: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DealJourney>;
|
||||
|
||||
// Mid-journey at the Agreement stage, the seeded deal state.
|
||||
export const Default: Story = {};
|
||||
|
||||
// Evaluating: the trial strip shows runway + key; next step builds the quote.
|
||||
export const AtTrial: Story = {
|
||||
args: { deal: { ...deal, currentStage: "trial" } },
|
||||
};
|
||||
|
||||
// Terminal stage, provisioning, no further CTA.
|
||||
export const Live: Story = {
|
||||
args: { deal: { ...deal, currentStage: "active" } },
|
||||
};
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card } from "@app/ui";
|
||||
import type { Deal, DealStage, JourneyStep } from "@portal/api/procurement";
|
||||
import { StageStepper } from "@portal/components/procurement/StageStepper";
|
||||
|
||||
/**
|
||||
* The deal's commercial journey in one card: who's guiding it (the solutions
|
||||
* engineer), where it sits (the stage stepper), trial runway while evaluating,
|
||||
* and the single next action that advances the deal. Mirrors "one next action
|
||||
* at a time"; the full per-stage checklist lives in the Documents card.
|
||||
*/
|
||||
export function DealJourney({
|
||||
deal,
|
||||
journey,
|
||||
onAdvance,
|
||||
advancing = false,
|
||||
}: {
|
||||
deal: Deal;
|
||||
journey: JourneyStep[];
|
||||
onAdvance: (stage: DealStage) => void;
|
||||
advancing?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { engineer, trial, currentStage } = deal;
|
||||
const currentStep = journey.find((s) => s.stage === currentStage);
|
||||
const isTerminal =
|
||||
journey.length > 0 && journey[journey.length - 1].stage === currentStage;
|
||||
|
||||
return (
|
||||
<Card padding="none" className="portal-proc__journey">
|
||||
<div className="portal-proc__journey-head">
|
||||
<div>
|
||||
<span className="portal-proc__eyebrow">
|
||||
{t("portal.procurement.journey.eyebrow")}
|
||||
</span>
|
||||
<h2 className="portal-proc__journey-title">
|
||||
{t("portal.procurement.journey.title")}
|
||||
</h2>
|
||||
<p className="portal-proc__journey-sub">
|
||||
{t("portal.procurement.journey.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="portal-proc__se">
|
||||
<span className="portal-proc__eyebrow">
|
||||
{t("portal.procurement.journey.engineerLabel")}
|
||||
</span>
|
||||
<span className="portal-proc__se-name">{engineer.name}</span>
|
||||
<a
|
||||
className="portal-proc__se-email"
|
||||
href={`mailto:${engineer.email}`}
|
||||
>
|
||||
{engineer.email}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="portal-proc__journey-stepper">
|
||||
<StageStepper journey={journey} currentStage={currentStage} />
|
||||
</div>
|
||||
|
||||
{currentStage === "trial" && (
|
||||
<div className="portal-proc__trial">
|
||||
<span className="portal-proc__trial-title">
|
||||
{t("portal.procurement.journey.trialTitle")}
|
||||
</span>
|
||||
<span className="portal-proc__trial-dim">
|
||||
{t("portal.procurement.journey.daysLeft", {
|
||||
count: trial.daysLeft,
|
||||
})}
|
||||
</span>
|
||||
<span className="portal-proc__trial-key">{trial.key}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="portal-proc__next">
|
||||
<div className="portal-proc__next-label">
|
||||
<span className="portal-proc__next-dot" data-live={isTerminal} />
|
||||
<span>
|
||||
{isTerminal
|
||||
? t("portal.procurement.journey.live")
|
||||
: t("portal.procurement.journey.nextStep", {
|
||||
action: currentStep ? t(currentStep.gatingAction) : "",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{!isTerminal && currentStep && (
|
||||
<Button
|
||||
variant="primary"
|
||||
accent="premium"
|
||||
loading={advancing}
|
||||
onClick={() => onAdvance(currentStage)}
|
||||
>
|
||||
{t(currentStep.gatingAction)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,10 @@ const base: ProcurementSnapshot = {
|
||||
trialExtensionsUsed: 0,
|
||||
licensed: false,
|
||||
licenseKey: null,
|
||||
businessName: null,
|
||||
contactName: null,
|
||||
contactEmail: null,
|
||||
agreementSignedVersion: null,
|
||||
latestQuote: null,
|
||||
};
|
||||
|
||||
@@ -23,11 +27,11 @@ const meta: Meta<typeof DealStatusHero> = {
|
||||
args: {
|
||||
canSchedule: true,
|
||||
onExpand: () => {},
|
||||
onAcceptQuote: () => {},
|
||||
onLicense: () => {},
|
||||
onInvite: () => {},
|
||||
onSchedule: () => {},
|
||||
onManageTrial: () => {},
|
||||
onNavigate: () => {},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
@@ -1,31 +1,62 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
import type { ViewId } from "@portal/contexts/ViewContext";
|
||||
import {
|
||||
FLOW_JOURNEY,
|
||||
type DealStage,
|
||||
type ProcurementSnapshot,
|
||||
} from "@portal/api/procurement";
|
||||
import { StageStepper } from "@portal/components/procurement/StageStepper";
|
||||
import {
|
||||
CalendarIcon,
|
||||
CheckIcon,
|
||||
DocumentsIcon,
|
||||
KeyIcon,
|
||||
UserPlusIcon,
|
||||
} from "@portal/components/icons";
|
||||
import { warmCalendly } from "@portal/components/procurement/CalendlyInline";
|
||||
import { openApiUrl } from "@portal/api/externalUrl";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/** What each stage asks of the buyer, read out in the stage sentence. */
|
||||
const STAGE_SENTENCE: Record<DealStage, string> = {
|
||||
// Exploring sits on the Trial rung: same sentence, since the ask is what differs.
|
||||
exploring: "portal.procurement.hero.sentenceTrial",
|
||||
trial: "portal.procurement.hero.sentenceTrial",
|
||||
quote: "portal.procurement.hero.sentenceQuote",
|
||||
security: "portal.procurement.hero.sentenceAgreement",
|
||||
procurement: "portal.procurement.hero.sentencePayment",
|
||||
active: "portal.procurement.hero.sentenceLive",
|
||||
};
|
||||
|
||||
/** The primary action for each stage; expanding the flow runs it. */
|
||||
const STAGE_CTA: Record<DealStage, string> = {
|
||||
exploring: "portal.procurement.hero.ctaExploring",
|
||||
trial: "portal.procurement.hero.ctaTrial",
|
||||
quote: "portal.procurement.hero.ctaQuote",
|
||||
security: "portal.procurement.hero.ctaAgreement",
|
||||
// Same label whether it links straight to Stripe or, lacking an invoice URL, opens the stage dialog
|
||||
// where the invoice actions live — the buyer is being sent to the invoice either way.
|
||||
procurement: "portal.procurement.payment.viewInvoice",
|
||||
active: "portal.procurement.hero.open",
|
||||
};
|
||||
|
||||
/**
|
||||
* The enterprise deal-status hero on Home (procurement lives here, not as a nav tab). Adapts to the
|
||||
* deal stage: quick-action chips (trial countdown → manage, licence key, invite teammates,
|
||||
* schedule a call), a rollout checklist during the trial, and a stage-specific primary CTA that
|
||||
* expands the flow into the takeover modal. Matches the marketing prototype.
|
||||
* The enterprise deal-status hero on Home (procurement lives here, not as a nav tab) — this card IS
|
||||
* the procurement surface. It carries the journey as a segmented progress band plus a stage
|
||||
* sentence, and one primary action with quiet icon buttons beside it; the flow itself opens in the
|
||||
* takeover modal. Rollout setup lives in the non-procurement setup checklist, not here.
|
||||
*/
|
||||
export function DealStatusHero({
|
||||
snapshot,
|
||||
busy = false,
|
||||
canSchedule,
|
||||
onExpand,
|
||||
onAcceptQuote,
|
||||
onLicense,
|
||||
onInvite,
|
||||
onSchedule,
|
||||
onManageTrial,
|
||||
onNavigate,
|
||||
onDocuments,
|
||||
}: {
|
||||
snapshot: ProcurementSnapshot;
|
||||
busy?: boolean;
|
||||
@@ -33,11 +64,17 @@ export function DealStatusHero({
|
||||
* "Schedule a call" action only appears when the org has linked its account. */
|
||||
canSchedule: boolean;
|
||||
onExpand: () => void;
|
||||
/**
|
||||
* Accept the issued quote, which advances the deal to the agreement. Offered here rather than
|
||||
* inside the quote review so the buyer can circulate the quote and come back to decide.
|
||||
*/
|
||||
onAcceptQuote: () => void;
|
||||
onLicense: () => void;
|
||||
onInvite: () => void;
|
||||
onSchedule: () => void;
|
||||
onManageTrial: () => void;
|
||||
onNavigate: (view: ViewId) => void;
|
||||
/** Open the Documents reference (agreement, quote, invoice, EULA, SLA, subprocessors). */
|
||||
onDocuments: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -49,129 +86,181 @@ export function DealStatusHero({
|
||||
|
||||
const stage = snapshot.stage ?? "trial";
|
||||
const inTrial = stage === "trial";
|
||||
const cta =
|
||||
stage === "trial"
|
||||
? t("portal.procurement.hero.ctaTrial")
|
||||
: stage === "quote"
|
||||
? t("portal.procurement.hero.ctaQuote")
|
||||
: stage === "procurement"
|
||||
? t("portal.procurement.hero.ctaPayment")
|
||||
: t("portal.procurement.hero.ctaLive");
|
||||
const isLive = stage === "active";
|
||||
// A live quote is sitting with the buyer. A draft (or an expired/cancelled one) is not something to
|
||||
// accept — that stage still means "finish building it".
|
||||
const quoteAwaitingDecision =
|
||||
stage === "quote" &&
|
||||
(snapshot.latestQuote?.status === "sent" ||
|
||||
snapshot.latestQuote?.status === "open");
|
||||
// Paying happens on Stripe, so the card links straight there rather than opening a dialog whose only
|
||||
// real action was the same link. Without an invoice URL there is nothing to link to, so the stage
|
||||
// falls back to its dialog, where the signed agreement is still reachable.
|
||||
const invoiceUrl =
|
||||
stage === "procurement" ? snapshot.latestQuote?.invoiceUrl : null;
|
||||
// Known from trial setup onward. The quote's own copy wins when present, since the buyer may have
|
||||
// corrected it there; before either exists the eyebrow stands alone rather than inventing a name.
|
||||
const company =
|
||||
snapshot.latestQuote?.config.businessName?.trim() ||
|
||||
snapshot.businessName?.trim();
|
||||
|
||||
const setupSteps: { title: string; sub: string; view: ViewId }[] = [
|
||||
{
|
||||
title: t("portal.procurement.hero.setup1Title"),
|
||||
sub: t("portal.procurement.hero.setup1Sub"),
|
||||
view: "users",
|
||||
},
|
||||
{
|
||||
title: t("portal.procurement.hero.setup2Title"),
|
||||
sub: t("portal.procurement.hero.setup2Sub"),
|
||||
view: "sources",
|
||||
},
|
||||
{
|
||||
title: t("portal.procurement.hero.setup3Title"),
|
||||
sub: t("portal.procurement.hero.setup3Sub"),
|
||||
view: "policies",
|
||||
},
|
||||
];
|
||||
// Exploring is presented as the Trial rung — same position, sentence and next step — because the
|
||||
// buyer has entered the journey; only the ask differs, since no trial has actually started.
|
||||
const journeyStage = stage === "exploring" ? "trial" : stage;
|
||||
const currentIdx = Math.max(
|
||||
0,
|
||||
FLOW_JOURNEY.findIndex((s) => s.stage === journeyStage),
|
||||
);
|
||||
const nextStage = FLOW_JOURNEY[currentIdx + 1];
|
||||
|
||||
return (
|
||||
<div className="portal-hero">
|
||||
<div className="portal-hero__top">
|
||||
<div>
|
||||
<div className="portal-hero__ident">
|
||||
<span className="portal-hero__eyebrow">
|
||||
{t("portal.procurement.hero.eyebrow")}
|
||||
{company
|
||||
? t("portal.procurement.hero.eyebrowCompany", { company })
|
||||
: t("portal.procurement.hero.eyebrow")}
|
||||
</span>
|
||||
<span className="portal-hero__company">
|
||||
{t("portal.procurement.hero.company")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="portal-hero__chips">
|
||||
|
||||
<div
|
||||
className="portal-hero__bar"
|
||||
role="img"
|
||||
aria-label={t("portal.procurement.hero.barAria", {
|
||||
current: currentIdx + 1,
|
||||
total: FLOW_JOURNEY.length,
|
||||
})}
|
||||
>
|
||||
{FLOW_JOURNEY.map((s, i) => (
|
||||
<span key={s.stage} data-on={i <= currentIdx || undefined} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="portal-hero__sentence">
|
||||
<strong>{t(FLOW_JOURNEY[currentIdx].label)}</strong>
|
||||
{` · ${t(STAGE_SENTENCE[stage])} `}
|
||||
{nextStage && (
|
||||
<span className="portal-hero__sentence-next">
|
||||
{t("portal.procurement.hero.next", {
|
||||
stage: t(nextStage.label),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{inTrial && snapshot.trialEndsAt && (
|
||||
<button
|
||||
type="button"
|
||||
className="portal-hero__chip portal-hero__chip--action"
|
||||
onClick={onManageTrial}
|
||||
>
|
||||
{t("portal.procurement.journey.daysLeft", {
|
||||
count: daysLeft(snapshot.trialEndsAt),
|
||||
})}
|
||||
</button>
|
||||
)}
|
||||
{snapshot.licenseKey && (
|
||||
<button
|
||||
type="button"
|
||||
className="portal-hero__chip portal-hero__chip--action"
|
||||
onClick={onLicense}
|
||||
>
|
||||
{t("portal.procurement.hero.licenseKey")}
|
||||
</button>
|
||||
)}
|
||||
{stage !== "active" && (
|
||||
<button
|
||||
type="button"
|
||||
className="portal-hero__chip portal-hero__chip--action"
|
||||
onClick={onInvite}
|
||||
>
|
||||
{t("portal.procurement.hero.inviteTeammates")}
|
||||
</button>
|
||||
)}
|
||||
{canSchedule && (
|
||||
<button
|
||||
type="button"
|
||||
className="portal-hero__chip portal-hero__chip--action"
|
||||
onClick={onSchedule}
|
||||
>
|
||||
{t("portal.procurement.hero.scheduleCall")}
|
||||
</button>
|
||||
<div className="portal-hero__chips">
|
||||
<button
|
||||
type="button"
|
||||
className="portal-hero__chip portal-hero__chip--action"
|
||||
onClick={onManageTrial}
|
||||
>
|
||||
{t("portal.procurement.journey.daysLeft", {
|
||||
count: daysLeft(snapshot.trialEndsAt),
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="portal-hero__stepper">
|
||||
<StageStepper journey={FLOW_JOURNEY} currentStage={stage} />
|
||||
</div>
|
||||
|
||||
{inTrial && (
|
||||
<ul className="portal-hero__checklist">
|
||||
{setupSteps.map((s) => (
|
||||
<li key={s.title}>
|
||||
<button type="button" onClick={() => onNavigate(s.view)}>
|
||||
<span className="portal-hero__check-dot" aria-hidden />
|
||||
<span className="portal-hero__check-text">
|
||||
<span className="portal-hero__check-title">{s.title}</span>
|
||||
<span className="portal-hero__check-sub">{s.sub}</span>
|
||||
</span>
|
||||
<span className="portal-hero__check-pill">
|
||||
{t("portal.procurement.hero.notStarted")}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{isLive && (
|
||||
<div className="portal-hero__live">
|
||||
<span className="portal-hero__live-tile" aria-hidden>
|
||||
<CheckIcon size={15} />
|
||||
</span>
|
||||
<span className="portal-hero__live-text">
|
||||
<span className="portal-hero__live-title">
|
||||
{t("portal.procurement.hero.liveTitle")}
|
||||
</span>
|
||||
<span className="portal-hero__live-sub">
|
||||
{t("portal.procurement.hero.liveSub")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="portal-hero__next">
|
||||
<span className="portal-hero__next-label">
|
||||
<span className="portal-hero__next-dot" />
|
||||
{t("portal.procurement.hero.nextStep", { action: cta })}
|
||||
</span>
|
||||
<div className="portal-hero__next-actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
accent="premium"
|
||||
loading={busy}
|
||||
onClick={onExpand}
|
||||
>
|
||||
{stage === "active" ? t("portal.procurement.hero.open") : cta}
|
||||
<div className="portal-hero__cta">
|
||||
{/* An issued quote is a decision point, so the card carries both halves of it: accept it, or
|
||||
open it again to read and circulate first. Every other stage has one next step. */}
|
||||
{quoteAwaitingDecision ? (
|
||||
<>
|
||||
<Button variant="primary" loading={busy} onClick={onAcceptQuote}>
|
||||
{t("portal.procurement.review.acceptCta")}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onExpand}>
|
||||
{t("portal.procurement.hero.ctaReviewQuote")}
|
||||
</Button>
|
||||
</>
|
||||
) : invoiceUrl ? (
|
||||
<Button variant="primary" onClick={() => openApiUrl(invoiceUrl)}>
|
||||
{t("portal.procurement.payment.viewInvoice")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" loading={busy} onClick={onExpand}>
|
||||
{t(STAGE_CTA[stage])}
|
||||
</Button>
|
||||
)}
|
||||
<div className="portal-hero__icons">
|
||||
{snapshot.licenseKey && (
|
||||
<IconAction
|
||||
label={t("portal.procurement.hero.licenseKey")}
|
||||
onClick={onLicense}
|
||||
>
|
||||
<KeyIcon size={15} />
|
||||
</IconAction>
|
||||
)}
|
||||
<IconAction
|
||||
label={t("portal.procurement.hero.documents")}
|
||||
onClick={onDocuments}
|
||||
>
|
||||
<DocumentsIcon size={15} />
|
||||
</IconAction>
|
||||
{!isLive && (
|
||||
<IconAction
|
||||
label={t("portal.procurement.hero.inviteTeammates")}
|
||||
onClick={onInvite}
|
||||
>
|
||||
<UserPlusIcon size={15} />
|
||||
</IconAction>
|
||||
)}
|
||||
{canSchedule && (
|
||||
<IconAction
|
||||
label={t("portal.procurement.hero.scheduleCall")}
|
||||
onClick={onSchedule}
|
||||
>
|
||||
<CalendarIcon size={15} />
|
||||
</IconAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A quiet icon-only secondary action; its label carries in the tooltip and to screen readers. */
|
||||
function IconAction({
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="portal-hero__iconbtn"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function daysLeft(iso: string): number {
|
||||
const end = new Date(iso).getTime();
|
||||
return Math.max(0, Math.ceil((end - Date.now()) / 86_400_000));
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DocRow } from "@portal/components/procurement/DocRow";
|
||||
import type { LedgerDoc } from "@portal/api/procurement";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
const meta: Meta<typeof DocRow> = {
|
||||
title: "Portal/Procurement/DocRow",
|
||||
component: DocRow,
|
||||
parameters: { layout: "padded" },
|
||||
args: { onAction: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DocRow>;
|
||||
|
||||
const sign: LedgerDoc = {
|
||||
id: "d1",
|
||||
name: "Stirling Enterprise Agreement",
|
||||
sub: "One signature: MSA + order form + EULA + DPA.",
|
||||
status: "action",
|
||||
action: "sign",
|
||||
};
|
||||
|
||||
const download: LedgerDoc = {
|
||||
id: "d2",
|
||||
name: "SOC 2 Type II report",
|
||||
sub: "Independent audit of our security controls.",
|
||||
status: "available",
|
||||
action: "download",
|
||||
};
|
||||
|
||||
const paidAddon: LedgerDoc = {
|
||||
id: "d3",
|
||||
name: "Onboarding & training",
|
||||
sub: "Guided rollout and live training for your team.",
|
||||
status: "request",
|
||||
action: "request",
|
||||
optional: true,
|
||||
fee: 7_500,
|
||||
};
|
||||
|
||||
const done: LedgerDoc = {
|
||||
id: "d4",
|
||||
name: "Formal quote",
|
||||
sub: "Committed-volume pricing, term and line items.",
|
||||
status: "complete",
|
||||
action: "download",
|
||||
};
|
||||
|
||||
// Deal-advancing action, filled purple CTA.
|
||||
export const SignAction: Story = { args: { doc: sign } };
|
||||
|
||||
// Quiet outline action for a ready download.
|
||||
export const Download: Story = { args: { doc: download } };
|
||||
|
||||
// Optional paid add-on, chips flag it and the fee folds into the CTA.
|
||||
export const PaidAddon: Story = { args: { doc: paidAddon } };
|
||||
|
||||
// Completed paperwork keeps a record but offers no further action.
|
||||
export const Complete: Story = { args: { doc: done } };
|
||||
|
||||
// A row in a future, not-yet-reached stage, dimmed, marked "Upcoming", inert.
|
||||
export const Locked: Story = { args: { doc: sign, locked: true } };
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Chip, StatusBadge } from "@app/ui";
|
||||
import type { LedgerDoc } from "@portal/api/procurement";
|
||||
import {
|
||||
ACTION_LABEL_KEY,
|
||||
STATUS_LABEL_KEY,
|
||||
STATUS_TONE,
|
||||
USD,
|
||||
} from "@portal/components/procurement/format";
|
||||
|
||||
/** Maps a document's action to the button accent + variant. */
|
||||
function buttonStyle(doc: LedgerDoc): {
|
||||
variant: "primary" | "secondary";
|
||||
accent: "premium" | "default";
|
||||
} {
|
||||
// The agreement signature and online payment are the deal-advancing actions;
|
||||
// give them the filled premium CTA. Everything else is a quieter outline.
|
||||
if (doc.action === "sign" || doc.action === "pay") {
|
||||
return { variant: "primary", accent: "premium" };
|
||||
}
|
||||
return { variant: "secondary", accent: "default" };
|
||||
}
|
||||
|
||||
/**
|
||||
* A single document in the ledger or supporting pool: name + sub-line on the
|
||||
* left, status badge and action button on the right. Optional/fee-bearing docs
|
||||
* carry a chip so the buyer sees a paid add-on before clicking. `locked` is for
|
||||
* rows in a future, not-yet-reached stage: dimmed, marked "Upcoming", inert.
|
||||
*/
|
||||
export function DocRow({
|
||||
doc,
|
||||
onAction,
|
||||
locked = false,
|
||||
}: {
|
||||
doc: LedgerDoc;
|
||||
onAction: (doc: LedgerDoc) => void;
|
||||
locked?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { variant, accent } = buttonStyle(doc);
|
||||
// Locked (future-stage), in-progress (pending) and completed paperwork all
|
||||
// offer no action; only "available", "action" and "request" docs do.
|
||||
const actionable =
|
||||
!locked && doc.status !== "complete" && doc.status !== "pending";
|
||||
const label = t(ACTION_LABEL_KEY[doc.action]);
|
||||
const actionLabel =
|
||||
doc.fee !== undefined ? `${label} · ${USD.format(doc.fee)}` : label;
|
||||
|
||||
return (
|
||||
<div className="portal-proc__doc" data-locked={locked || undefined}>
|
||||
<div className="portal-proc__doc-text">
|
||||
<div className="portal-proc__doc-name-row">
|
||||
<span className="portal-proc__doc-name">{doc.name}</span>
|
||||
{doc.optional && (
|
||||
<Chip accent="neutral" size="sm">
|
||||
{t("portal.procurement.docs.optional")}
|
||||
</Chip>
|
||||
)}
|
||||
{doc.fee !== undefined && (
|
||||
<Chip accent="warning" size="sm">
|
||||
{t("portal.procurement.docs.paidAddon")}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
<p className="portal-proc__doc-sub">{doc.sub}</p>
|
||||
</div>
|
||||
<div className="portal-proc__doc-actions">
|
||||
<StatusBadge
|
||||
tone={locked ? "neutral" : STATUS_TONE[doc.status]}
|
||||
size="sm"
|
||||
>
|
||||
{locked
|
||||
? t("portal.procurement.docs.upcoming")
|
||||
: t(STATUS_LABEL_KEY[doc.status])}
|
||||
</StatusBadge>
|
||||
{actionable && (
|
||||
<Button
|
||||
variant={variant}
|
||||
accent={accent}
|
||||
size="sm"
|
||||
onClick={() => onAction(doc)}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DocumentLedger } from "@portal/components/procurement/DocumentLedger";
|
||||
import { buildProcurement } from "@portal/mocks/procurement";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
const data = buildProcurement("enterprise");
|
||||
|
||||
const meta: Meta<typeof DocumentLedger> = {
|
||||
title: "Portal/Procurement/DocumentLedger",
|
||||
component: DocumentLedger,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
groups: data.ledger,
|
||||
supporting: data.supporting,
|
||||
journey: data.journey,
|
||||
currentStage: data.deal?.currentStage ?? "trial",
|
||||
onAction: () => {},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DocumentLedger>;
|
||||
|
||||
// Mid-journey: the Agreement stage is open, earlier stages read as done, later
|
||||
// stages are locked previews, and the supporting pool sits collapsed below.
|
||||
export const Default: Story = {};
|
||||
|
||||
// Day one: only the Trial stage has been reached; everything ahead is locked.
|
||||
export const AtTrial: Story = {
|
||||
args: { currentStage: "trial" },
|
||||
};
|
||||
@@ -1,160 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, Chip, Collapsible } from "@app/ui";
|
||||
import type {
|
||||
DealStage,
|
||||
JourneyStep,
|
||||
LedgerDoc,
|
||||
LedgerGroup,
|
||||
SupportingGroup,
|
||||
} from "@portal/api/procurement";
|
||||
import { DocRow } from "@portal/components/procurement/DocRow";
|
||||
|
||||
/**
|
||||
* The "Documents" card: every artifact the deal needs, as a stage accordion
|
||||
* that mirrors the journey. Only the current stage is open by default; earlier
|
||||
* stages read as done, later stages are locked previews. A collapsed-by-default
|
||||
* "Supporting your evaluation" pool holds the stage-agnostic paperwork.
|
||||
*/
|
||||
export function DocumentLedger({
|
||||
groups,
|
||||
supporting,
|
||||
journey,
|
||||
currentStage,
|
||||
onAction,
|
||||
}: {
|
||||
groups: LedgerGroup[];
|
||||
supporting: SupportingGroup[];
|
||||
journey: JourneyStep[];
|
||||
currentStage: DealStage;
|
||||
onAction: (doc: LedgerDoc) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const order = journey.map((s) => s.stage);
|
||||
const curIdx = order.indexOf(currentStage);
|
||||
// Follow the deal: the stage you're in opens first; any other stage can be
|
||||
// peeked. null collapses them all. Advancing moves the open section along.
|
||||
const [openStage, setOpenStage] = useState<DealStage | null>(currentStage);
|
||||
const [supportingOpen, setSupportingOpen] = useState(false);
|
||||
useEffect(() => setOpenStage(currentStage), [currentStage]);
|
||||
|
||||
return (
|
||||
<Card padding="none" className="portal-proc__docs">
|
||||
<div className="portal-proc__docs-head">
|
||||
<h2 className="portal-proc__docs-title">
|
||||
{t("portal.procurement.docs.title")}
|
||||
</h2>
|
||||
<p className="portal-proc__docs-sub">
|
||||
{t("portal.procurement.docs.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="portal-proc__docs-body">
|
||||
{groups.map((group) => {
|
||||
const idx = order.indexOf(group.stage);
|
||||
const done = idx < curIdx;
|
||||
const cur = group.stage === currentStage;
|
||||
const locked = idx > curIdx;
|
||||
const blurb = journey.find((s) => s.stage === group.stage)?.blurb;
|
||||
const open = openStage === group.stage;
|
||||
const count = group.docs.length;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={group.stage}
|
||||
open={open}
|
||||
onToggle={() => setOpenStage(open ? null : group.stage)}
|
||||
header={
|
||||
<>
|
||||
<span
|
||||
className="portal-proc__stage-dot"
|
||||
data-state={done ? "done" : cur ? "current" : "upcoming"}
|
||||
aria-hidden
|
||||
/>
|
||||
<span
|
||||
className="portal-proc__stage-label"
|
||||
data-current={cur || undefined}
|
||||
>
|
||||
{t(group.label)}
|
||||
</span>
|
||||
{blurb && (
|
||||
<span className="portal-proc__stage-hint">
|
||||
· {t(blurb)}
|
||||
</span>
|
||||
)}
|
||||
{cur && (
|
||||
<Chip accent="premium" size="sm">
|
||||
{t("portal.procurement.docs.here")}
|
||||
</Chip>
|
||||
)}
|
||||
{done && (
|
||||
<Chip accent="success" size="sm">
|
||||
{t("portal.procurement.docs.done")}
|
||||
</Chip>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
aside={
|
||||
<span className="portal-proc__stage-count">
|
||||
{t("portal.procurement.docs.count", { count })}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="portal-proc__doc-list">
|
||||
{group.docs.map((doc) => (
|
||||
<DocRow
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
onAction={onAction}
|
||||
locked={locked}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
|
||||
{supporting.length > 0 && (
|
||||
<Collapsible
|
||||
className="portal-proc__supporting-acc"
|
||||
open={supportingOpen}
|
||||
onToggle={() => setSupportingOpen((o) => !o)}
|
||||
header={
|
||||
<span className="portal-proc__supporting-head">
|
||||
<span className="portal-proc__stage-label">
|
||||
{t("portal.procurement.docs.supportingTitle")}
|
||||
</span>
|
||||
<span className="portal-proc__supporting-sub">
|
||||
{t("portal.procurement.docs.supportingSubtitle")}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
aside={
|
||||
<span className="portal-proc__acc-toggle-label">
|
||||
{supportingOpen
|
||||
? t("portal.procurement.docs.hide")
|
||||
: t("portal.procurement.docs.show")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="portal-proc__supporting-groups">
|
||||
{supporting.map((group) => (
|
||||
<div
|
||||
key={group.category}
|
||||
className="portal-proc__supporting-group"
|
||||
>
|
||||
<div className="portal-proc__group-label">{group.label}</div>
|
||||
<div className="portal-proc__doc-list portal-proc__doc-list--boxed">
|
||||
{group.docs.map((doc) => (
|
||||
<DocRow key={doc.id} doc={doc} onAction={onAction} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LockedState } from "@portal/components/procurement/LockedState";
|
||||
import { JOURNEY } from "@portal/api/procurement";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
const meta: Meta<typeof LockedState> = {
|
||||
title: "Portal/Procurement/LockedState",
|
||||
component: LockedState,
|
||||
parameters: { layout: "padded" },
|
||||
args: { onTalkToSales: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LockedState>;
|
||||
|
||||
// Shown to free/pro buyers, the journey preview behind the upgrade prompt.
|
||||
export const Default: Story = {
|
||||
args: { journey: JOURNEY },
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card, EmptyState } from "@app/ui";
|
||||
import type { JourneyStep } from "@portal/api/procurement";
|
||||
import { StageStepper } from "@portal/components/procurement/StageStepper";
|
||||
|
||||
/**
|
||||
* Enterprise-only gate for free/pro buyers. Shows the journey as a greyed
|
||||
* preview behind an upgrade prompt so the buyer understands what the
|
||||
* commercial track looks like before they talk to sales.
|
||||
*/
|
||||
export function LockedState({
|
||||
journey,
|
||||
onTalkToSales,
|
||||
}: {
|
||||
journey: JourneyStep[];
|
||||
onTalkToSales: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="portal-proc__locked">
|
||||
<EmptyState
|
||||
eyebrow={t("portal.procurement.locked.eyebrow")}
|
||||
title={t("portal.procurement.locked.title")}
|
||||
description={t("portal.procurement.locked.description")}
|
||||
actions={
|
||||
<Button variant="primary" accent="premium" onClick={onTalkToSales}>
|
||||
{t("portal.procurement.locked.talkToSales")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Card padding="loose" className="portal-proc__journey-stepper">
|
||||
<StageStepper journey={journey} currentStage="trial" locked />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,10 +69,9 @@ const meta: Meta<typeof ProcurementAgreement> = {
|
||||
args: {
|
||||
quote,
|
||||
busy: false,
|
||||
downloading: false,
|
||||
onAgree: () => {},
|
||||
onDownload: () => {},
|
||||
onEdit: () => {},
|
||||
onRequestChanges: () => {},
|
||||
onClose: () => {},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
@@ -81,12 +80,7 @@ type Story = StoryObj<typeof ProcurementAgreement>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
// Agreeing: the primary CTA shows its loading state while the accept call is in flight.
|
||||
export const Agreeing: Story = {
|
||||
// Signing: the primary CTA shows its loading state while the accept call is in flight.
|
||||
export const Signing: Story = {
|
||||
args: { busy: true },
|
||||
};
|
||||
|
||||
// Downloading: the secondary action shows its loading state instead.
|
||||
export const Downloading: Story = {
|
||||
args: { downloading: true },
|
||||
};
|
||||
|
||||
@@ -1,152 +1,267 @@
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card } from "@app/ui";
|
||||
import type { QuoteResult } from "@portal/api/procurement";
|
||||
import { money } from "@portal/components/procurement/format";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Button } from "@app/ui";
|
||||
import {
|
||||
fetchAgreementDocument,
|
||||
fetchAgreementPdf,
|
||||
recordAgreementSignature,
|
||||
type QuoteResult,
|
||||
} from "@portal/api/procurement";
|
||||
import { DownloadIcon } from "@portal/components/icons";
|
||||
import { StepModalHeader } from "@portal/components/shared/StepModalHeader";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/**
|
||||
* The agreement (security) step: a single combined Stirling Enterprise Agreement — Master Service
|
||||
* Agreement + Order Form (from the issued quote) + EULA + Data Processing Agreement — that the buyer
|
||||
* reviews and agrees to before it's accepted into a subscription. No e-signature for now: an explicit
|
||||
* "I agree" click stands in (the terms reference the accepted quote). Document body is static legal
|
||||
* copy; the surrounding UI is translated.
|
||||
* The agreement (security) step: the buyer reviews the full Stirling Enterprise Agreement — Master
|
||||
* Services Agreement + Order Form (from the quote) + Data Processing Addendum, one signature — then
|
||||
* signs it. The document body is served by the backend from the versioned legal registry (static
|
||||
* legal copy, English only); this component renders it, gates signing behind a scroll-through, and
|
||||
* captures the typed legal name, signatory, title, and authority. On sign it records the signature
|
||||
* (pinned to the exact document version + a hash) and then accepts the quote into a subscription.
|
||||
*
|
||||
* Presented as the document itself rather than a card about it: this step names the agreement in the
|
||||
* dialog's own header and carries its download there, so the terms are read on paper-like stock
|
||||
* instead of in app chrome. That is why it draws its own header — see ProcurementFlow.
|
||||
*/
|
||||
export function ProcurementAgreement({
|
||||
quote,
|
||||
busy,
|
||||
downloading,
|
||||
onAgree,
|
||||
onDownload,
|
||||
onEdit,
|
||||
onRequestChanges,
|
||||
onClose,
|
||||
}: {
|
||||
quote: QuoteResult;
|
||||
busy: boolean;
|
||||
downloading: boolean;
|
||||
/** Accept the quote straight into a committed subscription (this is also the agreement). */
|
||||
/** Accept the quote straight into a committed subscription (runs after the signature is saved). */
|
||||
onAgree: () => void;
|
||||
onDownload: () => void;
|
||||
onEdit: () => void;
|
||||
/** Hand the buyer to their SE to negotiate terms: closes this and opens scheduling. */
|
||||
onRequestChanges: () => void;
|
||||
/** This step draws the dialog's header, so it carries the close too. */
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [checked, setChecked] = useState(false);
|
||||
const annual = money(quote.annualNetMinor, quote.currency);
|
||||
const tcv = money(quote.tcvMinor, quote.currency);
|
||||
const renewal = money(quote.renewalAnnualNetMinor, quote.currency);
|
||||
const years = quote.config.termYears;
|
||||
const { data: doc, loading } = useAsync(fetchAgreementDocument, []);
|
||||
|
||||
const [legalName, setLegalName] = useState(quote.config.businessName ?? "");
|
||||
const [signatory, setSignatory] = useState(quote.config.contactName ?? "");
|
||||
const [title, setTitle] = useState("");
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [scrolledToEnd, setScrolledToEnd] = useState(false);
|
||||
const [signing, setSigning] = useState(false);
|
||||
const [downloadingMsa, setDownloadingMsa] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [downloadError, setDownloadError] = useState(false);
|
||||
const docRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const downloadMsa = async () => {
|
||||
setDownloadingMsa(true);
|
||||
setDownloadError(false);
|
||||
try {
|
||||
const blob = await fetchAgreementPdf();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "stirling-enterprise-agreement.pdf";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} catch {
|
||||
// Surface the failure — the PDF is rendered server-side, so a failure here means the
|
||||
// render service is unavailable rather than something the buyer can retry around.
|
||||
setDownloadError(true);
|
||||
} finally {
|
||||
setDownloadingMsa(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
const el = docRef.current;
|
||||
if (!el) return;
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 24) {
|
||||
setScrolledToEnd(true);
|
||||
}
|
||||
};
|
||||
|
||||
const ready =
|
||||
scrolledToEnd &&
|
||||
confirmed &&
|
||||
legalName.trim().length > 0 &&
|
||||
signatory.trim().length > 0;
|
||||
|
||||
const sign = async () => {
|
||||
setError(false);
|
||||
setSigning(true);
|
||||
try {
|
||||
await recordAgreementSignature({
|
||||
customerLegalName: legalName.trim(),
|
||||
signatoryName: signatory.trim(),
|
||||
signatoryTitle: title.trim(),
|
||||
authorityConfirmed: confirmed,
|
||||
});
|
||||
onAgree(); // proceed into the committed subscription
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
// In `finally`, not only on failure: accepting can fail after the signature is recorded, and
|
||||
// the controller deliberately keeps this dialog open on failure so the error is readable. With
|
||||
// the flag left set, the button span the rest of the session and there was no way to retry.
|
||||
setSigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="loose">
|
||||
<span className="portal-proc__eyebrow">
|
||||
{t("portal.procurement.agreement.eyebrow")}
|
||||
</span>
|
||||
<h3 className="portal-proc__builder-title">
|
||||
{t("portal.procurement.agreement.title")}
|
||||
</h3>
|
||||
<p className="portal-proc__subtitle">
|
||||
{t("portal.procurement.agreement.intro")}
|
||||
</p>
|
||||
<div className="portal-agreement">
|
||||
<StepModalHeader
|
||||
title={t("portal.procurement.agreement.docName")}
|
||||
subtitle={t("portal.procurement.agreement.docSub")}
|
||||
onClose={onClose}
|
||||
aside={
|
||||
// Grouped so the two document actions read as a pair, set apart from the close.
|
||||
<div className="portal-agreement__actions">
|
||||
{/* On the document, like the quote's: it downloads what is on screen. */}
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
leftSection={<DownloadIcon size={14} />}
|
||||
loading={downloadingMsa}
|
||||
onClick={downloadMsa}
|
||||
>
|
||||
{t("portal.procurement.agreement.download")}
|
||||
</Button>
|
||||
{/* Redlines are a conversation, not a form: this hands the buyer to their SE rather than
|
||||
pretending the terms can be amended in the app. */}
|
||||
<Button variant="tertiary" size="sm" onClick={onRequestChanges}>
|
||||
{t("portal.procurement.agreement.requestChanges")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="portal-agreement__doc">
|
||||
<h4>1. Master Service Agreement</h4>
|
||||
<p>
|
||||
This Stirling Enterprise Agreement ("Agreement") is entered into
|
||||
between Stirling PDF Inc. ("Stirling") and the customer identified on
|
||||
the Order Form ("Customer"). It governs Customer's access to and use
|
||||
of the Stirling enterprise platform and related services (the
|
||||
"Service"). Stirling will provide the Service with commercially
|
||||
reasonable skill and care and in accordance with the service levels
|
||||
set out in the Order Form.
|
||||
</p>
|
||||
{/* The tray scrolls, not the paper. The paper is its natural height inside it, so mid-document it
|
||||
runs flush to the footer with no grey beneath, and the tray's bottom padding only comes into
|
||||
view once the buyer reaches the end — the page ending is what shows they got there. */}
|
||||
<div
|
||||
className="portal-agreement__tray portal-agreement__scroll"
|
||||
ref={docRef}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
<div className="portal-agreement__doc">
|
||||
{loading && <p>{t("portal.procurement.agreement.loading")}</p>}
|
||||
{!loading && !doc && (
|
||||
<p>{t("portal.procurement.agreement.loadError")}</p>
|
||||
)}
|
||||
{doc && (
|
||||
<>
|
||||
{/* Letterhead: the reference ties the terms to the quote they price, and the version
|
||||
label pins what was signed. The document's own heading follows, so this adds a
|
||||
masthead rather than repeating the title. */}
|
||||
<div className="portal-agreement__letterhead">
|
||||
<span className="portal-agreement__confidential">
|
||||
{t("portal.procurement.agreement.confidential")}
|
||||
</span>
|
||||
<span>
|
||||
{t("portal.procurement.agreement.ref", {
|
||||
ref: quote.quoteNumber,
|
||||
version: doc.versionLabel,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="portal-agreement__md">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{doc.markdown}</Markdown>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>2. Order Form</h4>
|
||||
<p>
|
||||
Quote <strong>{quote.quoteNumber}</strong> forms the Order Form for
|
||||
this Agreement. Customer commits to a {years}-year term at{" "}
|
||||
<strong>{annual}</strong> per year (total contract value{" "}
|
||||
<strong>{tcv}</strong>), billed annually in advance by invoice. Fees
|
||||
are exclusive of taxes. The committed volume, service level, and
|
||||
add-ons are itemised below:
|
||||
{error && (
|
||||
<p className="portal-proc__error">
|
||||
{t("portal.procurement.agreement.signError")}
|
||||
</p>
|
||||
<ul className="portal-qb__lines portal-agreement__lines">
|
||||
{quote.lineItems.map((li) => (
|
||||
<li key={li.key} data-kind={li.kind}>
|
||||
<span>{li.label}</span>
|
||||
<span>
|
||||
{li.kind === "INCLUDED"
|
||||
? t("portal.procurement.builder.included")
|
||||
: money(li.amountMinor, quote.currency)}
|
||||
)}
|
||||
{downloadError && (
|
||||
<p className="portal-proc__error">
|
||||
{t("portal.procurement.agreement.downloadDraftError")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* The signature block: who is bound, who signs, and the act of signing, on one line — the
|
||||
shape of a paper signature block rather than a form above a button. The consent sits
|
||||
directly under the fields it qualifies, with no rule between them: it is part of signing,
|
||||
not a separate section, and boxing it cost the document a quarter of its height. */}
|
||||
<div className="portal-qb__foot portal-agreement__signbar">
|
||||
<div className="portal-agreement__signrow">
|
||||
<div className="portal-qb__row portal-agreement__signfields">
|
||||
<label className="portal-qb__field">
|
||||
<span className="portal-qb__field-label">
|
||||
{t("portal.procurement.agreement.legalName")}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<input
|
||||
value={legalName}
|
||||
placeholder={t(
|
||||
"portal.procurement.agreement.legalNamePlaceholder",
|
||||
)}
|
||||
onChange={(e) => setLegalName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="portal-qb__field">
|
||||
<span className="portal-qb__field-label">
|
||||
{t("portal.procurement.agreement.signatory")}
|
||||
</span>
|
||||
<input
|
||||
value={signatory}
|
||||
placeholder={t(
|
||||
"portal.procurement.agreement.signatoryPlaceholder",
|
||||
)}
|
||||
onChange={(e) => setSignatory(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="portal-qb__field">
|
||||
<span className="portal-qb__field-label">
|
||||
{t("portal.procurement.agreement.signatoryTitle")}
|
||||
</span>
|
||||
<input
|
||||
value={title}
|
||||
placeholder={t(
|
||||
"portal.procurement.agreement.signatoryTitlePlaceholder",
|
||||
)}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={busy || signing}
|
||||
disabled={!ready}
|
||||
onClick={sign}
|
||||
>
|
||||
{t("portal.procurement.agreement.agreeCta")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h4>3. Term, renewal and annual fee adjustment</h4>
|
||||
<p>
|
||||
This Agreement runs for the committed {years}-year term set out in the
|
||||
Order Form. It then renews automatically for successive one-year terms
|
||||
unless either party gives written notice of non-renewal at least 30
|
||||
days before the end of the then-current term. On each renewal the
|
||||
annual fee increases by {quote.cpiRatePct}%, a fixed CPI adjustment.
|
||||
Based on this quote, the first renewal year would be approximately{" "}
|
||||
<strong>{renewal}</strong> per year; the committed term above is
|
||||
billed at the rate in the Order Form and is not affected.
|
||||
</p>
|
||||
|
||||
<h4>4. End-User License Agreement</h4>
|
||||
<p>
|
||||
Subject to the terms of this Agreement, Stirling grants Customer a
|
||||
non-exclusive, non-transferable right to use the Service for its
|
||||
internal business purposes during the term. Customer is responsible
|
||||
for its users' compliance and for the content it processes. The
|
||||
Service, and all intellectual property in it, remains Stirling's.
|
||||
</p>
|
||||
|
||||
<h4>5. Data Processing Agreement</h4>
|
||||
<p>
|
||||
Where Stirling processes personal data on Customer's behalf, it does
|
||||
so only on Customer's documented instructions and applies appropriate
|
||||
technical and organisational measures. Sub-processors, international
|
||||
transfers, and security commitments are as described in Stirling's
|
||||
Data Processing Agreement and Trust Center, incorporated here by
|
||||
reference.
|
||||
</p>
|
||||
|
||||
<h4>6. Acceptance</h4>
|
||||
<p>
|
||||
By agreeing below, Customer accepts this Agreement and the Order Form.
|
||||
On acceptance, Stirling will issue the committed annual subscription
|
||||
and its first invoice. This preview stands in for e-signature during
|
||||
the pilot.
|
||||
</p>
|
||||
{/* Consent under the fields it qualifies; the gate's state under the button it gates, so the
|
||||
reason signing is unavailable sits beside the unavailable thing. */}
|
||||
<div className="portal-agreement__signfoot">
|
||||
<label className="portal-agreement__accept">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
disabled={!scrolledToEnd}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
/>
|
||||
<span>{t("portal.procurement.agreement.confirm")}</span>
|
||||
</label>
|
||||
{doc && !scrolledToEnd && (
|
||||
<span className="portal-agreement__gate">
|
||||
{t("portal.procurement.agreement.scrollHint")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="portal-qb__eula portal-agreement__accept">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => setChecked(e.target.checked)}
|
||||
/>
|
||||
<span>{t("portal.procurement.agreement.confirm")}</span>
|
||||
</label>
|
||||
|
||||
<div className="portal-proc__payment-actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
accent="premium"
|
||||
loading={busy}
|
||||
disabled={!checked}
|
||||
onClick={onAgree}
|
||||
>
|
||||
{t("portal.procurement.agreement.agreeCta")}
|
||||
</Button>
|
||||
<Button variant="secondary" loading={downloading} onClick={onDownload}>
|
||||
{t("portal.procurement.milestone.download")}
|
||||
</Button>
|
||||
<Button variant="tertiary" onClick={onEdit}>
|
||||
{t("portal.procurement.milestone.edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ProcurementBanner } from "@portal/components/procurement/ProcurementBanner";
|
||||
import type { ProcurementController } from "@portal/components/procurement/useProcurement";
|
||||
import type { ProcurementSnapshot } from "@portal/api/procurement";
|
||||
|
||||
const snapshot: ProcurementSnapshot = {
|
||||
dealId: 1,
|
||||
stage: "trial",
|
||||
deployment: "cloud",
|
||||
seats: 250,
|
||||
trialStartedAt: "2026-06-25T00:00:00Z",
|
||||
trialEndsAt: "2026-07-09T00:00:00Z",
|
||||
trialExtensionsUsed: 0,
|
||||
licensed: false,
|
||||
licenseKey: null,
|
||||
latestQuote: null,
|
||||
};
|
||||
|
||||
function makeController(
|
||||
overrides: Partial<ProcurementController> = {},
|
||||
): ProcurementController {
|
||||
return {
|
||||
isLinked: true,
|
||||
loading: false,
|
||||
data: null,
|
||||
started: false,
|
||||
stage: undefined,
|
||||
latest: null,
|
||||
isIssued: false,
|
||||
isDraft: true,
|
||||
busy: false,
|
||||
downloading: false,
|
||||
downloadingLicense: false,
|
||||
error: null,
|
||||
setError: () => {},
|
||||
open: false,
|
||||
setOpen: () => {},
|
||||
editing: false,
|
||||
setEditing: () => {},
|
||||
extra: null,
|
||||
setExtra: () => {},
|
||||
invoicePdf: null,
|
||||
onStartTrial: () => {},
|
||||
onConfirmSetup: () => {},
|
||||
onExtendTrial: () => {},
|
||||
onReset: () => {},
|
||||
onGenerate: () => {},
|
||||
onAgree: () => {},
|
||||
onDownloadPdf: async () => {},
|
||||
onDownloadOfflineLicense: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Deal-status hero once a deal is underway, otherwise the enterprise on-ramp. */
|
||||
const meta: Meta<typeof ProcurementBanner> = {
|
||||
title: "Portal/Procurement/ProcurementBanner",
|
||||
component: ProcurementBanner,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** No deal yet: the enterprise on-ramp upsell. */
|
||||
export const Upsell: Story = {
|
||||
args: { controller: makeController() },
|
||||
};
|
||||
|
||||
/** A deal is underway: the wired deal-status hero. */
|
||||
export const DealUnderway: Story = {
|
||||
args: {
|
||||
controller: makeController({
|
||||
started: true,
|
||||
data: snapshot,
|
||||
stage: snapshot.stage,
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -1,13 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card } from "@app/ui";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import { DealStatusHero } from "@portal/components/procurement/DealStatusHero";
|
||||
import type { ProcurementController } from "@portal/components/procurement/useProcurement";
|
||||
|
||||
/**
|
||||
* The deal-status hero, wired to a shared ProcurementController. Rendered both
|
||||
* standalone (the /procurement route) and as the Home hero card's footer once a
|
||||
* deal is underway. Assumes an active deal (controller.data present).
|
||||
* The deal-status hero, wired to a shared ProcurementController. Rendered as the Home hero card's
|
||||
* footer once a deal is underway; assumes an active deal (controller.data present).
|
||||
*/
|
||||
export function ControlledDealStatusHero({
|
||||
controller,
|
||||
@@ -21,60 +18,18 @@ export function ControlledDealStatusHero({
|
||||
snapshot={controller.data}
|
||||
busy={controller.busy}
|
||||
canSchedule={controller.isLinked}
|
||||
onExpand={() => controller.setOpen(true)}
|
||||
onExpand={() =>
|
||||
// Exploring has no journey to expand into yet — its ask is to set the trial up.
|
||||
controller.stage === "exploring"
|
||||
? controller.onStartTrial()
|
||||
: controller.setOpen(true)
|
||||
}
|
||||
onAcceptQuote={() => void controller.onAcceptQuote()}
|
||||
onLicense={() => controller.setExtra("license")}
|
||||
onInvite={() => setActiveView("users")}
|
||||
onSchedule={() => controller.setExtra("schedule")}
|
||||
onManageTrial={() => controller.setExtra("trial")}
|
||||
onNavigate={setActiveView}
|
||||
onDocuments={() => controller.setExtra("documents")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enterprise on-ramp shown when no deal exists yet. Only used on the dedicated
|
||||
* /procurement route — on Home the setup checklist's Enterprise rung owns the
|
||||
* on-ramp, so this doesn't render there.
|
||||
*/
|
||||
export function ProcurementUpsell({
|
||||
controller,
|
||||
}: {
|
||||
controller: ProcurementController;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card className="portal-proc__upsell">
|
||||
<div className="portal-proc__upsell-text">
|
||||
<span className="portal-proc__upsell-badge">
|
||||
{t("portal.procurement.upsell.homeBadge")}
|
||||
</span>
|
||||
<p className="portal-proc__upsell-copy">
|
||||
<strong>{t("portal.procurement.upsell.homeHeadline")} </strong>
|
||||
{t("portal.procurement.upsell.homeBody")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
accent="default"
|
||||
loading={controller.busy}
|
||||
disabled={!controller.isLinked}
|
||||
onClick={controller.onStartTrial}
|
||||
>
|
||||
{t("portal.procurement.upsell.homeCta")}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Deal-status hero when a deal is underway, otherwise the enterprise on-ramp. */
|
||||
export function ProcurementBanner({
|
||||
controller,
|
||||
}: {
|
||||
controller: ProcurementController;
|
||||
}) {
|
||||
return controller.isLinked && controller.started && controller.data ? (
|
||||
<ControlledDealStatusHero controller={controller} />
|
||||
) : (
|
||||
<ProcurementUpsell controller={controller} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ const SNAPSHOT: ProcurementSnapshot = {
|
||||
trialExtensionsUsed: 0,
|
||||
licensed: false,
|
||||
licenseKey: null,
|
||||
agreementSignedVersion: null,
|
||||
businessName: null,
|
||||
contactName: null,
|
||||
contactEmail: null,
|
||||
latestQuote: null,
|
||||
};
|
||||
|
||||
@@ -72,13 +76,14 @@ export const ScheduleCall: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
// Deployment + seat count captured before the trial starts.
|
||||
// Two steps before the trial starts: how they'll run it, then who is buying.
|
||||
export const TrialSetup: Story = {
|
||||
render: () => (
|
||||
<TrialSetupModal
|
||||
open
|
||||
onClose={() => {}}
|
||||
busy={false}
|
||||
onScheduleCall={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Button } from "@app/ui";
|
||||
import type { ProcurementSnapshot } from "@portal/api/procurement";
|
||||
import {
|
||||
fetchLegalDocument,
|
||||
recordLegalConsent,
|
||||
type ProcurementSnapshot,
|
||||
type TrialSetupDetails,
|
||||
} from "@portal/api/procurement";
|
||||
import { StepModalHeader } from "@portal/components/shared/StepModalHeader";
|
||||
import { CalendlyInline } from "@portal/components/procurement/CalendlyInline";
|
||||
import { LicensePanel } from "@portal/components/procurement/ProcurementStages";
|
||||
import { useFocusTrap } from "@portal/components/procurement/ProcurementModal";
|
||||
import { FlowModal } from "@portal/components/shared/FlowModal";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { openApiUrl } from "@portal/api/externalUrl";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/**
|
||||
@@ -14,6 +23,8 @@ import "@portal/views/Procurement.css";
|
||||
* scheduler. The shells and wiring are real so the hero behaves like the marketing prototype.
|
||||
*/
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function SideModal({
|
||||
open,
|
||||
onClose,
|
||||
@@ -21,56 +32,243 @@ function SideModal({
|
||||
subtitle,
|
||||
children,
|
||||
footer,
|
||||
headerAside,
|
||||
wide = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** Sits on the title row, before the close button (e.g. a "Step 1 of 2" badge). */
|
||||
headerAside?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const trapRef = useFocusTrap(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
return createPortal(
|
||||
<div
|
||||
className="portal-sidemodal"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div
|
||||
ref={trapRef}
|
||||
className={`portal-sidemodal__panel${wide ? " portal-sidemodal__panel--wide" : ""}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-procmodal__close"
|
||||
onClick={onClose}
|
||||
aria-label={t("portal.procurement.modal.close")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div className="portal-sidemodal__header">
|
||||
<h3 className="portal-sidemodal__title">{title}</h3>
|
||||
return (
|
||||
<FlowModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
label={title}
|
||||
size={wide ? "lg" : "md"}
|
||||
footer={footer}
|
||||
header={
|
||||
<>
|
||||
<div className="portal-sidemodal__title-row">
|
||||
<h2 className="portal-sidemodal__title">{title}</h2>
|
||||
{headerAside}
|
||||
</div>
|
||||
{subtitle && <p className="portal-sidemodal__sub">{subtitle}</p>}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</FlowModal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reader for a versioned legal document (EULA, SLA exhibit, subprocessors), fetched from the
|
||||
* backend registry and rendered as markdown. Open when {@code docId} is set. Drafts are badged.
|
||||
*/
|
||||
export function LegalDocumentModal({
|
||||
docId,
|
||||
onClose,
|
||||
}: {
|
||||
docId: string | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data, loading } = useAsync(
|
||||
() => (docId ? fetchLegalDocument(docId) : Promise.resolve(null)),
|
||||
[docId],
|
||||
);
|
||||
return (
|
||||
<SideModal
|
||||
open={docId !== null}
|
||||
onClose={onClose}
|
||||
wide
|
||||
title={data?.displayName ?? t("portal.legal.title")}
|
||||
subtitle={
|
||||
data
|
||||
? data.status !== "final"
|
||||
? t("portal.legal.draft", { label: data.versionLabel })
|
||||
: data.versionLabel
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{loading && (
|
||||
<p className="portal-sidemodal__text">{t("portal.legal.loading")}</p>
|
||||
)}
|
||||
{!loading && !data && (
|
||||
<p className="portal-sidemodal__text">{t("portal.legal.loadError")}</p>
|
||||
)}
|
||||
{data && (
|
||||
<div className="portal-agreement__md">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{data.markdown}</Markdown>
|
||||
</div>
|
||||
<div className="portal-sidemodal__body">{children}</div>
|
||||
{footer && <div className="portal-sidemodal__footer">{footer}</div>}
|
||||
)}
|
||||
</SideModal>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Documents ────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* The deal's paperwork in one place, reachable throughout the journey (not tied to the current
|
||||
* stage): the enterprise agreement, the quote, the invoice, and the reference documents (EULA, SLA
|
||||
* exhibit, subprocessors). Each row downloads or views the real artifact when it's available, and
|
||||
* reads as "available later" until then. The per-stage download buttons remain the primary path;
|
||||
* this is the secondary, always-on reference.
|
||||
*/
|
||||
export function DocumentsModal({
|
||||
open,
|
||||
onClose,
|
||||
agreementVersion,
|
||||
downloadingAgreement,
|
||||
onDownloadAgreement,
|
||||
onViewAgreement,
|
||||
quoteAvailable,
|
||||
downloadingQuote,
|
||||
onDownloadQuote,
|
||||
invoiceUrl,
|
||||
invoicePdf,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
agreementVersion?: string | null;
|
||||
downloadingAgreement?: boolean;
|
||||
onDownloadAgreement: () => void;
|
||||
/** Jump to the agreement/sign stage in the flow (used before it's signed). */
|
||||
onViewAgreement: () => void;
|
||||
quoteAvailable: boolean;
|
||||
downloadingQuote?: boolean;
|
||||
onDownloadQuote: () => void;
|
||||
invoiceUrl?: string | null;
|
||||
invoicePdf?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [legalDoc, setLegalDoc] = useState<string | null>(null);
|
||||
const invoice = invoiceUrl || invoicePdf || null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t("portal.procurement.documents.title")}
|
||||
subtitle={t("portal.procurement.documents.subtitle")}
|
||||
>
|
||||
<ul className="portal-docmodal">
|
||||
<DocItem
|
||||
name={t("portal.procurement.documents.agreement")}
|
||||
sub={
|
||||
agreementVersion ?? t("portal.procurement.documents.agreementSub")
|
||||
}
|
||||
action={
|
||||
agreementVersion
|
||||
? {
|
||||
label: t("portal.procurement.documents.download"),
|
||||
onClick: onDownloadAgreement,
|
||||
loading: downloadingAgreement,
|
||||
}
|
||||
: quoteAvailable
|
||||
? {
|
||||
label: t("portal.procurement.documents.view"),
|
||||
onClick: onViewAgreement,
|
||||
}
|
||||
: {
|
||||
unavailable: t("portal.procurement.documents.laterQuote"),
|
||||
}
|
||||
}
|
||||
/>
|
||||
<DocItem
|
||||
name={t("portal.procurement.documents.quote")}
|
||||
sub={t("portal.procurement.documents.quoteSub")}
|
||||
action={
|
||||
quoteAvailable
|
||||
? {
|
||||
label: t("portal.procurement.documents.download"),
|
||||
onClick: onDownloadQuote,
|
||||
loading: downloadingQuote,
|
||||
}
|
||||
: { unavailable: t("portal.procurement.documents.laterQuote") }
|
||||
}
|
||||
/>
|
||||
<DocItem
|
||||
name={t("portal.procurement.documents.invoice")}
|
||||
sub={t("portal.procurement.documents.invoiceSub")}
|
||||
action={
|
||||
invoice
|
||||
? {
|
||||
label: t("portal.procurement.documents.view"),
|
||||
onClick: () => openApiUrl(invoice),
|
||||
}
|
||||
: {
|
||||
unavailable: t("portal.procurement.documents.laterInvoice"),
|
||||
}
|
||||
}
|
||||
/>
|
||||
<DocItem
|
||||
name={t("portal.procurement.documents.eula")}
|
||||
sub={t("portal.procurement.documents.eulaSub")}
|
||||
action={{
|
||||
label: t("portal.procurement.documents.view"),
|
||||
onClick: () => setLegalDoc("eula"),
|
||||
}}
|
||||
/>
|
||||
<DocItem
|
||||
name={t("portal.procurement.documents.sla")}
|
||||
sub={t("portal.procurement.documents.slaSub")}
|
||||
action={{
|
||||
label: t("portal.procurement.documents.view"),
|
||||
onClick: () => setLegalDoc("sla"),
|
||||
}}
|
||||
/>
|
||||
<DocItem
|
||||
name={t("portal.procurement.documents.subprocessors")}
|
||||
sub={t("portal.procurement.documents.subprocessorsSub")}
|
||||
action={{
|
||||
label: t("portal.procurement.documents.view"),
|
||||
onClick: () => setLegalDoc("subprocessors"),
|
||||
}}
|
||||
/>
|
||||
</ul>
|
||||
</SideModal>
|
||||
<LegalDocumentModal docId={legalDoc} onClose={() => setLegalDoc(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row in the Documents list: name + sub on the left, an action button or a muted note. */
|
||||
function DocItem({
|
||||
name,
|
||||
sub,
|
||||
action,
|
||||
}: {
|
||||
name: string;
|
||||
sub: string;
|
||||
action:
|
||||
| { label: string; onClick: () => void; loading?: boolean }
|
||||
| { unavailable: string };
|
||||
}) {
|
||||
return (
|
||||
<li className="portal-docmodal__row">
|
||||
<div className="portal-docmodal__text">
|
||||
<span className="portal-docmodal__name">{name}</span>
|
||||
<span className="portal-docmodal__sub">{sub}</span>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
{"unavailable" in action ? (
|
||||
<span className="portal-docmodal__later">{action.unavailable}</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
loading={action.loading}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,82 +352,233 @@ export function TrialSetupModal({
|
||||
open,
|
||||
onClose,
|
||||
busy,
|
||||
email,
|
||||
onScheduleCall,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
busy: boolean;
|
||||
onConfirm: (deployment: string, seats: number) => void;
|
||||
/** Linked-account email, prefilled as the work email on the details step. */
|
||||
email?: string;
|
||||
/** Open the scheduler — the step-1 escape hatch for buyers who want to talk first. */
|
||||
onScheduleCall: () => void;
|
||||
onConfirm: (
|
||||
deployment: string,
|
||||
seats: number,
|
||||
details: TrialSetupDetails,
|
||||
) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState(0);
|
||||
const [deployment, setDeployment] = useState<string>("cloud");
|
||||
const [seats, setSeats] = useState("");
|
||||
const [contactName, setContactName] = useState("");
|
||||
const [businessName, setBusinessName] = useState("");
|
||||
const [contactEmail, setContactEmail] = useState("");
|
||||
const [inviteEmails, setInviteEmails] = useState("");
|
||||
const [eula, setEula] = useState(false);
|
||||
const [legalDoc, setLegalDoc] = useState<string | null>(null);
|
||||
|
||||
// Reset to defaults each time the dialog opens, so a cancelled setup doesn't linger.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep(0);
|
||||
setDeployment("cloud");
|
||||
setSeats("");
|
||||
setContactName("");
|
||||
setBusinessName("");
|
||||
setContactEmail(email ?? "");
|
||||
setInviteEmails("");
|
||||
setEula(false);
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, email]);
|
||||
|
||||
// The buying entity is what the quote and agreement are drawn against, so it is required here
|
||||
// rather than deferred to the quote; invites are genuinely optional.
|
||||
const detailsValid =
|
||||
contactName.trim().length > 0 &&
|
||||
businessName.trim().length > 0 &&
|
||||
EMAIL_RE.test(contactEmail.trim());
|
||||
|
||||
const confirm = () => {
|
||||
void recordLegalConsent("eula", "trial"); // clickwrap consent, best-effort
|
||||
onConfirm(deployment, Math.max(0, Number(seats) || 0), {
|
||||
businessName: businessName.trim(),
|
||||
contactName: contactName.trim(),
|
||||
contactEmail: contactEmail.trim(),
|
||||
inviteEmails: inviteEmails.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SideModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t("portal.procurement.setup.title")}
|
||||
subtitle={t("portal.procurement.setup.subtitle")}
|
||||
footer={
|
||||
<Button
|
||||
variant="primary"
|
||||
accent="premium"
|
||||
loading={busy}
|
||||
onClick={() => onConfirm(deployment, Math.max(0, Number(seats) || 0))}
|
||||
>
|
||||
{t("portal.procurement.setup.start")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<label className="portal-qb__field">
|
||||
<span className="portal-qb__field-label">
|
||||
{t("portal.procurement.setup.deployment")}
|
||||
</span>
|
||||
<div className="portal-qb__opts">
|
||||
{DEPLOYMENTS.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className="portal-qb__opt"
|
||||
data-on={deployment === d || undefined}
|
||||
onClick={() => setDeployment(d)}
|
||||
>
|
||||
<span className="portal-qb__opt-title">
|
||||
{t(`portal.procurement.setup.${d}`)}
|
||||
<>
|
||||
<SideModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t("portal.procurement.setup.title")}
|
||||
subtitle={t(
|
||||
step === 0
|
||||
? "portal.procurement.setup.subtitle"
|
||||
: "portal.procurement.setup.subtitleDetails",
|
||||
)}
|
||||
headerAside={
|
||||
<span className="portal-stepmodal__step">
|
||||
{t("portal.procurement.setup.stepOf", { n: step + 1, total: 2 })}
|
||||
</span>
|
||||
}
|
||||
footer={
|
||||
step === 0 ? (
|
||||
<>
|
||||
<span className="portal-sidemodal__foot-hint">
|
||||
{t("portal.procurement.setup.talkFirst")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="portal-legal__link"
|
||||
onClick={onScheduleCall}
|
||||
>
|
||||
{t("portal.procurement.setup.scheduleCall")}
|
||||
</button>
|
||||
</span>
|
||||
<span className="portal-qb__opt-sub">
|
||||
{t(`portal.procurement.setup.${d}Sub`)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</label>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={Number(seats) <= 0}
|
||||
onClick={() => setStep(1)}
|
||||
>
|
||||
{t("portal.procurement.setup.continue")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setStep(0)}>
|
||||
{t("portal.procurement.setup.back")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={busy}
|
||||
disabled={!eula || !detailsValid}
|
||||
onClick={confirm}
|
||||
>
|
||||
{t("portal.procurement.setup.start")}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<StepModalHeader step={step + 1} total={2} />
|
||||
|
||||
<label className="portal-qb__field">
|
||||
<span className="portal-qb__field-label">
|
||||
{t("portal.procurement.setup.seats")}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t("portal.procurement.setup.seatsPlaceholder")}
|
||||
value={seats}
|
||||
onChange={(e) => setSeats(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<p className="portal-sidemodal__text">
|
||||
{t("portal.procurement.setup.seatsHint")}
|
||||
</p>
|
||||
</SideModal>
|
||||
{step === 0 && (
|
||||
<>
|
||||
<label className="portal-qb__field">
|
||||
<span className="portal-qb__field-label">
|
||||
{t("portal.procurement.setup.seats")}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t("portal.procurement.setup.seatsPlaceholder")}
|
||||
value={seats}
|
||||
onChange={(e) => setSeats(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="portal-qb__field">
|
||||
<span className="portal-qb__field-label">
|
||||
{t("portal.procurement.setup.deployment")}
|
||||
</span>
|
||||
<div className="portal-qb__opts portal-qb__opts--across">
|
||||
{DEPLOYMENTS.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className="portal-qb__opt"
|
||||
data-on={deployment === d || undefined}
|
||||
onClick={() => setDeployment(d)}
|
||||
>
|
||||
<span className="portal-qb__opt-title">
|
||||
{t(`portal.procurement.setup.${d}`)}
|
||||
</span>
|
||||
<span className="portal-qb__opt-sub">
|
||||
{t(`portal.procurement.setup.${d}Sub`)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<div className="portal-qb__row">
|
||||
<label className="portal-qb__field">
|
||||
<span className="portal-qb__field-label">
|
||||
{t("portal.procurement.setup.fullName")}
|
||||
</span>
|
||||
<input
|
||||
value={contactName}
|
||||
placeholder={t(
|
||||
"portal.procurement.setup.fullNamePlaceholder",
|
||||
)}
|
||||
onChange={(e) => setContactName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="portal-qb__field">
|
||||
<span className="portal-qb__field-label">
|
||||
{t("portal.procurement.setup.businessName")}
|
||||
</span>
|
||||
<input
|
||||
value={businessName}
|
||||
placeholder={t(
|
||||
"portal.procurement.setup.businessNamePlaceholder",
|
||||
)}
|
||||
onChange={(e) => setBusinessName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="portal-qb__field">
|
||||
<span className="portal-qb__field-label">
|
||||
{t("portal.procurement.setup.workEmail")}
|
||||
</span>
|
||||
<input
|
||||
type="email"
|
||||
value={contactEmail}
|
||||
placeholder={t("portal.procurement.setup.workEmailPlaceholder")}
|
||||
onChange={(e) => setContactEmail(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="portal-qb__field">
|
||||
<span className="portal-qb__field-label">
|
||||
{t("portal.procurement.setup.invites")}
|
||||
</span>
|
||||
<input
|
||||
value={inviteEmails}
|
||||
placeholder={t("portal.procurement.setup.invitesPlaceholder")}
|
||||
onChange={(e) => setInviteEmails(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="portal-qb__eula">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={eula}
|
||||
onChange={(e) => setEula(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t("portal.procurement.setup.eula")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="portal-legal__link"
|
||||
onClick={() => setLegalDoc("eula")}
|
||||
>
|
||||
{t("portal.procurement.setup.viewEula")}
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</SideModal>
|
||||
<LegalDocumentModal docId={legalDoc} onClose={() => setLegalDoc(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,7 +629,6 @@ export function TrialManageModal({
|
||||
</button>
|
||||
<Button
|
||||
variant="primary"
|
||||
accent="premium"
|
||||
loading={busy}
|
||||
disabled={maxed}
|
||||
onClick={onExtend}
|
||||
|
||||
@@ -56,6 +56,10 @@ const snapshot: ProcurementSnapshot = {
|
||||
trialExtensionsUsed: 0,
|
||||
licensed: false,
|
||||
licenseKey: null,
|
||||
agreementSignedVersion: null,
|
||||
businessName: null,
|
||||
contactName: null,
|
||||
contactEmail: null,
|
||||
latestQuote: quote,
|
||||
};
|
||||
|
||||
@@ -88,10 +92,14 @@ function makeController(
|
||||
onConfirmSetup: () => {},
|
||||
onExtendTrial: () => {},
|
||||
onReset: () => {},
|
||||
onGenerate: () => {},
|
||||
onGenerate: async () => {},
|
||||
onAgree: () => {},
|
||||
onDownloadPdf: async () => {},
|
||||
onDownloadOfflineLicense: async () => {},
|
||||
downloadingAgreement: false,
|
||||
onAcceptQuote: async () => {},
|
||||
onDownloadSignedAgreement: async () => {},
|
||||
onExploreEnterprise: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, EmptyState, Skeleton } from "@app/ui";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useLinkedAccountEmail } from "@portal/hooks/useLinkedAccountEmail";
|
||||
import { FLOW_JOURNEY } from "@portal/api/procurement";
|
||||
import { ProcurementAgreement } from "@portal/components/procurement/ProcurementAgreement";
|
||||
import {
|
||||
DocumentsModal,
|
||||
LicenseModal,
|
||||
ScheduleCallModal,
|
||||
TrialManageModal,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
PaymentStageCard,
|
||||
} from "@portal/components/procurement/ProcurementStages";
|
||||
import { QuoteBuilder } from "@portal/components/procurement/QuoteBuilder";
|
||||
import { StageStepper } from "@portal/components/procurement/StageStepper";
|
||||
import type { ProcurementController } from "@portal/components/procurement/useProcurement";
|
||||
|
||||
/**
|
||||
@@ -42,7 +41,6 @@ export function ProcurementFlow({
|
||||
stage,
|
||||
latest,
|
||||
isIssued,
|
||||
isDraft,
|
||||
busy,
|
||||
downloading,
|
||||
downloadingLicense,
|
||||
@@ -62,8 +60,21 @@ export function ProcurementFlow({
|
||||
onAgree,
|
||||
onDownloadPdf,
|
||||
onDownloadOfflineLicense,
|
||||
downloadingAgreement,
|
||||
onDownloadSignedAgreement,
|
||||
} = controller;
|
||||
|
||||
// The builder owns the dialog's chrome while it is the visible step. It spans the whole quote
|
||||
// stage now, not just the draft part of it: issuing turns its last step into the review of the
|
||||
// issued paper rather than handing off to a separate card.
|
||||
const builderShowing =
|
||||
isLinked && started && (editing || stage === "trial" || stage === "quote");
|
||||
const agreementShowing =
|
||||
isLinked && started && !editing && stage === "security" && latest != null;
|
||||
// Both of these name their own document in the header and carry its download there, so the shell
|
||||
// must not stack a second header (nor a second close) above them.
|
||||
const ownsHeader = builderShowing || agreementShowing;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProcurementModal
|
||||
@@ -71,6 +82,9 @@ export function ProcurementFlow({
|
||||
onClose={() => setOpen(false)}
|
||||
title={t("portal.procurement.title")}
|
||||
subtitle={t("portal.procurement.subtitle")}
|
||||
// The builder and the agreement render their own heading and close; the payment and live
|
||||
// steps have none, so they keep the shell's header.
|
||||
headerless={ownsHeader}
|
||||
>
|
||||
{error && (
|
||||
<Banner
|
||||
@@ -88,11 +102,7 @@ export function ProcurementFlow({
|
||||
title={t("portal.procurement.link.title")}
|
||||
description={t("portal.procurement.link.description")}
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
accent="premium"
|
||||
onClick={() => openLinkModal()}
|
||||
>
|
||||
<Button variant="primary" onClick={() => openLinkModal()}>
|
||||
{t("portal.procurement.link.cta")}
|
||||
</Button>
|
||||
}
|
||||
@@ -103,46 +113,63 @@ export function ProcurementFlow({
|
||||
|
||||
{isLinked && started && (
|
||||
<>
|
||||
<div className="portal-proc__modal-stepper">
|
||||
<StageStepper journey={FLOW_JOURNEY} currentStage={stage!} />
|
||||
</div>
|
||||
|
||||
{(editing ||
|
||||
(isDraft && (stage === "trial" || stage === "quote"))) && (
|
||||
{builderShowing && (
|
||||
<QuoteBuilder
|
||||
deployment={data?.deployment ?? "cloud"}
|
||||
seats={data?.seats ?? 0}
|
||||
email={scheduleEmail}
|
||||
dealDetails={
|
||||
data
|
||||
? {
|
||||
businessName: data.businessName,
|
||||
contactName: data.contactName,
|
||||
contactEmail: data.contactEmail,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
initial={latest?.config}
|
||||
eulaAlreadyAgreed={data?.trialStartedAt != null}
|
||||
onClose={() => setOpen(false)}
|
||||
onGenerate={onGenerate}
|
||||
// Null while re-editing: the buyer asked for the form, not the paper they just left.
|
||||
issued={!editing && isIssued ? latest : null}
|
||||
downloading={downloading}
|
||||
onDownload={onDownloadPdf}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quote + agreement are one step: review the itemised quote and the agreement, then
|
||||
accept straight into a committed subscription. Once accepted you can't go back.
|
||||
("security" is the retired agreement stage — still handled so an older deal that
|
||||
stopped there isn't left blank.) */}
|
||||
{!editing &&
|
||||
isIssued &&
|
||||
(stage === "quote" || stage === "security") &&
|
||||
latest && (
|
||||
<ProcurementAgreement
|
||||
quote={latest}
|
||||
busy={busy}
|
||||
downloading={downloading}
|
||||
onAgree={onAgree}
|
||||
onDownload={onDownloadPdf}
|
||||
onEdit={() => setEditing(true)}
|
||||
/>
|
||||
)}
|
||||
{/* Agreement step: review and sign the enterprise agreement. Signing accepts the quote
|
||||
into a committed subscription (Stripe). */}
|
||||
{agreementShowing && latest && (
|
||||
<ProcurementAgreement
|
||||
quote={latest}
|
||||
busy={busy}
|
||||
onAgree={onAgree}
|
||||
onRequestChanges={() => {
|
||||
setOpen(false);
|
||||
setExtra("schedule");
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!editing && stage === "procurement" && latest && (
|
||||
<PaymentStageCard
|
||||
invoiceUrl={latest.invoiceUrl}
|
||||
invoicePdf={latest.invoicePdf ?? invoicePdf}
|
||||
signedAgreementVersion={data?.agreementSignedVersion}
|
||||
downloadingAgreement={downloadingAgreement}
|
||||
onDownloadSignedAgreement={onDownloadSignedAgreement}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!editing && stage === "active" && <LiveStageCard />}
|
||||
{!editing && stage === "active" && (
|
||||
<LiveStageCard
|
||||
signedAgreementVersion={data?.agreementSignedVersion}
|
||||
downloadingAgreement={downloadingAgreement}
|
||||
onDownloadSignedAgreement={onDownloadSignedAgreement}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ProcurementModal>
|
||||
@@ -151,6 +178,8 @@ export function ProcurementFlow({
|
||||
open={extra === "setup"}
|
||||
onClose={() => setExtra(null)}
|
||||
busy={busy}
|
||||
email={scheduleEmail ?? undefined}
|
||||
onScheduleCall={() => setExtra("schedule")}
|
||||
onConfirm={onConfirmSetup}
|
||||
/>
|
||||
{data?.licenseKey && (
|
||||
@@ -185,6 +214,23 @@ export function ProcurementFlow({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<DocumentsModal
|
||||
open={extra === "documents"}
|
||||
onClose={() => setExtra(null)}
|
||||
agreementVersion={data?.agreementSignedVersion}
|
||||
downloadingAgreement={downloadingAgreement}
|
||||
onDownloadAgreement={onDownloadSignedAgreement}
|
||||
onViewAgreement={() => {
|
||||
setExtra(null);
|
||||
setEditing(false);
|
||||
setOpen(true);
|
||||
}}
|
||||
quoteAvailable={!!latest?.stripeQuoteId}
|
||||
downloadingQuote={downloading}
|
||||
onDownloadQuote={onDownloadPdf}
|
||||
invoiceUrl={latest?.invoiceUrl}
|
||||
invoicePdf={latest?.invoicePdf ?? invoicePdf}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ProcurementHome } from "@portal/components/procurement/ProcurementHome";
|
||||
|
||||
/**
|
||||
* The end-to-end procurement experience (Home hero + takeover modal), driven by the `procurementSaas`
|
||||
* MSW handlers: start trial → build quote → generate (issue Stripe Quote) → milestone (download PDF /
|
||||
* accept). `autoOpen` opens the modal so the flow is immediately clickable.
|
||||
*/
|
||||
const meta: Meta<typeof ProcurementHome> = {
|
||||
title: "Portal/Procurement/ProcurementHome",
|
||||
component: ProcurementHome,
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ProcurementHome>;
|
||||
|
||||
export const Default: Story = { args: { autoOpen: true } };
|
||||
@@ -1,22 +0,0 @@
|
||||
import { ProcurementBanner } from "@portal/components/procurement/ProcurementBanner";
|
||||
import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow";
|
||||
import { useProcurement } from "@portal/components/procurement/useProcurement";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/**
|
||||
* The standalone procurement experience: a deal-status hero (or enterprise
|
||||
* on-ramp when no deal exists) above the full-screen takeover flow that holds
|
||||
* the journey — build + issue a quote, review + agree to the enterprise
|
||||
* agreement, then accept into a committed subscription. Rendered at
|
||||
* /procurement (autoOpen). On Home the deal-status hero instead attaches to the
|
||||
* tier hero card's footer (see HomeHero) so this component isn't used there.
|
||||
*/
|
||||
export function ProcurementHome({ autoOpen = false }: { autoOpen?: boolean }) {
|
||||
const controller = useProcurement(autoOpen);
|
||||
return (
|
||||
<>
|
||||
<ProcurementBanner controller={controller} />
|
||||
<ProcurementFlow controller={controller} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -31,9 +31,7 @@ export const Open: Story = {
|
||||
contract and go live.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: "0.6rem" }}>
|
||||
<Button variant="primary" accent="premium">
|
||||
Continue to checkout
|
||||
</Button>
|
||||
<Button variant="primary">Continue to checkout</Button>
|
||||
<Button variant="secondary">Edit quote</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,104 +1,49 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ReactNode } from "react";
|
||||
import { FlowModal } from "@portal/components/shared/FlowModal";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/** Keep keyboard focus inside an open dialog: focus it on open and wrap Tab at the edges. */
|
||||
export function useFocusTrap(open: boolean) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const panel = ref.current;
|
||||
if (!panel) return;
|
||||
const prev = document.activeElement as HTMLElement | null;
|
||||
const focusables = () =>
|
||||
Array.from(
|
||||
panel.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter((el) => !el.hasAttribute("disabled"));
|
||||
(focusables()[0] ?? panel).focus();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab") return;
|
||||
const items = focusables();
|
||||
if (items.length === 0) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
panel.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
panel.removeEventListener("keydown", onKey);
|
||||
prev?.focus?.();
|
||||
};
|
||||
}, [open]);
|
||||
return ref;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen takeover modal for the procurement flow, copying the prototype's modal design
|
||||
* (portaled to body, dimmed + blurred backdrop, rounded panel, close button). The Home deal-status
|
||||
* hero expands into this.
|
||||
* The procurement takeover: the shared {@link FlowModal} at takeover width. Chrome and copy only —
|
||||
* the shell (portal, focus trap, Escape, close, header/body bands) is shared, so this dialog cannot
|
||||
* drift from the trial and licence dialogs the way two hand-rolled shells did.
|
||||
*/
|
||||
export function ProcurementModal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
headerless = false,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Dialog label. Omit `subtitle` (and pass `headerless`) when the step renders its own heading. */
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Skip the title block, which takes the shell's close with it: the step inside supplies the
|
||||
* heading, step badge and its own close (see StepModalHeader), so the shell would otherwise stack
|
||||
* a second header and leave a stray close above it. Escape and the backdrop still dismiss.
|
||||
*/
|
||||
headerless?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const trapRef = useFocusTrap(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="portal-procmodal"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
return (
|
||||
<FlowModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
label={title}
|
||||
size="lg"
|
||||
header={
|
||||
headerless ? undefined : (
|
||||
<>
|
||||
<h2 className="portal-procmodal__title">{title}</h2>
|
||||
{subtitle && <p className="portal-procmodal__sub">{subtitle}</p>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={trapRef}
|
||||
className="portal-procmodal__panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-procmodal__close"
|
||||
onClick={onClose}
|
||||
aria-label={t("portal.procurement.modal.close")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div className="portal-procmodal__header">
|
||||
<h2 className="portal-procmodal__title">{title}</h2>
|
||||
{subtitle && <p className="portal-procmodal__sub">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="portal-procmodal__body">{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
{children}
|
||||
</FlowModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,61 +1,92 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card } from "@app/ui";
|
||||
import { Button } from "@app/ui";
|
||||
import { openApiUrl } from "@portal/api/externalUrl";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/**
|
||||
* The stage-specific cards shown inside the procurement takeover modal once a quote exists: the
|
||||
* issued-quote milestone, the subscription-created payment step, and the live confirmation. Each is
|
||||
* a pure presentational view driven by props; ProcurementHome owns the state and the actions.
|
||||
* The stage-specific views shown inside the procurement takeover modal once the agreement is signed:
|
||||
* the subscription-created payment step and the live confirmation. Each is a pure presentational view
|
||||
* driven by props; the controller owns the state and the actions.
|
||||
*
|
||||
* Neither is wrapped in a Card: the dialog is already the surface, and a card inside it drew a second
|
||||
* border around content that filled it. Both wear the same eyebrow/title/description stack and put
|
||||
* their actions in the flow's footer bar, so the last two steps of the journey read like the ones
|
||||
* before them rather than like panels that wandered in.
|
||||
*/
|
||||
|
||||
/** The subscription-created step: pay or download the first invoice. */
|
||||
/** The subscription-created step: pay or download the first invoice, and the signed agreement. */
|
||||
export function PaymentStageCard({
|
||||
invoiceUrl,
|
||||
invoicePdf,
|
||||
signedAgreementVersion,
|
||||
downloadingAgreement,
|
||||
onDownloadSignedAgreement,
|
||||
}: {
|
||||
invoiceUrl?: string | null;
|
||||
invoicePdf?: string | null;
|
||||
/** Version label of the signed agreement PDF, if one is available to download. */
|
||||
signedAgreementVersion?: string | null;
|
||||
downloadingAgreement?: boolean;
|
||||
onDownloadSignedAgreement?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card padding="loose">
|
||||
<div className="portal-procstage">
|
||||
<span className="portal-proc__eyebrow">
|
||||
{t("portal.procurement.payment.eyebrow")}
|
||||
</span>
|
||||
<h3 className="portal-proc__builder-title">
|
||||
{t("portal.procurement.payment.title")}
|
||||
</h3>
|
||||
<p className="portal-proc__subtitle">
|
||||
{t("portal.procurement.payment.description")}
|
||||
</p>
|
||||
{(invoiceUrl || invoicePdf) && (
|
||||
<div className="portal-proc__payment-actions">
|
||||
{invoiceUrl && (
|
||||
<Button
|
||||
variant="primary"
|
||||
accent="premium"
|
||||
onClick={() => window.open(invoiceUrl, "_blank", "noopener")}
|
||||
>
|
||||
{t("portal.procurement.payment.viewInvoice")}
|
||||
</Button>
|
||||
)}
|
||||
{invoicePdf && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => window.open(invoicePdf, "_blank", "noopener")}
|
||||
>
|
||||
{t("portal.procurement.payment.downloadInvoice")}
|
||||
</Button>
|
||||
)}
|
||||
{(invoiceUrl || invoicePdf || signedAgreementVersion) && (
|
||||
<div className="portal-qb__foot portal-procstage__foot">
|
||||
<div className="portal-qb__foot-btns">
|
||||
{signedAgreementVersion && onDownloadSignedAgreement && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
loading={downloadingAgreement}
|
||||
onClick={onDownloadSignedAgreement}
|
||||
>
|
||||
{t("portal.procurement.payment.downloadAgreement")}
|
||||
</Button>
|
||||
)}
|
||||
{invoicePdf && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openApiUrl(invoicePdf)}
|
||||
>
|
||||
{t("portal.procurement.payment.downloadInvoice")}
|
||||
</Button>
|
||||
)}
|
||||
{invoiceUrl && (
|
||||
<Button variant="primary" onClick={() => openApiUrl(invoiceUrl)}>
|
||||
{t("portal.procurement.payment.viewInvoice")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The live confirmation once the deal is active. */
|
||||
export function LiveStageCard() {
|
||||
export function LiveStageCard({
|
||||
signedAgreementVersion,
|
||||
downloadingAgreement,
|
||||
onDownloadSignedAgreement,
|
||||
}: {
|
||||
signedAgreementVersion?: string | null;
|
||||
downloadingAgreement?: boolean;
|
||||
onDownloadSignedAgreement?: () => void;
|
||||
} = {}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card padding="loose">
|
||||
<div className="portal-procstage">
|
||||
<span className="portal-proc__eyebrow">
|
||||
{t("portal.procurement.live.eyebrow")}
|
||||
</span>
|
||||
@@ -65,7 +96,20 @@ export function LiveStageCard() {
|
||||
<p className="portal-proc__subtitle">
|
||||
{t("portal.procurement.live.description")}
|
||||
</p>
|
||||
</Card>
|
||||
{signedAgreementVersion && onDownloadSignedAgreement && (
|
||||
<div className="portal-qb__foot portal-procstage__foot">
|
||||
<div className="portal-qb__foot-btns">
|
||||
<Button
|
||||
variant="secondary"
|
||||
loading={downloadingAgreement}
|
||||
onClick={onDownloadSignedAgreement}
|
||||
>
|
||||
{t("portal.procurement.payment.downloadAgreement")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,24 @@ import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
import {
|
||||
DocumentsIcon,
|
||||
DownloadIcon,
|
||||
PoliciesIcon,
|
||||
UsersIcon,
|
||||
} from "@portal/components/icons";
|
||||
import { money } from "@portal/components/procurement/format";
|
||||
import {
|
||||
buildQuote,
|
||||
recordLegalConsent,
|
||||
type QuoteConfigInput,
|
||||
type QuoteResult,
|
||||
} from "@portal/api/procurement";
|
||||
import { LegalDocumentModal } from "@portal/components/procurement/ProcurementExtras";
|
||||
import { StepModalHeader } from "@portal/components/shared/StepModalHeader";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
const STEPS = ["volume", "plan", "details"] as const;
|
||||
const STEPS = ["volume", "plan", "details", "review"] as const;
|
||||
const DETAILS_STEP = 2;
|
||||
const REVIEW_STEP = 3;
|
||||
const TERM_DISCOUNT = [0, 0.03, 0.05, 0.06, 0.07]; // 1..5 years — meter-only discount (D71)
|
||||
// Governance posture: the intensity (runs per PDF) fed to the committed-volume curve.
|
||||
const POSTURES = [
|
||||
@@ -30,22 +36,57 @@ const SIZE_TIERS = [
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The enterprise quote builder — volume → commitment & service → details. A client-side preview
|
||||
* drives the live footer total; the backend is authoritative. Completing the form generates the
|
||||
* quote directly (build + issue in one step) — the issued quote is then shown as the milestone, so
|
||||
* there's no redundant in-builder preview.
|
||||
* The enterprise quote builder — volume → commitment & service → details → review. A client-side
|
||||
* preview drives the live footer total; the backend is authoritative. Generating builds and issues in
|
||||
* one go, and the issued quote comes back as the fourth step: the buyer reads the real itemised paper
|
||||
* and can download it, but does not accept here. Accepting is a decision taken from the deal card,
|
||||
* deliberately, so circulating the quote internally is not a dead end in a modal.
|
||||
*/
|
||||
export function QuoteBuilder({
|
||||
deployment,
|
||||
seats = 0,
|
||||
email,
|
||||
onClose,
|
||||
dealDetails,
|
||||
initial,
|
||||
eulaAlreadyAgreed = false,
|
||||
onGenerate,
|
||||
issued,
|
||||
downloading = false,
|
||||
onDownload,
|
||||
}: {
|
||||
deployment: string;
|
||||
/** Seat count from the trial setup; seeds the users field + volume estimate on a fresh quote. */
|
||||
seats?: number;
|
||||
/** Linked-account email; prefills the contact email on a fresh quote's details step. */
|
||||
email?: string | null;
|
||||
/** Dismiss the dialog. The builder draws its own header, so it carries the close too. */
|
||||
onClose?: () => void;
|
||||
/**
|
||||
* The buying entity captured at trial setup. Seeds a fresh quote's details step so it confirms
|
||||
* what is already known rather than asking twice; the buyer can still correct it here, since a
|
||||
* deal can change hands between trial and quote.
|
||||
*/
|
||||
dealDetails?: {
|
||||
businessName?: string | null;
|
||||
contactName?: string | null;
|
||||
contactEmail?: string | null;
|
||||
};
|
||||
/** Seed the builder from an existing quote's config (re-editing a quote). */
|
||||
initial?: QuoteConfigInput;
|
||||
/**
|
||||
* The issued quote, which is what the review step shows. Its arrival is also what opens that step:
|
||||
* the parent issues the quote and it lands by snapshot refresh, so there is no synchronous result
|
||||
* to advance on. Null while re-editing, so editing reopens the form rather than the paper.
|
||||
*/
|
||||
issued?: QuoteResult | null;
|
||||
downloading?: boolean;
|
||||
onDownload?: () => void;
|
||||
/**
|
||||
* The buyer already accepted the EULA (e.g. at trial start). When true, the EULA clickwrap is
|
||||
* hidden here and no consent is recorded at quote time — it's only collected once.
|
||||
*/
|
||||
eulaAlreadyAgreed?: boolean;
|
||||
/** Called with the priced DRAFT quote; the parent issues it as a Stripe Quote. */
|
||||
onGenerate: (quote: QuoteResult) => void;
|
||||
}) {
|
||||
@@ -65,9 +106,9 @@ export function QuoteBuilder({
|
||||
indemnification: false,
|
||||
training: false,
|
||||
qbr: false,
|
||||
businessName: "",
|
||||
contactName: "",
|
||||
contactEmail: "",
|
||||
businessName: dealDetails?.businessName ?? "",
|
||||
contactName: dealDetails?.contactName ?? "",
|
||||
contactEmail: dealDetails?.contactEmail ?? email ?? "",
|
||||
addressLine1: "",
|
||||
addressLine2: "",
|
||||
city: "",
|
||||
@@ -79,29 +120,81 @@ export function QuoteBuilder({
|
||||
);
|
||||
// A seeded quote carries a volume but no user count, so treat it as manually set.
|
||||
const [manualVolume, setManualVolume] = useState(initial != null);
|
||||
const [eula, setEula] = useState(initial != null);
|
||||
// Never pre-ticked, even when re-editing a quote: a consent the buyer did not tick in this session
|
||||
// is not a consent, and recordLegalConsent would have logged one as though they had.
|
||||
const [eula, setEula] = useState(false);
|
||||
const [legalDoc, setLegalDoc] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Only surface field errors once the buyer tries to generate — no red fields on first sight.
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
|
||||
function set<K extends keyof QuoteConfigInput>(k: K, v: QuoteConfigInput[K]) {
|
||||
setCfg((c) => ({ ...c, [k]: v }));
|
||||
}
|
||||
|
||||
// Re-editing an existing quote: everything is seeded, so jump to the last step (details) with the
|
||||
// Required buyer details before a quote can be generated (Order Form / invoice need these).
|
||||
const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
|
||||
(cfg.contactEmail ?? "").trim(),
|
||||
);
|
||||
const valid = {
|
||||
businessName: cfg.businessName.trim().length > 0,
|
||||
contactName: (cfg.contactName ?? "").trim().length > 0,
|
||||
contactEmail: emailOk,
|
||||
addressLine1: (cfg.addressLine1 ?? "").trim().length > 0,
|
||||
city: (cfg.city ?? "").trim().length > 0,
|
||||
region: (cfg.region ?? "").trim().length > 0,
|
||||
postalCode: (cfg.postalCode ?? "").trim().length > 0,
|
||||
};
|
||||
const detailsValid = Object.values(valid).every(Boolean);
|
||||
const eulaOk = eulaAlreadyAgreed || eula;
|
||||
const canGenerate = detailsValid && eulaOk;
|
||||
|
||||
// Re-editing an existing quote: everything is seeded, so jump to the details step with the
|
||||
// agreement pre-accepted — one click re-generates, or Back to change a field. No walking from step 1.
|
||||
// Mount-only: seed the step from `initial` once (deliberately no deps).
|
||||
useEffect(() => {
|
||||
if (initial) setStep(STEPS.length - 1);
|
||||
if (initial) setStep(DETAILS_STEP);
|
||||
}, []);
|
||||
|
||||
// Issuing lands by snapshot refresh rather than as a return value, so the arrival of the issued
|
||||
// quote is what opens the review step. Keyed on the quote's id, not the object: React Query hands
|
||||
// back a fresh object on every refetch, which would yank a buyer who had walked Back to the form.
|
||||
useEffect(() => {
|
||||
if (issued) setStep(REVIEW_STEP);
|
||||
}, [issued?.quoteId]);
|
||||
|
||||
const preview = previewAnnualMinor(cfg);
|
||||
const tcvPreview = preview * cfg.termYears + (cfg.training ? 750_000 : 0);
|
||||
|
||||
// On the review step the footer quotes the issued figures rather than the client-side preview, so
|
||||
// the running total never disagrees with the paper directly above it.
|
||||
const onPaper = issued != null && step === REVIEW_STEP;
|
||||
const running = onPaper
|
||||
? {
|
||||
annual: money(issued.annualNetMinor, issued.currency),
|
||||
years: issued.config.termYears,
|
||||
tcv: money(issued.tcvMinor, issued.currency),
|
||||
}
|
||||
: {
|
||||
annual: money(preview),
|
||||
years: cfg.termYears,
|
||||
tcv: money(tcvPreview),
|
||||
};
|
||||
|
||||
// Fully filled → price + hand the draft to the parent to issue as a Stripe Quote (which then shows
|
||||
// as the milestone). No separate in-builder preview step.
|
||||
async function generate() {
|
||||
if (!canGenerate) {
|
||||
setShowErrors(true);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
onGenerate(await buildQuote(cfg));
|
||||
const quote = await buildQuote(cfg);
|
||||
// Record the EULA clickwrap only when it's collected here — i.e. the buyer didn't already
|
||||
// accept it at trial start. Best-effort.
|
||||
if (!eulaAlreadyAgreed) void recordLegalConsent("eula", "quote");
|
||||
onGenerate(quote);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -109,22 +202,16 @@ export function QuoteBuilder({
|
||||
|
||||
return (
|
||||
<div className="portal-qb">
|
||||
<div className="portal-qb__head">
|
||||
<h3 className="portal-qb__title">
|
||||
{t("portal.procurement.builder.title")}
|
||||
</h3>
|
||||
<span className="portal-qb__stepchip">
|
||||
{t("portal.procurement.builder.stepOf", {
|
||||
n: step + 1,
|
||||
total: STEPS.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="portal-qb__progress">
|
||||
{STEPS.map((s, i) => (
|
||||
<span key={s} data-on={i <= step || undefined} />
|
||||
))}
|
||||
</div>
|
||||
<StepModalHeader
|
||||
title={t("portal.procurement.builder.title")}
|
||||
step={step + 1}
|
||||
total={STEPS.length}
|
||||
stepLabel={t("portal.procurement.builder.stepOf", {
|
||||
n: step + 1,
|
||||
total: STEPS.length,
|
||||
})}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<div className="portal-qb__body">
|
||||
{step === 0 && (
|
||||
@@ -171,15 +258,6 @@ export function QuoteBuilder({
|
||||
? t("portal.procurement.builder.volManual")
|
||||
: t("portal.procurement.builder.volNoUsers")}
|
||||
</p>
|
||||
</Step>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<Step
|
||||
icon={<PoliciesIcon size={22} />}
|
||||
title={t("portal.procurement.builder.s2Title")}
|
||||
sub={t("portal.procurement.builder.s2Sub")}
|
||||
>
|
||||
<Field label={t("portal.procurement.builder.posture")}>
|
||||
<div className="portal-qb__opts">
|
||||
{POSTURES.map((p) => (
|
||||
@@ -209,7 +287,15 @@ export function QuoteBuilder({
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</Step>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<Step
|
||||
icon={<PoliciesIcon size={22} />}
|
||||
title={t("portal.procurement.builder.s2Title")}
|
||||
sub={t("portal.procurement.builder.s2Sub")}
|
||||
>
|
||||
<Field label={t("portal.procurement.builder.term")}>
|
||||
<div className="portal-qb__pills">
|
||||
{[1, 2, 3, 4, 5].map((y) => (
|
||||
@@ -287,7 +373,11 @@ export function QuoteBuilder({
|
||||
sub={t("portal.procurement.builder.s3Sub")}
|
||||
>
|
||||
<div className="portal-qb__row">
|
||||
<Field label={t("portal.procurement.builder.businessName")}>
|
||||
<Field
|
||||
label={t("portal.procurement.builder.businessName")}
|
||||
required
|
||||
invalid={showErrors && !valid.businessName}
|
||||
>
|
||||
<input
|
||||
placeholder={t(
|
||||
"portal.procurement.builder.businessNamePlaceholder",
|
||||
@@ -296,7 +386,11 @@ export function QuoteBuilder({
|
||||
onChange={(e) => set("businessName", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("portal.procurement.builder.contactName")}>
|
||||
<Field
|
||||
label={t("portal.procurement.builder.contactName")}
|
||||
required
|
||||
invalid={showErrors && !valid.contactName}
|
||||
>
|
||||
<input
|
||||
placeholder={t(
|
||||
"portal.procurement.builder.contactNamePlaceholder",
|
||||
@@ -306,7 +400,11 @@ export function QuoteBuilder({
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t("portal.procurement.builder.contactEmail")}>
|
||||
<Field
|
||||
label={t("portal.procurement.builder.contactEmail")}
|
||||
required
|
||||
invalid={showErrors && !valid.contactEmail}
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
placeholder={t(
|
||||
@@ -316,7 +414,11 @@ export function QuoteBuilder({
|
||||
onChange={(e) => set("contactEmail", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("portal.procurement.builder.addressLine1")}>
|
||||
<Field
|
||||
label={t("portal.procurement.builder.addressLine1")}
|
||||
required
|
||||
invalid={showErrors && !valid.addressLine1}
|
||||
>
|
||||
<input
|
||||
placeholder={t(
|
||||
"portal.procurement.builder.addressLine1Placeholder",
|
||||
@@ -335,14 +437,22 @@ export function QuoteBuilder({
|
||||
/>
|
||||
</Field>
|
||||
<div className="portal-qb__row">
|
||||
<Field label={t("portal.procurement.builder.city")}>
|
||||
<Field
|
||||
label={t("portal.procurement.builder.city")}
|
||||
required
|
||||
invalid={showErrors && !valid.city}
|
||||
>
|
||||
<input
|
||||
placeholder={t("portal.procurement.builder.cityPlaceholder")}
|
||||
value={cfg.city ?? ""}
|
||||
onChange={(e) => set("city", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("portal.procurement.builder.region")}>
|
||||
<Field
|
||||
label={t("portal.procurement.builder.region")}
|
||||
required
|
||||
invalid={showErrors && !valid.region}
|
||||
>
|
||||
<input
|
||||
placeholder={t(
|
||||
"portal.procurement.builder.regionPlaceholder",
|
||||
@@ -351,7 +461,11 @@ export function QuoteBuilder({
|
||||
onChange={(e) => set("region", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("portal.procurement.builder.postalCode")}>
|
||||
<Field
|
||||
label={t("portal.procurement.builder.postalCode")}
|
||||
required
|
||||
invalid={showErrors && !valid.postalCode}
|
||||
>
|
||||
<input
|
||||
placeholder={t(
|
||||
"portal.procurement.builder.postalCodePlaceholder",
|
||||
@@ -379,25 +493,133 @@ export function QuoteBuilder({
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<label className="portal-qb__eula">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={eula}
|
||||
onChange={(e) => setEula(e.target.checked)}
|
||||
/>
|
||||
<span>{t("portal.procurement.builder.eula")}</span>
|
||||
</label>
|
||||
{!eulaAlreadyAgreed && (
|
||||
<label className="portal-qb__eula">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={eula}
|
||||
onChange={(e) => setEula(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t("portal.procurement.builder.eula")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="portal-legal__link"
|
||||
onClick={() => setLegalDoc("eula")}
|
||||
>
|
||||
{t("portal.procurement.builder.viewEula")}
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{showErrors && !canGenerate && (
|
||||
<p className="portal-qb__error">
|
||||
{t("portal.procurement.builder.completeRequired")}
|
||||
</p>
|
||||
)}
|
||||
</Step>
|
||||
)}
|
||||
|
||||
{/* No step heading here, unlike the form steps: the quote is the content, and a heading over
|
||||
it only repeats what the paper already says. The real issued figures, not the footer's
|
||||
client-side preview — this is the document the buyer circulates, so it has to match the
|
||||
PDF and the Stripe quote exactly. */}
|
||||
{step === REVIEW_STEP && issued && (
|
||||
<div className="portal-qb__papertray">
|
||||
<div className="portal-qb__paper">
|
||||
<div className="portal-qb__paper-head">
|
||||
<div>
|
||||
<div className="portal-qb__paper-brand">Stirling PDF</div>
|
||||
<div className="portal-qb__paper-eyebrow">
|
||||
{t("portal.procurement.builder.paperEyebrow")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="portal-qb__paper-meta">
|
||||
<div className="portal-qb__quote-number">
|
||||
{issued.quoteNumber}
|
||||
</div>
|
||||
{issued.validUntil && (
|
||||
<div>
|
||||
{t("portal.procurement.review.validUntil", {
|
||||
date: new Date(issued.validUntil).toLocaleDateString(),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* On the document rather than in the footer: it downloads this paper, so it
|
||||
belongs to it, and the footer stays the flow's own Back/Done. */}
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
className="portal-qb__paper-download"
|
||||
leftSection={<DownloadIcon size={14} />}
|
||||
loading={downloading}
|
||||
onClick={onDownload}
|
||||
>
|
||||
{t("portal.procurement.review.downloadCta")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{issued.config.businessName?.trim() && (
|
||||
<div className="portal-qb__paper-for">
|
||||
<div className="portal-qb__paper-eyebrow">
|
||||
{t("portal.procurement.builder.paperFor")}
|
||||
</div>
|
||||
<div className="portal-qb__paper-company">
|
||||
{issued.config.businessName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="portal-qb__lines">
|
||||
{issued.lineItems.map((li) => (
|
||||
<li key={li.key} data-kind={li.kind}>
|
||||
<span>{li.label}</span>
|
||||
<span>{money(li.amountMinor, issued.currency)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="portal-qb__total">
|
||||
<div>
|
||||
<div className="portal-qb__total-label">
|
||||
{t("portal.procurement.review.annual")}
|
||||
</div>
|
||||
<div className="portal-qb__total-tcv">
|
||||
{t("portal.procurement.review.tcv", {
|
||||
years: issued.config.termYears,
|
||||
tcv: money(issued.tcvMinor, issued.currency),
|
||||
})}
|
||||
</div>
|
||||
<div className="portal-qb__total-tcv">
|
||||
{t("portal.procurement.review.renewal", {
|
||||
amount: money(
|
||||
issued.renewalAnnualNetMinor,
|
||||
issued.currency,
|
||||
),
|
||||
pct: issued.cpiRatePct,
|
||||
})}
|
||||
</div>
|
||||
{issued.config.poNumber?.trim() && (
|
||||
<div className="portal-qb__total-tcv">
|
||||
{t("portal.procurement.review.poNumber", {
|
||||
po: issued.config.poNumber.trim(),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="portal-qb__total-num">
|
||||
{money(issued.annualNetMinor, issued.currency)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="portal-qb__foot">
|
||||
<span className="portal-qb__running">
|
||||
{t("portal.procurement.builder.running", {
|
||||
annual: money(preview),
|
||||
years: cfg.termYears,
|
||||
tcv: money(tcvPreview),
|
||||
})}
|
||||
{t("portal.procurement.builder.running", running)}
|
||||
</span>
|
||||
<div className="portal-qb__foot-btns">
|
||||
{step > 0 && (
|
||||
@@ -408,7 +630,6 @@ export function QuoteBuilder({
|
||||
{step === 0 && (
|
||||
<Button
|
||||
variant="primary"
|
||||
accent="premium"
|
||||
disabled={cfg.volume <= 0}
|
||||
onClick={() => setStep(1)}
|
||||
>
|
||||
@@ -416,27 +637,26 @@ export function QuoteBuilder({
|
||||
</Button>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<Button
|
||||
variant="primary"
|
||||
accent="premium"
|
||||
onClick={() => setStep(2)}
|
||||
>
|
||||
<Button variant="primary" onClick={() => setStep(2)}>
|
||||
{t("portal.procurement.builder.continue")}
|
||||
</Button>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Button
|
||||
variant="primary"
|
||||
accent="premium"
|
||||
loading={busy}
|
||||
disabled={!eula}
|
||||
onClick={generate}
|
||||
>
|
||||
{step === DETAILS_STEP && (
|
||||
<Button variant="primary" loading={busy} onClick={generate}>
|
||||
{t("portal.procurement.builder.generate")}
|
||||
</Button>
|
||||
)}
|
||||
{/* No Accept here: the review step ends on the deal card, where accepting is one of two
|
||||
deliberate choices rather than the only way out of a modal. Download lives on the
|
||||
document itself. */}
|
||||
{step === REVIEW_STEP && (
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
{t("portal.procurement.builder.done")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<LegalDocumentModal docId={legalDoc} onClose={() => setLegalDoc(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -470,14 +690,25 @@ function Step({
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
invalid,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
invalid?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="portal-qb__field">
|
||||
<span className="portal-qb__field-label">{label}</span>
|
||||
<label className="portal-qb__field" data-invalid={invalid || undefined}>
|
||||
<span className="portal-qb__field-label">
|
||||
{label}
|
||||
{required && (
|
||||
<span className="portal-qb__req" aria-hidden>
|
||||
{" *"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { StageStepper } from "@portal/components/procurement/StageStepper";
|
||||
import { JOURNEY } from "@portal/api/procurement";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
const meta: Meta<typeof StageStepper> = {
|
||||
title: "Portal/Procurement/StageStepper",
|
||||
component: StageStepper,
|
||||
parameters: { layout: "padded" },
|
||||
args: { journey: JOURNEY },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof StageStepper>;
|
||||
|
||||
export const AtAgreement: Story = { args: { currentStage: "security" } };
|
||||
|
||||
export const AtTrial: Story = { args: { currentStage: "trial" } };
|
||||
|
||||
// Greyed preview for the free/pro upgrade gate, no stage is current.
|
||||
export const Locked: Story = { args: { currentStage: "trial", locked: true } };
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DealStage, JourneyStep } from "@portal/api/procurement";
|
||||
|
||||
/** Status of a step relative to the deal's current stage. */
|
||||
type StepState = "complete" | "current" | "upcoming";
|
||||
|
||||
/**
|
||||
* The five-stage commercial journey as a horizontal band of labelled dots with
|
||||
* connectors between them. Purely presentational, the gating action lives in
|
||||
* the journey card's next-step row, not on the dots. `locked` greys the whole
|
||||
* band for the free/pro upgrade preview, where no stage is current.
|
||||
*/
|
||||
export function StageStepper({
|
||||
journey,
|
||||
currentStage,
|
||||
locked = false,
|
||||
}: {
|
||||
journey: JourneyStep[];
|
||||
currentStage: DealStage;
|
||||
locked?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const order = journey.map((s) => s.stage);
|
||||
const curIdx = locked ? -1 : order.indexOf(currentStage);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`portal-proc__steps${locked ? " portal-proc__steps--locked" : ""}`}
|
||||
>
|
||||
{journey.map((step, i) => {
|
||||
const state: StepState =
|
||||
i < curIdx ? "complete" : i === curIdx ? "current" : "upcoming";
|
||||
return (
|
||||
<Fragment key={step.stage}>
|
||||
{i > 0 && (
|
||||
<span
|
||||
className="portal-proc__step-line"
|
||||
data-filled={i <= curIdx}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<div className={`portal-proc__step portal-proc__step--${state}`}>
|
||||
<span className="portal-proc__step-dot" aria-hidden />
|
||||
<span className="portal-proc__step-label">{t(step.label)}</span>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
/** Shared formatting + status/action mappings for the Procurement surface. */
|
||||
|
||||
import type { StatusTone } from "@app/ui";
|
||||
import type { DocAction, DocStatus } from "@portal/api/procurement";
|
||||
/** Money formatting for the procurement surface. */
|
||||
|
||||
export const USD = new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
@@ -17,30 +14,3 @@ export function money(minor: number, currency: string = "USD"): string {
|
||||
maximumFractionDigits: 0,
|
||||
}).format(minor / 100);
|
||||
}
|
||||
|
||||
/** Document status → badge tone. Action items lean amber to pull the eye. */
|
||||
export const STATUS_TONE: Record<DocStatus, StatusTone> = {
|
||||
available: "success",
|
||||
action: "warning",
|
||||
pending: "info",
|
||||
request: "neutral",
|
||||
complete: "neutral",
|
||||
};
|
||||
|
||||
/** Document status → translation key for the short badge label. */
|
||||
export const STATUS_LABEL_KEY: Record<DocStatus, string> = {
|
||||
available: "portal.procurement.status.available",
|
||||
action: "portal.procurement.status.action",
|
||||
pending: "portal.procurement.status.pending",
|
||||
request: "portal.procurement.status.request",
|
||||
complete: "portal.procurement.status.complete",
|
||||
};
|
||||
|
||||
/** Action → translation key for the button label (the fee, when present, is appended by the caller). */
|
||||
export const ACTION_LABEL_KEY: Record<DocAction, string> = {
|
||||
download: "portal.procurement.action.download",
|
||||
sign: "portal.procurement.action.sign",
|
||||
pay: "portal.procurement.action.pay",
|
||||
upload: "portal.procurement.action.upload",
|
||||
request: "portal.procurement.action.request",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* Most deal stages are their own visit: a buyer can sit in one for days, so finishing a step hands
|
||||
* them back to the deal card rather than gliding on to the next.
|
||||
*
|
||||
* Two steps are deliberately not like that, and both are easy to regress into the old behaviour.
|
||||
* Generating a quote stays open, because the issued quote becomes the builder's review step.
|
||||
* Accepting one opens the agreement, because the buyer just took that decision from the card and
|
||||
* being returned to it to press a second button reads as a dead end.
|
||||
*
|
||||
* The failure cases matter most: closing on a failure would tear down the error banner with the
|
||||
* modal, and *opening* on a failure would show the buyer an agreement for a deal that never left the
|
||||
* quote stage.
|
||||
*/
|
||||
const { fetchSnapshot, startAgreement, issueQuote } = vi.hoisted(() => ({
|
||||
fetchSnapshot: vi.fn(),
|
||||
startAgreement: vi.fn(),
|
||||
issueQuote: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@portal/api/procurement", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@portal/api/procurement")>()),
|
||||
fetchSnapshot,
|
||||
startAgreement,
|
||||
issueQuote,
|
||||
}));
|
||||
vi.mock("@portal/contexts/usePortalLinked", () => ({
|
||||
usePortalLinked: () => true,
|
||||
}));
|
||||
|
||||
import {
|
||||
useProcurement,
|
||||
type ProcurementController,
|
||||
} from "@portal/components/procurement/useProcurement";
|
||||
import type { QuoteResult } from "@portal/api/procurement";
|
||||
|
||||
const SNAPSHOT = {
|
||||
dealId: 1,
|
||||
stage: "quote" as const,
|
||||
deployment: "cloud",
|
||||
seats: 10,
|
||||
trialStartedAt: "2026-07-01T00:00:00Z",
|
||||
trialEndsAt: "2026-08-01T00:00:00Z",
|
||||
trialExtensionsUsed: 0,
|
||||
licensed: false,
|
||||
licenseKey: null,
|
||||
agreementSignedVersion: null,
|
||||
latestQuote: null,
|
||||
};
|
||||
|
||||
let ctl: ProcurementController;
|
||||
function Probe() {
|
||||
ctl = useProcurement();
|
||||
return null;
|
||||
}
|
||||
|
||||
function mount() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<Probe />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSnapshot.mockReset().mockResolvedValue(SNAPSHOT);
|
||||
startAgreement.mockReset().mockResolvedValue(SNAPSHOT);
|
||||
issueQuote.mockReset().mockResolvedValue(SNAPSHOT);
|
||||
});
|
||||
|
||||
describe("useProcurement", () => {
|
||||
it("opens the agreement once accepting the quote succeeds", async () => {
|
||||
mount();
|
||||
await waitFor(() => expect(ctl.started).toBe(true));
|
||||
|
||||
await act(async () => {
|
||||
await ctl.onAcceptQuote();
|
||||
});
|
||||
|
||||
expect(startAgreement).toHaveBeenCalledTimes(1);
|
||||
expect(ctl.open).toBe(true);
|
||||
expect(ctl.error).toBeNull();
|
||||
});
|
||||
|
||||
it("does not open the agreement when accepting fails, and says why", async () => {
|
||||
startAgreement.mockRejectedValue(new Error("stripe refused"));
|
||||
const quiet = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mount();
|
||||
await waitFor(() => expect(ctl.started).toBe(true));
|
||||
|
||||
await act(async () => {
|
||||
await ctl.onAcceptQuote();
|
||||
});
|
||||
|
||||
// The deal never left the quote stage, so there is no agreement to show.
|
||||
expect(ctl.open).toBe(false);
|
||||
expect(ctl.error).toBe("stripe refused");
|
||||
quiet.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps an open modal open when accepting fails, so the banner survives", async () => {
|
||||
startAgreement.mockRejectedValue(new Error("stripe refused"));
|
||||
const quiet = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mount();
|
||||
await waitFor(() => expect(ctl.started).toBe(true));
|
||||
|
||||
act(() => ctl.setOpen(true));
|
||||
await act(async () => {
|
||||
await ctl.onAcceptQuote();
|
||||
});
|
||||
|
||||
expect(ctl.open).toBe(true);
|
||||
expect(ctl.error).toBe("stripe refused");
|
||||
quiet.mockRestore();
|
||||
});
|
||||
|
||||
it("stays open after generating, so the review step can show the issued quote", async () => {
|
||||
mount();
|
||||
await waitFor(() => expect(ctl.started).toBe(true));
|
||||
|
||||
act(() => ctl.setOpen(true));
|
||||
await act(async () => {
|
||||
// Only the id is read on the way out; the priced quote comes back via the snapshot.
|
||||
await ctl.onGenerate({ quoteId: "q_1" } as unknown as QuoteResult);
|
||||
});
|
||||
|
||||
expect(issueQuote).toHaveBeenCalledWith("q_1");
|
||||
expect(ctl.open).toBe(true);
|
||||
expect(ctl.error).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { usePortalLinked } from "@portal/contexts/usePortalLinked";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { qk } from "@portal/queries/keys";
|
||||
import { toAsyncState } from "@portal/queries/adapters";
|
||||
import {
|
||||
acceptQuote,
|
||||
extendTrial,
|
||||
fetchLicenseFile,
|
||||
fetchQuotePdf,
|
||||
fetchSignedAgreementPdf,
|
||||
fetchSnapshot,
|
||||
issueQuote,
|
||||
recordInterest,
|
||||
resetProcurement,
|
||||
startAgreement,
|
||||
startTrial,
|
||||
type ProcurementSnapshot,
|
||||
type QuoteResult,
|
||||
type TrialSetupDetails,
|
||||
} from "@portal/api/procurement";
|
||||
|
||||
export type ProcurementExtra =
|
||||
@@ -20,7 +26,8 @@ export type ProcurementExtra =
|
||||
| "license"
|
||||
| "schedule"
|
||||
| "trial"
|
||||
| "setup";
|
||||
| "setup"
|
||||
| "documents";
|
||||
|
||||
/**
|
||||
* Owns the procurement deal state and actions shared by the Home hero footer
|
||||
@@ -40,6 +47,7 @@ export interface ProcurementController {
|
||||
busy: boolean;
|
||||
downloading: boolean;
|
||||
downloadingLicense: boolean;
|
||||
downloadingAgreement: boolean;
|
||||
error: string | null;
|
||||
setError: (e: string | null) => void;
|
||||
open: boolean;
|
||||
@@ -51,35 +59,62 @@ export interface ProcurementController {
|
||||
invoicePdf: string | null;
|
||||
/** Open the trial-setup dialog (deployment + seats) — the trial only starts once it's confirmed. */
|
||||
onStartTrial: () => void;
|
||||
/**
|
||||
* Record enterprise interest and open trial setup. Persisting the intent is what keeps the
|
||||
* enterprise surface off accounts that never asked for it, and makes drop-off at this step
|
||||
* visible; the dialog opens either way, so a failed write never blocks the buyer.
|
||||
*/
|
||||
onExploreEnterprise: () => void;
|
||||
/** Confirm the setup dialog: start the trial with the chosen deployment/seats, then open the flow. */
|
||||
onConfirmSetup: (deployment: string, seats: number) => void;
|
||||
onConfirmSetup: (
|
||||
deployment: string,
|
||||
seats: number,
|
||||
details: TrialSetupDetails,
|
||||
) => void;
|
||||
onExtendTrial: () => void;
|
||||
onReset: () => void;
|
||||
onGenerate: (draft: QuoteResult) => void;
|
||||
/** Issue the priced draft. Leaves the modal open: the issued quote becomes the review step. */
|
||||
onGenerate: (draft: QuoteResult) => Promise<void>;
|
||||
/**
|
||||
* Accept the reviewed quote and advance to the agreement step — does NOT charge Stripe. Taken from
|
||||
* the deal card, and opens the agreement on success.
|
||||
*/
|
||||
onAcceptQuote: () => Promise<void>;
|
||||
onAgree: () => void;
|
||||
onDownloadPdf: () => Promise<void>;
|
||||
onDownloadOfflineLicense: () => Promise<void>;
|
||||
/** Download the stored signed enterprise agreement (available once signed). */
|
||||
onDownloadSignedAgreement: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useProcurement(autoOpen = false): ProcurementController {
|
||||
export function useProcurement(): ProcurementController {
|
||||
const { t } = useTranslation();
|
||||
const isLinked = usePortalLinked();
|
||||
|
||||
const state = useAsync<ProcurementSnapshot | null>(
|
||||
() => (isLinked ? fetchSnapshot() : Promise.resolve(null)),
|
||||
[isLinked],
|
||||
// Through the shared query cache, not a per-mount fetch: the snapshot survives navigation, so
|
||||
// returning to Home renders the deal from cache instead of flashing the loading state again.
|
||||
// No retry — a failing snapshot must not hold `loading` true through backoff, since the hero
|
||||
// gates its whole card on it.
|
||||
const queryClient = useQueryClient();
|
||||
const snapshotKey = qk.procurement(isLinked);
|
||||
const state = toAsyncState(
|
||||
useQuery<ProcurementSnapshot | null>({
|
||||
queryKey: snapshotKey,
|
||||
queryFn: () => (isLinked ? fetchSnapshot() : Promise.resolve(null)),
|
||||
retry: false,
|
||||
}),
|
||||
);
|
||||
const [snap, setSnap] = useState<ProcurementSnapshot | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloadingLicense, setDownloadingLicense] = useState(false);
|
||||
const [downloadingAgreement, setDownloadingAgreement] = useState(false);
|
||||
const [invoicePdf, setInvoicePdf] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [extra, setExtra] = useState<ProcurementExtra>(null);
|
||||
|
||||
const data = snap ?? (state.loading ? null : state.data);
|
||||
const data = state.data;
|
||||
const started = data?.dealId != null;
|
||||
const stage = data?.stage;
|
||||
const latest = data?.latestQuote ?? null;
|
||||
@@ -89,27 +124,56 @@ export function useProcurement(autoOpen = false): ProcurementController {
|
||||
!latest ||
|
||||
["draft", "expired", "canceled", "cancelled"].includes(latest.status);
|
||||
|
||||
async function run(fn: () => Promise<unknown>) {
|
||||
/**
|
||||
* Run a deal action, then refresh the snapshot so every reader sees the new stage.
|
||||
*
|
||||
* `closeOnSuccess` ends the step. Each stage is its own visit — a buyer can sit in one for days —
|
||||
* so finishing one returns them to the deal card instead of gliding into the next. Deliberately
|
||||
* not in a `finally`: a failure has to leave the modal open, or the error banner this sets would
|
||||
* be torn down with it and the buyer would be left with no idea what went wrong.
|
||||
*
|
||||
* Returns whether the action succeeded, since failures are reported through the banner rather than
|
||||
* thrown — a caller that follows up with navigation has no other way to tell.
|
||||
*/
|
||||
async function run(
|
||||
fn: () => Promise<unknown>,
|
||||
opts?: { closeOnSuccess?: boolean },
|
||||
): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
setSnap(await fetchSnapshot());
|
||||
queryClient.setQueryData(snapshotKey, await fetchSnapshot());
|
||||
if (opts?.closeOnSuccess) setOpen(false);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("[procurement] action failed", e);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// The setup dialog collects deployment + seats first; the trial starts on confirm.
|
||||
// The setup dialog collects deployment + seats first; the trial starts on confirm. Starting the
|
||||
// trial is a step in its own right, so it ends on the card — the buyer builds a quote when ready.
|
||||
const onStartTrial = () => setExtra("setup");
|
||||
const onConfirmSetup = (deployment: string, seats: number) =>
|
||||
const onExploreEnterprise = () => {
|
||||
setExtra("setup");
|
||||
void recordInterest()
|
||||
.then((snap) => queryClient.setQueryData(snapshotKey, snap))
|
||||
.catch((e) =>
|
||||
console.error("[procurement] recording interest failed", e),
|
||||
);
|
||||
};
|
||||
const onConfirmSetup = (
|
||||
deployment: string,
|
||||
seats: number,
|
||||
details: TrialSetupDetails,
|
||||
) =>
|
||||
run(async () => {
|
||||
await startTrial(deployment, seats);
|
||||
await startTrial(deployment, seats, details);
|
||||
setExtra(null);
|
||||
setOpen(true);
|
||||
});
|
||||
const onExtendTrial = () => run(extendTrial);
|
||||
const onReset = () =>
|
||||
@@ -118,19 +182,40 @@ export function useProcurement(autoOpen = false): ProcurementController {
|
||||
setEditing(false);
|
||||
setInvoicePdf(null);
|
||||
});
|
||||
const onGenerate = (draft: QuoteResult) =>
|
||||
run(async () => {
|
||||
// Deliberately does not close: the issued quote returns through the snapshot and becomes the
|
||||
// builder's review step, so the buyer reads the paper they just generated instead of being dropped
|
||||
// back on the card to find it again.
|
||||
const onGenerate = async (draft: QuoteResult) => {
|
||||
await run(async () => {
|
||||
await issueQuote(draft.quoteId);
|
||||
setEditing(false);
|
||||
});
|
||||
// Quote + agreement are one step now: agreeing accepts the issued quote straight into a
|
||||
// committed subscription (Stripe), and provisioning upgrades the licence server-side.
|
||||
};
|
||||
|
||||
/**
|
||||
* Accept the reviewed quote: advance to the agreement step only. No Stripe — the buyer can read and
|
||||
* download the plain quote before taking on the legal documents.
|
||||
*
|
||||
* This is the one transition that carries straight on into the next stage rather than ending on the
|
||||
* card. Accepting is an explicit decision the buyer has just taken from the card itself, so putting
|
||||
* them back there to press a second button to see what they accepted reads as a dead end.
|
||||
*/
|
||||
const onAcceptQuote = async () => {
|
||||
if (await run(startAgreement)) setOpen(true);
|
||||
};
|
||||
|
||||
// Signing the agreement is the commitment point: it accepts the issued quote into a committed
|
||||
// subscription (Stripe), and provisioning upgrades the licence server-side. Payment itself is
|
||||
// invoice + bank transfer, so it settles out of band — there is no in-app step to close.
|
||||
const onAgree = () =>
|
||||
run(async () => {
|
||||
if (!latest) return;
|
||||
const res = await acceptQuote(latest.quoteId);
|
||||
setInvoicePdf(res.invoicePdf);
|
||||
});
|
||||
run(
|
||||
async () => {
|
||||
if (!latest) return;
|
||||
const res = await acceptQuote(latest.quoteId);
|
||||
setInvoicePdf(res.invoicePdf);
|
||||
},
|
||||
{ closeOnSuccess: true },
|
||||
);
|
||||
|
||||
async function onDownloadPdf() {
|
||||
if (!latest) return;
|
||||
@@ -176,11 +261,25 @@ export function useProcurement(autoOpen = false): ProcurementController {
|
||||
}
|
||||
}
|
||||
|
||||
// A deep link (/procurement) opens the flow when a deal is already underway; if there's no deal
|
||||
// yet it must NOT silently start a trial — leave the modal closed so the Start-trial CTA shows.
|
||||
useEffect(() => {
|
||||
if (autoOpen && started) setOpen(true);
|
||||
}, [autoOpen, started]);
|
||||
async function onDownloadSignedAgreement() {
|
||||
setDownloadingAgreement(true);
|
||||
try {
|
||||
const blob = await fetchSignedAgreementPdf();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "stirling-enterprise-agreement.pdf";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} catch (e) {
|
||||
console.error("[procurement] signed agreement download failed", e);
|
||||
setError(t("portal.procurement.agreement.downloadError"));
|
||||
} finally {
|
||||
setDownloadingAgreement(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isLinked,
|
||||
@@ -194,6 +293,7 @@ export function useProcurement(autoOpen = false): ProcurementController {
|
||||
busy,
|
||||
downloading,
|
||||
downloadingLicense,
|
||||
downloadingAgreement,
|
||||
error,
|
||||
setError,
|
||||
open,
|
||||
@@ -204,12 +304,15 @@ export function useProcurement(autoOpen = false): ProcurementController {
|
||||
setExtra,
|
||||
invoicePdf,
|
||||
onStartTrial,
|
||||
onExploreEnterprise,
|
||||
onConfirmSetup,
|
||||
onExtendTrial,
|
||||
onReset,
|
||||
onGenerate,
|
||||
onAcceptQuote,
|
||||
onAgree,
|
||||
onDownloadPdf,
|
||||
onDownloadOfflineLicense,
|
||||
onDownloadSignedAgreement,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* FlowModal — the flows' deltas on top of the shared Modal (sui-modal) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* The shared Modal owns the backdrop, panel, header band and dismissal chrome. Only the flows'
|
||||
own density and width live here, so a procurement dialog cannot drift from a billing one.
|
||||
Every rule below pairs .portal-flowmodal with a sui-modal class so it outranks the base rule
|
||||
regardless of which stylesheet the bundler emits first. */
|
||||
|
||||
.portal-flowmodal {
|
||||
/* The shell's horizontal inset, published so content that has to bleed out to the panel's edges
|
||||
(see the quote builder's footer) reads it rather than hard-coding a copy that silently drifts.
|
||||
Mirrors .sui-modal__body's padding. */
|
||||
--flowmodal-inset: 1.125rem;
|
||||
--flowmodal-body-end: 1.125rem;
|
||||
}
|
||||
|
||||
/* The takeover needs the room for its quote tables, and Calendly's two-pane layout collapses into a
|
||||
tall scrolling single column below roughly 680px of inner width. Sits between the shared lg
|
||||
(48rem) and xl (64rem), so it is set here rather than bent into a shared token. */
|
||||
.sui-modal.portal-flowmodal--lg {
|
||||
max-width: 52rem;
|
||||
}
|
||||
|
||||
/* A flow's header is a slot, not a string: it carries its own heading, step badge and subtitle, each
|
||||
with its own type. Neutralise the base title wrapper's weight and size so those children inherit
|
||||
nothing from it — it is 600, which would otherwise render every flow subtitle semibold.
|
||||
Colour is deliberately NOT inherited here: the panel sets none, so `inherit` reached past it to the
|
||||
document and dropped the header below the contrast floor. */
|
||||
.portal-flowmodal .sui-modal__title {
|
||||
font-size: inherit;
|
||||
font-weight: 400;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
/* The flows stack blocks down the body and space them evenly; the base modal leaves spacing to its
|
||||
content. Text stays at full contrast rather than the base body's muted grey. */
|
||||
.portal-flowmodal .sui-modal__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
color: var(--c-text);
|
||||
/* So a step that fills the dialog's height (the agreement's document) can shrink to it: without
|
||||
this a flex child refuses to go below its content height and overflows instead. */
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* One space-between row, so a step passes its two ends as siblings (Back on the left, Continue on
|
||||
the right) instead of the base modal's right-aligned button cluster. */
|
||||
.portal-flowmodal .sui-modal__footer {
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FlowModal } from "@portal/components/shared/FlowModal";
|
||||
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
|
||||
|
||||
/**
|
||||
* The shell backs every procurement and trial dialog, so its dismissal paths and its header
|
||||
* branching are the highest-traffic behaviour in the portal's flows.
|
||||
*
|
||||
* The branching matters beyond looks: a step that draws its own stepped header carries the close
|
||||
* beside its step badge, so the shell must draw neither. Shipping that wrong once left a band
|
||||
* holding nothing but a stray close above the step's real heading; shipping it the other way would
|
||||
* leave a dialog with no visible close at all. Since the shell now delegates to the shared Modal,
|
||||
* "supplies no header" is what expresses it — the same convention the prepay checkout follows.
|
||||
*/
|
||||
function open(props: Partial<Parameters<typeof FlowModal>[0]> = {}) {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<PortalTestProviders>
|
||||
<FlowModal open onClose={onClose} label="Test dialog" {...props}>
|
||||
<p>body</p>
|
||||
</FlowModal>
|
||||
</PortalTestProviders>,
|
||||
);
|
||||
return onClose;
|
||||
}
|
||||
|
||||
describe("FlowModal", () => {
|
||||
it("renders nothing while closed", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<PortalTestProviders>
|
||||
<FlowModal open={false} onClose={onClose} label="Test dialog">
|
||||
<p>body</p>
|
||||
</FlowModal>
|
||||
</PortalTestProviders>,
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes on Escape", () => {
|
||||
const onClose = open();
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes on a backdrop click but not on a click inside the panel", () => {
|
||||
const onClose = open();
|
||||
fireEvent.click(screen.getByRole("dialog"));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
const backdrop = screen.getByRole("dialog").parentElement!;
|
||||
fireEvent.click(backdrop);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("draws a header band with a working close when given a header", () => {
|
||||
const onClose = open({ header: <h2>Heading</h2> });
|
||||
expect(document.querySelector(".sui-modal__header")).not.toBeNull();
|
||||
expect(screen.getByText("Heading")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /close/i }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drops the band, close included, when the content owns its header", () => {
|
||||
const onClose = open();
|
||||
expect(document.querySelector(".sui-modal__header")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /close/i })).toBeNull();
|
||||
|
||||
// Never inescapable: the step's own close aside, Escape still exits.
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("names the dialog from `label` when the content owns the heading", () => {
|
||||
open();
|
||||
expect(screen.getByRole("dialog")).toHaveAccessibleName("Test dialog");
|
||||
});
|
||||
|
||||
it("names the dialog from the header once one is supplied", () => {
|
||||
open({ header: <h2>Heading</h2> });
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).not.toHaveAttribute("aria-label");
|
||||
expect(dialog).toHaveAccessibleName("Heading");
|
||||
});
|
||||
|
||||
it("renders the footer only when given one", () => {
|
||||
open({ footer: <button type="button">Continue</button> });
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Continue" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Modal } from "@app/ui";
|
||||
import "@portal/components/shared/FlowModal.css";
|
||||
|
||||
/**
|
||||
* The one dialog shell for the portal's flows — procurement's takeover, the trial/licence/schedule
|
||||
* dialogs, the legal reader.
|
||||
*
|
||||
* A thin wrapper over the shared {@link Modal}, which already owns the portal, focus trap, scroll
|
||||
* lock, Escape and backdrop dismissal, and the close button. This used to reimplement all of it,
|
||||
* which is precisely how the procurement dialogs drifted from the billing ones that were already on
|
||||
* the shared Modal. What is left here is only what the flows genuinely add: a stacked body, a
|
||||
* space-between footer for Back/Continue, and the takeover width.
|
||||
*
|
||||
* `header` is the shared Modal's title slot, so a flow can seat its own stepped header (see
|
||||
* StepModalHeader) in the band. Omit it and the band goes entirely, close included — the convention
|
||||
* the prepay checkout already follows for a step that draws its own heading and close. Escape and
|
||||
* the backdrop still dismiss, so such a dialog is never inescapable.
|
||||
*/
|
||||
export function FlowModal({
|
||||
open,
|
||||
onClose,
|
||||
label,
|
||||
header,
|
||||
footer,
|
||||
size = "md",
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Accessible name, used when `header` supplies no visible heading of its own. */
|
||||
label: string;
|
||||
header?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
/** `md` for the task dialogs, `lg` for the procurement takeover and Calendly. */
|
||||
size?: "md" | "lg";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
ariaLabel={label}
|
||||
width={size}
|
||||
className={`portal-flowmodal portal-flowmodal--${size}`}
|
||||
title={header}
|
||||
footer={footer}
|
||||
>
|
||||
{children}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Stepped flow-modal header (prepay wizard, metered checkout, quote builder) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-stepmodal__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
/* No bottom margin: the host band owns the space beneath it (FlowModal's header padding, or
|
||||
billing's own .portal-billing__bundle-head). Keeping one here doubled the header's inset. */
|
||||
}
|
||||
|
||||
.portal-stepmodal__head-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.portal-stepmodal__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
/* Trademarked wordmark SVG (theme-switched via .wordmark-light-only/.wordmark-dark-only). Height
|
||||
matches the portal nav's 22px wordmark so the modal and app read as one brand. No `display` here —
|
||||
the theme-switch utilities own visibility. */
|
||||
.portal-stepmodal__wordmark {
|
||||
height: 1.375rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Flow title, for modals that head with their own name rather than the wordmark. */
|
||||
.portal-stepmodal__ident {
|
||||
min-width: 0;
|
||||
}
|
||||
.portal-stepmodal__flow-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--c-text);
|
||||
}
|
||||
/* Qualifies the title — for a step whose heading names a document that needs saying what it covers. */
|
||||
.portal-stepmodal__flow-sub {
|
||||
margin: 0.15rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 400;
|
||||
color: var(--c-text-subtle);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.portal-stepmodal__head-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-stepmodal__step {
|
||||
padding: 0.1875rem 0.625rem;
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--c-text-subtle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.portal-stepmodal__progress {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.portal-stepmodal__progress > span {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--c-border-subtle);
|
||||
}
|
||||
|
||||
.portal-stepmodal__progress > span.is-filled {
|
||||
background: var(--c-primary);
|
||||
}
|
||||
|
||||
.portal-stepmodal__title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--c-text);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
// The trademarked Stirling wordmark — the font is baked into the SVG (no brand webfont is loaded),
|
||||
// so we render the same asset the portal nav uses rather than styled text. Theme-switched in CSS.
|
||||
import wordmarkLight from "@app/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg";
|
||||
import wordmarkDark from "@app/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg";
|
||||
import "@portal/components/shared/StepModalHeader.css";
|
||||
|
||||
/**
|
||||
* Header for any stepped flow modal: an identity row (brand wordmark or the flow's own title) with a
|
||||
* "Step N of M" badge and close, an M-segment progress bar, and the current step's title.
|
||||
*
|
||||
* Shared so a flow's chrome is not re-implemented per flow — the prepay wizard, the metered
|
||||
* checkout, and the procurement quote builder all wear this. The step label is passed already
|
||||
* translated: each flow keeps its own copy key rather than this component inventing a shared one.
|
||||
*/
|
||||
export function StepModalHeader({
|
||||
title,
|
||||
subtitle,
|
||||
step,
|
||||
total,
|
||||
stepLabel,
|
||||
aside,
|
||||
brand = false,
|
||||
className,
|
||||
closeLabel,
|
||||
onClose,
|
||||
}: {
|
||||
/** The current step's heading. Omit when the host modal already renders one. */
|
||||
title?: string;
|
||||
/** A line under the title, for a step whose heading needs qualifying (what the document covers). */
|
||||
subtitle?: ReactNode;
|
||||
/** Actions belonging to what is on screen (e.g. download this document), seated before the close. */
|
||||
aside?: ReactNode;
|
||||
/** 1-based current step. Omit to hide the badge and the progress bar (e.g. a terminal receipt). */
|
||||
step?: number;
|
||||
/** Segments to draw. Any length — a flow is not limited to three steps. */
|
||||
total?: number;
|
||||
/** Pre-translated "Step 2 of 3"; omitted renders no badge. */
|
||||
stepLabel?: string;
|
||||
/** Show the Stirling wordmark instead of a plain heading, for flows that stand alone. */
|
||||
brand?: boolean;
|
||||
/** Extra class on the root, so a host modal can still own its padding/layout. */
|
||||
className?: string;
|
||||
closeLabel?: string;
|
||||
/** Omit when the host modal already owns a close control, so there is only ever one. */
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const showSteps = step != null && total != null && total > 0;
|
||||
|
||||
return (
|
||||
<div className={`portal-stepmodal__head ${className ?? ""}`.trim()}>
|
||||
<div className="portal-stepmodal__head-top">
|
||||
{brand ? (
|
||||
<div className="portal-stepmodal__brand">
|
||||
<img
|
||||
src={wordmarkLight}
|
||||
alt="Stirling"
|
||||
className="portal-stepmodal__wordmark wordmark-light-only"
|
||||
/>
|
||||
<img
|
||||
src={wordmarkDark}
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="portal-stepmodal__wordmark wordmark-dark-only"
|
||||
/>
|
||||
</div>
|
||||
) : title ? (
|
||||
<div className="portal-stepmodal__ident">
|
||||
<h3 className="portal-stepmodal__flow-title">{title}</h3>
|
||||
{subtitle && (
|
||||
<p className="portal-stepmodal__flow-sub">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="portal-stepmodal__head-right">
|
||||
{aside}
|
||||
{stepLabel && (
|
||||
<span className="portal-stepmodal__step">{stepLabel}</span>
|
||||
)}
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
shape="circle"
|
||||
onClick={onClose}
|
||||
aria-label={closeLabel ?? t("portal.stepModal.close", "Close")}
|
||||
leftSection={
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSteps && (
|
||||
<div className="portal-stepmodal__progress" aria-hidden>
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<span key={i} className={i < step ? "is-filled" : ""} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* With the wordmark up top the step title carries the heading; without it the flow title
|
||||
already did, so repeating it here would say the same thing twice. */}
|
||||
{brand && <div className="portal-stepmodal__title">{title}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { UIProvider, useUI } from "@portal/contexts/UIContext";
|
||||
|
||||
/**
|
||||
* The context value is memoised, so every piece of state it exposes has to appear in the memo's
|
||||
* dependency array or consumers never see it change. `trialSetupRequested` was added without it and
|
||||
* the enterprise CTA silently did nothing: the flag flipped, the memo did not, and no consumer
|
||||
* re-rendered. There is no `react-hooks/exhaustive-deps` rule in this repo to catch that, so it gets
|
||||
* a test instead.
|
||||
*/
|
||||
function probe() {
|
||||
const seen: boolean[] = [];
|
||||
let api: ReturnType<typeof useUI>;
|
||||
|
||||
function Probe() {
|
||||
api = useUI();
|
||||
seen.push(api.trialSetupRequested);
|
||||
return null;
|
||||
}
|
||||
|
||||
render(
|
||||
<UIProvider>
|
||||
<Probe />
|
||||
</UIProvider>,
|
||||
);
|
||||
return {
|
||||
seen,
|
||||
get api() {
|
||||
return api;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("UIContext — the trial-setup signal reaches consumers", () => {
|
||||
it("re-renders consumers when the request is raised and cleared", () => {
|
||||
const p = probe();
|
||||
expect(p.api.trialSetupRequested).toBe(false);
|
||||
|
||||
act(() => p.api.requestTrialSetup());
|
||||
expect(p.api.trialSetupRequested).toBe(true);
|
||||
expect(p.seen).toContain(true);
|
||||
|
||||
act(() => p.api.clearTrialSetupRequest());
|
||||
expect(p.api.trialSetupRequested).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,14 @@ interface UIContextValue {
|
||||
linkModalMode: "link" | "reauth";
|
||||
openLinkModal: (mode?: "link" | "reauth") => void;
|
||||
closeLinkModal: () => void;
|
||||
/**
|
||||
* A request to begin the enterprise trial, raised from wherever the buyer said yes (the billing
|
||||
* upsell, a sales link). The deal controller lives on Home, so this is a one-shot signal rather
|
||||
* than a direct call: Home consumes it, opens trial setup, and clears it.
|
||||
*/
|
||||
trialSetupRequested: boolean;
|
||||
requestTrialSetup: () => void;
|
||||
clearTrialSetupRequest: () => void;
|
||||
}
|
||||
|
||||
const UIContext = createContext<UIContextValue | null>(null);
|
||||
@@ -83,6 +91,7 @@ export function UIProvider({ children }: { children: ReactNode }) {
|
||||
string | null
|
||||
>(null);
|
||||
const [linkModalOpen, setLinkModalOpen] = useState(false);
|
||||
const [trialSetupRequested, setTrialSetupRequested] = useState(false);
|
||||
const [linkModalMode, setLinkModalMode] = useState<"link" | "reauth">("link");
|
||||
// When the link modal is opened from inside Settings, remember the section to
|
||||
// restore so closing the modal returns the admin to where they were.
|
||||
@@ -146,6 +155,12 @@ export function UIProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
setLinkModalOpen(true);
|
||||
},
|
||||
trialSetupRequested,
|
||||
requestTrialSetup: () => {
|
||||
setMobileNavOpen(false);
|
||||
setTrialSetupRequested(true);
|
||||
},
|
||||
clearTrialSetupRequest: () => setTrialSetupRequested(false),
|
||||
closeLinkModal: () => {
|
||||
setLinkModalOpen(false);
|
||||
setLinkModalMode("link");
|
||||
@@ -166,6 +181,7 @@ export function UIProvider({ children }: { children: ReactNode }) {
|
||||
linkModalOpen,
|
||||
linkModalMode,
|
||||
reopenSettingsAfterLink,
|
||||
trialSetupRequested,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ export type ViewId =
|
||||
| "infrastructure"
|
||||
| "usage"
|
||||
| "docs"
|
||||
| "procurement"
|
||||
| "settings";
|
||||
|
||||
export const VIEW_PATHS: Record<ViewId, string> = {
|
||||
@@ -29,7 +28,6 @@ export const VIEW_PATHS: Record<ViewId, string> = {
|
||||
infrastructure: "/infrastructure",
|
||||
usage: "/usage",
|
||||
docs: "/docs",
|
||||
procurement: "/procurement",
|
||||
settings: "/settings",
|
||||
};
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useEditorInstalled } from "@portal/hooks/useEditorInstalled";
|
||||
import { useEditorDeployment } from "@portal/queries/infrastructure";
|
||||
import { usePoliciesOverview } from "@portal/queries/policies";
|
||||
import { useUsersRoster } from "@portal/queries/users";
|
||||
|
||||
/**
|
||||
* Getting-started completion, derived live from the org's real state, composed
|
||||
* from the shared editor-deployment / policies / users queries.
|
||||
*
|
||||
* Best-effort per source: a step reads `data ?? fallback` and query errors are
|
||||
* never folded into `loading`, so an endpoint that isn't served yet (e.g.
|
||||
* editor-deployment on a bare backend) leaves its step incomplete instead of
|
||||
* breaking the card.
|
||||
*/
|
||||
export interface OnboardingProgress {
|
||||
loading: boolean;
|
||||
/** A real deployment is reporting in (drives the live-status header). */
|
||||
deployed: boolean;
|
||||
/** Download step done — a real deployment OR the user's own download/Done. */
|
||||
editorDone: boolean;
|
||||
policiesDone: boolean;
|
||||
/** More than just the admin on the team. */
|
||||
inviteDone: boolean;
|
||||
policiesActive: number;
|
||||
policiesRecommended: number;
|
||||
allComplete: boolean;
|
||||
}
|
||||
|
||||
export function useOnboardingProgress(): OnboardingProgress {
|
||||
const { tier } = useTier();
|
||||
const editorInstalled = useEditorInstalled();
|
||||
const deployQuery = useEditorDeployment(tier);
|
||||
const policiesQuery = usePoliciesOverview();
|
||||
const usersQuery = useUsersRoster(tier);
|
||||
|
||||
const deploy = deployQuery.data;
|
||||
const policies = policiesQuery.data;
|
||||
const users = usersQuery.data;
|
||||
|
||||
const deployed = (deploy?.instances.length ?? 0) > 0;
|
||||
// Authoritative signal is a deployed instance; the user's own download/Done
|
||||
// action marks it complete immediately via the persisted flag.
|
||||
const editorDone = editorInstalled || deployed;
|
||||
const policiesActive = policies?.summary.active ?? 0;
|
||||
const policiesRecommended = Math.max(
|
||||
0,
|
||||
(policies?.summary.categories ?? 0) - policiesActive,
|
||||
);
|
||||
const policiesDone = policiesActive > 0;
|
||||
const inviteDone = (users?.summary.totalMembers ?? 0) > 1;
|
||||
|
||||
return {
|
||||
loading: deployQuery.loading || policiesQuery.loading || usersQuery.loading,
|
||||
deployed,
|
||||
editorDone,
|
||||
policiesDone,
|
||||
inviteDone,
|
||||
policiesActive,
|
||||
policiesRecommended,
|
||||
allComplete: editorDone && policiesDone && inviteDone,
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { searchHandlers } from "@portal/mocks/handlers/search";
|
||||
import { pipelinesHandlers } from "@portal/mocks/handlers/pipelines";
|
||||
import { sourcesHandlers } from "@portal/mocks/handlers/sources";
|
||||
import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure";
|
||||
import { procurementHandlers } from "@portal/mocks/handlers/procurement";
|
||||
import { procurementSaasHandlers } from "@portal/mocks/handlers/procurementSaas";
|
||||
import { docsHandlers } from "@portal/mocks/handlers/docs";
|
||||
import { usersHandlers } from "@portal/mocks/handlers/users";
|
||||
@@ -26,7 +25,6 @@ export const handlers = [
|
||||
...sourcesHandlers,
|
||||
...infrastructureHandlers,
|
||||
...docsHandlers,
|
||||
...procurementHandlers,
|
||||
...procurementSaasHandlers,
|
||||
...usersHandlers,
|
||||
...teamSaasHandlers,
|
||||
@@ -39,5 +37,4 @@ export const handlers = [
|
||||
];
|
||||
|
||||
export { resetNotificationsStore } from "@portal/mocks/handlers/notifications";
|
||||
export { resetProcurementStore } from "@portal/mocks/handlers/procurement";
|
||||
export { resetTeamSaasStore } from "@portal/mocks/handlers/teamSaas";
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import {
|
||||
JOURNEY,
|
||||
type DealStage,
|
||||
type ProcurementResponse,
|
||||
} from "@portal/api/procurement";
|
||||
import {
|
||||
buildProcurement,
|
||||
seedEnterpriseDeal,
|
||||
} from "@portal/mocks/procurement";
|
||||
import {
|
||||
advanceDeal,
|
||||
payDeal,
|
||||
requestDoc,
|
||||
signDoc,
|
||||
uploadPurchaseOrder,
|
||||
} from "@portal/mocks/procurementMachine";
|
||||
|
||||
/**
|
||||
* Stateful mock of the commercial backend. The write endpoints drive the
|
||||
* procurement state machine over one in-memory enterprise deal, so the journey
|
||||
* genuinely progresses within a session, advancing a stage flips the gating
|
||||
* paperwork, unlocks the next stage's documents, and the GET reflects every
|
||||
* prior write. Swapping in the real backend is just deleting these handlers;
|
||||
* the api/ contracts and the state-machine semantics stay.
|
||||
*/
|
||||
let store = seedEnterpriseDeal();
|
||||
|
||||
/** Reseed the deal to its starting (mid-journey) state, used by tests + replay. */
|
||||
export function resetProcurementStore() {
|
||||
store = seedEnterpriseDeal();
|
||||
}
|
||||
|
||||
function snapshot(): ProcurementResponse {
|
||||
return {
|
||||
tier: "enterprise",
|
||||
unlocked: true,
|
||||
deal: store.deal,
|
||||
journey: JOURNEY,
|
||||
ledger: store.ledger,
|
||||
supporting: store.supporting,
|
||||
};
|
||||
}
|
||||
|
||||
export const procurementHandlers = [
|
||||
http.get("/v1/procurement", async ({ request }) => {
|
||||
await delay(120);
|
||||
const tier = (new URL(request.url).searchParams.get("tier") ??
|
||||
"pro") as Tier;
|
||||
// Only the enterprise tenant has a live deal; others get the locked payload.
|
||||
if (tier !== "enterprise") return HttpResponse.json(buildProcurement(tier));
|
||||
return HttpResponse.json(snapshot());
|
||||
}),
|
||||
|
||||
// POST /v1/procurement/advance, the journey's primary "next step".
|
||||
http.post("/v1/procurement/advance", async ({ request }) => {
|
||||
await delay(140);
|
||||
const { fromStage } = (await request.json()) as { fromStage: DealStage };
|
||||
advanceDeal(store, fromStage);
|
||||
return HttpResponse.json(snapshot());
|
||||
}),
|
||||
|
||||
// POST /v1/procurement/sign, e-sign the agreement, then advance.
|
||||
http.post("/v1/procurement/sign", async ({ request }) => {
|
||||
await delay(160);
|
||||
const { docId } = (await request.json()) as { docId: string };
|
||||
signDoc(store, docId);
|
||||
return HttpResponse.json(snapshot());
|
||||
}),
|
||||
|
||||
// POST /v1/procurement/pay, confirm online payment, then advance.
|
||||
http.post("/v1/procurement/pay", async () => {
|
||||
await delay(160);
|
||||
payDeal(store);
|
||||
return HttpResponse.json(snapshot());
|
||||
}),
|
||||
|
||||
// POST /v1/procurement/purchase-order, upload a PO (an alternate pay path).
|
||||
http.post("/v1/procurement/purchase-order", async () => {
|
||||
await delay(160);
|
||||
uploadPurchaseOrder(store);
|
||||
return HttpResponse.json(snapshot());
|
||||
}),
|
||||
|
||||
// POST /v1/procurement/documents/:docId/request, queue an on-demand doc.
|
||||
http.post("/v1/procurement/documents/:docId/request", async ({ params }) => {
|
||||
await delay(140);
|
||||
requestDoc(store, String(params.docId));
|
||||
return HttpResponse.json(snapshot());
|
||||
}),
|
||||
];
|
||||
@@ -17,6 +17,7 @@ const EMPTY = {
|
||||
trialExtensionsUsed: 0,
|
||||
licensed: false,
|
||||
licenseKey: null,
|
||||
agreementSignedVersion: null,
|
||||
latestQuote: null,
|
||||
};
|
||||
|
||||
@@ -161,7 +162,9 @@ export function priceQuote(cfg: Cfg) {
|
||||
seq += 1;
|
||||
return {
|
||||
quoteId: seq,
|
||||
quoteNumber: `QT-DEMO-${String(seq).padStart(4, "0")}`,
|
||||
// A draft has no reference: Stripe assigns the quote number at finalisation, so the mock leaves
|
||||
// it null here and fills it when the quote is issued, exactly as the real flow does.
|
||||
quoteNumber: null,
|
||||
status: "draft",
|
||||
currency: "USD",
|
||||
annualNetMinor,
|
||||
@@ -205,10 +208,21 @@ export function resetProcurementSaasStore() {
|
||||
|
||||
export const procurementSaasHandlers = [
|
||||
http.get(`${SAAS}/api/v1/procurement`, () => HttpResponse.json(deal)),
|
||||
// Interest: creates the deal at `exploring` when there is none, and never disturbs one that
|
||||
// already exists — so the enterprise surface only appears for accounts that asked for it.
|
||||
http.post(`${SAAS}/api/v1/procurement/interest`, () => {
|
||||
if (deal.dealId == null) {
|
||||
deal = { ...deal, dealId: 1, stage: "exploring" };
|
||||
}
|
||||
return HttpResponse.json(deal);
|
||||
}),
|
||||
http.post(`${SAAS}/api/v1/procurement/trial/start`, async ({ request }) => {
|
||||
const body = (await request.json().catch(() => ({}))) as Partial<{
|
||||
deployment: string;
|
||||
users: number;
|
||||
businessName: string;
|
||||
contactName: string;
|
||||
contactEmail: string;
|
||||
}>;
|
||||
const allowed = ["cloud", "selfhost", "airgap"];
|
||||
const now = Date.now();
|
||||
@@ -224,6 +238,9 @@ export const procurementSaasHandlers = [
|
||||
trialExtensionsUsed: 0,
|
||||
licensed: true,
|
||||
licenseKey: "MOCK-TRIAL-KEY-0001",
|
||||
businessName: body.businessName ?? null,
|
||||
contactName: body.contactName ?? null,
|
||||
contactEmail: body.contactEmail ?? null,
|
||||
latestQuote: null,
|
||||
};
|
||||
return HttpResponse.json(deal);
|
||||
@@ -257,6 +274,78 @@ export const procurementSaasHandlers = [
|
||||
(deal as Record<string, unknown>).stage = "security";
|
||||
return HttpResponse.json(deal);
|
||||
}),
|
||||
http.get(`${SAAS}/api/v1/procurement/agreement/document`, () => {
|
||||
const q = (deal as { latestQuote: { quoteNumber?: string } | null })
|
||||
.latestQuote;
|
||||
if (!q) return new HttpResponse(null, { status: 404 });
|
||||
return HttpResponse.json({
|
||||
docId: "enterprise-agreement",
|
||||
version: "0.9.1",
|
||||
versionLabel: "SEA v0.9.1",
|
||||
displayName: "Stirling Enterprise Agreement",
|
||||
effectiveDate: "2026-07-10",
|
||||
status: "draft",
|
||||
markdown: [
|
||||
"# Stirling Enterprise Agreement",
|
||||
"## Part A — Master Services Agreement",
|
||||
"Provider will provide the Stirling PDF Processor and Editor as described in the Order Form.",
|
||||
`## Part B — Order Form · ${q.quoteNumber ?? "Q-MOCK"}`,
|
||||
"| Term | Value |",
|
||||
"| --- | --- |",
|
||||
"| Subscription | Enterprise · Stirling Cloud |",
|
||||
"| Escalator | +3% at each anniversary during the Term |",
|
||||
"## Part C — Data Processing Addendum",
|
||||
"Provider processes Personal Data only on Customer's documented instructions.",
|
||||
].join("\n\n"),
|
||||
});
|
||||
}),
|
||||
http.post(
|
||||
`${SAAS}/api/v1/procurement/agreement/sign`,
|
||||
async ({ request }) => {
|
||||
const body = (await request.json().catch(() => ({}))) as Partial<{
|
||||
signatoryName: string;
|
||||
authorityConfirmed: boolean;
|
||||
}>;
|
||||
if (!body.signatoryName || !body.authorityConfirmed) {
|
||||
return new HttpResponse(null, { status: 400 });
|
||||
}
|
||||
// Surface the signed agreement for download on the payment/live stage.
|
||||
(deal as Record<string, unknown>).agreementSignedVersion = "SEA v0.9.1";
|
||||
return HttpResponse.json({
|
||||
signatureId: 1,
|
||||
versionLabel: "SEA v0.9.1",
|
||||
pdfStored: true,
|
||||
});
|
||||
},
|
||||
),
|
||||
http.get(`${SAAS}/api/v1/procurement/agreement/signature/pdf`, () => {
|
||||
const signed = (deal as { agreementSignedVersion?: string })
|
||||
.agreementSignedVersion;
|
||||
if (!signed) return new HttpResponse(null, { status: 404 });
|
||||
// A minimal one-page PDF so the browser download works in mock mode.
|
||||
const pdf =
|
||||
"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n" +
|
||||
"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n" +
|
||||
"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 300 120]>>endobj\n" +
|
||||
"trailer<</Root 1 0 R>>\n%%EOF";
|
||||
return new HttpResponse(pdf, {
|
||||
headers: { "Content-Type": "application/pdf" },
|
||||
});
|
||||
}),
|
||||
http.get(`${SAAS}/api/v1/procurement/agreement/document/pdf`, () => {
|
||||
// The unsigned agreement PDF is available once a quote exists.
|
||||
if (!(deal as { latestQuote: unknown }).latestQuote) {
|
||||
return new HttpResponse(null, { status: 404 });
|
||||
}
|
||||
const pdf =
|
||||
"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n" +
|
||||
"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n" +
|
||||
"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 300 120]>>endobj\n" +
|
||||
"trailer<</Root 1 0 R>>\n%%EOF";
|
||||
return new HttpResponse(pdf, {
|
||||
headers: { "Content-Type": "application/pdf" },
|
||||
});
|
||||
}),
|
||||
http.post(`${SAAS}/api/v1/procurement/go-live`, () => {
|
||||
const d = deal as Record<string, unknown>;
|
||||
if (d.dealId) {
|
||||
@@ -281,6 +370,34 @@ export const procurementSaasHandlers = [
|
||||
resetProcurementSaasStore();
|
||||
return HttpResponse.json(EMPTY);
|
||||
}),
|
||||
http.get(`${SAAS}/api/v1/legal/:docId`, ({ params }) => {
|
||||
const docId = String(params.docId);
|
||||
const titles: Record<string, string> = {
|
||||
eula: "Stirling EULA & Commercial Terms",
|
||||
sla: "Stirling SLA Exhibit",
|
||||
subprocessors: "Stirling Subprocessors",
|
||||
};
|
||||
if (!(docId in titles)) return new HttpResponse(null, { status: 404 });
|
||||
return HttpResponse.json({
|
||||
docId,
|
||||
version: "1.0.0",
|
||||
versionLabel: `${docId.toUpperCase()} v1.0.0`,
|
||||
displayName: titles[docId],
|
||||
effectiveDate: "2026-07-10",
|
||||
status: "draft",
|
||||
markdown: `# ${titles[docId]}\n\nThis is a mock of the ${docId} document for local development.\n\n## 1. Terms\n\nThe real text is served from the backend legal registry.`,
|
||||
});
|
||||
}),
|
||||
http.post(`${SAAS}/api/v1/legal/consent`, async ({ request }) => {
|
||||
const body = (await request.json().catch(() => ({}))) as Partial<{
|
||||
documentId: string;
|
||||
context: string;
|
||||
}>;
|
||||
if (!body.documentId || !body.context) {
|
||||
return new HttpResponse(null, { status: 400 });
|
||||
}
|
||||
return new HttpResponse(null, { status: 200 });
|
||||
}),
|
||||
|
||||
// Stripe Quote edge functions (supabase.functions.invoke → ${url}/functions/v1/{name}).
|
||||
http.post(`${SAAS}/functions/v1/issue-procurement-quote`, () => {
|
||||
@@ -289,6 +406,9 @@ export const procurementSaasHandlers = [
|
||||
if (q) {
|
||||
q.status = "sent";
|
||||
q.stripeQuoteId = `qt_mock_${q.quoteId}`;
|
||||
// Issuing is what mints the reference. Stripe's shape is
|
||||
// QT-{customer invoice prefix}-{quote seq}-{revision}.
|
||||
q.quoteNumber = `QT-MOCKPFX-${String(q.quoteId).padStart(4, "0")}-1`;
|
||||
}
|
||||
return HttpResponse.json(q);
|
||||
}),
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
/**
|
||||
* Procurement fixtures. Types and the journey definition live in
|
||||
* api/procurement.ts (the backend contract); this module only builds the fake
|
||||
* deal data the MSW handlers in mocks/handlers/procurement.ts serve over the
|
||||
* intercepted httpJson() calls, for Storybook and tests.
|
||||
*
|
||||
* The handlers serve Storybook and tests, so these fixtures stay in sync with
|
||||
* the api contract for as long as those need them.
|
||||
*/
|
||||
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type {
|
||||
Deal,
|
||||
LedgerGroup,
|
||||
ProcurementResponse,
|
||||
SupportingGroup,
|
||||
} from "@portal/api/procurement";
|
||||
import { JOURNEY } from "@portal/api/procurement";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Fixtures */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const ENTERPRISE_DEAL: Deal = {
|
||||
company: "Northwind Logistics",
|
||||
// The buyer has accepted the quote and is at the agreement signature, the
|
||||
// mid-journey state shows the most surface area (completed + active + ahead).
|
||||
currentStage: "security",
|
||||
engineer: {
|
||||
name: "Priya Raman",
|
||||
title: "Senior Solutions Engineer",
|
||||
email: "priya.raman@stirlingpdf.com",
|
||||
},
|
||||
trial: {
|
||||
key: "TRIAL-NWND-7F3A-2C9E",
|
||||
startedOn: daysFromNow(-23),
|
||||
endsOn: daysFromNow(7),
|
||||
daysLeft: 7,
|
||||
extensionsUsed: 1,
|
||||
maxExtensions: 2,
|
||||
},
|
||||
quote: {
|
||||
number: "Q-2026-0488",
|
||||
amount: 84_000,
|
||||
term: "12 months",
|
||||
validUntil: daysFromNow(14),
|
||||
},
|
||||
};
|
||||
|
||||
/** Document ledger for the live enterprise deal, grouped by stage. */
|
||||
const ENTERPRISE_LEDGER: LedgerGroup[] = [
|
||||
{
|
||||
stage: "trial",
|
||||
label: "portal.procurement.journeySteps.trial.label",
|
||||
docs: [
|
||||
{
|
||||
id: "doc-trial-quickstart",
|
||||
name: "Trial quick-start guide",
|
||||
sub: "Stand up the evaluation environment in under an hour.",
|
||||
status: "available",
|
||||
action: "download",
|
||||
},
|
||||
{
|
||||
id: "doc-trial-handout",
|
||||
name: "Evaluator handout",
|
||||
sub: "Share Stirling's capabilities with your evaluation team.",
|
||||
status: "available",
|
||||
action: "download",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
stage: "quote",
|
||||
label: "portal.procurement.journeySteps.quote.label",
|
||||
docs: [
|
||||
{
|
||||
id: "doc-quote-formal",
|
||||
name: "Formal quote",
|
||||
sub: "Committed-volume pricing, term and line items, Q-2026-0488.",
|
||||
status: "complete",
|
||||
action: "download",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
stage: "security",
|
||||
label: "portal.procurement.journeySteps.agreement.label",
|
||||
docs: [
|
||||
{
|
||||
id: "doc-agreement-enterprise",
|
||||
name: "Stirling Enterprise Agreement",
|
||||
sub: "One signature: MSA + order form + EULA + DPA.",
|
||||
status: "action",
|
||||
action: "sign",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
stage: "procurement",
|
||||
label: "portal.procurement.journeySteps.payment.label",
|
||||
docs: [
|
||||
{
|
||||
id: "doc-pay-online",
|
||||
name: "Pay online",
|
||||
sub: "Card or bank transfer via Stripe.",
|
||||
status: "pending",
|
||||
action: "pay",
|
||||
},
|
||||
{
|
||||
id: "doc-pay-wire",
|
||||
name: "Bank transfer instructions",
|
||||
sub: "Wire details, plus RIB for EU buyers.",
|
||||
status: "pending",
|
||||
action: "download",
|
||||
},
|
||||
{
|
||||
id: "doc-pay-po",
|
||||
name: "Purchase order",
|
||||
sub: "Upload it and we invoice against it.",
|
||||
status: "request",
|
||||
action: "upload",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
stage: "active",
|
||||
label: "portal.procurement.journeySteps.implementation.label",
|
||||
docs: [
|
||||
{
|
||||
id: "doc-active-playbook",
|
||||
name: "Go-live playbook",
|
||||
sub: "Cut-over steps, rollback plan, and success checks.",
|
||||
status: "pending",
|
||||
action: "download",
|
||||
},
|
||||
{
|
||||
id: "doc-active-admin",
|
||||
name: "Administrator setup guide",
|
||||
sub: "SSO, regions, audit export and seat provisioning.",
|
||||
status: "pending",
|
||||
action: "download",
|
||||
},
|
||||
{
|
||||
id: "doc-active-onboarding",
|
||||
name: "Onboarding & training",
|
||||
sub: "Guided rollout and live training for your team.",
|
||||
status: "request",
|
||||
action: "request",
|
||||
optional: true,
|
||||
fee: 7_500,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Stage-agnostic supporting documents, grouped by category. */
|
||||
const ENTERPRISE_SUPPORTING: SupportingGroup[] = [
|
||||
{
|
||||
category: "security",
|
||||
label: "Security",
|
||||
docs: [
|
||||
{
|
||||
id: "sup-soc2",
|
||||
name: "SOC 2 Type II report",
|
||||
sub: "Independent audit of our security controls.",
|
||||
status: "available",
|
||||
action: "download",
|
||||
},
|
||||
{
|
||||
id: "sup-caiq",
|
||||
name: "Security questionnaire (CAIQ)",
|
||||
sub: "Pre-filled Consensus Assessments Initiative Questionnaire.",
|
||||
status: "available",
|
||||
action: "download",
|
||||
},
|
||||
{
|
||||
id: "sup-pentest",
|
||||
name: "Penetration test summary",
|
||||
sub: "Latest third-party penetration test results.",
|
||||
status: "available",
|
||||
action: "download",
|
||||
},
|
||||
{
|
||||
id: "sup-custom-review",
|
||||
name: "Custom security review",
|
||||
sub: "Dedicated session with our security team for your assessment.",
|
||||
status: "request",
|
||||
action: "request",
|
||||
fee: 5_000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "legal",
|
||||
label: "Legal",
|
||||
docs: [
|
||||
{
|
||||
id: "sup-baa",
|
||||
name: "Business Associate Agreement (HIPAA)",
|
||||
sub: "Required when processing protected health information.",
|
||||
status: "request",
|
||||
action: "request",
|
||||
fee: 2_500,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "corporate",
|
||||
label: "Corporate",
|
||||
docs: [
|
||||
{
|
||||
id: "sup-w9",
|
||||
name: "IRS Form W-9",
|
||||
sub: "Our taxpayer identification for your records.",
|
||||
status: "available",
|
||||
action: "download",
|
||||
},
|
||||
{
|
||||
id: "sup-incorporation",
|
||||
name: "Certificate of Incorporation",
|
||||
sub: "Proof of our legal entity registration.",
|
||||
status: "available",
|
||||
action: "download",
|
||||
},
|
||||
{
|
||||
id: "sup-insurance",
|
||||
name: "Certificate of Insurance",
|
||||
sub: "Liability and cyber insurance coverage evidence.",
|
||||
status: "available",
|
||||
action: "download",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "procurement",
|
||||
label: "Procurement",
|
||||
docs: [
|
||||
{
|
||||
id: "sup-vendor-onboarding",
|
||||
name: "Vendor onboarding form",
|
||||
sub: "We fill out your procurement-portal forms for you.",
|
||||
status: "request",
|
||||
action: "request",
|
||||
fee: 1_500,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Structured deep clone of plain fixture data (no functions / class instances). */
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh, mutable copy of the live enterprise deal, deal header, stage ledger
|
||||
* and supporting pool. The MSW layer seeds its in-memory store from this so the
|
||||
* write handlers can advance the journey within a session without mutating the
|
||||
* shared fixtures. The journey definition (JOURNEY) is immutable and shared.
|
||||
*/
|
||||
export function seedEnterpriseDeal(): {
|
||||
deal: Deal;
|
||||
ledger: LedgerGroup[];
|
||||
supporting: SupportingGroup[];
|
||||
} {
|
||||
return {
|
||||
deal: clone(ENTERPRISE_DEAL),
|
||||
ledger: clone(ENTERPRISE_LEDGER),
|
||||
supporting: clone(ENTERPRISE_SUPPORTING),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the procurement payload for a tier. Enterprise gets the full live
|
||||
* deal; free/pro get a minimal locked payload the view renders as an
|
||||
* "enterprise-only" upgrade state.
|
||||
*/
|
||||
export function buildProcurement(tier: Tier): ProcurementResponse {
|
||||
if (tier !== "enterprise") {
|
||||
// Locked tiers still receive the journey definition so the view can render
|
||||
// a greyed preview of the steps behind the upgrade prompt.
|
||||
return {
|
||||
tier,
|
||||
unlocked: false,
|
||||
deal: null,
|
||||
journey: JOURNEY,
|
||||
ledger: [],
|
||||
supporting: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tier,
|
||||
unlocked: true,
|
||||
deal: ENTERPRISE_DEAL,
|
||||
journey: JOURNEY,
|
||||
ledger: ENTERPRISE_LEDGER,
|
||||
supporting: ENTERPRISE_SUPPORTING,
|
||||
};
|
||||
}
|
||||
|
||||
/** ISO date (YYYY-MM-DD) `n` days from today; negative for the past. */
|
||||
function daysFromNow(n: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { seedEnterpriseDeal } from "@portal/mocks/procurement";
|
||||
import {
|
||||
advanceDeal,
|
||||
payDeal,
|
||||
requestDoc,
|
||||
signDoc,
|
||||
uploadPurchaseOrder,
|
||||
type ProcurementStore,
|
||||
} from "@portal/mocks/procurementMachine";
|
||||
|
||||
let store: ProcurementStore;
|
||||
beforeEach(() => {
|
||||
store = seedEnterpriseDeal();
|
||||
});
|
||||
|
||||
function status(s: ProcurementStore, id: string): string | undefined {
|
||||
return [
|
||||
...s.ledger.flatMap((g) => g.docs),
|
||||
...s.supporting.flatMap((g) => g.docs),
|
||||
].find((d) => d.id === id)?.status;
|
||||
}
|
||||
|
||||
describe("seed", () => {
|
||||
it("starts mid-journey at the agreement, awaiting signature", () => {
|
||||
expect(store.deal.currentStage).toBe("security");
|
||||
expect(status(store, "doc-agreement-enterprise")).toBe("action");
|
||||
});
|
||||
|
||||
it("is an independent copy each call (writes never touch the fixture)", () => {
|
||||
advanceDeal(store, "security");
|
||||
const fresh = seedEnterpriseDeal();
|
||||
expect(fresh.deal.currentStage).toBe("security");
|
||||
});
|
||||
});
|
||||
|
||||
describe("advanceDeal", () => {
|
||||
it("completes the stage's gating doc and unlocks the next stage", () => {
|
||||
advanceDeal(store, "security");
|
||||
expect(store.deal.currentStage).toBe("procurement");
|
||||
expect(status(store, "doc-agreement-enterprise")).toBe("complete");
|
||||
// Payment paperwork becomes actionable / downloadable on entry.
|
||||
expect(status(store, "doc-pay-online")).toBe("action");
|
||||
expect(status(store, "doc-pay-wire")).toBe("available");
|
||||
});
|
||||
|
||||
it("ignores a stale stage so a double-click can't skip ahead", () => {
|
||||
advanceDeal(store, "security"); // → procurement
|
||||
advanceDeal(store, "security"); // stale, no-op
|
||||
expect(store.deal.currentStage).toBe("procurement");
|
||||
});
|
||||
|
||||
it("is a no-op at the terminal stage", () => {
|
||||
advanceDeal(store, "security"); // → procurement
|
||||
advanceDeal(store, "procurement"); // → active
|
||||
expect(store.deal.currentStage).toBe("active");
|
||||
advanceDeal(store, "active"); // terminal
|
||||
expect(store.deal.currentStage).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("document actions", () => {
|
||||
it("signing completes the agreement and advances the deal", () => {
|
||||
signDoc(store, "doc-agreement-enterprise");
|
||||
expect(status(store, "doc-agreement-enterprise")).toBe("complete");
|
||||
expect(store.deal.currentStage).toBe("procurement");
|
||||
});
|
||||
|
||||
it("paying clears payment and advances to implementation", () => {
|
||||
advanceDeal(store, "security"); // → procurement
|
||||
payDeal(store);
|
||||
expect(store.deal.currentStage).toBe("active");
|
||||
expect(status(store, "doc-pay-online")).toBe("complete");
|
||||
});
|
||||
|
||||
it("uploading a PO is an alternate payment path that also advances", () => {
|
||||
advanceDeal(store, "security"); // → procurement
|
||||
uploadPurchaseOrder(store);
|
||||
expect(store.deal.currentStage).toBe("active");
|
||||
});
|
||||
|
||||
it("requesting an on-demand document moves it to pending", () => {
|
||||
requestDoc(store, "sup-custom-review");
|
||||
expect(status(store, "sup-custom-review")).toBe("pending");
|
||||
});
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* The procurement deal's state machine, the exact server-side semantics the
|
||||
* mock backend enforces, as pure functions over a {@link ProcurementStore}.
|
||||
*
|
||||
* Kept separate from the MSW handler (which just owns one in-memory store and
|
||||
* wires these to endpoints) so the rules are unit-testable without a network,
|
||||
* and so a real backend has a precise spec to mirror. Every function mutates
|
||||
* the store in place, there's a single instance per session.
|
||||
*/
|
||||
|
||||
import {
|
||||
JOURNEY,
|
||||
type Deal,
|
||||
type DealStage,
|
||||
type DocAction,
|
||||
type DocStatus,
|
||||
type LedgerDoc,
|
||||
type LedgerGroup,
|
||||
type SupportingGroup,
|
||||
} from "@portal/api/procurement";
|
||||
|
||||
export interface ProcurementStore {
|
||||
deal: Deal;
|
||||
ledger: LedgerGroup[];
|
||||
supporting: SupportingGroup[];
|
||||
}
|
||||
|
||||
/** Document actions that gate a stage, completing them moves the deal forward. */
|
||||
const GATING: DocAction[] = ["sign", "pay", "upload"];
|
||||
|
||||
function nextStage(stage: DealStage): DealStage | null {
|
||||
const i = JOURNEY.findIndex((s) => s.stage === stage);
|
||||
return i >= 0 && i < JOURNEY.length - 1 ? JOURNEY[i + 1].stage : null;
|
||||
}
|
||||
|
||||
function allDocs(store: ProcurementStore): LedgerDoc[] {
|
||||
return [
|
||||
...store.ledger.flatMap((g) => g.docs),
|
||||
...store.supporting.flatMap((g) => g.docs),
|
||||
];
|
||||
}
|
||||
|
||||
function setDocStatus(store: ProcurementStore, id: string, status: DocStatus) {
|
||||
const doc = allDocs(store).find((d) => d.id === id);
|
||||
if (doc) doc.status = status;
|
||||
}
|
||||
|
||||
function stageOfDoc(
|
||||
store: ProcurementStore,
|
||||
id: string,
|
||||
): DealStage | undefined {
|
||||
return store.ledger.find((g) => g.docs.some((d) => d.id === id))?.stage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the deal one stage. Completing a stage marks its outstanding gating
|
||||
* docs done; entering the next stage promotes that stage's paperwork from
|
||||
* "pending" to actionable (gating → action needed, downloads → available).
|
||||
* No-ops on a stale `from` so a double-click can't skip a stage.
|
||||
*/
|
||||
export function advanceDeal(store: ProcurementStore, from: DealStage) {
|
||||
if (store.deal.currentStage !== from) return;
|
||||
const to = nextStage(from);
|
||||
if (!to) return;
|
||||
|
||||
store.ledger
|
||||
.find((g) => g.stage === from)
|
||||
?.docs.forEach((d) => {
|
||||
if (d.status === "action") d.status = "complete";
|
||||
});
|
||||
|
||||
store.deal.currentStage = to;
|
||||
|
||||
store.ledger
|
||||
.find((g) => g.stage === to)
|
||||
?.docs.forEach((d) => {
|
||||
if (d.status !== "pending") return;
|
||||
if (GATING.includes(d.action)) d.status = "action";
|
||||
else if (d.action === "download") d.status = "available";
|
||||
});
|
||||
}
|
||||
|
||||
/** Complete the current stage's doc matching an action, then advance past it. */
|
||||
function completeAndAdvance(store: ProcurementStore, action: DocAction) {
|
||||
const stage = store.deal.currentStage;
|
||||
store.ledger
|
||||
.find((g) => g.stage === stage)
|
||||
?.docs.forEach((d) => {
|
||||
if (d.action === action) d.status = "complete";
|
||||
});
|
||||
advanceDeal(store, stage);
|
||||
}
|
||||
|
||||
/** Sign the agreement: complete the doc and advance out of its stage. */
|
||||
export function signDoc(store: ProcurementStore, docId: string) {
|
||||
setDocStatus(store, docId, "complete");
|
||||
const stage = stageOfDoc(store, docId);
|
||||
if (stage && stage === store.deal.currentStage) advanceDeal(store, stage);
|
||||
}
|
||||
|
||||
/** Confirm online payment, then advance to implementation. */
|
||||
export function payDeal(store: ProcurementStore) {
|
||||
completeAndAdvance(store, "pay");
|
||||
}
|
||||
|
||||
/** Upload a purchase order (an alternate payment path), then advance. */
|
||||
export function uploadPurchaseOrder(store: ProcurementStore) {
|
||||
completeAndAdvance(store, "upload");
|
||||
}
|
||||
|
||||
/** Queue an on-demand document, it moves to "pending" until generated. */
|
||||
export function requestDoc(store: ProcurementStore, docId: string) {
|
||||
setDocStatus(store, docId, "pending");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user