diff --git a/.gitignore b/.gitignore
index a9d7efa1..efad076a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,5 @@ helix-importer-ui
.claude
content
+
+.hlx/.da-token.json
diff --git a/.well-known/apple-developer-merchantid-domain-association.txt b/.well-known/apple-developer-merchantid-domain-association.txt
index cd4be7b3..f3c45bdd 100644
--- a/.well-known/apple-developer-merchantid-domain-association.txt
+++ b/.well-known/apple-developer-merchantid-domain-association.txt
@@ -1,74 +1,74 @@
-MIIQYgYJKoZIhvcNAQcCoIIQUzCCEE8CAQExCzAJBgUrDgMCGgUAMHEGCSqGSIb3DQEHAaBkBGJ7
-InRlYW1JZCI6IkRRVkZYTUxYNTkiLCJkb21haW4iOiJ1YXQudml0YW1peC5jb20iLCJkYXRlQ3Jl
-YXRlZCI6IjIwMjUtMTEtMTQsMTk6NDQ6MzIiLCJ2ZXJzaW9uIjoxfaCCDT8wggQ0MIIDHKADAgEC
-Agg9Wfg36tHYnzANBgkqhkiG9w0BAQsFADBzMS0wKwYDVQQDDCRBcHBsZSBpUGhvbmUgQ2VydGlm
-aWNhdGlvbiBBdXRob3JpdHkxIDAeBgNVBAsMF0NlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYD
-VQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzAeFw0yNDEyMTYxOTIxMDFaFw0yOTEyMTExODEz
-NTlaMFkxNTAzBgNVBAMMLEFwcGxlIGlQaG9uZSBPUyBQcm92aXNpb25pbmcgUHJvZmlsZSBTaWdu
-aW5nMRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQAD
-ggEPADCCAQoCggEBANCTMav4Ux7frR4vZPfJTdeWvl9LPXlkXEPuKcNA0vovHKC2vBFz7_AisN_e
--fnOVeP1QgG1I2VBEjv3fEZ9iRNFlUTslpViZpeQAwDZ4K7F2bGcIC2W4IXtb2vTUtODPNQBIyXp
-5cbUEdh5qgjC3RVY9e-Kk0sNS-4NtoeTdREQVcsMeAfbN3BGO5f6xOt4KeD07HjjYdpAV4AHu4ic
-pcdJbcgm05UfTSGijWhzgx7mWVqFllVUsJUuJdx3DWGHgY2JpAN7PAB3LIlqWdNkRNl0pVuKsVJh
-X24EMNTz4hA0DJWMS-F71iuFg_InOY1wCCPiFIj_k_QtbUwm4os3hi0CAwEAAaOB5TCB4jAMBgNV
-HRMBAf8EAjAAMB8GA1UdIwQYMBaAFG_xlRhiXODI8cXtbBjJ4NNkUpggMEAGCCsGAQUFBwEBBDQw
-MjAwBggrBgEFBQcwAYYkaHR0cDovL29jc3AuYXBwbGUuY29tL29jc3AwMy1haXBjYTA3MC8GA1Ud
-HwQoMCYwJKAioCCGHmh0dHA6Ly9jcmwuYXBwbGUuY29tL2FpcGNhLmNybDAdBgNVHQ4EFgQUvLXF
-6b38y9Ce3JSwHvghlFz_CS4wDgYDVR0PAQH_BAQDAgeAMA8GCSqGSIb3Y2QGOgQCBQAwDQYJKoZI
-hvcNAQELBQADggEBADI0wul3ql_gxsqi83dZ54pnuPFR8_uw9pe_sRGj4aE8uyOS6RKTonEdvPGa
-cW-kPG82krbgR4Kik-PnuI-73yVEYgLPzbz3-42KCXB4ZcIZTSXLcmIh5Klo-RCaLnoPKL6mAwbR
-VWEfr3z4lNRxDuLTJVSLzq3VaAdbvS17x2JFebmph0z4GDuArhBLcdh4K-YKr5rn2U3M6lu3o5dV
-a-wNoHjHwLDPy9wQTDCSE3GU1q_g7MnpyZvOJTLuEQ0hFySL8ZUuImJGRX_g29cWVMG5PtPairll
-9rS0I394XdlydmRjpwhVx9m3lNsjv_OTp9QEREMNyuJWsiuUKKQ9cocwggREMIIDLKADAgECAghc
-Y8rkSjdTyTANBgkqhkiG9w0BAQsFADBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5j
-LjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxl
-IFJvb3QgQ0EwHhcNMTcwNTEwMjEyNzMwWhcNMzAxMjMxMDAwMDAwWjBzMS0wKwYDVQQDDCRBcHBs
-ZSBpUGhvbmUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxIDAeBgNVBAsMF0NlcnRpZmljYXRpb24g
-QXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcN
-AQEBBQADggEPADCCAQoCggEBAMlFagEPPoMEhsf8v9xe8B6B7hcwc2MmLt49eiTNkz5POUe6db7z
-wNLxWaKrH_4KhjzZLZoH8g5ruSmRGl8iCovxclgFrkxLRMV5p4A8sIjgjAwnhF0Z5YcZNsvjxXa3
-sPRBclH0BVyDS6JtplG48Sbfe16tZQzGsphRjLt9G0zBTsgIx9LtZAu03RuNT0B9G49IlpJb89CY
-ftm8pBkOmWG7QV0BzFt3en0k0NzTU__D3MWULLZaTY4YIzm92cZSPtHy9CWKoSqH_dgMRilR_-0X
-bIkla4e_imkUn3efwxW3aLOIRb2E5gYCQWQPrSoouBXJ4KynirpyBDSyeIz4soUCAwEAAaOB7DCB
-6TAPBgNVHRMBAf8EBTADAQH_MB8GA1UdIwQYMBaAFCvQaUeUdgn-9GuNLkCm90dNfwheMEQGCCsG
-AQUFBwEBBDgwNjA0BggrBgEFBQcwAYYoaHR0cDovL29jc3AuYXBwbGUuY29tL29jc3AwMy1hcHBs
-ZXJvb3RjYTAuBgNVHR8EJzAlMCOgIaAfhh1odHRwOi8vY3JsLmFwcGxlLmNvbS9yb290LmNybDAd
-BgNVHQ4EFgQUb_GVGGJc4Mjxxe1sGMng02RSmCAwDgYDVR0PAQH_BAQDAgEGMBAGCiqGSIb3Y2QG
-AhIEAgUAMA0GCSqGSIb3DQEBCwUAA4IBAQA6z6yYjb6SICEJrZXzsVwh-jYtVyBEdHNkkgizlqz3
-bZf6WzQ4J88SRtM8EfAHyZmQsdHoEQml46VrbGMIP54l-tWZnEzm5c6Osk1o7Iuro6JPihEVPtwU
-KxzGRLZvZ8VbT5UpLYdcP9yDHndP7dpUpy3nE4HBY8RUCxtLCmooIgjUN5J8f2coX689P7esWR04
-NGRa7jNKGUJEKcTKGGvhwVMtLfRNwhX2MzIYePEmb4pN65RMo-j_D7MDi2Xa6y7YZVCf3J-K3zGo
-hFTcUlJB0rITHTFGR4hfPu7D8owjBJXrrIo-gmwGny7ji0OaYls0DfSZzyzuunKGGSOl_I61MIIE
-uzCCA6OgAwIBAgIBAjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBw
-bGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMT
-DUFwcGxlIFJvb3QgQ0EwHhcNMDYwNDI1MjE0MDM2WhcNMzUwMjA5MjE0MDM2WjBiMQswCQYDVQQG
-EwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBB
-dXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw
-ggEKAoIBAQDkkakJH5HbHkdQ6wXtXnmELes2oldMVeyLGYne-Uts9QerIjAC6Bg--FAJ039BqJj5
-0cpmnCRrEdCju-QbKsMflZ56DKRHi1vUFjczy8QPTc4UadHJGXL1XQ7Vf1-b8iUDulWPTV0N8WQ1
-IxVLFVkds5T39pyez1C6wVhQZ48ItCD3y6wsIG9wtj8BMIy3Q88PnT3zK0koGsj-zrW5DtleHNbL
-PbU6rfQPDgCSC7EhFi501TwN22IWq6NxkkdTVcGvL0Gz-PvjcM3mo0xFfh9Ma1CWQYnEdGILEINB
-hzOKgbEwWOxaBDKMaLOPHd5lc_9nXmW8Sdh2nzMUZaF3lMktAgMBAAGjggF6MIIBdjAOBgNVHQ8B
-Af8EBAMCAQYwDwYDVR0TAQH_BAUwAwEB_zAdBgNVHQ4EFgQUK9BpR5R2Cf70a40uQKb3R01_CF4w
-HwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01_CF4wggERBgNVHSAEggEIMIIBBDCCAQAGCSqG
-SIb3Y2QFATCB8jAqBggrBgEFBQcCARYeaHR0cHM6Ly93d3cuYXBwbGUuY29tL2FwcGxlY2EvMIHD
-BggrBgEFBQcCAjCBthqBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5
-IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1z
-IGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0
-aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMA0GCSqGSIb3DQEBBQUAA4IBAQBcNplMLXi37Yyb3PN3
-m_J20ncwT8EfhYOFG5k9RzfyqZtAjizUsZAS2L70c5vu0mQPy3lPNNiiPvl4_2vIB-x9OYOLUyDT
-OMSxv5pPCmv_K_xZpwUJfBdAVhEedNO3iyM7R6PVbyTi69G3cN8PReEnyvFteO3ntRcXqNx-IjXK
-JdXZD9Zr1KIkIxH3oayPc4FgxhtbCS-SsvhESPBgOJ4V9T0mZyCKM2r3DYLP3uujL_lTaltkwGMz
-d_c6ByxW69oPIQ7aunMZT7XZNn_Bh1XZp5m5MkL72NVxnn6hUrcbvZNCJBIqxw8dtk2cXmPIS4AX
-UKqK1drk_NAJBzewdXUhMYIChTCCAoECAQEwfzBzMS0wKwYDVQQDDCRBcHBsZSBpUGhvbmUgQ2Vy
-dGlmaWNhdGlvbiBBdXRob3JpdHkxIDAeBgNVBAsMF0NlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMw
-EQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUwIIPVn4N-rR2J8wCQYFKw4DAhoFAKCB3DAY
-BgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwGCSqGSIb3DQEJBTEPFw0yNTExMTQxOTQ0MzJaMCMG
-CSqGSIb3DQEJBDEWBBTDYcbzmjvgUD_BJy52hE3C2PQ5BDApBgkqhkiG9w0BCTQxHDAaMAkGBSsO
-AwIaBQChDQYJKoZIhvcNAQEBBQAwUgYJKoZIhvcNAQkPMUUwQzAKBggqhkiG9w0DBzAOBggqhkiG
-9w0DAgICAIAwDQYIKoZIhvcNAwICAUAwBwYFKw4DAgcwDQYIKoZIhvcNAwICASgwDQYJKoZIhvcN
-AQEBBQAEggEAd87EgZRUrfBCi-D60noQGneyElcfShOPjalmNPCAIduV1Xt5SIFZ3OgFL24kVZzw
-_V9wFFzqb0RRP9ZDTijNrGREzMORTa0z_absTqNFI526Rkq9ivXAzEiNBsxBxqtFY0Od3NIr60bd
-ADUV5zdT4kj69MOXO0iFsuuUwtWknfr8-rFwHqX5Bkhmk7UzsseLrDnVnvRGMSDJYyeH4PBiw5E4
-moYy3-M-QcjM-GUPOnZTmmiCsHuWfHE98wXYUwxMJCpFWpecSMP3TCSzZgqWrk_SYfTrXyizohvZ
-4lJpYKlWwGcTJLtJW-qjNWZ9FmAcyr57W9SDxkxzKw62-ibmXw
+MIIQagYJKoZIhvcNAQcCoIIQWzCCEFcCAQExCzAJBgUrDgMCGgUAMHkGCSqGSIb3DQEHAaBsBGp7
+InRlYW1JZCI6IkRRVkZYTUxYNTkiLCJkb21haW4iOiJpbnRlZ3JhdGlvbi52aXRhbWl4LmNvbSIs
+ImRhdGVDcmVhdGVkIjoiMjAyNi0wNC0yMiwyMDoxMzo1NiIsInZlcnNpb24iOjF9oIINPzCCBDQw
+ggMcoAMCAQICCEYgLbwDT4-2MA0GCSqGSIb3DQEBCwUAMHMxLTArBgNVBAMMJEFwcGxlIGlQaG9u
+ZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEgMB4GA1UECwwXQ2VydGlmaWNhdGlvbiBBdXRob3Jp
+dHkxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMB4XDTI2MDEyODE5NTU1MloXDTMw
+MTIzMTAwMDAwMFowWTE1MDMGA1UEAwwsQXBwbGUgaVBob25lIE9TIFByb3Zpc2lvbmluZyBQcm9m
+aWxlIFNpZ25pbmcxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMIIBIjANBgkqhkiG
+9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuAHGAqf6LSCrzkatFVhzLum8NWXlrHLZBlgdGZc6A6KQHeeq
+QX3r-eQDXmsJ4qh0jw-zQjPFR53T04WsiypiLm0sTY6JesoMU-ndpB5upQJp_HboBGw4PS3yqjN1
+gSPLhwne5tv_epkSn5xSg3ExVfl-rTsldVJT4DKzYGFOGat7BEW8q1drCA-UzpUyRAJbiq1yIMCq
+ZzsyzzeHVUWjk6LiHnD_pG-CBYTBmT5p1mp3EwpZQTRaEQ2kLhDZscZrP2FVIANCo05Bivk8Rd_V
+lwS_TShNfBC5fm0sPrAjet1olpNpJ1qOp7l0EYHnwSDb_J2bZMoPLcwNmVQzAxe-fQIDAQABo4Hl
+MIHiMAwGA1UdEwEB_wQCMAAwHwYDVR0jBBgwFoAUb_GVGGJc4Mjxxe1sGMng02RSmCAwQAYIKwYB
+BQUHAQEENDAyMDAGCCsGAQUFBzABhiRodHRwOi8vb2NzcC5hcHBsZS5jb20vb2NzcDAzLWFpcGNh
+MDcwLwYDVR0fBCgwJjAkoCKgIIYeaHR0cDovL2NybC5hcHBsZS5jb20vYWlwY2EuY3JsMB0GA1Ud
+DgQWBBQ7FGgQTyYtjtoLSVAXba5kPv-WETAOBgNVHQ8BAf8EBAMCB4AwDwYJKoZIhvdjZAY6BAIF
+ADANBgkqhkiG9w0BAQsFAAOCAQEAOkB4qcVsCxyO4PH-Em0zTr5uD9JWzi--CTbitmmRSbEKo3xo
+EkNYevljx-Oe6wd-REaW8HWnpHx6Hv49_N9C9rxzjLNAylE50ULz4feKfdbIZ2o-8yTA9U3CHCf3
+OuuFXubvO2MbfEgGsjCIw7cAWWj1QO2Fz5ga4dbSYi1MlZvSLkvkLeodwYFEnmozgS4WS_JXUI0O
+zgk2mBcgPf4E4LL8nNM6Y6Ui_tOAMOMiYw-dd0AOo2ziyOYat1mNNSHY8p43nTxFTdoxhlOrHpLx
+SWIAJbBODwxiETDrYnRw_OgE2YZXoV4o0-Ll2ooYeRbkcMF5mLlDjM_HBd_eKnGnvzCCBEQwggMs
+oAMCAQICCFxjyuRKN1PJMA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRMwEQYDVQQKEwpB
+cHBsZSBJbmMuMSYwJAYDVQQLEx1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEWMBQGA1UE
+AxMNQXBwbGUgUm9vdCBDQTAeFw0xNzA1MTAyMTI3MzBaFw0zMDEyMzEwMDAwMDBaMHMxLTArBgNV
+BAMMJEFwcGxlIGlQaG9uZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEgMB4GA1UECwwXQ2VydGlm
+aWNhdGlvbiBBdXRob3JpdHkxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMIIBIjAN
+BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyUVqAQ8-gwSGx_y_3F7wHoHuFzBzYyYu3j16JM2T
+Pk85R7p1vvPA0vFZoqsf_gqGPNktmgfyDmu5KZEaXyIKi_FyWAWuTEtExXmngDywiOCMDCeEXRnl
+hxk2y-PFdrew9EFyUfQFXINLom2mUbjxJt97Xq1lDMaymFGMu30bTMFOyAjH0u1kC7TdG41PQH0b
+j0iWklvz0Jh-2bykGQ6ZYbtBXQHMW3d6fSTQ3NNT_8PcxZQstlpNjhgjOb3ZxlI-0fL0JYqhKof9
+2AxGKVH_7RdsiSVrh7-KaRSfd5_DFbdos4hFvYTmBgJBZA-tKii4FcngrKeKunIENLJ4jPiyhQID
+AQABo4HsMIHpMA8GA1UdEwEB_wQFMAMBAf8wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01_
+CF4wRAYIKwYBBQUHAQEEODA2MDQGCCsGAQUFBzABhihodHRwOi8vb2NzcC5hcHBsZS5jb20vb2Nz
+cDAzLWFwcGxlcm9vdGNhMC4GA1UdHwQnMCUwI6AhoB-GHWh0dHA6Ly9jcmwuYXBwbGUuY29tL3Jv
+b3QuY3JsMB0GA1UdDgQWBBRv8ZUYYlzgyPHF7WwYyeDTZFKYIDAOBgNVHQ8BAf8EBAMCAQYwEAYK
+KoZIhvdjZAYCEgQCBQAwDQYJKoZIhvcNAQELBQADggEBADrPrJiNvpIgIQmtlfOxXCH6Ni1XIER0
+c2SSCLOWrPdtl_pbNDgnzxJG0zwR8AfJmZCx0egRCaXjpWtsYwg_niX61ZmcTOblzo6yTWjsi6uj
+ok-KERU-3BQrHMZEtm9nxVtPlSkth1w_3IMed0_t2lSnLecTgcFjxFQLG0sKaigiCNQ3knx_Zyhf
+rz0_t6xZHTg0ZFruM0oZQkQpxMoYa-HBUy0t9E3CFfYzMhh48SZvik3rlEyj6P8PswOLZdrrLthl
+UJ_cn4rfMaiEVNxSUkHSshMdMUZHiF8-7sPyjCMEleusij6CbAafLuOLQ5piWzQN9JnPLO66coYZ
+I6X8jrUwggS7MIIDo6ADAgECAgECMA0GCSqGSIb3DQEBBQUAMGIxCzAJBgNVBAYTAlVTMRMwEQYD
+VQQKEwpBcHBsZSBJbmMuMSYwJAYDVQQLEx1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEW
+MBQGA1UEAxMNQXBwbGUgUm9vdCBDQTAeFw0wNjA0MjUyMTQwMzZaFw0zNTAyMDkyMTQwMzZaMGIx
+CzAJBgNVBAYTAlVTMRMwEQYDVQQKEwpBcHBsZSBJbmMuMSYwJAYDVQQLEx1BcHBsZSBDZXJ0aWZp
+Y2F0aW9uIEF1dGhvcml0eTEWMBQGA1UEAxMNQXBwbGUgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEB
+BQADggEPADCCAQoCggEBAOSRqQkfkdseR1DrBe1eeYQt6zaiV0xV7IsZid75S2z1B6siMALoGD74
+UAnTf0GomPnRymacJGsR0KO75Bsqwx-VnnoMpEeLW9QWNzPLxA9NzhRp0ckZcvVdDtV_X5vyJQO6
+VY9NXQ3xZDUjFUsVWR2zlPf2nJ7PULrBWFBnjwi0IPfLrCwgb3C2PwEwjLdDzw-dPfMrSSgayP7O
+tbkO2V4c1ss9tTqt9A8OAJILsSEWLnTVPA3bYharo3GSR1NVwa8vQbP4--NwzeajTEV-H0xrUJZB
+icR0YgsQg0GHM4qBsTBY7FoEMoxos48d3mVz_2deZbxJ2HafMxRloXeUyS0CAwEAAaOCAXowggF2
+MA4GA1UdDwEB_wQEAwIBBjAPBgNVHRMBAf8EBTADAQH_MB0GA1UdDgQWBBQr0GlHlHYJ_vRrjS5A
+pvdHTX8IXjAfBgNVHSMEGDAWgBQr0GlHlHYJ_vRrjS5ApvdHTX8IXjCCAREGA1UdIASCAQgwggEE
+MIIBAAYJKoZIhvdjZAUBMIHyMCoGCCsGAQUFBwIBFh5odHRwczovL3d3dy5hcHBsZS5jb20vYXBw
+bGVjYS8wgcMGCCsGAQUFBwICMIG2GoGzUmVsaWFuY2Ugb24gdGhpcyBjZXJ0aWZpY2F0ZSBieSBh
+bnkgcGFydHkgYXNzdW1lcyBhY2NlcHRhbmNlIG9mIHRoZSB0aGVuIGFwcGxpY2FibGUgc3RhbmRh
+cmQgdGVybXMgYW5kIGNvbmRpdGlvbnMgb2YgdXNlLCBjZXJ0aWZpY2F0ZSBwb2xpY3kgYW5kIGNl
+cnRpZmljYXRpb24gcHJhY3RpY2Ugc3RhdGVtZW50cy4wDQYJKoZIhvcNAQEFBQADggEBAFw2mUwt
+eLftjJvc83eb8nbSdzBPwR-Fg4UbmT1HN_Kpm0COLNSxkBLYvvRzm-7SZA_LeU802KI--Xj_a8gH
+7H05g4tTINM4xLG_mk8Ka_8r_FmnBQl8F0BWER5007eLIztHo9VvJOLr0bdw3w9F4SfK8W147ee1
+Fxeo3H4iNcol1dkP1mvUoiQjEfehrI9zgWDGG1sJL5Ky-ERI8GA4nhX1PSZnIIozavcNgs_e66Mv
+-VNqW2TAYzN39zoHLFbr2g8hDtq6cxlPtdk2f8GHVdmnmbkyQvvY1XGefqFStxu9k0IkEirHDx22
+TZxeY8hLgBdQqorV2uT80AkHN7B1dSExggKFMIICgQIBATB_MHMxLTArBgNVBAMMJEFwcGxlIGlQ
+aG9uZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEgMB4GA1UECwwXQ2VydGlmaWNhdGlvbiBBdXRo
+b3JpdHkxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTAghGIC28A0-PtjAJBgUrDgMC
+GgUAoIHcMBgGCSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTI2MDQyMjIw
+MTM1NlowIwYJKoZIhvcNAQkEMRYEFKkKE52twp4cRLnVuyaDJsj3edyfMCkGCSqGSIb3DQEJNDEc
+MBowCQYFKw4DAhoFAKENBgkqhkiG9w0BAQEFADBSBgkqhkiG9w0BCQ8xRTBDMAoGCCqGSIb3DQMH
+MA4GCCqGSIb3DQMCAgIAgDANBggqhkiG9w0DAgIBQDAHBgUrDgMCBzANBggqhkiG9w0DAgIBKDAN
+BgkqhkiG9w0BAQEFAASCAQA0X_G2tsTS9YcF9QyDLM3qWmHiMz8qKwHte_f7yyZpc7-GFqoV1OzT
+39lMtIm37fQvBU5c-i3lad-FdU-bHzNzdM1CSPOr8lgF-uQ4eKEjHnw3uc_2gEzFw5LUj3tPTKZU
+6O0xHhOiZuN2uWkNcEG7gLPYQNyudGgAXDp7FCdCEj_E9EvgqMGyjM1nK4W-FDdFq8fuGbLAesu2
+bjl5cugH7HPAlPI-fLl-gQUU_32UCUYOB5lvsxDgwG-FjLd7kfWb7OPkaGwJ-BlOcZp93ZbPMMXp
+MdZpZzIUuZZIT5A4W0lQ-9x3hdJkGIIFNVobwB5qZ_Ur7ZgX0shTopQJBkG6
\ No newline at end of file
diff --git a/blocks/cart-summary/cart-summary.css b/blocks/cart-summary/cart-summary.css
new file mode 100644
index 00000000..912828e1
--- /dev/null
+++ b/blocks/cart-summary/cart-summary.css
@@ -0,0 +1,397 @@
+/* Two-column layout: section containing cart-summary becomes a flex row.
+ Pull horizontal spacing onto the section and strip it from inner wrappers,
+ which global styles would otherwise pad with --horizontal-spacing each side. */
+.section:has(.cart-summary-wrapper) {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ gap: 24px 48px;
+ padding-block: 40px;
+ padding-inline: var(--horizontal-spacing);
+}
+
+.section:has(.cart-summary-wrapper):has(.checkout-progress-wrapper) {
+ padding-block-start: 10px;
+}
+
+/* Heading authored alongside the cart sits above the two columns */
+div.section.cart-section:has(.cart-summary-wrapper) > .default-content-wrapper {
+ flex-basis: 100%;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+}
+
+div.section.cart-section:has(.cart-summary-wrapper) > .default-content-wrapper h1 {
+ margin-top: 0;
+ padding-top: 0;
+}
+
+.cart-continue-shopping {
+ font-size: var(--commerce-font-size-sm);
+ color: var(--commerce-color-text-muted);
+ text-decoration: none;
+ white-space: nowrap;
+}
+
+.cart-continue-shopping:hover {
+ color: var(--color-text);
+ text-decoration: underline;
+}
+
+/* Hide the cart block's own checkout button — cart-summary provides it */
+.section:has(.cart-summary-wrapper) .cart-controls {
+ display: none;
+}
+
+.cart-summary-wrapper {
+ width: var(--commerce-summary-width);
+ flex-shrink: 0;
+}
+
+.section:has(.cart-summary-wrapper) > .cart-wrapper {
+ flex: 1;
+ min-width: 0;
+}
+
+.section:has(.cart-summary-wrapper) > .cart-wrapper,
+.section:has(.cart-summary-wrapper) > .cart-summary-wrapper {
+ padding: 0;
+ margin: 0;
+}
+
+/* Remove margin-top that cart.css adds to .cart-wrapper so it top-aligns with the summary */
+div.section.cart-section:has(.cart-summary-wrapper) > .cart-wrapper {
+ margin-top: 0;
+}
+
+@media (width <= 999px) {
+ .section:has(.cart-summary-wrapper) {
+ flex-direction: column;
+ padding-inline: var(--horizontal-spacing);
+ padding-block-start: 20px;
+ }
+
+ .section:has(.cart-summary-wrapper) > .cart-wrapper {
+ width: 100%;
+ flex: none;
+ }
+
+ .cart-summary-wrapper {
+ width: 100%;
+ position: static;
+ }
+
+ div.section.cart-section:has(.cart-summary-wrapper) > .default-content-wrapper {
+ width: 100%;
+ }
+}
+
+/* Card */
+.cart-summary {
+ background-color: var(--commerce-color-background);
+ border: 1px solid var(--commerce-color-border);
+ border-radius: var(--commerce-radius-card);
+ overflow: hidden;
+}
+
+/* Header */
+.cart-summary-header {
+ padding: 20px 24px 12px;
+ border-bottom: 1px solid var(--commerce-color-border);
+}
+
+.cart-summary-header h3 {
+ margin: 0;
+ font-size: var(--heading-size-l);
+}
+
+/* Content panel — always visible */
+.cart-summary-content {
+ display: block;
+ padding: var(--commerce-card-padding);
+}
+
+/* Express checkout buttons */
+.cart-summary-express-section {
+ margin-bottom: 20px;
+}
+
+.cart-summary-express-buttons {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.cart-summary-express-buttons apple-pay-button {
+ --apple-pay-button-width: 100%;
+ --apple-pay-button-height: 55px;
+ --apple-pay-button-border-radius: var(--commerce-button-radius);
+ --apple-pay-button-padding: 0;
+
+ display: block;
+}
+
+.cart-summary-express-divider {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-top: 16px;
+ color: var(--commerce-color-text-muted);
+ font-size: var(--commerce-font-size-sm);
+}
+
+.cart-summary-express-divider::before,
+.cart-summary-express-divider::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: var(--commerce-color-border);
+}
+
+/* PayPal stub buttons */
+.cart-summary-express-buttons .paypal-express-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 55px;
+ background: #ffc439;
+ border: none;
+ border-radius: var(--commerce-button-radius);
+ cursor: pointer;
+ transition: background 0.15s;
+ box-sizing: border-box;
+ overflow: hidden;
+}
+
+.cart-summary-express-buttons .pp-wordmark {
+ font-family: Arial, Helvetica, sans-serif;
+ font-size: 20px;
+ font-weight: bold;
+ line-height: 1;
+}
+
+.cart-summary-express-buttons .pp-pay { color: #003087; }
+
+.cart-summary-express-buttons .pp-pal { color: #009cde; }
+
+.cart-summary-express-buttons .pp-later {
+ font-family: Arial, Helvetica, sans-serif;
+ font-size: 13px;
+ font-weight: bold;
+ color: #003087;
+ margin-left: 6px;
+}
+
+.cart-summary-express-buttons .paypal-express-btn:hover {
+ background: #f0b929;
+}
+
+.cart-summary-express-buttons .paypal-express-btn:disabled {
+ opacity: 0.7;
+ cursor: not-allowed;
+ font-size: var(--commerce-font-size-sm);
+ color: #003087;
+}
+
+.cart-summary-express-buttons .paylater-express-btn {
+ background: #e8f4fb;
+ border: 1px solid #b0d8f0;
+}
+
+.cart-summary-express-buttons .paylater-express-btn:hover {
+ background: #d0eaf7;
+}
+
+/* Modal dialog for not-configured Apple Pay / PayPal (appended to body) */
+dialog.paypal-not-configured-dialog {
+ border: 1px solid var(--commerce-color-border);
+ border-radius: var(--commerce-radius-card);
+ padding: var(--commerce-card-padding);
+ min-width: 280px;
+ text-align: center;
+}
+
+dialog.paypal-not-configured-dialog p {
+ margin: 0 0 16px;
+ font-size: var(--commerce-font-size-sm);
+}
+
+dialog.paypal-not-configured-dialog::backdrop {
+ background: rgb(0 0 0 / 40%);
+}
+
+.cart-summary-express-buttons .paypal-not-configured-dialog {
+ border: 1px solid var(--commerce-color-border);
+ border-radius: var(--commerce-radius-card);
+ padding: var(--commerce-card-padding);
+ width: 100%;
+ text-align: center;
+ box-sizing: border-box;
+}
+
+.cart-summary-express-buttons .paypal-not-configured-dialog p {
+ margin: 0 0 16px;
+ font-size: var(--commerce-font-size-sm);
+}
+
+/* Promo code disclosure */
+.cart-summary-promo {
+ margin-bottom: 20px;
+ padding-bottom: 20px;
+ border-bottom: 1px solid var(--commerce-color-border);
+}
+
+.cart-summary-promo-toggle {
+ font-size: var(--commerce-font-size-sm);
+ cursor: pointer;
+ list-style: none;
+ display: flex;
+ align-items: center;
+ color: var(--commerce-color-text);
+}
+
+.cart-summary-promo-toggle::-webkit-details-marker {
+ display: none;
+}
+
+.cart-summary-promo-toggle::after {
+ content: '+';
+ font-size: 16px;
+ margin-left: auto;
+}
+
+.cart-summary-promo[open] .cart-summary-promo-toggle::after {
+ content: '−';
+}
+
+.cart-summary-discount {
+ display: flex;
+ gap: var(--commerce-gap-sm);
+ margin-top: 12px;
+}
+
+.cart-summary .discount-input {
+ flex: 1;
+ padding: 12px 16px;
+ border: 1px solid var(--commerce-input-border);
+ border-radius: var(--commerce-input-radius);
+ font-size: var(--commerce-font-size-sm);
+ background: var(--commerce-input-background);
+}
+
+.cart-summary .discount-input:focus {
+ outline: none;
+ border-color: var(--commerce-input-border-focus);
+}
+
+.cart-summary .discount-apply {
+ padding: 12px 24px;
+ background: var(--commerce-color-text-muted);
+ color: #fff;
+ border: none;
+ border-radius: var(--commerce-button-radius);
+ font-size: var(--commerce-font-size-sm);
+ font-weight: var(--commerce-font-weight-bold);
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.cart-summary .discount-apply:hover {
+ background: var(--commerce-color-text);
+}
+
+/* Totals */
+.cart-summary-totals {
+ display: flex;
+ flex-direction: column;
+ gap: var(--commerce-gap-sm);
+}
+
+.cart-summary-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: var(--commerce-font-size-sm);
+}
+
+.cart-summary-shipping {
+ color: var(--commerce-color-text-muted);
+ font-style: italic;
+}
+
+.cart-summary-row.cart-summary-final {
+ margin-top: var(--commerce-gap-sm);
+ padding-top: 16px;
+ border-top: 1px solid var(--commerce-color-border);
+ font-size: var(--commerce-font-size-base);
+}
+
+.cart-summary-final-amount {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.cart-summary-discount-pending {
+ color: var(--commerce-color-text-muted);
+ font-style: italic;
+}
+
+.cart-summary-final-amount .currency {
+ font-size: var(--commerce-font-size-xs);
+ font-weight: 400;
+ color: var(--commerce-color-text-muted);
+}
+
+.cart-summary-final-amount strong {
+ font-size: var(--commerce-font-size-total);
+}
+
+/* Error */
+.cart-summary-error {
+ margin-top: 12px;
+ padding: 12px;
+ background: var(--commerce-color-error-bg);
+ border: 1px solid var(--commerce-color-error-border);
+ border-radius: var(--commerce-radius-badge);
+ color: var(--commerce-color-error);
+ font-size: var(--commerce-font-size-sm);
+}
+
+/* Checkout CTA */
+.cart-summary-checkout-btn {
+ display: block;
+ width: 100%;
+ margin-top: 20px;
+ text-align: center;
+ box-sizing: border-box;
+}
+
+/* Loading state */
+.cart-summary-content.loading {
+ position: relative;
+ opacity: 0.4;
+ pointer-events: none;
+}
+
+.cart-summary-content.loading::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 32px;
+ height: 32px;
+ margin: -16px 0 0 -16px;
+ border: 3px solid var(--commerce-color-border);
+ border-top-color: var(--commerce-color-text);
+ border-radius: 50%;
+ animation: cart-summary-spin 0.7s linear infinite;
+}
+
+@keyframes cart-summary-spin {
+ to { transform: rotate(360deg); }
+}
diff --git a/blocks/cart-summary/cart-summary.js b/blocks/cart-summary/cart-summary.js
new file mode 100644
index 00000000..ac7b2b05
--- /dev/null
+++ b/blocks/cart-summary/cart-summary.js
@@ -0,0 +1,232 @@
+import { loadCSS } from '../../scripts/aem.js';
+import { getConfig, formatPrice } from '../../scripts/commerce-config.js';
+import cart from '../../scripts/cart.js';
+import { previewOrder, createOrder, initiatePayment } from '../../scripts/commerce-api.js';
+import applePay from '../../scripts/payments/apple-pay.js';
+import googlePay from '../../scripts/payments/google-pay.js';
+import paypal from '../../scripts/payments/paypal.js';
+import { getActiveProviders } from '../checkout/checkout-payment.js';
+
+const ALL_PROVIDERS = [applePay, googlePay, paypal];
+
+const LOCAL_STRINGS = {
+ 'en-us': {
+ havePromoCode: 'Have a promo code?',
+ shippingPlaceholder: 'Calculated at checkout',
+ checkoutSecurely: 'Checkout securely',
+ applied: 'Code saved',
+ discountPending: 'Applied at checkout',
+ },
+ 'fr-ca': {
+ havePromoCode: 'Vous avez un code promo?',
+ shippingPlaceholder: 'Calculée à la caisse',
+ checkoutSecurely: 'Payer en toute sécurité',
+ applied: 'Code sauvegardé',
+ discountPending: 'Appliqué à la caisse',
+ },
+};
+
+function getStrings() {
+ const config = getConfig();
+ const lang = config.getLanguage().toLowerCase().replace('_', '-');
+ return { ...config.getStrings(), ...(LOCAL_STRINGS[lang] || LOCAL_STRINGS['en-us']) };
+}
+
+function getCurrencyCode() {
+ const config = getConfig();
+ return typeof config.currency === 'function' ? config.currency(config.getLocale()) : config.currency;
+}
+
+/**
+ * If cart-summary is authored in its own section, move its wrapper into the
+ * adjacent section containing the cart block so the CSS two-column layout fires.
+ * @param {HTMLDivElement} block
+ */
+function colocateWithCart(block) {
+ const wrapper = block.closest('.cart-summary-wrapper');
+ const mySection = wrapper?.closest('.section');
+ if (!wrapper || !mySection) return;
+ if (mySection.querySelector('.cart-wrapper')) return;
+
+ const main = mySection.closest('main') || document;
+ const target = [...main.querySelectorAll('.section')]
+ .find((s) => s !== mySection && s.querySelector('.cart-wrapper'));
+ if (!target) return;
+
+ target.appendChild(wrapper);
+ if (!mySection.children.length) mySection.remove();
+}
+
+function buildTemplate(s) {
+ return /* html */`
+
+
+
+
+
+ ${s.havePromoCode}
+
+
+
+
+
+
+
+ ${s.subtotal}
+
+
+
+
+ ${s.discountPending}
+
+
+ ${s.shipping}
+ ${s.shippingPlaceholder}
+
+
+ ${s.estimatedTaxes}
+ --
+
+
+
+
+
${s.checkoutSecurely}
+
+
+`;
+}
+
+/**
+ * Decorates the cart-summary block.
+ *
+ * 1. Colocate with the cart section for two-column CSS layout
+ * 2. Build template with i18n strings, set checkout href and currency
+ * 3. Bind cart:change to keep subtotal and total in sync
+ * 4. Restore saved promo code; persist to sessionStorage on apply
+ * 5. Build express-checkout callbacks, load SDKs, render available wallet buttons
+ *
+ * @param {HTMLDivElement} block
+ */
+export default async function decorate(block) {
+ await loadCSS('/styles/commerce-tokens.css');
+ const config = getConfig();
+ const s = getStrings();
+
+ // 1. Colocate with the cart section for two-column CSS layout
+ colocateWithCart(block);
+ block.innerHTML = buildTemplate(s);
+
+ const subtotalEl = block.querySelector('.cart-summary-subtotal');
+ const grandTotalEl = block.querySelector('.cart-summary-grand-total');
+ const currencyEl = block.querySelector('.currency');
+ const expressSection = block.querySelector('.cart-summary-express-section');
+ const expressContainer = block.querySelector('.cart-summary-express-buttons');
+ const discountInput = block.querySelector('.discount-input');
+ const discountApply = block.querySelector('.discount-apply');
+ const discountRow = block.querySelector('.cart-summary-discount-row');
+ const discountRowLabel = block.querySelector('.cart-summary-discount-label');
+ const errorEl = block.querySelector('.cart-summary-error');
+ const checkoutBtn = block.querySelector('.cart-summary-checkout-btn');
+
+ // 2. Set checkout href and currency
+ checkoutBtn.href = config.getOrderPath('checkout');
+ currencyEl.textContent = getCurrencyCode();
+
+ // 3. Bind cart:change to keep totals in sync
+ const updateTotals = () => {
+ const formatted = formatPrice(cart.subtotal, getCurrencyCode());
+ subtotalEl.textContent = formatted;
+ grandTotalEl.textContent = formatted;
+ };
+ updateTotals();
+ document.addEventListener('cart:change', updateTotals);
+
+ // 4. Restore saved promo code; persist to sessionStorage on apply
+ const showDiscountRow = (code) => {
+ discountRowLabel.textContent = `${s.discount} (${code})`;
+ discountRow.hidden = false;
+ };
+ const hideDiscountRow = () => { discountRow.hidden = true; };
+
+ const savedCoupon = sessionStorage.getItem('checkout_coupon_code') || '';
+ if (savedCoupon) {
+ discountInput.value = savedCoupon;
+ block.querySelector('.cart-summary-promo').open = true;
+ showDiscountRow(savedCoupon);
+ }
+ discountApply.addEventListener('click', () => {
+ const code = discountInput.value.trim();
+ if (code) {
+ sessionStorage.setItem('checkout_coupon_code', code);
+ showDiscountRow(code);
+ discountApply.textContent = s.applied;
+ discountApply.disabled = true;
+ setTimeout(() => {
+ discountApply.textContent = s.apply;
+ discountApply.disabled = false;
+ }, 2000);
+ } else {
+ sessionStorage.removeItem('checkout_coupon_code');
+ hideDiscountRow();
+ }
+ });
+
+ // 5. Build express-checkout callbacks, load SDKs, render available wallet buttons
+ const state = { currentEstimateToken: null, currentPreview: null };
+
+ const callbacks = {
+ getCart: () => cart,
+ getConfig: () => config,
+ getState: () => state,
+ previewOrderDirect: async (body) => {
+ const couponCode = sessionStorage.getItem('checkout_coupon_code') || undefined;
+ const result = await previewOrder({ ...body, ...(couponCode ? { couponCode } : {}) });
+ if (result.estimateToken) state.currentEstimateToken = result.estimateToken;
+ state.currentPreview = result;
+ return result;
+ },
+ createOrder: (orderBody) => createOrder(orderBody),
+ initiatePayment: (...args) => initiatePayment(...args),
+ showError: (msg) => {
+ errorEl.textContent = msg;
+ errorEl.hidden = false;
+ },
+ onComplete: (createdOrder) => {
+ const order = createdOrder?.order ?? createdOrder;
+ const orderId = order?.id;
+ try {
+ if (order?.customer?.email) sessionStorage.setItem('checkout_email', order.customer.email);
+ sessionStorage.setItem('checkout_cart_items', JSON.stringify(cart.items));
+ if (state.currentPreview) sessionStorage.setItem('checkout_preview', JSON.stringify(state.currentPreview));
+ if (order) sessionStorage.setItem('checkout_order', JSON.stringify(order));
+ } catch { /* ignore */ }
+ cart.clear();
+ const path = config.getOrderPath('complete');
+ window.location.href = orderId ? `${path}?orderId=${orderId}` : path;
+ },
+ };
+
+ const active = getActiveProviders(ALL_PROVIDERS).filter((p) => p.supportsExpress);
+ await Promise.all(active.map(async (p) => {
+ try { await p.load(config); } catch { /* provider load failure handled by isAvailable check */ }
+ }));
+ const available = active.filter((p) => {
+ try { return p.isAvailable(); } catch { return false; }
+ });
+ if (available.length) {
+ available.forEach((p) => p.renderExpressButton(expressContainer, callbacks));
+ expressSection.hidden = false;
+ }
+}
diff --git a/blocks/cart/cart.css b/blocks/cart/cart.css
new file mode 100644
index 00000000..7b47a427
--- /dev/null
+++ b/blocks/cart/cart.css
@@ -0,0 +1,103 @@
+div.section.cart-section {
+ max-width: var(--commerce-max-width);
+ margin: 0 auto;
+}
+
+div.section.cart-section > .default-content-wrapper {
+ margin: var(--spacing-l) auto;
+}
+
+div.section.cart-section .cart-wrapper {
+ margin-top: var(--spacing-l);
+}
+
+.cart-items {
+ background: var(--commerce-color-background);
+ border: 1px solid var(--commerce-color-border);
+ border-radius: var(--commerce-radius-card);
+ padding: 0 var(--commerce-card-padding);
+}
+
+.cart-items-list {
+ display: flex;
+ flex-direction: column;
+}
+
+/* Larger image on the full cart page */
+.cart-items .cart-item-image {
+ width: 88px;
+ height: 88px;
+}
+
+/* Cart controls (checkout + view cart buttons) */
+.cart-view-cart {
+ width: 100%;
+ text-align: center;
+ box-sizing: border-box;
+}
+
+.cart-controls {
+ margin: 2em 0 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.cart-controls .cart-checkout {
+ width: 100%;
+ text-align: center;
+ box-sizing: border-box;
+}
+
+/* In the minicart, remove the full-height stretch */
+.minicart .cart {
+ display: block;
+}
+
+
+/* Empty state */
+.cart-empty-message {
+ display: none;
+}
+
+.cart.cart-is-empty .cart-controls {
+ display: none;
+}
+
+.cart.cart-is-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: calc(100vh - var(--header-height, 80px) - 120px);
+}
+
+.cart.cart-is-empty .cart-empty-message {
+ display: block;
+ text-align: center;
+ color: #555;
+}
+
+.minicart .cart.cart-is-empty {
+ min-height: unset;
+}
+
+/* Compact layout for narrow drawer */
+.minicart .cart-item {
+ grid-template-columns: 64px 1fr auto;
+ padding: 12px 0;
+}
+
+.minicart .cart-item-image {
+ width: 64px;
+ height: 100%;
+ align-self: stretch;
+}
+
+.minicart .cart-items {
+ padding: 0 16px;
+}
+
+.minicart .cart-item-qty-control {
+ flex-shrink: 0;
+ border-radius: 6px;
+}
diff --git a/blocks/cart/cart.js b/blocks/cart/cart.js
new file mode 100644
index 00000000..bebf9557
--- /dev/null
+++ b/blocks/cart/cart.js
@@ -0,0 +1,100 @@
+import { loadCSS } from '../../scripts/aem.js';
+import cart from '../../scripts/cart.js';
+import { getConfig } from '../../scripts/commerce-config.js';
+import buildCartItem from '../../scripts/commerce/cart-item.js';
+
+const LOCAL_STRINGS = {
+ 'en-us': {
+ cartEmpty: 'Your cart is empty.',
+ viewCart: 'View cart',
+ checkout: 'Checkout',
+ },
+ 'fr-ca': {
+ cartEmpty: 'Votre panier est vide.',
+ viewCart: 'Voir le panier',
+ checkout: 'Passer à la caisse',
+ },
+};
+
+function getStrings(config) {
+ const lang = config.getLanguage().toLowerCase().replace('_', '-');
+ return { ...config.getStrings(), ...(LOCAL_STRINGS[lang] || LOCAL_STRINGS['en-us']) };
+}
+
+function buildTemplate(s) {
+ return /* html */`
+`;
+}
+
+export default async function decorate(block) {
+ await Promise.all([
+ loadCSS('/styles/commerce-tokens.css'),
+ loadCSS('/blocks/cart/cart.css'),
+ ]);
+ block.closest('div.section')?.classList.add('cart-section');
+
+ const config = getConfig();
+ const s = getStrings(config);
+ const currencyCode = typeof config.currency === 'function' ? config.currency(config.getLocale()) : config.currency;
+
+ block.innerHTML = buildTemplate(s);
+ block.querySelector('.cart-checkout').href = config.getOrderPath('checkout');
+
+ const viewCartBtn = block.querySelector('.cart-view-cart');
+ if (block.closest('.minicart')) {
+ viewCartBtn.href = config.getOrderPath('cart');
+ } else {
+ viewCartBtn.remove();
+ }
+
+ const itemList = block.querySelector('.cart-items-list');
+ const cartEl = block.querySelector('.cart');
+
+ const heading = block.closest('.section')?.querySelector('.default-content-wrapper h1, .default-content-wrapper h2');
+ if (heading) {
+ const shopRoot = `/${config.getLocale()}/${config.getLanguage()}/`;
+ const link = document.createElement('a');
+ link.href = shopRoot;
+ link.className = 'cart-continue-shopping';
+ link.textContent = s.continueShopping;
+ heading.insertAdjacentElement('afterend', link);
+ }
+
+ const updateEmptyState = () => {
+ cartEl.classList.toggle('cart-is-empty', cart.items.length === 0);
+ };
+
+ const populatelist = () => {
+ itemList.innerHTML = '';
+ updateEmptyState();
+
+ cart.items.forEach((item) => {
+ const itemEl = buildCartItem(
+ item,
+ {
+ onQtyChange: (sku, qty) => cart.updateItem(sku, qty),
+ onRemove: (sku) => cart.removeItem(sku),
+ currencyCode,
+ },
+ { remove: s.remove, removeItem: s.removeItem },
+ );
+ itemList.appendChild(itemEl);
+ });
+ };
+
+ populatelist();
+ document.addEventListener('cart:change', populatelist);
+}
diff --git a/blocks/checkout-progress/checkout-progress.css b/blocks/checkout-progress/checkout-progress.css
new file mode 100644
index 00000000..2416a80a
--- /dev/null
+++ b/blocks/checkout-progress/checkout-progress.css
@@ -0,0 +1,103 @@
+.section > .checkout-progress-wrapper {
+ margin: 0 auto;
+ width: 100%;
+}
+
+.checkout-progress nav {
+ display: flex;
+ justify-content: center;
+ padding: 16px 0;
+}
+
+.checkout-progress ol {
+ display: flex;
+ align-items: center;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ gap: 0;
+ counter-reset: step-counter;
+}
+
+.checkout-progress .step {
+ display: flex;
+ align-items: center;
+ font-size: var(--commerce-font-size-sm);
+ color: var(--commerce-color-text-muted);
+}
+
+/* Connector line between steps */
+.checkout-progress .step + .step::before {
+ content: '';
+ display: block;
+ width: 48px;
+ height: 2px;
+ background: var(--commerce-step-color-inactive);
+ margin: 0 12px;
+}
+
+.checkout-progress .step-complete + .step::before {
+ background: var(--commerce-color-text);
+}
+
+.checkout-progress .step a,
+.checkout-progress .step span {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ text-decoration: none;
+ color: inherit;
+ white-space: nowrap;
+}
+
+/* Circle — inactive (future) steps: white fill, border only, no number */
+.checkout-progress .step a::before,
+.checkout-progress .step span::before {
+ content: '';
+ counter-increment: step-counter;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ font-size: 11px;
+ font-weight: var(--commerce-font-weight-bold);
+ line-height: 1;
+ flex-shrink: 0;
+ background: #fff;
+ border: 2px solid var(--commerce-color-border);
+ color: #fff;
+ box-sizing: border-box;
+}
+
+/* Complete steps: dark fill with checkmark */
+.checkout-progress .step-complete a::before,
+.checkout-progress .step-complete span::before {
+ content: '✓';
+ background: var(--commerce-color-text);
+ border-color: var(--commerce-color-text);
+ color: #fff;
+}
+
+/* Active step: brand fill with step number */
+.checkout-progress .step-active span::before {
+ content: counter(step-counter);
+ background: var(--commerce-step-color-active);
+ border-color: var(--commerce-step-color-active);
+ color: #fff;
+}
+
+.checkout-progress .step-complete a,
+.checkout-progress .step-complete span {
+ color: var(--commerce-color-text);
+}
+
+.checkout-progress .step-active span {
+ color: var(--commerce-color-text);
+ font-weight: var(--commerce-font-weight-bold);
+}
+
+.checkout-progress .step-complete a:hover {
+ text-decoration: underline;
+}
diff --git a/blocks/checkout-progress/checkout-progress.js b/blocks/checkout-progress/checkout-progress.js
new file mode 100644
index 00000000..5422335a
--- /dev/null
+++ b/blocks/checkout-progress/checkout-progress.js
@@ -0,0 +1,61 @@
+import { loadCSS } from '../../scripts/aem.js';
+import { getConfig } from '../../scripts/commerce-config.js';
+
+const STEP_KEYS = ['cart-new', 'checkout', 'complete'];
+
+export default async function decorate(block) {
+ await loadCSS('/styles/commerce-tokens.css');
+ const config = getConfig();
+ const s = config.getStrings();
+ const currentPath = window.location.pathname;
+
+ const steps = [
+ { key: 'cart-new', label: s.stepCart },
+ { key: 'checkout', label: s.stepCheckout },
+ { key: 'complete', label: s.stepConfirmation },
+ ];
+
+ const stepPaths = STEP_KEYS.map((key) => config.getOrderPath(key));
+ const activeIndex = stepPaths.findIndex((path) => currentPath.includes(path));
+
+ const nav = document.createElement('nav');
+ nav.setAttribute('aria-label', s.checkoutStepsLabel);
+
+ const ol = document.createElement('ol');
+
+ steps.forEach(({ key, label }, i) => {
+ const li = document.createElement('li');
+ li.className = 'step';
+ li.classList.add(`step-${key}`);
+
+ if (i < activeIndex) {
+ li.classList.add('step-complete');
+ const isLastStep = activeIndex === steps.length - 1;
+ if (isLastStep) {
+ const span = document.createElement('span');
+ span.textContent = label;
+ li.appendChild(span);
+ } else {
+ const a = document.createElement('a');
+ a.href = stepPaths[i];
+ a.textContent = label;
+ li.appendChild(a);
+ }
+ } else if (i === activeIndex) {
+ li.classList.add('step-active');
+ li.setAttribute('aria-current', 'step');
+ const span = document.createElement('span');
+ span.textContent = label;
+ li.appendChild(span);
+ } else {
+ const span = document.createElement('span');
+ span.textContent = label;
+ li.appendChild(span);
+ }
+
+ ol.appendChild(li);
+ });
+
+ nav.appendChild(ol);
+ block.replaceChildren(nav);
+}
diff --git a/blocks/checkout/checkout-address.js b/blocks/checkout/checkout-address.js
new file mode 100644
index 00000000..c87681bc
--- /dev/null
+++ b/blocks/checkout/checkout-address.js
@@ -0,0 +1,276 @@
+const US_STATES = [
+ ['AL', 'Alabama'], ['AK', 'Alaska'], ['AZ', 'Arizona'], ['AR', 'Arkansas'],
+ ['CA', 'California'], ['CO', 'Colorado'], ['CT', 'Connecticut'], ['DE', 'Delaware'],
+ ['DC', 'District of Columbia'], ['FL', 'Florida'], ['GA', 'Georgia'], ['HI', 'Hawaii'],
+ ['ID', 'Idaho'], ['IL', 'Illinois'], ['IN', 'Indiana'], ['IA', 'Iowa'],
+ ['KS', 'Kansas'], ['KY', 'Kentucky'], ['LA', 'Louisiana'], ['ME', 'Maine'],
+ ['MD', 'Maryland'], ['MA', 'Massachusetts'], ['MI', 'Michigan'], ['MN', 'Minnesota'],
+ ['MS', 'Mississippi'], ['MO', 'Missouri'], ['MT', 'Montana'], ['NE', 'Nebraska'],
+ ['NV', 'Nevada'], ['NH', 'New Hampshire'], ['NJ', 'New Jersey'], ['NM', 'New Mexico'],
+ ['NY', 'New York'], ['NC', 'North Carolina'], ['ND', 'North Dakota'], ['OH', 'Ohio'],
+ ['OK', 'Oklahoma'], ['OR', 'Oregon'], ['PA', 'Pennsylvania'], ['RI', 'Rhode Island'],
+ ['SC', 'South Carolina'], ['SD', 'South Dakota'], ['TN', 'Tennessee'], ['TX', 'Texas'],
+ ['UT', 'Utah'], ['VT', 'Vermont'], ['VA', 'Virginia'], ['WA', 'Washington'],
+ ['WV', 'West Virginia'], ['WI', 'Wisconsin'], ['WY', 'Wyoming'],
+ ['AS', 'American Samoa'], ['GU', 'Guam'], ['MP', 'Northern Mariana Islands'],
+ ['PR', 'Puerto Rico'], ['VI', 'U.S. Virgin Islands'],
+];
+
+const CA_PROVINCES = [
+ ['AB', 'Alberta'], ['BC', 'British Columbia'], ['MB', 'Manitoba'],
+ ['NB', 'New Brunswick'], ['NL', 'Newfoundland and Labrador'],
+ ['NS', 'Nova Scotia'], ['NT', 'Northwest Territories'], ['NU', 'Nunavut'],
+ ['ON', 'Ontario'], ['PE', 'Prince Edward Island'], ['QC', 'Quebec'],
+ ['SK', 'Saskatchewan'], ['YT', 'Yukon'],
+];
+
+const ESTIMATE_FIELDS = new Set([
+ 'shipping-street-0', 'shipping-street-1', 'shipping-city', 'shipping-state', 'shipping-zip',
+]);
+
+/**
+ * Builds an address object from form data for the given prefix ('shipping-' or 'billing-').
+ * @param {HTMLFormElement} form
+ * @param {FormData} formData
+ * @param {string} prefix
+ * @param {string} email
+ * @param {string} country
+ * @returns {Object}
+ */
+export function collectAddress(form, formData, prefix, email, country) {
+ const get = (name) => formData.get(`${prefix}${name}`) || '';
+ return {
+ name: `${get('firstname')} ${get('lastname')}`.trim(),
+ company: get('company'),
+ address1: get('street-0'),
+ address2: get('street-1'),
+ city: get('city'),
+ state: get('state'),
+ zip: get('zip'),
+ country,
+ phone: get('telephone').replace(/\D/g, ''),
+ email,
+ };
+}
+
+/**
+ * Populates the state/province select for the given prefix.
+ * @param {HTMLFormElement} form
+ * @param {string} prefix
+ * @param {boolean} isCanada
+ * @param {Object} strings
+ */
+function populateStateSelect(form, prefix, isCanada, strings) {
+ const select = form.querySelector(`[name="${prefix}state"]`);
+ if (!select) return;
+
+ const regions = isCanada ? CA_PROVINCES : US_STATES;
+ const label = select.closest('.form-field')?.querySelector('label');
+ if (label) label.textContent = isCanada ? strings.province : strings.state;
+
+ select.innerHTML = '';
+ regions.forEach(([code, name]) => {
+ const option = document.createElement('option');
+ option.value = code;
+ option.textContent = name;
+ select.appendChild(option);
+ });
+
+ select.addEventListener('change', () => {
+ select.classList.toggle('has-value', !!select.value);
+ });
+}
+
+/**
+ * Wires the billing address radio cards and keeps the shipping summary in sync.
+ * @param {HTMLFormElement} form
+ */
+function wireBillingToggle(form) {
+ const billingFieldsWrapper = form.querySelector('.billing-fields-wrapper');
+ const sameDetail = form.querySelector('.billing-option-detail');
+ if (!billingFieldsWrapper) return;
+
+ const getShippingSummary = () => {
+ const data = new FormData(form);
+ const street = data.get('shipping-street-0') || '';
+ const city = data.get('shipping-city') || '';
+ const stateCode = data.get('shipping-state') || '';
+ const zip = data.get('shipping-zip') || '';
+ const cityStateZip = [city, [stateCode, zip].filter(Boolean).join(' ')].filter(Boolean).join(', ');
+ return [street, cityStateZip].filter(Boolean).join(', ');
+ };
+
+ const updateBillingVisibility = () => {
+ const useDifferent = form.querySelector('[name="billing-choice"]:checked')?.value === 'different';
+ billingFieldsWrapper.hidden = !useDifferent;
+ billingFieldsWrapper.querySelectorAll('input, select').forEach((el) => {
+ el.disabled = !useDifferent;
+ });
+ if (!useDifferent && sameDetail) {
+ sameDetail.textContent = getShippingSummary();
+ }
+ };
+
+ form.querySelectorAll('[name="billing-choice"]').forEach((radio) => {
+ radio.addEventListener('change', updateBillingVisibility);
+ });
+
+ const syncSummary = (e) => {
+ if (!e.target.name?.startsWith('shipping-')) return;
+ const isSame = form.querySelector('[name="billing-choice"]:checked')?.value !== 'different';
+ if (isSame && sameDetail) sameDetail.textContent = getShippingSummary();
+ };
+
+ form.addEventListener('input', syncSummary);
+ form.addEventListener('change', syncSummary);
+ updateBillingVisibility();
+}
+
+function fillAddressFields(section, addressInput, addressComponents) {
+ const c = {};
+ addressComponents.forEach((comp) => {
+ comp.types.forEach((type) => { c[type] = comp; });
+ });
+
+ const street = [c.street_number?.longText, c.route?.longText].filter(Boolean).join(' ');
+ addressInput.value = street;
+
+ const address2Input = section.querySelector('[autocomplete="address-line2"]');
+ if (address2Input && c.subpremise) {
+ address2Input.value = c.subpremise.longText;
+ }
+
+ const cityInput = section.querySelector('[autocomplete="address-level2"]');
+ if (cityInput) {
+ cityInput.value = (c.locality || c.sublocality || c.postal_town)?.longText || '';
+ }
+
+ const zipInput = section.querySelector('[autocomplete="postal-code"]');
+ if (zipInput) {
+ zipInput.value = c.postal_code?.longText || '';
+ }
+
+ // Set state last so FormData is complete when the change event triggers fetchAndPreview.
+ // Always dispatch even if the component is absent — the state may already be set from
+ // a prior interaction, and we still need to re-trigger the shipping/tax estimate.
+ const stateSelect = section.querySelector('select[name$="-state"]');
+ if (stateSelect) {
+ if (c.administrative_area_level_1) {
+ stateSelect.value = c.administrative_area_level_1.shortText;
+ stateSelect.classList.toggle('has-value', !!stateSelect.value);
+ }
+ stateSelect.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+}
+
+function initPlacesAutocomplete(section, config) {
+ const addressInput = section.querySelector('[autocomplete="address-line1"]');
+ if (!addressInput) return;
+
+ const wrapper = document.createElement('div');
+ wrapper.className = 'places-autocomplete-wrapper';
+ addressInput.parentElement.insertBefore(wrapper, addressInput);
+ wrapper.append(addressInput);
+
+ // type="search" + autocomplete="off" is the reliable way to suppress Chrome's
+ // address autofill dropdown — Chrome ignores autocomplete="off" on text inputs
+ // it heuristically classifies as address fields, but respects it on search inputs.
+ addressInput.type = 'search';
+ addressInput.setAttribute('autocomplete', 'off');
+
+ let sessionToken = crypto.randomUUID();
+ let debounceTimer;
+ let dropdown;
+
+ function removeDropdown() {
+ if (dropdown) { dropdown.remove(); dropdown = null; }
+ }
+
+ function showDropdown(suggestions) {
+ removeDropdown();
+ if (!suggestions.length) return;
+
+ dropdown = document.createElement('ul');
+ dropdown.className = 'places-autocomplete-dropdown';
+
+ suggestions.forEach(({ placePrediction: p }) => {
+ const li = document.createElement('li');
+ const main = document.createElement('span');
+ main.className = 'places-main';
+ main.textContent = p.structuredFormat.mainText.text;
+ const secondary = document.createElement('span');
+ secondary.className = 'places-secondary';
+ secondary.textContent = p.structuredFormat.secondaryText.text;
+ li.append(main, secondary);
+
+ li.addEventListener('mousedown', async (e) => {
+ e.preventDefault();
+ addressInput.value = p.structuredFormat.mainText.text;
+ removeDropdown();
+
+ try {
+ const params = new URLSearchParams({ place_id: p.placeId, sessiontoken: sessionToken });
+ const resp = await fetch(`${config.apiOrigin}/places/details?${params}`);
+ if (resp.ok) {
+ const data = await resp.json();
+ if (data.addressComponents) {
+ fillAddressFields(section, addressInput, data.addressComponents);
+ }
+ }
+ } catch { /* silent */ }
+
+ sessionToken = crypto.randomUUID();
+ });
+
+ dropdown.append(li);
+ });
+
+ wrapper.append(dropdown);
+ }
+
+ addressInput.addEventListener('input', () => {
+ clearTimeout(debounceTimer);
+ const { value } = addressInput;
+ if (value.length < 3) { removeDropdown(); return; }
+
+ debounceTimer = setTimeout(async () => {
+ try {
+ const params = new URLSearchParams({ input: value, sessiontoken: sessionToken });
+ const resp = await fetch(`${config.apiOrigin}/places/autocomplete?${params}`);
+ if (!resp.ok) return;
+ const data = await resp.json();
+ showDropdown(data.suggestions || []);
+ } catch { /* silent */ }
+ }, 300);
+ });
+
+ addressInput.addEventListener('blur', () => {
+ setTimeout(removeDropdown, 200);
+ });
+}
+
+/**
+ * @param {HTMLFormElement} form
+ * @param {Object} state
+ * @param {Object} config
+ * @param {Object} strings
+ */
+export function initAddress(form, state, config, strings) {
+ const isCanada = config.getLocale() === 'ca';
+ populateStateSelect(form, 'shipping-', isCanada, strings);
+ populateStateSelect(form, 'billing-', isCanada, strings);
+ wireBillingToggle(form);
+
+ const shippingSection = form.querySelector('.shipping-address-section');
+ const billingSection = form.querySelector('.billing-fields-wrapper');
+ if (shippingSection) initPlacesAutocomplete(shippingSection, config);
+ if (billingSection) initPlacesAutocomplete(billingSection, config);
+
+ // Invalidate estimate token when estimate-affecting fields change
+ form.addEventListener('change', (e) => {
+ if (ESTIMATE_FIELDS.has(e.target.name)) {
+ state.currentEstimateToken = null;
+ state.currentPreview = null;
+ }
+ });
+}
diff --git a/blocks/checkout/checkout-form.js b/blocks/checkout/checkout-form.js
new file mode 100644
index 00000000..cccd419d
--- /dev/null
+++ b/blocks/checkout/checkout-form.js
@@ -0,0 +1,469 @@
+import { getMetadata } from '../../scripts/aem.js';
+
+const SUBMIT_LOCK_SVG = '';
+
+function getContactFields(strings) {
+ return [
+ {
+ name: 'email', type: 'email', label: strings.email, required: true, autocomplete: 'email',
+ },
+ { name: 'newsletter', type: 'checkbox', label: strings.newsletter },
+ ];
+}
+
+function getAddressFields(strings, isCanada) {
+ return [
+ {
+ name: 'firstname', type: 'text', label: strings.firstName, required: true, autocomplete: 'given-name', width: 'half',
+ },
+ {
+ name: 'lastname', type: 'text', label: strings.lastName, required: true, autocomplete: 'family-name', width: 'half',
+ },
+ {
+ name: 'street-0', type: 'text', label: strings.address, required: true, autocomplete: 'address-line1',
+ },
+ {
+ name: 'street-1', type: 'text', label: strings.addressLine2, autocomplete: 'address-line2',
+ },
+ {
+ name: 'city', type: 'text', label: strings.city, required: true, autocomplete: 'address-level2', width: 'third',
+ },
+ {
+ name: 'state', type: 'select', label: isCanada ? strings.province : strings.state, required: true, autocomplete: 'address-level1', width: 'third',
+ },
+ {
+ name: 'zip', type: 'text', label: isCanada ? strings.postalCode : strings.zip, required: true, autocomplete: 'postal-code', width: 'third', inputmode: isCanada ? undefined : 'numeric',
+ },
+ {
+ name: 'telephone', type: 'tel', label: strings.phone, autocomplete: 'tel', format: 'phone',
+ },
+ ];
+}
+
+// North American Numbering Plan: (555) 123-4567
+function formatPhoneDisplay(raw) {
+ let digits = raw.replace(/\D/g, '');
+ // Strip the +1 country code if present — North America only, so leading digit is always 1
+ if (digits.length === 11) digits = digits.slice(1);
+ digits = digits.slice(0, 10);
+ if (digits.length < 4) return digits;
+ if (digits.length < 7) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
+ return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
+}
+
+function attachPhoneFormatter(input) {
+ input.addEventListener('input', () => {
+ const { selectionStart } = input;
+ const digitsBefore = input.value.slice(0, selectionStart).replace(/\D/g, '').length;
+
+ const formatted = formatPhoneDisplay(input.value);
+ input.value = formatted;
+
+ // Restore cursor to the same logical digit position
+ let newPos = formatted.length;
+ if (digitsBefore === 0) {
+ newPos = 0;
+ } else {
+ let count = 0;
+ for (let i = 0; i < formatted.length; i += 1) {
+ if (/\d/.test(formatted[i])) count += 1;
+ if (count === digitsBefore) { newPos = i + 1; break; }
+ }
+ }
+ // Advance past any trailing punctuation so cursor lands on the next digit
+ while (newPos < formatted.length && !/\d/.test(formatted[newPos])) newPos += 1;
+
+ input.setSelectionRange(newPos, newPos);
+ });
+}
+
+function buildBreakdownRow(label, cls) {
+ const row = document.createElement('div');
+ row.className = 'order-breakdown-row';
+ const labelEl = document.createElement('span');
+ labelEl.textContent = label;
+ const valueEl = document.createElement('span');
+ valueEl.className = cls;
+ row.append(labelEl, valueEl);
+ return row;
+}
+
+/**
+ * Creates a floating-label field wrapper.
+ * @param {Object} field
+ * @param {string} namePrefix
+ * @returns {HTMLElement}
+ */
+function buildField(field, namePrefix = '') {
+ const fullName = `${namePrefix}${field.name}`;
+
+ if (field.type === 'checkbox') {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'form-field form-field-checkbox';
+ const label = document.createElement('label');
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ input.name = fullName;
+ input.id = fullName;
+ const span = document.createElement('span');
+ span.textContent = field.label;
+ label.append(input, span);
+ wrapper.appendChild(label);
+ return wrapper;
+ }
+
+ const wrapper = document.createElement('div');
+ wrapper.className = 'form-field floating-label-field';
+ if (field.width) wrapper.classList.add(`field-${field.width}`);
+
+ let input;
+ if (field.type === 'select') {
+ input = document.createElement('select');
+ // select doesn't support :placeholder-shown — use has-value class toggle
+ input.addEventListener('change', () => {
+ input.classList.toggle('has-value', !!input.value);
+ });
+ } else {
+ input = document.createElement('input');
+ input.type = field.type;
+ input.placeholder = ' ';
+ if (field.autocomplete) input.autocomplete = field.autocomplete;
+ if (field.inputmode) input.inputMode = field.inputmode;
+ if (field.format === 'phone') attachPhoneFormatter(input);
+ }
+
+ input.name = fullName;
+ input.id = fullName;
+ if (field.required) input.required = true;
+
+ const label = document.createElement('label');
+ label.htmlFor = fullName;
+ label.textContent = field.label;
+
+ wrapper.append(input, label);
+ return wrapper;
+}
+
+/**
+ * Builds an address fieldset for the given prefix.
+ * @param {string} prefix
+ * @param {string} legend
+ * @param {Object} strings
+ * @param {boolean} isCanada
+ * @returns {HTMLElement}
+ */
+function buildAddressSection(prefix, legend, strings, isCanada) {
+ const section = document.createElement('div');
+ section.className = `form-section ${prefix.replace('-', '')}-address-section`;
+
+ const heading = document.createElement('h3');
+ heading.textContent = legend;
+ section.appendChild(heading);
+
+ const grid = document.createElement('div');
+ grid.className = 'form-fields';
+ getAddressFields(strings, isCanada).forEach((field) => {
+ grid.appendChild(buildField(field, prefix));
+ });
+ section.appendChild(grid);
+ return section;
+}
+
+/**
+ * Builds and returns the checkout form element.
+ * @param {HTMLElement} container
+ * @param {Object} config
+ * @param {Object} strings
+ * @returns {HTMLFormElement}
+ */
+export default function buildForm(container, config, strings) {
+ const isCanada = config.getLocale() === 'ca';
+ const form = document.createElement('form');
+ form.className = 'checkout-form';
+ form.noValidate = true;
+
+ // Express checkout section (populated by checkout-payment.js)
+ const expressSection = document.createElement('div');
+ expressSection.className = 'form-section express-checkout-section';
+
+ const expressHeader = document.createElement('div');
+ expressHeader.className = 'express-checkout-header';
+ const expressHeading = document.createElement('p');
+ expressHeading.className = 'express-checkout-label';
+ expressHeading.textContent = strings.expressCheckout;
+ const expressTagline = document.createElement('p');
+ expressTagline.className = 'express-checkout-tagline';
+ expressTagline.textContent = strings.expressTagline;
+ expressHeader.append(expressHeading, expressTagline);
+
+ const expressButtons = document.createElement('div');
+ expressButtons.className = 'express-checkout-buttons';
+
+ const dividerText = document.createElement('p');
+ dividerText.className = 'express-checkout-divider';
+ dividerText.textContent = strings.expressOr;
+
+ expressSection.append(expressHeader, expressButtons, dividerText);
+ form.appendChild(expressSection);
+
+ // Contact section
+ const contactSection = document.createElement('div');
+ contactSection.className = 'form-section contact-section';
+ const contactHeading = document.createElement('h3');
+ contactHeading.textContent = strings.contact;
+ contactSection.appendChild(contactHeading);
+ const contactFields = document.createElement('div');
+ contactFields.className = 'form-fields';
+ getContactFields(strings).forEach((field) => {
+ contactFields.appendChild(buildField(field, ''));
+ });
+ contactSection.appendChild(contactFields);
+ form.appendChild(contactSection);
+
+ // Shipping address
+ form.appendChild(buildAddressSection('shipping-', strings.shippingAddress, strings, isCanada));
+
+ // Gift message (conditional)
+ if (getMetadata('gift-message') === 'true') {
+ const giftSection = document.createElement('div');
+ giftSection.className = 'form-section gift-message-section';
+ const giftHeading = document.createElement('h3');
+ giftHeading.textContent = strings.giftMessage;
+ giftSection.appendChild(giftHeading);
+ const giftField = document.createElement('div');
+ giftField.className = 'form-field floating-label-field';
+ const textarea = document.createElement('textarea');
+ textarea.name = 'gift-message';
+ textarea.id = 'gift-message';
+ textarea.maxLength = 500;
+ textarea.placeholder = ' ';
+ textarea.rows = 3;
+ const giftLabel = document.createElement('label');
+ giftLabel.htmlFor = 'gift-message';
+ giftLabel.textContent = strings.giftMessagePlaceholder;
+ const charCount = document.createElement('span');
+ charCount.className = 'char-count';
+ charCount.textContent = '0 / 500';
+ textarea.addEventListener('input', () => {
+ charCount.textContent = `${textarea.value.length} / 500`;
+ });
+ giftField.append(textarea, giftLabel, charCount);
+ giftSection.appendChild(giftField);
+ form.appendChild(giftSection);
+ }
+
+ // Billing address section
+ const billingSection = document.createElement('div');
+ billingSection.className = 'form-section billing-section';
+
+ const billingHeading = document.createElement('h3');
+ billingHeading.textContent = strings.billingAddress;
+ billingSection.appendChild(billingHeading);
+
+ const billingSubtitle = document.createElement('p');
+ billingSubtitle.className = 'billing-subtitle';
+ billingSubtitle.textContent = strings.billingSubtitle;
+ billingSection.appendChild(billingSubtitle);
+
+ const billingOptions = document.createElement('div');
+ billingOptions.className = 'billing-options';
+
+ const sameCard = document.createElement('label');
+ sameCard.className = 'billing-option-card';
+ const sameRadio = document.createElement('input');
+ sameRadio.type = 'radio';
+ sameRadio.name = 'billing-choice';
+ sameRadio.value = 'same';
+ sameRadio.checked = true;
+ const sameContent = document.createElement('div');
+ sameContent.className = 'billing-option-content';
+ const sameLabelSpan = document.createElement('span');
+ sameLabelSpan.className = 'billing-option-label';
+ sameLabelSpan.textContent = strings.billingSame;
+ const sameDetail = document.createElement('span');
+ sameDetail.className = 'billing-option-detail';
+ sameContent.append(sameLabelSpan, sameDetail);
+ sameCard.append(sameRadio, sameContent);
+
+ const differentCard = document.createElement('label');
+ differentCard.className = 'billing-option-card';
+ const differentRadio = document.createElement('input');
+ differentRadio.type = 'radio';
+ differentRadio.name = 'billing-choice';
+ differentRadio.value = 'different';
+ const differentContent = document.createElement('div');
+ differentContent.className = 'billing-option-content';
+ const differentLabelSpan = document.createElement('span');
+ differentLabelSpan.className = 'billing-option-label';
+ differentLabelSpan.textContent = strings.billingDifferent;
+ differentContent.appendChild(differentLabelSpan);
+ differentCard.append(differentRadio, differentContent);
+
+ billingOptions.append(sameCard, differentCard);
+ billingSection.appendChild(billingOptions);
+
+ const billingFieldsWrapper = document.createElement('div');
+ billingFieldsWrapper.className = 'billing-fields-wrapper';
+ billingFieldsWrapper.hidden = true;
+ const billingGrid = document.createElement('div');
+ billingGrid.className = 'form-fields';
+ getAddressFields(strings, isCanada).forEach((field) => {
+ billingGrid.appendChild(buildField(field, 'billing-'));
+ });
+ billingFieldsWrapper.appendChild(billingGrid);
+ billingSection.appendChild(billingFieldsWrapper);
+
+ form.appendChild(billingSection);
+
+ // Shipping methods container (populated by checkout-shipping.js)
+ const shippingMethodsContainer = document.createElement('fieldset');
+ shippingMethodsContainer.className = 'form-section shipping-methods';
+ const shippingLegend = document.createElement('legend');
+ shippingLegend.textContent = strings.shipping;
+ shippingMethodsContainer.appendChild(shippingLegend);
+ form.appendChild(shippingMethodsContainer);
+
+ // Payment method section (populated by checkout-payment.js)
+ const paymentSection = document.createElement('fieldset');
+ paymentSection.className = 'form-section payment-method-section';
+ const paymentLegend = document.createElement('legend');
+ paymentLegend.textContent = strings.paymentMethod;
+ paymentSection.appendChild(paymentLegend);
+ form.appendChild(paymentSection);
+
+ // Error container
+ const errorContainer = document.createElement('div');
+ errorContainer.className = 'checkout-error';
+ errorContainer.hidden = true;
+ form.appendChild(errorContainer);
+
+ // Order total + submit panel
+ const orderTotalSection = document.createElement('div');
+ orderTotalSection.className = 'form-section order-total-section';
+
+ const orderTotalLeft = document.createElement('div');
+ orderTotalLeft.className = 'order-total-left';
+
+ const orderTotalLabel = document.createElement('span');
+ orderTotalLabel.className = 'order-total-label';
+ orderTotalLabel.textContent = strings.orderTotal;
+
+ const orderTotalAmount = document.createElement('span');
+ orderTotalAmount.className = 'order-total-amount';
+ orderTotalAmount.textContent = '--';
+
+ const orderTotalTnc = document.createElement('p');
+ orderTotalTnc.className = 'order-total-tnc';
+ orderTotalTnc.textContent = strings.tnc;
+
+ const breakdown = document.createElement('div');
+ breakdown.className = 'order-total-breakdown';
+ breakdown.append(
+ buildBreakdownRow(strings.subtotal, 'order-breakdown-subtotal'),
+ buildBreakdownRow(strings.shipping, 'order-breakdown-shipping'),
+ buildBreakdownRow(strings.estimatedTaxes, 'order-breakdown-taxes'),
+ );
+
+ orderTotalLeft.append(orderTotalLabel, breakdown, orderTotalAmount, orderTotalTnc);
+
+ const submitBtn = document.createElement('button');
+ submitBtn.type = 'button';
+ submitBtn.className = 'button emphasis checkout-submit-btn';
+
+ const lockIconSpan = document.createElement('span');
+ lockIconSpan.className = 'submit-lock-icon';
+ lockIconSpan.innerHTML = SUBMIT_LOCK_SVG;
+
+ const submitText = document.createElement('span');
+ submitText.className = 'submit-btn-text';
+ submitText.textContent = strings.continueToPayment;
+
+ submitBtn.append(lockIconSpan, submitText);
+ orderTotalSection.append(orderTotalLeft, submitBtn);
+ form.appendChild(orderTotalSection);
+
+ container.appendChild(form);
+ return form;
+}
+
+/**
+ * Wires a form section to collapse into a compact summary bar after valid input.
+ * @param {HTMLElement} section
+ * @param {Object} opts
+ * @param {function(): boolean} opts.getIsValid
+ * @param {function(): string} opts.getSummary
+ * @param {boolean} [opts.autoCollapse]
+ * @param {Object} strings
+ * @returns {{ collapse: function, expand: function }}
+ */
+export function initCollapse(section, opts = {}, strings = {}) {
+ const { getIsValid, getSummary, autoCollapse = true } = opts;
+ const title = section.querySelector('h3, legend')?.textContent || '';
+
+ const bar = document.createElement('div');
+ bar.className = 'section-collapsed-bar';
+ bar.hidden = true;
+
+ const checkIcon = document.createElement('span');
+ checkIcon.className = 'section-check';
+ checkIcon.innerHTML = '';
+
+ const textBlock = document.createElement('div');
+ textBlock.className = 'section-summary-text';
+
+ const titleSpan = document.createElement('span');
+ titleSpan.className = 'section-summary-title';
+ titleSpan.textContent = title;
+
+ const detailSpan = document.createElement('span');
+ detailSpan.className = 'section-summary-detail';
+
+ textBlock.append(titleSpan, detailSpan);
+
+ const editBtn = document.createElement('button');
+ editBtn.type = 'button';
+ editBtn.className = 'section-edit-btn';
+ editBtn.textContent = strings.edit || 'Edit';
+
+ bar.append(checkIcon, textBlock, editBtn);
+ section.prepend(bar);
+
+ const collapse = () => {
+ if (!getIsValid?.()) return;
+ detailSpan.textContent = getSummary?.() || '';
+ bar.hidden = false;
+ section.classList.add('is-collapsed');
+ };
+
+ const expand = () => {
+ bar.hidden = true;
+ section.classList.remove('is-collapsed');
+ // Re-sync floating label state: browsers may not re-evaluate :placeholder-shown
+ // after an ancestor transitions from display:none
+ section.querySelectorAll('input[placeholder], textarea[placeholder]').forEach((el) => {
+ if (el.value) {
+ const { value } = el;
+ el.value = '';
+ el.value = value;
+ }
+ });
+ section.querySelectorAll('select').forEach((el) => {
+ el.classList.toggle('has-value', !!el.value);
+ });
+ const firstInput = section.querySelector(
+ 'input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]), select, textarea',
+ );
+ firstInput?.focus();
+ };
+
+ editBtn.addEventListener('click', expand);
+
+ if (autoCollapse) {
+ section.addEventListener('focusout', (e) => {
+ if (section.contains(e.relatedTarget)) return;
+ collapse();
+ });
+ }
+
+ return { collapse, expand };
+}
diff --git a/blocks/checkout/checkout-order.js b/blocks/checkout/checkout-order.js
new file mode 100644
index 00000000..564d4a87
--- /dev/null
+++ b/blocks/checkout/checkout-order.js
@@ -0,0 +1,297 @@
+import { createOrder, initiatePayment, previewOrder } from '../../scripts/commerce-api.js';
+import { collectAddress } from './checkout-address.js';
+import { updatePreview } from './checkout-shipping.js';
+import { FORMS_ENDPOINT, getLocaleAndLanguage } from '../../scripts/scripts.js';
+
+/**
+ * Writes checkout state to sessionStorage before a payment redirect.
+ * @param {string} email
+ * @param {Object} cart
+ * @param {Object|null} preview
+ * @param {Object|null} order
+ */
+export function saveCheckoutSession(email, cart, preview, order) {
+ try {
+ sessionStorage.setItem('checkout_email', email);
+ sessionStorage.setItem('checkout_cart_items', JSON.stringify(cart.items));
+ if (preview) sessionStorage.setItem('checkout_preview', JSON.stringify(preview));
+ if (order) sessionStorage.setItem('checkout_order', JSON.stringify(order));
+ } catch { /* ignore */ }
+}
+
+/**
+ * Assembles the order JSON payload from form data, cart, and checkout state.
+ * @param {FormData} formData
+ * @param {HTMLFormElement} form
+ * @param {Object} cart
+ * @param {Object} state
+ * @param {Object} config
+ * @returns {Object}
+ */
+export function buildOrderJSON(formData, form, cart, state, config) {
+ const email = formData.get('email') || '';
+ const locale = config.getLocale();
+ const language = config.getLanguage();
+ const country = locale;
+
+ const shippingAddr = collectAddress(form, formData, 'shipping-', email, country);
+ const sameAsBilling = form.querySelector('[name="billing-choice"]:checked')?.value !== 'different';
+ const billingAddr = sameAsBilling ? null : collectAddress(form, formData, 'billing-', email, country);
+
+ const cleanAddr = (addr) => Object.fromEntries(
+ Object.entries(addr).filter(([, v]) => v !== ''),
+ );
+
+ const order = {
+ customer: {
+ firstName: formData.get('shipping-firstname') || '',
+ lastName: formData.get('shipping-lastname') || '',
+ email,
+ phone: formData.get('shipping-telephone') || '',
+ },
+ shipping: cleanAddr(shippingAddr),
+ billing: cleanAddr(billingAddr ?? shippingAddr),
+ items: cart.getItemsForAPI(),
+ };
+
+ if (state.selectedShippingMethodId) {
+ order.shippingMethod = { id: state.selectedShippingMethodId };
+ }
+ if (state.currentEstimateToken) {
+ order.estimateToken = state.currentEstimateToken;
+ }
+
+ const giftMessage = formData.get('gift-message');
+ if (giftMessage?.trim()) {
+ order.giftMessage = giftMessage.trim();
+ }
+
+ const couponCode = sessionStorage.getItem('checkout_coupon_code') || undefined;
+ if (couponCode) order.couponCode = couponCode;
+
+ order.locale = `${language.split('_')[0]}-${(language.split('_')[1] || locale).toUpperCase()}`;
+ order.country = locale;
+
+ const paymentMethod = formData.get('paymentMethod');
+ if (paymentMethod) order.paymentMethod = paymentMethod;
+
+ return order;
+}
+
+/**
+ * Shows an error message inside the form.
+ * @param {HTMLFormElement} form
+ * @param {string} message
+ */
+function showError(form, message) {
+ const el = form.querySelector('.checkout-error');
+ if (!el) return;
+ el.textContent = message;
+ el.hidden = false;
+ el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+}
+
+/**
+ * Clears the error message.
+ * @param {HTMLFormElement} form
+ */
+function clearError(form) {
+ const el = form.querySelector('.checkout-error');
+ if (el) { el.hidden = true; el.textContent = ''; }
+}
+
+/**
+ * Fire-and-forget newsletter subscription after order creation.
+ * Reads the newsletter checkbox from formData; does nothing if unchecked.
+ * @param {FormData} formData
+ */
+function subscribeNewsletter(formData) {
+ if (!formData.get('newsletter')) return;
+
+ const { locale, language } = getLocaleAndLanguage();
+ const email = formData.get('email') || '';
+ const firstName = formData.get('shipping-firstname') || '';
+ const lastName = formData.get('shipping-lastname') || '';
+
+ const url = `${FORMS_ENDPOINT}/${locale}/${language}/forms`;
+ fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ formId: `${locale}/${language}/newsletter`,
+ email,
+ emailOptIn: true,
+ firstName,
+ lastName,
+ country: locale,
+ }),
+ }).catch((err) => {
+ // eslint-disable-next-line no-console
+ console.error('newsletter subscription failed:', err);
+ });
+}
+
+/**
+ * Wires the Chase (credit card) submit button and provides a shared callbacks object
+ * that payment providers can use.
+ *
+ * @param {HTMLFormElement} form
+ * @param {Object} cart
+ * @param {Object} state
+ * @param {Object} config
+ * @param {Object} strings
+ * @returns {Object} callbacks
+ */
+export function initOrder(form, cart, state, config, strings) {
+ const callbacks = {
+ getCart: () => cart,
+ getConfig: () => config,
+ getFormData: () => new FormData(form),
+ getState: () => state,
+ updatePreview: () => updatePreview(form, cart, state, config),
+ previewOrderDirect: async (body) => {
+ const result = await previewOrder(body);
+ if (result.estimateToken) state.currentEstimateToken = result.estimateToken;
+ state.currentPreview = result;
+ return result;
+ },
+ buildOrderJSON: (formData) => buildOrderJSON(formData, form, cart, state, config),
+ saveCheckoutSession: (email, c, preview, order) => (
+ saveCheckoutSession(email, c, preview, order)
+ ),
+ createOrder: async (orderBody) => {
+ const result = await createOrder(orderBody);
+ subscribeNewsletter(new FormData(form));
+ return result;
+ },
+ initiatePayment: (...args) => initiatePayment(...args),
+ showError: (msg) => showError(form, msg),
+ clearError: () => clearError(form),
+ onComplete: (createdOrder) => {
+ const order = createdOrder?.order ?? createdOrder;
+ const orderId = order?.id;
+ const email = order?.customer?.email || '';
+ saveCheckoutSession(email, cart, state.currentPreview, order);
+ cart.clear();
+ const path = config.getOrderPath('complete');
+ window.location.href = orderId ? `${path}?orderId=${orderId}` : path;
+ },
+ };
+
+ // Wire Chase submit button
+ const submitBtn = form.querySelector('.checkout-submit-btn');
+ const submitTextEl = submitBtn?.querySelector('.submit-btn-text');
+
+ if (submitBtn) {
+ submitBtn.addEventListener('click', async (e) => {
+ e.preventDefault();
+ clearError(form);
+
+ if (!form.checkValidity()) {
+ form.reportValidity();
+ return;
+ }
+
+ if (!state.selectedShippingMethodId) {
+ showError(form, strings.errorSelectShipping);
+ return;
+ }
+
+ // Delegate to the selected provider's own button if not credit-card
+ const selectedMethod = form.querySelector('[name="paymentMethod"]:checked')?.value;
+
+ if (selectedMethod === 'apple-pay') {
+ submitBtn.disabled = true;
+ submitBtn.classList.add('is-loading');
+ if (submitTextEl) submitTextEl.textContent = strings.processing;
+ try {
+ await callbacks.beginApplePay();
+ } catch (err) {
+ let msg = 'Apple Pay payment failed. Please try again.';
+ if (err.message === 'not-available') msg = 'Apple Pay is not available. Please try a different payment method.';
+ if (err.message === 'no-preview') msg = 'Please complete your shipping information first.';
+ if (err.message === 'recaptcha-blocked') msg = 'Payment was declined for security reasons. Please refresh the page and try again.';
+ callbacks.showError(msg);
+ } finally {
+ submitBtn.disabled = false;
+ submitBtn.classList.remove('is-loading');
+ if (submitTextEl) submitTextEl.textContent = strings.continueToPayment;
+ }
+ return;
+ }
+
+ if (selectedMethod !== config.cardProvider) {
+ const providerBtn = form.querySelector(`.payment-button-container[data-provider="${selectedMethod}"] button`);
+ if (providerBtn) {
+ submitBtn.disabled = true;
+ submitBtn.classList.add('is-loading');
+ if (submitTextEl) submitTextEl.textContent = strings.processing;
+
+ // Restore submit button when the provider re-enables its own button (on fail/close)
+ const observer = new MutationObserver(() => {
+ if (!providerBtn.disabled) {
+ submitBtn.disabled = false;
+ submitBtn.classList.remove('is-loading');
+ if (submitTextEl) submitTextEl.textContent = strings.continueToPayment;
+ observer.disconnect();
+ }
+ });
+ observer.observe(providerBtn, { attributes: true, attributeFilter: ['disabled'] });
+
+ providerBtn.click();
+ }
+ return;
+ }
+
+ if (!state.currentEstimateToken) {
+ await callbacks.updatePreview();
+ if (!state.currentEstimateToken) {
+ showError(form, strings.errorCalculateTotals);
+ return;
+ }
+ }
+
+ const formData = new FormData(form);
+ const email = formData.get('email') || '';
+ const orderBody = buildOrderJSON(formData, form, cart, state, config);
+
+ submitBtn.disabled = true;
+ if (submitTextEl) submitTextEl.textContent = strings.processing;
+
+ try {
+ const createdOrder = await createOrder(orderBody);
+ subscribeNewsletter(formData);
+ saveCheckoutSession(email, cart, state.currentPreview, createdOrder.order ?? createdOrder);
+
+ const fraudToken = config.getFraudToken?.();
+ const idempotencyKey = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`;
+ const payment = await initiatePayment(
+ createdOrder.order?.id ?? createdOrder.id,
+ idempotencyKey,
+ fraudToken,
+ config.cardProvider || 'chase',
+ 'card',
+ );
+
+ if (payment.action === 'redirect' && payment.redirectUrl) {
+ window.location.href = payment.redirectUrl;
+ } else if (payment.status === 'completed') {
+ callbacks.onComplete(createdOrder);
+ } else {
+ showError(form, payment.reason || strings.errorGeneric);
+ submitBtn.disabled = false;
+ if (submitTextEl) submitTextEl.textContent = strings.continueToPayment;
+ }
+ } catch (err) {
+ const msg = err?.errorHeader === 'recaptcha score too low'
+ ? 'Payment was declined for security reasons. Please refresh the page and try again.'
+ : err.body?.message || strings.errorGeneric;
+ showError(form, msg);
+ submitBtn.disabled = false;
+ if (submitTextEl) submitTextEl.textContent = strings.continueToPayment;
+ }
+ });
+ }
+
+ return callbacks;
+}
diff --git a/blocks/checkout/checkout-payment.js b/blocks/checkout/checkout-payment.js
new file mode 100644
index 00000000..c70d8c7d
--- /dev/null
+++ b/blocks/checkout/checkout-payment.js
@@ -0,0 +1,248 @@
+import { getMetadata } from '../../scripts/aem.js';
+
+/**
+ * Filters the provider list using the `disabled-providers` metadata kill switch.
+ * The `?enable-providers=` query param re-enables specific providers for testing.
+ * @param {Array