// @requirements REQ-PRODUCT-CONTRACT-ACTIONS REQ-DE-CANCEL REQ-DE-WITHDRAWAL REQ-EU-WITHDRAWAL REQ-BR-CONTRACT REQ-PRODUCT-A11Y REQ-PRODUCT-MOBILE import {normalizeContractActionDefinition,normalizeContractActionFields,normalizeContractActionRequest,contractActionAcknowledgement,contractActionHash,contractActionIso,contractActionLabels} from './contract-action-policy.js'; import type {ContractActionDefinition,ContractActionFields,ContractActionRequest,ContractActionReceipt} from './contract-action-policy.js'; export {normalizeContractActionDefinition,contractActionLabels} from './contract-action-policy.js'; export type {ContractActionDefinition,ContractActionProfile,ContractActionFields,ContractActionRequest,ContractActionReceipt} from './contract-action-policy.js'; export interface ContractActionDraft {name:string;contract:string;email:string;cancellation?:{kind:''|'ordinary'|'extraordinary';reason:string;ending:string}} export interface ContractActionSnapshot {status:'editing'|'reviewing'|'submitting'|'uncertain'|'received'|'retired'|'unavailable'|'destroyed';fields:ContractActionDraft;receipt?:ContractActionReceipt;error?:'recording-uncertain'|'invalid-receipt'|'context-retired'|'invalid-definition'|'unavailable'} export interface ContractActionOptions {operationRef:string;submit(request:ContractActionRequest,context:{signal:AbortSignal}):Promise;allowPreview?:boolean;initial?:ContractActionDraft;recovery?:ContractActionRequest;timeoutMs?:number} export interface ContractActionController {getDefinition():ContractActionDefinition;getSnapshot():ContractActionSnapshot;getRecoveryRequest():ContractActionRequest|undefined;subscribe(listener:(snapshot:ContractActionSnapshot)=>void):()=>void;setFields(fields:ContractActionDraft):void;review():void;edit():void;submit():Promise;destroy():void} export class ContractActionError extends Error {constructor(readonly code:'invalid-fields'|'review-required'|'recording-uncertain'|'invalid-receipt'|'context-retired'|'unavailable'|'invalid-definition'|'destroyed'){super(code);this.name='ContractActionError';}} const canonical=(v:unknown):string=>v&&typeof v==='object'?Array.isArray(v)?'['+v.map(canonical).join(',')+']':'{'+Object.keys(v).sort().map(key=>JSON.stringify(key)+':'+canonical((v as Record)[key])).join(',')+'}':JSON.stringify(v); async function digest(value:string){return [...new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(value)))].map(byte=>byte.toString(16).padStart(2,'0')).join('');} function record(v:unknown,keys:string[]):Record{if(!v||typeof v!=='object'||Object.getPrototypeOf(v)!==Object.prototype)throw new ContractActionError('invalid-receipt');const ds=Object.getOwnPropertyDescriptors(v);if(Reflect.ownKeys(ds).length!==keys.length||keys.some(key=>!ds[key]||!('value' in ds[key]!)))throw new ContractActionError('invalid-receipt');return Object.fromEntries(keys.map(key=>[key,ds[key]!.value]));} function draft(input:unknown,cancellation:boolean):ContractActionDraft { const f=record(input,['name','contract','email',...(cancellation?['cancellation']:[])]); for(const [key,max] of [['name',300],['contract',2000],['email',320]] as const)if(typeof f[key]!=='string'||new TextEncoder().encode(f[key] as string).length>max)throw new ContractActionError('invalid-fields'); if(cancellation){const c=record(f.cancellation,['kind','reason','ending']);if(typeof c.kind!=='string'||!['','ordinary','extraordinary'].includes(c.kind)||typeof c.reason!=='string'||new TextEncoder().encode(c.reason).length>2000||typeof c.ending!=='string'||new TextEncoder().encode(c.ending).length>300)throw new ContractActionError('invalid-fields');f.cancellation=c;} return f as unknown as ContractActionDraft; } /** No mount-time transport or automatic personal-data persistence. Host recovery, identity/admission and actual delivery are separate. */ export function createContractAction(input:unknown,options:ContractActionOptions):ContractActionController { const definition=normalizeContractActionDefinition(input,{allowPreview:options.allowPreview===true}),cancellation=definition.profile==='de-cancellation',timeout=options.timeoutMs??15000,transport=options.submit,operationRef=options.operationRef; if(!contractActionHash(operationRef)||typeof transport!=='function'||!Number.isInteger(timeout)||timeout<100||timeout>60000||options.initial&&options.recovery)throw new ContractActionError('unavailable'); let request=options.recovery?normalizeContractActionRequest(options.recovery,definition):undefined; let state:ContractActionSnapshot={status:request?'uncertain':'editing',fields:draft(request?.fields??options.initial??{name:'',contract:'',email:'',...(cancellation?{cancellation:{kind:'',reason:'',ending:''}}:{})},cancellation)}; let pending:Promise|undefined,abort:AbortController|undefined;const listeners=new Set<(s:ContractActionSnapshot)=>void>(),snapshot=()=>structuredClone(state),emit=()=>{for(const fn of [...listeners])try{fn(snapshot());}catch{}}; const fields=():ContractActionFields=>{try{return normalizeContractActionFields(state.fields,definition.profile);}catch{throw new ContractActionError('invalid-fields');}}; async function receipt(input:unknown,command:ContractActionRequest):Promise{ const r=record(input,['schemaVersion','id','definitionId','requestId','operationRef','receivedAt','requestSha256','stage','acknowledgement']),ack=record(r.acknowledgement,['status','text','sha256']); if(r.schemaVersion!==1||r.id!==await digest(canonical([await digest(canonical([definition.siteId,definition.actionId,command.requestId])),command.requestId]))||r.definitionId!==definition.definitionId||r.requestId!==command.requestId||r.operationRef!==operationRef||!contractActionIso(r.receivedAt)||r.stage!=='received'||r.requestSha256!==await digest(canonical(command))||ack.status!=='pending'||ack.text!==contractActionAcknowledgement(definition,command,r.receivedAt)||ack.sha256!==await digest(String(ack.text)))throw new ContractActionError('invalid-receipt'); return {...r,acknowledgement:ack} as unknown as ContractActionReceipt; } function submit():Promise{ if(state.status==='destroyed')return Promise.reject(new ContractActionError('destroyed'));if(state.status==='unavailable')return Promise.reject(new ContractActionError('unavailable'));if(state.status==='retired')return Promise.reject(new ContractActionError('context-retired'));if(pending)return pending;if(state.receipt)return Promise.resolve(structuredClone(state.receipt)); if(!request){if(!cancellation&&state.status!=='reviewing')return Promise.reject(new ContractActionError('review-required'));try{request={schemaVersion:1,definitionId:definition.definitionId,requestId:crypto.randomUUID(),clientAt:new Date().toISOString(),fields:fields()};request=normalizeContractActionRequest(request,definition);}catch(error){request=undefined;return Promise.reject(error instanceof ContractActionError?error:new ContractActionError('unavailable'));}} const command=structuredClone(request),controller=new AbortController();abort=controller;const signal=controller.signal;state={status:'submitting',fields:structuredClone(command.fields)}; let timer:ReturnType|undefined,rejectAbort:()=>void; const cancelled=new Promise((_,reject)=>{rejectAbort=()=>reject(new ContractActionError('recording-uncertain'));signal.addEventListener('abort',rejectAbort,{once:true});timer=setTimeout(()=>controller.abort(),timeout);}); pending=Promise.race([Promise.resolve().then(async()=>{const {definitionId:_,...descriptor}=definition;if(await digest(canonical(descriptor))!==definition.definitionId)throw new ContractActionError('invalid-definition');if(signal.aborted||state.status==='destroyed')throw new ContractActionError('recording-uncertain');return transport(structuredClone(command),{signal});}),cancelled]).then(value=>receipt(value,command)).then(result=>{if(signal.aborted||state.status==='destroyed')throw new ContractActionError('destroyed');state={status:'received',fields:structuredClone(command.fields),receipt:result};emit();return structuredClone(result);}).catch(error=>{ if(state.status==='destroyed')throw new ContractActionError('destroyed');const code=error?.code==='context-retired'?'context-retired':error instanceof ContractActionError&&error.code==='invalid-definition'?'invalid-definition':error instanceof ContractActionError&&error.code==='invalid-receipt'?'invalid-receipt':'recording-uncertain';state={status:code==='context-retired'?'retired':code==='invalid-definition'?'unavailable':'uncertain',fields:structuredClone(command.fields),error:code};emit();throw new ContractActionError(code); }).finally(()=>{clearTimeout(timer);signal.removeEventListener('abort',rejectAbort);pending=undefined;});emit();return pending; } return {getDefinition:()=>structuredClone(definition),getSnapshot:snapshot,getRecoveryRequest:()=>request?structuredClone(request):undefined,subscribe(fn){listeners.add(fn);try{fn(snapshot());}catch{}return()=>listeners.delete(fn);},setFields(value){if(!['editing','reviewing'].includes(state.status)||request)throw new ContractActionError('unavailable');state={status:'editing',fields:draft(value,cancellation)};emit();},review(){if(!['editing','reviewing'].includes(state.status)||request)throw new ContractActionError('unavailable');state={status:'reviewing',fields:fields()};emit();},edit(){if(state.status!=='reviewing'||request)throw new ContractActionError('unavailable');state={status:'editing',fields:structuredClone(state.fields)};emit();},submit,destroy(){if(state.status==='destroyed')return;state={...state,status:'destroyed'};abort?.abort();emit();listeners.clear();}}; } /** Native, optional styling; direct cancellation confirmation and separate withdrawal review. No login or privacy-acceptance checkbox. */ export function mountContractAction(container:HTMLElement,options:{action:ContractActionController}):{destroy():void}{ if(container.closest('form'))throw Error('Mount the contract action outside another form.'); const {action}=options,d=action.getDefinition(),l=contractActionLabels(d.profile),doc=container.ownerDocument,cancellation=d.profile==='de-cancellation',root=doc.createElement('section');root.className='nb-contract-action';root.lang=d.locale; const title=doc.createElement('h2');title.textContent=d.wording.title; const form=doc.createElement('form'),fieldset=doc.createElement('fieldset'),legend=doc.createElement('legend');legend.textContent=d.wording.title;legend.className='nb-contract-visually-hidden';fieldset.append(legend); const controls=new Map(); function field(key:string,label:string,max:number,type='text',required=true){const wrap=doc.createElement('label'),name=doc.createElement('span');name.textContent=label;const input=type==='textarea'?doc.createElement('textarea'):doc.createElement('input');if(input instanceof doc.defaultView!.HTMLInputElement)input.type=type;input.name=key;input.maxLength=max;input.required=required;if(key==='name')input.autocomplete='name';if(key==='email')input.autocomplete='email';wrap.append(name,input);fieldset.append(wrap);controls.set(key,input);return wrap;} field('name',l.name,300);field('contract',l.contract,2000,'textarea');field('email',l.email,320,'email'); let reason:HTMLElement|undefined; if(cancellation){const wrap=doc.createElement('label'),label=doc.createElement('span'),select=doc.createElement('select');label.textContent=l.type;select.name='kind';select.required=true;for(const [value,text] of [['',l.choose],['ordinary',l.ordinary],['extraordinary',l.extraordinary]]){const option=doc.createElement('option');option.value=value!;option.textContent=text!;select.append(option);}wrap.append(label,select);fieldset.append(wrap);controls.set('kind',select);reason=field('reason',l.reason,2000,'textarea',false);field('ending',l.ending,300,'text',false);} const review=doc.createElement('dl');review.className='nb-contract-review'; const statement=doc.createElement('p');statement.className='nb-contract-statement';statement.textContent=d.wording.statement; const actions=doc.createElement('div');actions.className='nb-contract-actions';const submit=doc.createElement('button');submit.type='submit';submit.className='nb-choice-button nb-primary';const edit=doc.createElement('button');edit.type='button';edit.className='nb-choice-button';edit.textContent=l.edit;actions.append(submit,edit); const status=doc.createElement('p');status.className='nb-control-status';status.setAttribute('role','status');status.tabIndex=-1; const copy=doc.createElement('section'),copyTitle=doc.createElement('h3'),download=doc.createElement('a'),body=doc.createElement('pre');copy.className='nb-contract-copy';copyTitle.textContent=l.copy;download.textContent=l.download;body.tabIndex=0;copy.append(copyTitle,download,body); const links=doc.createElement('p');links.className='nb-contract-links';for(const kind of ['terms','privacy'] as const){const a=doc.createElement('a');a.href=d.documents[kind].url;a.target='_blank';a.rel='noopener';a.textContent=d.documents[kind].title;const hint=doc.createElement('span');hint.className='nb-contract-visually-hidden';hint.textContent=' ('+l.newTab+')';a.append(hint);links.append(a);} form.append(fieldset,review,statement,actions);root.append(title);if(d.mode==='preview'||d.reviewStatus!=='reviewed'){const badge=doc.createElement('p');badge.className='nb-draft';badge.textContent=l.preview;root.append(badge);}root.append(form,status,copy,links);container.append(root); function change(){const f:ContractActionDraft={name:controls.get('name')!.value,contract:controls.get('contract')!.value,email:controls.get('email')!.value};if(cancellation){const kind=controls.get('kind')!.value as ''|'ordinary'|'extraordinary';f.cancellation={kind,reason:kind==='extraordinary'?controls.get('reason')!.value:'',ending:controls.get('ending')!.value};}try{action.setFields(f);return true;}catch{status.textContent=l.invalid;return false;}} for(const control of controls.values())control.addEventListener(control instanceof doc.defaultView!.HTMLSelectElement?'change':'input',change); form.addEventListener('submit',event=>{event.preventDefault();const state=action.getSnapshot();if(state.status==='editing'&&!change()){status.focus();return;}if(!cancellation&&state.status==='editing'){try{action.review();submit.focus();}catch{status.textContent=l.invalid;status.focus();}}else void action.submit().catch(error=>{if(['editing','reviewing'].includes(action.getSnapshot().status)){status.textContent=error?.code==='invalid-fields'?l.invalid:l.uncertain;status.focus();}});}); edit.addEventListener('click',()=>{action.edit();controls.get('name')!.focus();}); let blobUrl:string|undefined,copyId:string|undefined; const unsubscribe=action.subscribe(s=>{ const focused=doc.activeElement,editing=s.status==='editing';fieldset.hidden=!editing;fieldset.disabled=!editing;review.hidden=editing||s.status==='received';edit.hidden=s.status!=='reviewing';form.hidden=s.status==='received'||s.status==='destroyed'||s.status==='retired'||s.status==='unavailable';copy.hidden=!s.receipt; const f=s.fields;for(const key of ['name','contract','email'])controls.get(key)!.value=f[key as keyof Pick];if(cancellation){for(const key of ['kind','reason','ending'] as const)controls.get(key)!.value=f.cancellation![key];reason!.hidden=f.cancellation!.kind!=='extraordinary';} review.replaceChildren();for(const [label,value] of [[l.name,f.name],[l.contract,f.contract],[l.email,f.email],...(f.cancellation?[[l.type,f.cancellation.kind==='ordinary'?l.ordinary:f.cancellation.kind==='extraordinary'?l.extraordinary:l.choose],...(f.cancellation.kind==='extraordinary'?[[l.reason,f.cancellation.reason]]:[]),[l.ending,f.cancellation.ending||l.earliest]]:[])]){const row=doc.createElement('div'),dt=doc.createElement('dt'),dd=doc.createElement('dd');dt.textContent=label!;dd.textContent=value!;row.append(dt,dd);review.append(row);} submit.textContent=s.status==='uncertain'?l.retry:!cancellation&&editing?l.review:d.wording.confirmLabel;submit.disabled=!['editing','reviewing','uncertain'].includes(s.status);status.textContent=s.status==='submitting'?l.saving:s.status==='uncertain'?l.uncertain:s.status==='received'?l.pending:s.status==='retired'||s.status==='unavailable'?l.unavailable:''; if(s.receipt&©Id!==s.receipt.id){copyId=s.receipt.id;body.textContent=s.receipt.acknowledgement.text;const api=doc.defaultView?.URL;if(api?.createObjectURL){blobUrl=api.createObjectURL(new doc.defaultView!.Blob([s.receipt.acknowledgement.text],{type:'text/plain;charset=utf-8'}));download.href=blobUrl;download.download='declaration-'+s.receipt.id+'.txt';}else download.hidden=true;title.textContent=l.received;} if(root.contains(focused)&&(focused instanceof doc.defaultView!.HTMLElement)&&((focused===submit&&submit.disabled)||focused===edit&&edit.hidden||fieldset.contains(focused)&&fieldset.hidden||form.contains(focused)&&form.hidden)){if(!form.hidden&&!submit.disabled)submit.focus();else status.focus();} }); return {destroy(){unsubscribe();if(blobUrl)doc.defaultView?.URL.revokeObjectURL(blobUrl);root.remove();}}; }