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.orderSummary}

+
+
+ +
+ ${s.havePromoCode} +
+ + +
+
+
+
+ ${s.subtotal} + +
+ +
+ ${s.shipping} + ${s.shippingPlaceholder} +
+
+ ${s.estimatedTaxes} + -- +
+
+ ${s.total} +
+ + +
+
+
+ + ${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 */` +
+
+
+
+
+
+

${s.cartEmpty}

+ ${s.continueShopping} +
+
+ ${s.viewCart} + ${s.checkout} +
+
`; +} + +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} allProviders + * @returns {Array} + */ +export function getActiveProviders(allProviders) { + const disabled = (getMetadata('disabled-providers') || '') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + + const reenabled = (new URLSearchParams(window.location.search).get('enable-providers') || '') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + + return allProviders.filter((p) => { + if (reenabled.includes(p.id)) return true; + if (disabled.includes(p.id)) return false; + return true; + }); +} + +const LOCK_ICON_SVG = ''; + +const CARD_ICON_SVG = ''; + +function getProviderMeta(strings) { + return { + paypal: { + label: strings.paypalLabel, + sublabel: strings.paypalSub, + icon: '', + }, + 'apple-pay': { + label: strings.applePayLabel, + sublabel: strings.applePaySub, + icon: '', + }, + affirm: { + label: strings.affirmLabel, + sublabel: strings.affirmSub, + icon: '', + }, + }; +} + +/** + * Renders the payment section into the given fieldset. + * @param {HTMLFieldSetElement} container + * @param {Array} activeProviders + * @param {Object} callbacks + * @param {Object} strings + */ +export function renderPaymentSection(container, activeProviders, callbacks, strings, config = {}) { + const providerMeta = getProviderMeta(strings); + const cardProviderId = config.cardProvider || 'chase'; + const chaseActive = activeProviders.some((p) => p.id === cardProviderId); + + // Security notice banner + const notice = document.createElement('div'); + notice.className = 'payment-security-notice'; + + const noticeIcon = document.createElement('span'); + noticeIcon.className = 'payment-security-icon'; + noticeIcon.innerHTML = LOCK_ICON_SVG; + + const noticeText = document.createElement('div'); + noticeText.className = 'payment-security-text'; + + const noticeTitle = document.createElement('strong'); + noticeTitle.textContent = strings.paymentSecureTitle; + + const noticeSub = document.createElement('span'); + noticeSub.textContent = strings.paymentSecureSub; + + noticeText.append(noticeTitle, noticeSub); + notice.append(noticeIcon, noticeText); + container.appendChild(notice); + + // Section label + const howLabel = document.createElement('p'); + howLabel.className = 'payment-how-label'; + howLabel.textContent = strings.paymentHow; + container.appendChild(howLabel); + + // Options wrapper + const optionsWrapper = document.createElement('div'); + optionsWrapper.className = 'payment-options'; + + // Credit card option + if (chaseActive) { + const cardLabel = document.createElement('label'); + cardLabel.className = 'payment-option-card payment-option-active'; + + const cardRadio = document.createElement('input'); + cardRadio.type = 'radio'; + cardRadio.name = 'paymentMethod'; + cardRadio.value = cardProviderId; + cardRadio.checked = true; + cardRadio.id = 'payment-credit-card'; + + const cardIcon = document.createElement('span'); + cardIcon.className = 'payment-option-icon'; + cardIcon.innerHTML = CARD_ICON_SVG; + + const cardContent = document.createElement('div'); + cardContent.className = 'payment-option-content'; + + const cardTitle = document.createElement('span'); + cardTitle.className = 'payment-option-label'; + cardTitle.textContent = strings.creditCard; + + const cardSub = document.createElement('span'); + cardSub.className = 'payment-option-sublabel'; + cardSub.textContent = strings.creditCardSub; + + cardContent.append(cardTitle, cardSub); + + const cardBadges = document.createElement('div'); + cardBadges.className = 'payment-option-badges'; + ['VISA', 'MC', 'AMEX'].forEach((name) => { + const badge = document.createElement('span'); + badge.className = 'card-badge'; + badge.textContent = name; + cardBadges.appendChild(badge); + }); + + cardLabel.append(cardRadio, cardIcon, cardContent, cardBadges); + optionsWrapper.appendChild(cardLabel); + } + + // Provider options (Chase is rendered above as the card block) + activeProviders.filter((p) => p.id !== cardProviderId).forEach((provider) => { + const meta = providerMeta[provider.id] || { label: provider.id, sublabel: '', icon: '' }; + + const optLabel = document.createElement('label'); + optLabel.className = 'payment-option-card'; + + const radio = document.createElement('input'); + radio.type = 'radio'; + radio.name = 'paymentMethod'; + radio.value = provider.id; + radio.id = `payment-${provider.id}`; + + const optIcon = document.createElement('span'); + optIcon.className = 'payment-option-icon'; + optIcon.innerHTML = meta.icon; + + const optContent = document.createElement('div'); + optContent.className = 'payment-option-content'; + + const optTitle = document.createElement('span'); + optTitle.className = 'payment-option-label'; + optTitle.textContent = meta.label || provider.label; + + const optSub = document.createElement('span'); + optSub.className = 'payment-option-sublabel'; + optSub.textContent = meta.sublabel; + + optContent.append(optTitle, optSub); + optLabel.append(radio, optIcon, optContent); + optionsWrapper.appendChild(optLabel); + + // Hidden container for provider SDK button (outside label to avoid click conflicts) + const btnContainer = document.createElement('div'); + btnContainer.className = 'payment-button-container'; + btnContainer.dataset.provider = provider.id; + btnContainer.setAttribute('aria-hidden', 'true'); + optionsWrapper.appendChild(btnContainer); + + provider.renderCheckoutButton(btnContainer, callbacks); + }); + + container.appendChild(optionsWrapper); + + // When Chase is disabled, default-select the first available provider + if (!chaseActive) { + const firstRadio = optionsWrapper.querySelector('input[type="radio"]'); + if (firstRadio) { + firstRadio.checked = true; + firstRadio.closest('.payment-option-card')?.classList.add('payment-option-active'); + const firstProvider = activeProviders.find((p) => p.id !== cardProviderId); + const billingSection = container.closest('form')?.querySelector('.billing-section'); + if (billingSection && firstProvider) { + billingSection.hidden = firstProvider.hidesBilling ?? false; + } + } + } + + // Wire radio changes + container.addEventListener('change', (e) => { + if (e.target.name !== 'paymentMethod') return; + const selectedId = e.target.value; + + optionsWrapper.querySelectorAll('.payment-option-card').forEach((opt) => { + const optRadio = opt.querySelector('input[type="radio"]'); + opt.classList.toggle('payment-option-active', optRadio?.value === selectedId); + }); + + // Hide billing for providers that collect it themselves + const selectedProvider = activeProviders.find((p) => p.id === selectedId); + const billingSection = container.closest('form')?.querySelector('.billing-section'); + if (billingSection) { + billingSection.hidden = selectedProvider?.hidesBilling ?? false; + } + }); +} + +/** + * @param {HTMLFieldSetElement} container + * @param {Array} providers + * @param {Object} callbacks + * @param {Object} config + * @param {Object} strings + */ +export async function initPayment(container, providers, callbacks, config, strings) { + const active = getActiveProviders(providers); + + await Promise.all( + active.map(async (provider) => { + try { + await provider.load(config); + } catch { /* provider failed to load — will be filtered by isAvailable() */ } + }), + ); + + const available = active.filter((p) => { + try { return p.isAvailable(); } catch { return false; } + }); + + renderPaymentSection(container, available, callbacks, strings, config); + + // Render express buttons on the cart page (order-summary block) + const expressContainer = document.querySelector('.express-checkout-buttons'); + if (expressContainer) { + available.filter((p) => p.supportsExpress).forEach((p) => { + p.renderExpressButton(expressContainer, callbacks); + }); + const expressSection = expressContainer.closest('.express-checkout-section'); + if (expressSection) { + expressSection.hidden = !available.some((p) => p.supportsExpress); + } + } +} diff --git a/blocks/checkout/checkout-shipping.js b/blocks/checkout/checkout-shipping.js new file mode 100644 index 00000000..4a827c78 --- /dev/null +++ b/blocks/checkout/checkout-shipping.js @@ -0,0 +1,190 @@ +import { estimateShipping, previewOrder } from '../../scripts/commerce-api.js'; +import { formatPrice } from '../../scripts/commerce-config.js'; + +/** + * Renders shipping method radio buttons into the container. + * @param {HTMLFieldSetElement} container + * @param {Array<{id: string, label: string, rate: number, eta?: string}>} rates + * @param {Object} strings + */ +export function renderShippingMethods(container, rates, strings, currencyCode = 'USD') { + [...container.children].forEach((child) => { + if (child.tagName !== 'LEGEND') child.remove(); + }); + + if (!rates?.length) { + const empty = document.createElement('p'); + empty.className = 'shipping-methods-empty'; + empty.textContent = strings.noShippingMethods; + container.appendChild(empty); + return; + } + + rates.forEach((rate, i) => { + const label = document.createElement('label'); + label.className = 'shipping-method-option'; + + const radio = document.createElement('input'); + radio.type = 'radio'; + radio.name = 'shippingMethod'; + radio.value = rate.id; + if (i === 0) radio.checked = true; + + const body = document.createElement('div'); + body.className = 'shipping-method-body'; + + const labelText = document.createElement('span'); + labelText.className = 'shipping-method-label'; + labelText.textContent = rate.label; + + const isFree = rate.rate === 0; + const price = document.createElement('span'); + price.className = isFree ? 'shipping-method-price shipping-method-price-free' : 'shipping-method-price'; + price.textContent = isFree ? strings.free : formatPrice(parseFloat(rate.rate), currencyCode); + + body.append(labelText, price); + + if (rate.eta) { + const eta = document.createElement('span'); + eta.className = 'shipping-method-eta'; + eta.textContent = rate.eta; + body.appendChild(eta); + } + + label.append(body, radio); + + container.appendChild(label); + }); +} + +/** + * Fetches a preview and dispatches checkout:preview event. + * @param {HTMLFormElement} form + * @param {Object} cart + * @param {Object} state + * @param {Object} config + */ +export async function updatePreview(form, cart, state, config) { + if (!state.selectedShippingMethodId) return; + + const data = new FormData(form); + const email = data.get('email') || ''; + const firstName = data.get('shipping-firstname') || ''; + const lastName = data.get('shipping-lastname') || ''; + const locale = config.getLocale(); + const language = config.getLanguage(); + + const shippingAddr = { + name: `${firstName} ${lastName}`.trim(), + company: data.get('shipping-company') || '', + address1: data.get('shipping-street-0') || '', + address2: data.get('shipping-street-1') || '', + city: data.get('shipping-city') || '', + state: data.get('shipping-state') || '', + zip: data.get('shipping-zip') || '', + country: locale, + phone: data.get('shipping-telephone') || '', + email, + }; + + const orderBody = { + shipping: Object.fromEntries(Object.entries(shippingAddr).filter(([, v]) => v !== '')), + items: cart.getItemsForAPI(), + shippingMethod: { id: state.selectedShippingMethodId }, + locale: `${language.split('_')[0]}-${(language.split('_')[1] || locale).toUpperCase()}`, + country: locale, + paymentMethod: data.get('paymentMethod') || null, + }; + + const couponCode = sessionStorage.getItem('checkout_coupon_code') || undefined; + if (couponCode) orderBody.couponCode = couponCode; + + if (email && firstName && lastName) { + orderBody.customer = { + firstName, + lastName, + email, + phone: data.get('shipping-telephone') || '', + }; + } + + document.dispatchEvent(new CustomEvent('checkout:preview-loading')); + + try { + const preview = await previewOrder(orderBody); + state.currentEstimateToken = preview.estimateToken; + state.currentPreview = preview; + document.dispatchEvent(new CustomEvent('checkout:preview', { detail: { preview } })); + } catch { + document.dispatchEvent(new CustomEvent('checkout:preview', { detail: { preview: null } })); + } +} + +/** + * Fetches shipping rates then triggers a preview. + * @param {HTMLFormElement} form + * @param {HTMLFieldSetElement} shippingContainer + * @param {Object} cart + * @param {Object} state + * @param {Object} config + * @param {Object} strings + */ +// eslint-disable-next-line max-len +async function fetchAndPreview(form, shippingContainer, cart, state, config, strings, currencyCode) { + const data = new FormData(form); + const stateCode = data.get('shipping-state'); + if (!stateCode || !cart.itemCount) return; + + const locale = config.getLocale(); + const country = locale === 'ca' ? 'ca' : 'us'; + + try { + const result = await estimateShipping(country, stateCode, cart.getItemsForAPI()); + renderShippingMethods(shippingContainer, result.rates || [], strings, currencyCode); + + const firstRadio = shippingContainer.querySelector('input[type="radio"]'); + if (firstRadio) { + state.selectedShippingMethodId = firstRadio.value; + shippingContainer.dispatchEvent(new CustomEvent('checkout:shipping-selected', { bubbles: true })); + await updatePreview(form, cart, state, config); + } + } catch { + renderShippingMethods(shippingContainer, [], strings, currencyCode); + } +} + +/** + * @param {HTMLFormElement} form + * @param {HTMLFieldSetElement} shippingContainer + * @param {Object} cart + * @param {Object} state + * @param {Object} config + * @param {Object} strings + */ +export function initShipping(form, shippingContainer, cart, state, config, strings) { + const currencyCode = typeof config.currency === 'function' ? config.currency(config.getLocale()) : config.currency; + + // Show placeholder until an address is entered + const placeholder = document.createElement('p'); + placeholder.className = 'shipping-methods-placeholder'; + placeholder.textContent = strings.shippingPlaceholder; + shippingContainer.appendChild(placeholder); + + // Fetch rates when state/province changes + const stateSelect = form.querySelector('[name="shipping-state"]'); + if (stateSelect) { + stateSelect.addEventListener('change', () => { + state.selectedShippingMethodId = null; + fetchAndPreview(form, shippingContainer, cart, state, config, strings, currencyCode); + }); + } + + // Update preview when shipping method is selected + shippingContainer.addEventListener('change', (e) => { + if (e.target.name === 'shippingMethod') { + state.selectedShippingMethodId = e.target.value; + shippingContainer.dispatchEvent(new CustomEvent('checkout:shipping-selected', { bubbles: true })); + updatePreview(form, cart, state, config); + } + }); +} diff --git a/blocks/checkout/checkout.css b/blocks/checkout/checkout.css new file mode 100644 index 00000000..66d08de0 --- /dev/null +++ b/blocks/checkout/checkout.css @@ -0,0 +1,968 @@ +/* Constrain the combined form + order summary to the commerce max-width */ +main > .section:has(.checkout-wrapper) { + max-width: var(--commerce-max-width); + margin-right: auto; + margin-left: auto; + gap: 24px; + isolation: isolate; +} + +/* Stack section cards with spacing */ +.checkout-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Sign-in prompt — not a card, just a small text link above */ +.checkout-signin-prompt { + padding: 0 0 4px; + font-size: var(--commerce-font-size-sm); +} + +.checkout-signin-link { + color: var(--commerce-color-text); + text-decoration: underline; +} + +/* ── Section cards — each section is a white bordered card ──────── */ +.checkout-form .form-section { + background: var(--commerce-color-background); + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); + padding: 20px 24px; +} + +/* Fieldset sections inherit card styling */ +.checkout-form fieldset.form-section { + margin: 0; +} + +.checkout-form .form-section h3 { + margin: 0 0 16px; + font-size: var(--commerce-font-size-base); + font-weight: var(--commerce-font-weight-bold); +} + +.checkout-form fieldset.form-section legend { + float: left; + grid-column: 1 / -1; + width: 100%; + padding: 0; + margin-bottom: 16px; + font-size: var(--commerce-font-size-base); + font-weight: var(--commerce-font-weight-bold); +} + +/* Clearfix for fieldset legend float */ +.checkout-form fieldset.form-section::after { + content: ''; + display: block; + clear: both; +} + +/* Express checkout — card panel */ +.checkout-form .express-checkout-section { + background: var(--commerce-color-surface); +} + +.checkout-form .express-checkout-section[hidden] { + display: none; +} + +/* Billing section — card with radio options */ + +/* ── Express checkout ─────────────────────────────────────────────── */ +.express-checkout-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.express-checkout-label { + margin: 0; + font-size: var(--commerce-font-size-base); + font-weight: var(--commerce-font-weight-bold); +} + +.express-checkout-tagline { + margin: 0; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); +} + +.express-checkout-buttons { + container-type: inline-size; + display: flex; + align-items: stretch; + flex-wrap: wrap; + gap: 12px; + overflow: hidden; + max-width: 100%; +} + +/* All buttons share one row at wide sizes — div covers SDK iframes, button covers stubs */ +.express-checkout-buttons > div, +.express-checkout-buttons > button { + flex: 1; + min-width: 0; + display: flex; + height: 55px; +} + +/* Stub Pay Later button fills its wrapper in the default (wide) state */ +.paypal-paylater-wrapper .paypal-express-btn { + height: 55px; +} + +/* Pay Later drops to a full-width second row when space is tight */ +@container (max-width: 480px) { + .express-checkout-buttons > .paypal-paylater-wrapper { + flex: 0 0 100%; + } +} + +@media (width >= 1118px) and (width <= 1250px) { + .express-checkout-buttons > .paypal-paylater-wrapper { + flex: 0 0 100%; + } +} + +@media (width >= 610px) and (width <= 714px) { + .express-checkout-buttons > .paypal-paylater-wrapper { + flex: 0 0 100%; + } +} + +.express-checkout-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 16px 0 0; + padding-top: 14px; + font-size: 11px; + font-weight: var(--commerce-font-weight-bold); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--commerce-color-text-muted); + white-space: nowrap; +} + +.express-checkout-divider::before, +.express-checkout-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--commerce-color-border); +} + +/* ── Field grid ───────────────────────────────────────────────────── */ +.form-fields { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 12px; +} + +.form-field { + grid-column: 1 / -1; +} + +.form-field.field-half { + grid-column: span 3; +} + +.form-field.field-third { + grid-column: span 2; +} + +/* ── Floating labels — checkbox first (lower specificity than :has() below) ── */ +.form-field-checkbox label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: var(--commerce-font-size-sm); +} + +.form-field-checkbox input[type="checkbox"] { + width: 16px; + height: 16px; + margin: 0; + flex-shrink: 0; + cursor: pointer; +} + +.floating-label-field { + position: relative; +} + +.floating-label-field input, +.floating-label-field select, +.floating-label-field textarea { + width: 100%; + box-sizing: border-box; + padding: 20px 14px 4px; + height: var(--commerce-input-height); + border: 1px solid var(--commerce-input-border); + border-radius: var(--commerce-input-radius); + background: var(--commerce-input-background); + font-size: var(--commerce-font-size-sm); + font-weight: 400; + font-family: inherit; + color: var(--commerce-color-text); + outline: none; + appearance: none; +} + +.floating-label-field textarea { + height: auto; + padding-top: 24px; + resize: vertical; +} + +.floating-label-field select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23666' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 14px center; + padding-right: 36px; + cursor: pointer; +} + +.floating-label-field input:focus, +.floating-label-field select:focus, +.floating-label-field textarea:focus { + border-color: var(--commerce-input-border-focus); +} + +.floating-label-field input:-webkit-autofill, +.floating-label-field input:-webkit-autofill:hover, +.floating-label-field input:-webkit-autofill:focus { + box-shadow: 0 0 0 1000px var(--commerce-input-background) inset; + -webkit-text-fill-color: var(--commerce-color-text); +} + +.floating-label-field select:-webkit-autofill, +.floating-label-field select:-webkit-autofill:hover, +.floating-label-field select:-webkit-autofill:focus { + background-color: var(--commerce-input-background); + color: var(--commerce-color-text); +} + +.floating-label-field input[required] + label::after, +.floating-label-field select[required] + label::after { + content: ' *'; + color: var(--commerce-color-error, #c00); +} + +.floating-label-field label { + position: absolute; + left: 14px; + top: 16px; + margin: 0; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); + pointer-events: none; + transition: top 0.15s ease, font-size 0.15s ease; +} + +.floating-label-field:has(input:focus) label, +.floating-label-field:has(input:not(:placeholder-shown)) label, +.floating-label-field:has(select:focus) label, +.floating-label-field:has(select.has-value) label, +.floating-label-field:has(textarea:focus) label, +.floating-label-field:has(textarea:not(:placeholder-shown)) label { + top: 8px; + font-size: 0.7rem; +} + +.floating-label-field input.field-required, +.floating-label-field select.field-required { + border-color: var(--commerce-color-error); +} + +/* Gift message char count */ +.char-count { + display: block; + font-size: 0.7rem; + color: var(--commerce-color-text-muted); + text-align: right; + margin-top: 4px; +} + +/* ── Shipping methods — horizontal card layout ───────────────────── */ +.shipping-methods { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.checkout-form .shipping-methods:has(.shipping-methods-placeholder) { + padding-bottom: 20px; +} + +.checkout-form .shipping-methods:has(.shipping-methods-placeholder) legend { + margin-bottom: 8px; +} + +.shipping-methods.loading { + opacity: 0.5; + pointer-events: none; +} + +.shipping-method-option { + flex: 1 1 auto; + min-width: 150px; + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-badge); + cursor: pointer; + transition: border-color 0.15s; +} + +.shipping-method-option:hover { + border-color: var(--commerce-color-text); +} + +.shipping-method-option:has(input:checked) { + border-color: var(--commerce-color-text); + background: var(--commerce-color-surface); +} + +.shipping-method-option input[type="radio"] { + flex-shrink: 0; + margin: 2px 0 0; + width: 18px; + height: 18px; + cursor: pointer; + accent-color: var(--commerce-color-text); +} + +.shipping-method-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.shipping-method-label { + font-size: var(--commerce-font-size-sm); + font-weight: var(--commerce-font-weight-bold); +} + +.shipping-method-eta { + font-size: 12px; + color: var(--commerce-color-text-muted); +} + +.shipping-method-price { + font-size: var(--commerce-font-size-sm); + font-weight: var(--commerce-font-weight-bold); +} + +.shipping-method-price-free { + color: #2a7c43; +} + +.shipping-methods-empty, +.shipping-methods-placeholder { + color: var(--commerce-color-text-muted); + font-size: var(--commerce-font-size-sm); + font-style: italic; + margin-top: 4px; +} + +/* ── Billing address ─────────────────────────────────────────────── */ +.billing-subtitle { + margin: 0 0 16px; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); +} + +.billing-options { + display: flex; + flex-direction: column; + gap: 8px; +} + +.billing-option-card { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); + cursor: pointer; + transition: border-color 0.15s; +} + +.billing-option-card:has(input:checked) { + border-color: var(--commerce-color-text); +} + +.billing-option-card input[type="radio"] { + width: 18px; + height: 18px; + margin: 2px 0 0; + flex-shrink: 0; + cursor: pointer; + accent-color: var(--commerce-color-text); +} + +.billing-option-content { + display: flex; + flex-direction: column; + gap: 3px; +} + +.billing-option-label { + font-size: var(--commerce-font-size-sm); + font-weight: var(--commerce-font-weight-bold); +} + +.billing-option-detail { + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); +} + +.billing-fields-wrapper { + margin-top: 20px; +} + +/* ── Payment method ──────────────────────────────────────────────── */ +.payment-security-notice { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + margin-bottom: 20px; + background: var(--commerce-color-surface); + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); +} + +.payment-security-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 32px; + height: 32px; + background: #e8e8e8; + border-radius: 8px; + color: var(--commerce-color-text-muted); +} + +.payment-security-text { + display: flex; + flex-direction: column; + gap: 4px; + font-size: var(--commerce-font-size-sm); +} + +.payment-security-text strong { + color: var(--commerce-color-text); + font-weight: var(--commerce-font-weight-bold); +} + +.payment-security-text span { + color: var(--commerce-color-text-muted); +} + +.payment-how-label { + margin: 0 0 12px; + font-size: 11px; + font-weight: var(--commerce-font-weight-bold); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--commerce-color-text-muted); +} + +.payment-options { + display: flex; + flex-direction: column; + gap: 8px; +} + +.payment-option-card { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); + cursor: pointer; + transition: border-color 0.15s; +} + +.payment-option-card:hover { + border-color: var(--commerce-color-text-muted); +} + +.payment-option-card.payment-option-active { + border-color: var(--commerce-color-text); +} + +.payment-option-card input[type="radio"] { + width: 18px; + height: 18px; + margin: 0; + flex-shrink: 0; + cursor: pointer; + accent-color: var(--commerce-color-text); +} + +.payment-option-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 40px; + color: var(--commerce-color-text-muted); +} + +.payment-icon-pp { + font-family: Arial, Helvetica, sans-serif; + font-size: 14px; + font-weight: bold; + line-height: 1; +} + +.payment-icon-text { + font-size: 12px; + font-weight: var(--commerce-font-weight-bold); + color: var(--commerce-color-text-muted); +} + +.payment-option-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.payment-option-label { + font-size: var(--commerce-font-size-sm); + font-weight: var(--commerce-font-weight-bold); + color: var(--commerce-color-text); +} + +.payment-option-sublabel { + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); +} + +.payment-option-badges { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.card-badge { + display: inline-flex; + align-items: center; + padding: 2px 5px; + border: 1px solid var(--commerce-color-border); + border-radius: 3px; + font-size: 10px; + font-weight: var(--commerce-font-weight-bold); + color: var(--commerce-color-text-muted); + letter-spacing: 0.02em; +} + +.payment-button-container { + display: none; +} + +/* PayPal stub buttons */ +.paypal-btn, +.paypal-express-btn { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-width: 0; + width: 100%; + height: 55px; + overflow: hidden; + background: #ffc439; + border: none; + border-radius: var(--commerce-button-radius); + cursor: pointer; + transition: background 0.15s; + box-sizing: border-box; +} + +.pp-wordmark { + font-family: Arial, Helvetica, sans-serif; + font-size: 20px; + font-weight: bold; + line-height: 1; +} + +.pp-pay { color: #003087; } + +.pp-pal { color: #009cde; } + +.pp-later { + font-family: Arial, Helvetica, sans-serif; + font-size: 13px; + font-weight: bold; + color: #003087; + margin-left: 6px; +} + +.paypal-btn:hover, +.paypal-express-btn:hover { + background: #f0b929; +} + +.paypal-btn:disabled, +.paypal-express-btn:disabled { + opacity: 0.7; + cursor: not-allowed; + font-size: var(--commerce-font-size-sm); + color: #003087; +} + +.paylater-btn, +.paylater-express-btn { + background: #e8f4fb; + border: 1px solid #b0d8f0; +} + +.paylater-btn:hover, +.paylater-express-btn:hover { + background: #d0eaf7; +} + +/* Not-configured 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; +} + +.paypal-not-configured-dialog p { + margin: 0 0 16px; + font-size: var(--commerce-font-size-sm); +} + +.paypal-not-configured-dialog::backdrop { + background: rgb(0 0 0 / 40%); +} + +/* Apple Pay */ +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; + + flex: 1; + min-width: 0; + display: block; +} + + +/* ── Order total breakdown (mobile only) ─────────────────────────── */ +.order-total-breakdown { + display: none; +} + +.order-breakdown-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); +} + +/* ── Order total + submit ─────────────────────────────────────────── */ +.order-total-section { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.order-total-left { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.order-total-label { + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); +} + +.order-total-amount { + font-size: 28px; + font-weight: var(--commerce-font-weight-bold); + color: var(--commerce-color-text); + line-height: 1.1; +} + +.order-total-tnc { + margin: 0; + font-size: 11px; + color: var(--commerce-color-text-muted); +} + +button.checkout-submit-btn { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + white-space: nowrap; + padding: 14px 24px; + font-size: var(--commerce-font-size-base); +} + +.submit-lock-icon { + display: flex; + align-items: center; + flex-shrink: 0; + line-height: 0; +} + +.checkout-submit-btn:disabled { + opacity: 0.7; + pointer-events: none; +} + +.checkout-submit-btn.is-loading .submit-lock-icon svg { + display: none; +} + +.checkout-submit-btn.is-loading .submit-lock-icon::after { + content: ''; + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgb(255 255 255 / 40%); + border-top-color: #fff; + border-radius: 50%; + animation: submit-btn-spin 0.7s linear infinite; +} + +@keyframes submit-btn-spin { + to { transform: rotate(360deg); } +} + +@media (width < 1000px) { + .card-badge { + display: none; + } + + .order-total-breakdown { + display: flex; + flex-direction: column; + gap: 8px; + margin: 12px 0; + padding-bottom: 12px; + border-bottom: 1px solid var(--commerce-color-border); + position: relative; + } + + .order-total-breakdown.loading { + opacity: 0.4; + pointer-events: none; + } + + .order-total-breakdown.loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + margin: -10px 0 0 -10px; + border: 2px solid var(--commerce-color-border); + border-top-color: var(--commerce-color-text); + border-radius: 50%; + animation: order-breakdown-spin 0.7s linear infinite; + } + + @keyframes order-breakdown-spin { + to { transform: rotate(360deg); } + } + + .order-total-amount { + text-align: right; + padding-bottom: 8px; + } + + .order-total-section { + flex-direction: column; + align-items: stretch; + } + + button.checkout-submit-btn { + justify-content: center; + width: 100%; + } +} + +/* ── Error ───────────────────────────────────────────────────────── */ +.checkout-error { + 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); + margin-top: 4px; + padding: 12px 16px; +} + +.checkout-error[hidden] { + display: none; +} + +/* ── Empty cart ──────────────────────────────────────────────────── */ +.checkout-empty { + text-align: center; + padding: 80px 24px; +} + +.checkout-empty h2 { + margin: 0 0 8px; +} + +.checkout-empty p { + margin: 0 0 24px; + color: var(--commerce-color-text-muted); +} + +/* ── Section collapse ────────────────────────────────────────────── */ +.form-section.is-collapsed > :not(.section-collapsed-bar) { + display: none; +} + +.section-collapsed-bar:not([hidden]) { + display: flex; + align-items: center; + gap: 12px; +} + +.section-check { + flex-shrink: 0; + display: flex; + align-items: center; +} + +.section-summary-text { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.section-summary-title { + font-size: var(--commerce-font-size-base); + font-weight: var(--commerce-font-weight-bold); + color: var(--commerce-color-text); +} + +.section-summary-detail { + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.section-edit-btn { + background: none; + border: none; + padding: 4px 0; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); + cursor: pointer; + text-decoration: underline; + flex-shrink: 0; +} + +.section-edit-btn:hover { + color: var(--commerce-color-text); +} + +/* ── Places autocomplete ─────────────────────────────────────────── */ +.places-autocomplete-wrapper { + position: relative; +} + +/* Suppress search-input browser chrome added when we set type="search" */ +.places-autocomplete-wrapper input[type="search"]::-webkit-search-decoration, +.places-autocomplete-wrapper input[type="search"]::-webkit-search-cancel-button, +.places-autocomplete-wrapper input[type="search"]::-webkit-search-results-button, +.places-autocomplete-wrapper input[type="search"]::-webkit-search-results-decoration { + display: none; +} + +.places-autocomplete-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 100; + margin: 0; + padding: 4px 0; + list-style: none; + background: var(--commerce-color-background); + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); + box-shadow: 0 4px 12px rgb(0 0 0 / 10%); + max-height: 240px; + overflow-y: auto; +} + +.places-autocomplete-dropdown li { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 14px; + cursor: pointer; + font-size: var(--commerce-font-size-sm); +} + +.places-autocomplete-dropdown li:hover { + background: var(--commerce-color-surface-alt, #f5f5f5); +} + +.places-main { + color: var(--commerce-color-text); + font-weight: var(--commerce-font-weight-medium, 500); +} + +.places-secondary { + color: var(--commerce-color-text-muted); + font-size: 0.75rem; +} + +/* ── Mobile ──────────────────────────────────────────────────────── */ +@media (width < 480px) { + .form-field.field-half, + .form-field.field-third { + grid-column: 1 / -1; + } + + .shipping-methods { + grid-template-columns: 1fr; + } + + .checkout-form .form-section { + padding: 16px; + } + +} diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js new file mode 100644 index 00000000..0f5f12fc --- /dev/null +++ b/blocks/checkout/checkout.js @@ -0,0 +1,305 @@ +import { loadCSS, loadBlock } from '../../scripts/aem.js'; +import { getConfig, formatPrice } from '../../scripts/commerce-config.js'; +import cart from '../../scripts/cart.js'; +import applePay, { beginCheckoutSession } from '../../scripts/payments/apple-pay.js'; +import paypal from '../../scripts/payments/paypal.js'; +import affirm from '../../scripts/payments/affirm.js'; +import chase from '../../scripts/payments/chase.js'; +import buildForm, { initCollapse } from './checkout-form.js'; +import { initAddress } from './checkout-address.js'; +import { initShipping, updatePreview } from './checkout-shipping.js'; +import { initOrder } from './checkout-order.js'; +import { initPayment } from './checkout-payment.js'; +import { parsePreview } from '../../scripts/commerce-api.js'; + +const ALL_PROVIDERS = [chase, applePay, paypal, affirm]; + +const LOCAL_STRINGS = { + 'en-us': { + signIn: 'Sign in for faster checkout', + expressCheckout: 'Express checkout', + expressTagline: 'Skip the form — pay in seconds', + expressOr: 'or continue manually', + contact: 'Contact', + email: 'Email', + newsletter: 'Email me with news and offers', + shippingAddress: 'Shipping address', + firstName: 'First name', + lastName: 'Last name', + address: 'Address', + addressLine2: 'Apt, suite, etc. (optional)', + city: 'City', + state: 'State', + province: 'Province', + zip: 'ZIP code', + postalCode: 'Postal code', + phone: 'Phone (optional)', + giftMessage: 'Gift message', + giftMessagePlaceholder: 'Add a gift message (optional)', + billingAddress: 'Billing address', + billingSubtitle: "Needed for the card you'll use on the secure payment page.", + billingSame: 'Same as shipping address', + billingDifferent: 'Use a different billing address', + paymentMethod: 'Payment method', + paymentSecureTitle: 'Payment happens on our secure, PCI-compliant page', + paymentSecureSub: "Review your order here, then we'll redirect you to finish paying. No card details are ever stored on our servers.", + paymentHow: "How you'd like to pay", + creditCard: 'Credit or debit card', + creditCardSub: "You'll enter your card on the next secure page", + paypalLabel: 'PayPal', + paypalSub: 'Continue on paypal.com', + applePayLabel: 'Apple Pay', + applePaySub: 'Continue with Apple Pay', + affirmLabel: 'Affirm', + affirmSub: 'Pay over time with Affirm', + orderTotal: 'Order total', + tnc: 'By placing this order you agree to our Terms & Privacy Policy.', + continueToPayment: 'Continue to payment', + edit: 'Edit', + cartEmpty: 'Your cart is empty.', + noShippingMethods: 'No shipping methods available for this address.', + shippingPlaceholder: 'Enter your shipping address to see available methods.', + errorSelectShipping: 'Please select a shipping method.', + errorCalculateTotals: 'Unable to calculate totals. Please try again.', + errorGeneric: 'An error occurred. Please try again.', + processing: 'Processing…', + }, + 'fr-ca': { + signIn: 'Se connecter pour un paiement plus rapide', + expressCheckout: 'Paiement express', + expressTagline: 'Évitez le formulaire — payez en quelques secondes', + expressOr: 'ou continuer manuellement', + contact: 'Contact', + email: 'Courriel', + newsletter: "M'envoyer des nouvelles et des offres par courriel", + shippingAddress: 'Adresse de livraison', + firstName: 'Prénom', + lastName: 'Nom de famille', + address: 'Adresse', + addressLine2: 'App., suite, etc. (facultatif)', + city: 'Ville', + state: 'État', + province: 'Province', + zip: 'Code ZIP', + postalCode: 'Code postal', + phone: 'Téléphone (facultatif)', + giftMessage: 'Message cadeau', + giftMessagePlaceholder: 'Ajouter un message cadeau (facultatif)', + billingAddress: 'Adresse de facturation', + billingSubtitle: 'Nécessaire pour la carte que vous utiliserez sur la page de paiement sécurisée.', + billingSame: "Même que l'adresse de livraison", + billingDifferent: 'Utiliser une adresse de facturation différente', + paymentMethod: 'Mode de paiement', + paymentSecureTitle: "Le paiement s'effectue sur notre page sécurisée et conforme PCI", + paymentSecureSub: "Vérifiez votre commande ici, puis nous vous redirigerons pour finaliser le paiement. Aucune donnée de carte n'est jamais stockée sur nos serveurs.", + paymentHow: 'Comment souhaitez-vous payer', + creditCard: 'Carte de crédit ou de débit', + creditCardSub: 'Vous saisirez votre carte sur la prochaine page sécurisée', + paypalLabel: 'PayPal', + paypalSub: 'Continuer sur paypal.com', + applePayLabel: 'Apple Pay', + applePaySub: 'Continuer avec Apple Pay', + affirmLabel: 'Affirm', + affirmSub: 'Payer en plusieurs fois avec Affirm', + orderTotal: 'Total de la commande', + tnc: 'En passant cette commande, vous acceptez nos Conditions générales et notre Politique de confidentialité.', + continueToPayment: 'Continuer vers le paiement', + edit: 'Modifier', + cartEmpty: 'Votre panier est vide.', + noShippingMethods: 'Aucune méthode de livraison disponible pour cette adresse.', + shippingPlaceholder: 'Entrez votre adresse de livraison pour voir les méthodes disponibles.', + errorSelectShipping: 'Veuillez sélectionner une méthode de livraison.', + errorCalculateTotals: 'Impossible de calculer les totaux. Veuillez réessayer.', + errorGeneric: 'Une erreur est survenue. Veuillez réessayer.', + processing: 'Traitement en cours…', + }, +}; + +function getStrings(config) { + const lang = config.getLanguage().toLowerCase().replace('_', '-'); + return { ...config.getStrings(), ...(LOCAL_STRINGS[lang] || LOCAL_STRINGS['en-us']) }; +} + +export default async function decorate(block) { + await loadCSS('/styles/commerce-tokens.css'); + const config = getConfig(); + const strings = getStrings(config); + + // Empty cart guard + if (cart.itemCount === 0) { + block.innerHTML = ''; + const empty = document.createElement('div'); + empty.className = 'checkout-empty'; + const p = document.createElement('p'); + p.textContent = strings.cartEmpty; + const link = document.createElement('a'); + link.href = '/'; + link.className = 'button'; + link.textContent = strings.continueShopping; + empty.append(p, link); + block.appendChild(empty); + return; + } + + block.innerHTML = ''; + + // If cart is emptied during checkout, reload to show the empty state + document.addEventListener('cart:change', () => { + if (cart.itemCount === 0) window.location.reload(); + }); + + // Shared mutable state passed by reference to all modules + const state = { + selectedShippingMethodId: null, + currentEstimateToken: null, + currentPreview: null, + }; + + // Build form + const form = buildForm(block, config, strings); + + // Seed and update order total amount + mobile breakdown + const orderTotalAmountEl = form.querySelector('.order-total-amount'); + if (orderTotalAmountEl) { + const currencyCode = typeof config.currency === 'function' ? config.currency(config.getLocale()) : config.currency; + const breakdownEl = form.querySelector('.order-total-breakdown'); + const breakdownSubtotalEl = form.querySelector('.order-breakdown-subtotal'); + const breakdownShippingEl = form.querySelector('.order-breakdown-shipping'); + const breakdownTaxesEl = form.querySelector('.order-breakdown-taxes'); + + const updateBreakdown = (subtotal, shipping, taxes) => { + // eslint-disable-next-line max-len + if (breakdownSubtotalEl) breakdownSubtotalEl.textContent = formatPrice(subtotal, currencyCode); + if (breakdownShippingEl) breakdownShippingEl.textContent = shipping; + if (breakdownTaxesEl) breakdownTaxesEl.textContent = taxes; + }; + + const seedInitial = () => { + orderTotalAmountEl.textContent = formatPrice(cart.subtotal, currencyCode); + updateBreakdown(cart.subtotal, '--', '--'); + }; + seedInitial(); + + document.addEventListener('cart:change', () => { if (cart.itemCount > 0) seedInitial(); }); + + document.addEventListener('checkout:preview-loading', () => { + breakdownEl?.classList.add('loading'); + }); + + document.addEventListener('checkout:preview', (e) => { + breakdownEl?.classList.remove('loading'); + const { preview } = e.detail || {}; + if (!preview) return; + const { + subtotal, taxAmount, shippingRate, total, + } = parsePreview(preview, cart.subtotal); + orderTotalAmountEl.textContent = formatPrice(total, currencyCode); + const shippingDisplay = shippingRate === 0 + ? strings.free + : formatPrice(parseFloat(shippingRate), currencyCode); + updateBreakdown(subtotal, shippingDisplay, formatPrice(taxAmount, currencyCode)); + }); + } + + // Wire address fields and billing toggle + initAddress(form, state, config, strings); + + // Section collapse — contact + const contactSection = form.querySelector('.contact-section'); + initCollapse(contactSection, { + getIsValid: () => { + const email = form.querySelector('[name="email"]'); + return !!email?.checkValidity() && email.value.trim() !== ''; + }, + getSummary: () => form.querySelector('[name="email"]')?.value || '', + }, strings); + + // Section collapse — shipping address + const shippingAddrSection = form.querySelector('.shipping-address-section'); + initCollapse(shippingAddrSection, { + getIsValid: () => [...shippingAddrSection.querySelectorAll('[required]')].every((el) => el.checkValidity()), + getSummary: () => { + const data = new FormData(form); + const name = `${data.get('shipping-firstname') || ''} ${data.get('shipping-lastname') || ''}`.trim(); + const city = data.get('shipping-city') || ''; + const stateCode = data.get('shipping-state') || ''; + const location = [city, stateCode].filter(Boolean).join(', '); + return [name, location].filter(Boolean).join(' · '); + }, + }, strings); + + // Wire shipping rate fetching and preview + const shippingContainer = form.querySelector('.shipping-methods'); + initShipping(form, shippingContainer, cart, state, config, strings); + + // When the cart changes mid-checkout, invalidate the stale estimate and + // re-run the preview so totals stay accurate. The empty-cart listener above + // handles the zero-items case via reload; this handles qty changes and + // partial removes while items remain. + document.addEventListener('cart:change', () => { + if (cart.itemCount === 0) return; + state.currentEstimateToken = null; + state.currentPreview = null; + if (state.selectedShippingMethodId) { + updatePreview(form, cart, state, config); + } + }); + + // Retry preview when customer identity fields are filled after shipping is already selected. + // This handles the case where state/province is chosen before email or name is entered, + // causing the first updatePreview call to bail out on the missing-fields guard. + ['email', 'shipping-firstname', 'shipping-lastname'].forEach((name) => { + const input = form.querySelector(`[name="${name}"]`); + if (!input) return; + input.addEventListener('blur', () => { + if (input.value && state.selectedShippingMethodId && !state.currentPreview) { + updatePreview(form, cart, state, config); + } + }); + }); + + // Re-run preview when a coupon code is applied from the order summary sidebar. + document.addEventListener('checkout:coupon-apply', () => { + if (state.selectedShippingMethodId) updatePreview(form, cart, state, config); + }); + + // Wire Chase submit and get shared callbacks for providers + const callbacks = initOrder(form, cart, state, config, strings); + + // Apple Pay must be initiated synchronously from the submit button's trusted click gesture + callbacks.beginApplePay = () => beginCheckoutSession(config, callbacks); + + // Register providers, check availability, render buttons + const paymentContainer = form.querySelector('.payment-method-section'); + await initPayment(paymentContainer, ALL_PROVIDERS, callbacks, config, strings); + + // Re-run preview on payment method change — tax may vary by type (e.g. Avalara surcharge) + paymentContainer.addEventListener('change', (e) => { + if (e.target.name === 'paymentMethod') updatePreview(form, cart, state, config); + }); + + // Inject order-summary block into this section if the author didn't add one + const section = block.closest('.section'); + if (section && !section.querySelector('.order-summary-wrapper')) { + const wrapper = document.createElement('div'); + wrapper.className = 'order-summary-wrapper'; + const orderSummaryBlock = document.createElement('div'); + orderSummaryBlock.className = 'order-summary block'; + orderSummaryBlock.dataset.blockName = 'order-summary'; + wrapper.appendChild(orderSummaryBlock); + section.appendChild(wrapper); + loadBlock(orderSummaryBlock); + } + + // Restore page from bfcache after payment provider redirect + window.addEventListener('pageshow', (e) => { + if (e.persisted) { + const submitBtn = form.querySelector('.checkout-submit-btn'); + if (submitBtn) { + submitBtn.disabled = false; + const submitTextEl = submitBtn.querySelector('.submit-btn-text'); + if (submitTextEl) submitTextEl.textContent = strings.continueToPayment; + } + } + }); +} diff --git a/blocks/feature-cards/feature-cards.css b/blocks/feature-cards/feature-cards.css new file mode 100644 index 00000000..83caff52 --- /dev/null +++ b/blocks/feature-cards/feature-cards.css @@ -0,0 +1,80 @@ +main > .section > .feature-cards-wrapper { + padding: 0; +} + +.feature-cards { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + width: 100%; +} + +.feature-card { + background: #fff; + border: 1px solid var(--commerce-color-border, #e5e7eb); + border-radius: 12px; + padding: 20px; +} + +.feature-card-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +.feature-card-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + flex-shrink: 0; +} + +.feature-card-icon img, +.feature-card-icon svg { + width: 100%; + height: 100%; + object-fit: contain; +} + +.feature-card-title { + font-weight: 600; + font-size: 15px; + line-height: 1.3; +} + +.feature-card-body { + font-size: var(--commerce-font-size-sm, 14px); + line-height: 1.6; + color: var(--commerce-color-text, #111); +} + +.feature-card-body p { + margin: 0 0 8px; +} + +.feature-card-body p:last-child { + margin-bottom: 0; +} + +/* Reset any global .button styling on links inside card bodies */ +.feature-card-body a.button { + all: unset; + color: inherit; + text-decoration: underline; + cursor: pointer; +} + +@media (width <= 999px) { + .feature-cards { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (width <= 599px) { + .feature-cards { + grid-template-columns: 1fr; + } +} diff --git a/blocks/feature-cards/feature-cards.js b/blocks/feature-cards/feature-cards.js new file mode 100644 index 00000000..6c8f4dc9 --- /dev/null +++ b/blocks/feature-cards/feature-cards.js @@ -0,0 +1,33 @@ +export default function decorate(block) { + const cards = [...block.querySelectorAll(':scope > div')].map((row) => { + const [titleCell, bodyCell] = row.querySelectorAll(':scope > div'); + const card = document.createElement('div'); + card.className = 'feature-card'; + + const header = document.createElement('div'); + header.className = 'feature-card-header'; + + const icon = titleCell.querySelector('img, svg, picture'); + if (icon) { + const iconWrap = document.createElement('span'); + iconWrap.className = 'feature-card-icon'; + iconWrap.appendChild(icon.closest('picture') || icon); + header.appendChild(iconWrap); + } + + const title = document.createElement('span'); + title.className = 'feature-card-title'; + title.innerHTML = titleCell.textContent.trim(); + header.appendChild(title); + + const body = document.createElement('div'); + body.className = 'feature-card-body'; + body.innerHTML = bodyCell.innerHTML; + + card.append(header, body); + return card; + }); + + block.innerHTML = ''; + block.append(...cards); +} diff --git a/blocks/form/form.js b/blocks/form/form.js index ad52151b..beebd8d4 100644 --- a/blocks/form/form.js +++ b/blocks/form/form.js @@ -1,5 +1,5 @@ import { toCamelCase, toClassName } from '../../scripts/aem.js'; -import { getLocaleAndLanguage } from '../../scripts/scripts.js'; +import { getFormSubmissionUrl, getLocaleAndLanguage } from '../../scripts/scripts.js'; /** * Creates an HTML element with an optional class name @@ -51,6 +51,25 @@ function buildLabel(text, type = 'label', id = null) { return label; } +/** + * @param {Object} field + * @returns {HTMLDivElement} Section element + */ +function buildSection(field) { + const { + label, field: fieldName, autocomplete, + } = field; + const section = createElement('fieldset', `form-section section-${fieldName}`); + // section.append(buildLabel(label, 'legend')); + if (label) { + const h3 = createElement('h3'); + h3.textContent = label; + section.append(h3); + } + if (autocomplete) section.autocomplete = `section-${autocomplete}`; + return section; +} + /** * Creates an input element with specified attributes * @param {Object} field - Field configuration object @@ -64,6 +83,7 @@ function buildInput(field) { default: defaultValue, placeholder, pattern, + autocomplete, } = field; const input = createElement('input'); @@ -74,6 +94,7 @@ function buildInput(field) { if (defaultValue) input.value = defaultValue; if (placeholder) input.placeholder = placeholder; + if (autocomplete) input.autocomplete = autocomplete; if (pattern) input.pattern = pattern; if (fieldName === 'mobile') { @@ -104,7 +125,7 @@ function buildInput(field) { */ function buildTextArea(field) { const { - field: fieldName, required, default: defaultValue, placeholder, + field: fieldName, required, default: defaultValue, placeholder, autocomplete, } = field; const textarea = createElement('textarea'); @@ -114,9 +135,42 @@ function buildTextArea(field) { textarea.rows = 5; if (defaultValue) textarea.value = defaultValue; if (placeholder) textarea.placeholder = placeholder; + if (autocomplete) textarea.autocomplete = autocomplete; return textarea; } +async function appendSelectOptions(select, url) { + try { + // fetch options as JSON sheet + const { pathname } = new URL(url); + const resp = await fetch(pathname); + if (!resp.ok) { + // eslint-disable-next-line no-console + console.error('Failed to fetch select options', resp.status); + return select; + } + const { data } = await resp.json(); + if (!data || !Array.isArray(data)) { + // eslint-disable-next-line no-console + console.error('Invalid select options JSON', data); + return select; + } + + if (select.dataset.optionsOverridden) return select; + data.forEach((option) => { + const optionEl = createElement('option'); + optionEl.value = option.Value || option.value; + optionEl.textContent = option.Label || option.label; + select.append(optionEl); + }); + return select; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to parse select options', error); + return select; + } +} + /** * Decodes option string into text and value pair * @param {string} option - Option string @@ -133,7 +187,7 @@ function decodeOption(option) { */ function buildSelect(field) { const { - field: fieldName, required, default: defaultValue, options, + field: fieldName, required, default: defaultValue, placeholder, options, } = field; const select = createElement('select'); @@ -141,7 +195,20 @@ function buildSelect(field) { select.name = select.id; select.required = required === 'true'; - if (options) { + if (options && /^https?:\/\//.test(options)) { + // URL-based options: fetch from JSON sheet + if (placeholder) { + const optionEl = createElement('option'); + if (defaultValue != null) { + optionEl.value = defaultValue; + } + optionEl.textContent = placeholder; + optionEl.setAttribute('disabled', 'true'); + select.append(optionEl); + } + appendSelectOptions(select, options); + } else if (options) { + // Inline comma-separated options options.split(',').forEach((o) => { const [text, value] = decodeOption(o); const option = createElement('option'); @@ -426,17 +493,25 @@ function enableFooterSignUp(form) { leadSource = `sub-em-${window.leadSourceOverride}-${country}`; } + const { locale, language } = getLocaleAndLanguage(); const payload = { + formId: `${locale}/${language}/newsletter`, + pageUrl: window.location.href, email, mobile, - sms_optin: optIn ? '1' : '0', - lead_source: leadSource, - pageUrl: window.location.href, - actionUrl: '/us/en_us/rest/V1/vitamix-api/newslettersubscribe', + smsOptIn: optIn, + emailOptIn: true, + leadSource, }; - const params = new URLSearchParams(payload); try { - const resp = await fetch(`https://www.vitamix.com/bin/vitamix/newslettersubscription?${params.toString()}`); + const url = getFormSubmissionUrl(); + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); if (!resp.ok) { // eslint-disable-next-line no-console console.error('Failed to submit newsletter subscription', resp); @@ -550,11 +625,15 @@ function buildForm(fields, path) { // group buttons at the end const buttons = []; + let section = form; fields.forEach((field) => { if (field.type === 'submit' || field.type === 'reset') { buttons.push(field); + } else if (field.type === 'section') { + section = buildSection(field, form); + form.append(section); } else { - form.append(buildField(field)); + section.append(buildField(field)); } }); diff --git a/blocks/header/auth-panel.js b/blocks/header/auth-panel.js new file mode 100644 index 00000000..d56509c7 --- /dev/null +++ b/blocks/header/auth-panel.js @@ -0,0 +1,242 @@ +import createSlidePanel from '../../scripts/slide-panel.js'; +import { login, verifyCode } from '../../scripts/auth-api.js'; +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; + +/** + * Builds the first step of the auth flow: an email input form. + * The form has no submit logic attached — that is wired in `showEmailStep` + * so it has access to the panel's closure state. + * + * @returns {HTMLElement} + */ +function buildEmailStep() { + const step = document.createElement('div'); + step.className = 'auth-step auth-step-email'; + step.innerHTML = ` +

Sign in

+

Enter your email to receive a one-time code.

+
+ + +

+
+

+ This site is protected by reCAPTCHA and the Google + Privacy Policy and + Terms of Service apply. +

+ `; + return step; +} + +/** + * Attaches keyboard and paste behaviour to the 6 individual digit input boxes + * inside a code step element: + * - Restricts each box to a single numeric digit + * - Auto-advances focus to the next box after a digit is entered + * - Moves focus back on Backspace when the current box is empty + * - Distributes a pasted string across the boxes and focuses the last filled box + * + * @param {HTMLElement} container - The code step element containing `.auth-code-box` inputs + */ +function wireCodeBoxes(container) { + const boxes = container.querySelectorAll('.auth-code-box'); + boxes.forEach((box, i) => { + box.addEventListener('input', () => { + box.value = box.value.replace(/[^0-9]/g, '').slice(0, 1); + if (box.value && i < boxes.length - 1) boxes[i + 1].focus(); + }); + box.addEventListener('keydown', (e) => { + if (e.key === 'Backspace' && !box.value && i > 0) { + boxes[i - 1].focus(); + boxes[i - 1].value = ''; + } + }); + box.addEventListener('paste', (e) => { + e.preventDefault(); + const paste = (e.clipboardData.getData('text') || '').replace(/\D/g, '').slice(0, 6); + paste.split('').forEach((ch, j) => { + if (boxes[j]) boxes[j].value = ch; + }); + const next = Math.min(paste.length, boxes.length - 1); + boxes[next].focus(); + }); + }); +} + +/** + * Reads the current value from all 6 digit boxes and concatenates them + * into a single string. May be shorter than 6 characters if not all boxes + * are filled — callers should validate length before submitting. + * + * @param {HTMLElement} container - The code step element containing `.auth-code-box` inputs + * @returns {string} The concatenated digit string (0–6 characters) + */ +function getCodeValue(container) { + return Array.from(container.querySelectorAll('.auth-code-box')) + .map((b) => b.value) + .join(''); +} + +/** + * Builds the second step of the auth flow: the 6-digit OTP entry form. + * Renders the user's email via `textContent` (not innerHTML) to prevent XSS. + * Code box interaction logic is attached via `wireCodeBoxes`. + * Submit logic is wired in `showCodeStep` so it has access to closure state. + * + * @param {string} email - The email address the OTP was sent to + * @returns {HTMLElement} + */ +function buildCodeStep(email) { + const step = document.createElement('div'); + step.className = 'auth-step auth-step-code'; + + const boxesHtml = Array.from({ length: 6 }, (_, i) => ``).join(''); + + step.innerHTML = ` +

Check your email

+

We sent a 6-digit code to

+
+
${boxesHtml}
+ +

+
+ + `; + step.querySelector('.auth-step-desc strong').textContent = email; + + wireCodeBoxes(step); + return step; +} + +/** + * Builds the final step shown after successful OTP verification. + * Renders the user's email via `textContent` (not innerHTML) to prevent XSS. + * The panel auto-closes 1.5s after this step is shown. + * + * @param {string} email - The authenticated user's email address + * @returns {HTMLElement} + */ +function buildSuccessStep(email) { + const step = document.createElement('div'); + step.className = 'auth-step auth-step-success'; + step.innerHTML = ` +
+

Welcome

+

+ `; + step.querySelector('.auth-step-desc').textContent = email; + return step; +} + +/** + * Creates the slide-out authentication panel and returns controls for it. + * + * The panel manages a two-step passwordless OTP flow: + * 1. Email step — user enters their email, triggering an OTP email + * 2. Code step — user enters the 6-digit code; on success the JWT is stored + * and the panel auto-closes after showing a brief success message + * + * OTP state (`hash` and `exp` returned by the login API) is held in a closure + * variable scoped to this panel instance, so it is never accessible outside + * and is cleared on successful verification. + * + * @returns {{ dialog: HTMLDialogElement, open: Function, close: Function, + * showEmailStep: Function }} + */ +export default function createAuthPanel() { + let otpState = null; + + const { + dialog, content, open, close, + } = createSlidePanel('auth-panel', 'Account', 'auth-panel'); + + /** + * Replaces the panel's current content with the given step element and + * moves focus to the first input within it. + * + * @param {HTMLElement} stepEl + */ + function showStep(stepEl) { + content.innerHTML = ''; + content.append(stepEl); + const firstInput = stepEl.querySelector('input'); + if (firstInput) setTimeout(() => firstInput.focus(), 100); + } + + /** + * Renders the email step and wires its submit handler. + * On submit, calls the login API and transitions to the code step. + * Exported so the header can reset the panel to this step on each open. + */ + function showEmailStep() { + const step = buildEmailStep(); + step.querySelector('form').addEventListener('submit', async (e) => { + e.preventDefault(); + const email = step.querySelector('[name="email"]').value.trim(); + const btn = step.querySelector('.auth-submit'); + const errEl = step.querySelector('.auth-error'); + errEl.textContent = ''; + btn.disabled = true; + btn.textContent = 'Sending code\u2026'; + + try { + const { locale: country, language: locale } = getLocaleAndLanguage(false, true); + otpState = await login(email, country, locale); + // eslint-disable-next-line no-use-before-define + showCodeStep(email); + } catch (err) { + errEl.textContent = err.message || 'Failed to send code'; + btn.disabled = false; + btn.textContent = 'Continue'; + } + }); + showStep(step); + } + + /** + * Renders the code entry step and wires its submit handler. + * Validates that all 6 digits are filled before calling the API to avoid + * consuming one of the 3 server-side attempts with an incomplete code. + * On success, clears OTP state, shows the success step, and auto-closes. + * + * @param {string} email - The email address the OTP was sent to + */ + function showCodeStep(email) { + const step = buildCodeStep(email); + step.querySelector('form').addEventListener('submit', async (e) => { + e.preventDefault(); + const code = getCodeValue(step); + const btn = step.querySelector('.auth-submit'); + const errEl = step.querySelector('.auth-error'); + errEl.textContent = ''; + + if (code.length < 6) { + errEl.textContent = 'Please enter all 6 digits.'; + return; + } + + btn.disabled = true; + btn.textContent = 'Verifying\u2026'; + + try { + await verifyCode(email, code, otpState.hash, otpState.exp); + otpState = null; + showStep(buildSuccessStep(email)); + setTimeout(close, 1500); + } catch (err) { + errEl.textContent = err.message || 'Invalid code'; + btn.disabled = false; + btn.textContent = 'Verify'; + } + }); + + step.querySelector('.auth-back').addEventListener('click', showEmailStep); + showStep(step); + } + + return { + dialog, open, close, showEmailStep, + }; +} diff --git a/blocks/header/header.css b/blocks/header/header.css index 0940d61b..a973f3c8 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -51,7 +51,7 @@ header .icon-wrapper .icon { text-align: center; } -header section { +header section#nav { display: grid; grid-template: 'cart title hamburger' var(--header-height) @@ -66,7 +66,7 @@ header section { box-shadow: 0 -2px 8px rgb(0 0 0 / 10%); } -header section[data-expanded='true'] { +header section#nav[data-expanded='true'] { height: 100dvh; max-height: 100dvh; padding: 0; @@ -77,7 +77,7 @@ header [aria-hidden='true'] { } @media (width >= 1000px) { - header section[data-expanded='true'] { + header section#nav[data-expanded='true'] { grid-template: 'title sections tools cart' var(--header-height) / auto 1fr max-content auto; gap: 0; @@ -359,6 +359,10 @@ header .nav-tools li a.icon-wrapper { flex-direction: column; } +header .nav-tools li a.icon-wrapper.is-logged-in { + font-weight: 600; +} + @media (width >= 1000px) { header .nav-tools ul { gap: var(--spacing-40); @@ -575,6 +579,45 @@ header .nav-cart [data-cart-items]::after { } } +/* minicart */ + +header .minicart { + width: min(400px, 100vw); + min-height: 100vh; + background: var(--layer-elevated); + padding: 2em; + box-shadow: -4px 4px 18px rgb(0 0 0 / 20%); + margin: 0 0 0 100%; + border: none; + transition: transform 0.3s ease-in-out; + overflow: hidden auto; +} + +header .minicart[aria-expanded="false"] { + transform: translateX(0); +} + +header .minicart[aria-expanded="true"] { + transform: translateX(-100%); +} + + +header .minicart > div:not(.slide-panel-header) { + margin-top: 1em; +} + +/* Account management drawer: wider than minicart; flat (no drawer drop shadow) */ +header .minicart.account-management { + width: min(90vw, 1200px); + box-shadow: none; +} + +header .minicart.account-management > div:not(.slide-panel-header) { + text-align: left; + width: 100%; + box-sizing: border-box; +} + /* hamburger */ header .nav-hamburger { grid-area: hamburger; @@ -702,3 +745,187 @@ header.header-commercial .nav-sections .submenu-wrapper ul > li.submenu-wrapper border-color: var(--color-gray-700); color: inherit; } + +/* slide panel (shared by minicart + auth panel) */ + +.slide-panel-header { + display: flex; + align-items: center; + margin-bottom: 1em; +} + +.slide-panel-header h2 { + margin: 0; + font-size: var(--font-size-600); + line-height: 1; +} + +.slide-panel-close { + padding: 0; + cursor: pointer; + font-size: 1.5rem; + line-height: 1; + margin-left: auto; + border: none; + background: none; + color: var(--color-text); +} + +/* auth panel */ + +header .auth-panel { + width: min(400px, 100vw); + min-height: 100vh; + background: var(--layer-elevated); + padding: 2em; + box-shadow: -4px 4px 18px rgb(0 0 0 / 20%); + margin: 0 0 0 100%; + border: none; + transition: transform 0.3s ease-in-out; + overflow: hidden auto; +} + +header .auth-panel[aria-expanded="false"] { + transform: translateX(0); +} + +header .auth-panel[aria-expanded="true"] { + transform: translateX(-100%); +} + +header .auth-panel::backdrop { + background: rgb(0 0 0 / 50%); +} + +.auth-step { + display: flex; + flex-direction: column; +} + +.auth-step h3 { + margin: 0 0 8px; + font-size: 1.125rem; + font-weight: 600; +} + +.auth-step-desc { + color: var(--color-gray-800, #333); + font-size: 0.875rem; + margin: 0 0 1.5em; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.auth-input { + width: 100%; + padding: 0.75rem 0.875rem; + border: 1px solid var(--color-gray-700); + border-radius: 4px; + font-family: inherit; + font-size: 0.875rem; + color: var(--color-text); + background: var(--color-white); + box-sizing: border-box; + transition: border-color 0.2s; +} + +.auth-input:focus { + outline: none; + border-color: var(--color-charcoal); +} + +.auth-code-boxes { + display: flex; + gap: 10px; + justify-content: center; + margin: 8px 0; +} + +.auth-code-box { + width: 48px; + height: 56px; + border: 1px solid var(--color-gray-700); + border-radius: 4px; + text-align: center; + font-size: 1.25rem; + font-weight: 600; + font-family: inherit; + color: var(--color-text); + background: var(--color-white); + box-sizing: border-box; + transition: border-color 0.2s, box-shadow 0.2s; + padding: 0; +} + +.auth-code-box:focus { + outline: none; + border-color: var(--color-charcoal); + box-shadow: 0 0 0 3px rgb(0 0 0 / 10%); +} + +.auth-submit { + width: 100%; + padding: 0.75rem; + margin-top: 16px; + background: var(--color-charcoal); + color: var(--color-white); + border: none; + border-radius: 4px; + font-family: inherit; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.auth-submit:hover { + background: var(--color-dark-charcoal); +} + +.auth-submit:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.auth-error { + color: var(--color-red); + font-size: 0.8125rem; + min-height: 1.2em; + margin: 0; +} + +.auth-back { + border: none; + background: none; + color: var(--color-link); + font-size: 0.8125rem; + cursor: pointer; + padding: 0; + margin-top: 20px; + text-align: left; +} + +.auth-back:hover { + text-decoration: underline; +} + +.auth-step-success { + text-align: center; + padding: 2em 0; +} + +.auth-success-icon { + width: 48px; + height: 48px; + margin: 0 auto 12px; + border-radius: 50%; + background: var(--color-asparagus); + color: var(--color-white); + font-size: 1.5rem; + line-height: 48px; + text-align: center; +} diff --git a/blocks/header/header.js b/blocks/header/header.js index d624d017..58d89dee 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -1,6 +1,22 @@ import { getMetadata, toClassName } from '../../scripts/aem.js'; -import { swapIcons, getCookies } from '../../scripts/scripts.js'; +import { swapIcons, getCookies, getOrderPath } from '../../scripts/scripts.js'; import { loadFragment } from '../fragment/fragment.js'; +import { AUTH_EVENT, getUser, isLoggedIn } from '../../scripts/auth-api.js'; +import { + addMagentoCacheListener, getLoggedInFromLocalStorage, getMagentoCache, +} from '../../scripts/storage/util.js'; +import { lockBodyScroll, unlockBodyScroll } from '../../scripts/body-scroll-lock.js'; + +/** True when OTP JWT or legacy Magento customer cache indicates signed in. */ +function isHeaderAuthSessionActive() { + try { + return isLoggedIn() || getLoggedInFromLocalStorage(); + } catch { + return false; + } +} + +const EDGE_CART_PATH = () => getOrderPath('cart'); // media query match that indicates desktop width const isDesktop = window.matchMedia('(width >= 1000px)'); @@ -464,16 +480,308 @@ export default async function decorate(block) { } } - const customer = cookies.vitamix_customer; - if (customer) { - const account = block.querySelector('.icon-account').parentElement; - account.lastChild.textContent = `${customer}'s Account`; + const accountLink = block.querySelector('.icon-account').parentElement; + const defaultAccountLabel = (accountLink?.lastChild?.textContent ?? '').trim(); + + /** + * Account link label: fragment default when signed out; cookie name, OTP email local-part, + * or Magento customer firstname when signed in (same "'s Account" pattern as legacy cookie). + */ + const resolveAccountLinkLabel = (signedIn) => { + const cookieName = getCookies().vitamix_customer; + if (!signedIn) { + if (cookieName) return `${cookieName}'s Account`; + return defaultAccountLabel; + } + if (cookieName) return `${cookieName}'s Account`; + if (isLoggedIn()) { + const email = getUser()?.email; + if (email) { + const local = email.split('@')[0]; + return `${local}'s Account`; + } + } + if (getLoggedInFromLocalStorage()) { + const first = getMagentoCache().customer?.firstname; + if (first) return `${first}'s Account`; + } + return defaultAccountLabel; + }; + + // --- Auth state display (all pages): OTP session and/or legacy Magento customer cache --- + + const syncHeaderAuthDisplay = () => { + const combined = isHeaderAuthSessionActive(); + accountLink.classList.toggle('is-logged-in', combined); + if (accountLink.lastChild) { + accountLink.lastChild.textContent = resolveAccountLinkLabel(combined); + } + }; + + document.addEventListener(AUTH_EVENT, syncHeaderAuthDisplay); + addMagentoCacheListener(syncHeaderAuthDisplay); + syncHeaderAuthDisplay(); + + // --- Auth panel + OTP account drawer (edge checkout mode only) --- + if (window.useEdgeCheckout) { + let authPanel = null; + const ensureAuthPanel = async () => { + if (authPanel) return authPanel; + const { default: createAuthPanel } = await import('./auth-panel.js'); + authPanel = createAuthPanel(); + block.append(authPanel.dialog); + return authPanel; + }; + + /** @type {HTMLDialogElement | null} */ + let accountMgmtDialog = null; + const ensureAccountManagementModal = async () => { + if (accountMgmtDialog) return accountMgmtDialog; + const base = window.hlx?.codeBasePath || ''; + const htmlResp = await fetch(`${base}/widgets/account/account.html`); + const html = await htmlResp.text(); + + accountMgmtDialog = document.createElement('dialog'); + accountMgmtDialog.id = 'account-management'; + accountMgmtDialog.className = 'minicart account-management'; + accountMgmtDialog.setAttribute('aria-expanded', 'false'); + block.append(accountMgmtDialog); + + const headerRow = document.createElement('div'); + headerRow.className = 'slide-panel-header'; + const accountTitle = document.createElement('h2'); + accountTitle.textContent = 'Account'; + const accountClose = document.createElement('button'); + accountClose.className = 'slide-panel-close'; + accountClose.textContent = '\u00D7'; + accountClose.setAttribute('aria-label', 'Close'); + headerRow.append(accountTitle, accountClose); + + const bodyHost = document.createElement('div'); + bodyHost.innerHTML = html.trim(); + const widgetRoot = bodyHost.firstElementChild; + if (!widgetRoot) { + accountMgmtDialog.remove(); + accountMgmtDialog = null; + throw new Error('account widget HTML missing root'); + } + accountMgmtDialog.append(headerRow, widgetRoot); + + accountClose.addEventListener('click', () => { + accountMgmtDialog.closeModal(); + }); + + accountMgmtDialog.addEventListener('click', (event) => { + const rect = accountMgmtDialog.getBoundingClientRect(); + const inside = rect.top <= event.clientY + && event.clientY <= rect.top + rect.height + && rect.left <= event.clientX + && event.clientX <= rect.left + rect.width; + if (!inside) accountMgmtDialog.closeModal(); + }); + + accountMgmtDialog.addEventListener('close', () => { + unlockBodyScroll(); + }); + + const open = () => { + lockBodyScroll(); + accountMgmtDialog.showModal(); + accountMgmtDialog.setAttribute('aria-expanded', 'true'); + }; + accountMgmtDialog.openModal = open; + + const close = () => { + accountMgmtDialog.setAttribute('aria-expanded', 'false'); + setTimeout(() => { + accountMgmtDialog.close(); + }, 300); + }; + accountMgmtDialog.closeModal = close; + + const { default: decorateAccount } = await import(`${base}/widgets/account/account.js`); + await decorateAccount(widgetRoot); + + return accountMgmtDialog; + }; + + accountLink.addEventListener('click', async (e) => { + if (!isHeaderAuthSessionActive()) { + e.preventDefault(); + const panel = await ensureAuthPanel(); + panel.showEmailStep(); + panel.open(); + return; + } + if (isLoggedIn()) { + e.preventDefault(); + try { + const dlg = await ensureAccountManagementModal(); + dlg.openModal(); + } catch { + /* fall through to default navigation */ + } + } + /* Legacy Magento session only: follow account href */ + }); + } + + const cartLink = block.querySelector('.icon-cart').parentElement; + + // Read item count from locale-specific localStorage key (same logic as Cart.STORAGE_KEY) + // to avoid stale domain-wide cookie when switching between locales (e.g. CA → US). + const getStoredCartCount = () => { + try { + const locale = window.location.pathname.split('/')[1] || 'default'; + const key = `cart:${locale === 'drafts' ? 'ca' : locale}`; + const stored = localStorage.getItem(key); + if (!stored) return 0; + const parsed = JSON.parse(stored); + return (parsed.items || []).reduce((acc, item) => acc + item.quantity, 0); + } catch { + return 0; + } + }; + + const initialCount = getStoredCartCount(); + if (initialCount > 0) { + cartLink.dataset.cartItems = initialCount; + cartLink.lastChild.textContent = `Cart (${initialCount})`; } - const cartItems = cookies.cart_items_count; - if (cartItems && +cartItems > 0) { - const cart = block.querySelector('.icon-cart').parentElement; - cart.dataset.cartItems = cartItems; - cart.lastChild.textContent = `Cart (${cartItems})`; + // update cart qty bubble on change + document.addEventListener('cart:change', (e) => { + const { itemCount } = e.detail.cart; + if (itemCount > 0) { + cartLink.dataset.cartItems = itemCount; + cartLink.lastChild.textContent = `Cart (${itemCount})`; + } else { + delete cartLink.dataset.cartItems; + cartLink.lastChild.textContent = 'Cart'; + } + }); + + // change to edge cart link + if (window.useEdgeCheckout) { + cartLink.href = EDGE_CART_PATH(); } + + const openOrRedirect = async (e) => { + // if already on cart page, do nothing + if (window.location.pathname.includes(EDGE_CART_PATH())) { + return; + } + + // if on mobile or not using edge checkout, redirect to cart page + if (!window.useEdgeCheckout || window.innerWidth < 900) { + return; + } + + // on desktop, open minicart popover + if (e) { + e.preventDefault(); + } + /** @type {HTMLDialogElement} */ + let minicart = document.querySelector('#minicart'); + + const scrollToItem = () => { + if (!e.detail?.item?.sku) return; + setTimeout(() => { + minicart.querySelector(`.cart-item-${e.detail.item.sku}`)?.scrollIntoView({ behavior: 'smooth' }); + }, 300); + }; + + if (minicart) { + minicart.openModal(); + scrollToItem(); + return; + } + + try { + const { default: decorateCart } = await import('../cart/cart.js'); + minicart = document.createElement('dialog'); + minicart.id = 'minicart'; + minicart.className = 'minicart'; + minicart.setAttribute('aria-expanded', false); + block.append(minicart); + + const headerRow = document.createElement('div'); + headerRow.className = 'slide-panel-header'; + + const cartTitle = document.createElement('h2'); + cartTitle.textContent = 'Cart'; + headerRow.append(cartTitle); + + const cartClose = document.createElement('button'); + cartClose.className = 'slide-panel-close'; + cartClose.textContent = '\u00D7'; + cartClose.setAttribute('aria-label', 'Close'); + cartClose.addEventListener('click', () => { + minicart.closeModal(); + }); + headerRow.append(cartClose); + minicart.append(headerRow); + + const cartBlock = document.createElement('div'); + minicart.append(cartBlock); + decorateCart(cartBlock, cartLink); + + minicart.addEventListener('click', (event) => { + const rect = minicart.getBoundingClientRect(); + const isInDialog = (rect.top <= event.clientY + && event.clientY <= rect.top + rect.height + && rect.left <= event.clientX + && event.clientX <= rect.left + rect.width); + if (!isInDialog) { + minicart.closeModal(); + } + }); + + minicart.addEventListener('close', () => { + unlockBodyScroll(); + }); + + const closeOnEmpty = (ev) => { + if (ev.detail.action === 'empty') { + minicart.closeModal(); + } + }; + + // open/close methods to account for transitions + const open = () => { + lockBodyScroll(); + minicart.showModal(); + minicart.setAttribute('aria-expanded', true); + document.addEventListener('cart:change', closeOnEmpty); + }; + minicart.openModal = open; + + const close = () => { + minicart.setAttribute('aria-expanded', false); + document.removeEventListener('cart:change', closeOnEmpty); + setTimeout(() => { + minicart.close(); + }, 300); + }; + minicart.closeModal = close; + + minicart.openModal(); + + // scroll item into view + scrollToItem(); + } catch (error) { + setTimeout(() => { + // redirect to cart page + window.location.href = EDGE_CART_PATH(); + }, 1000); + } + }; + + // handle cart click + cartLink.addEventListener('click', openOrRedirect); + + // and when adding to cart from PDP + document.addEventListener('pdp:add-to-cart', (ev) => { + openOrRedirect(ev); + }); } diff --git a/blocks/modal/modal.css b/blocks/modal/modal.css index 95a84982..f02d8516 100644 --- a/blocks/modal/modal.css +++ b/blocks/modal/modal.css @@ -86,6 +86,26 @@ body.modal-open { padding: 0; } +/* product registration find serial modal */ +.modal[data-modal-path*="product-registration-find-serial"] dialog { + max-width: 1000px; + max-height: 750px; + height: 90dvh; + width: 90dvw; +} + +.modal[data-modal-path*="product-registration-find-serial"] dialog .modal-content { + background-color: var(--color-gray-300); + display: flex; + max-height: +} + +@media (width >= 1000px) { + .modal[data-modal-path*="product-registration-find-serial"] dialog .modal-content { + max-height: calc(90dvh - 64px); + } +} + /* sign-up modal */ .modal[data-modal-path*="sign-up"] dialog { max-width: 1000px; diff --git a/blocks/order-complete/order-complete.css b/blocks/order-complete/order-complete.css new file mode 100644 index 00000000..e2b583a4 --- /dev/null +++ b/blocks/order-complete/order-complete.css @@ -0,0 +1,176 @@ +.section > .order-complete-wrapper { + margin-top: 32px; +} + +/* Order confirmation page */ +.order-result { + max-width: 900px; + margin: 0 auto; + padding: 32px 24px 64px; +} + +/* Header */ +.order-header { + text-align: center; + padding-bottom: 32px; + border-bottom: 1px solid #e0e0e0; + margin-bottom: 32px; +} + +.order-checkmark { + margin-bottom: 16px; +} + +.order-header h2 { + margin: 0 0 8px; + font-size: 28px; +} + +.order-id { + margin: 0 0 4px; + font-size: 14px; + color: #555; +} + +.order-email { + margin: 0; + font-size: 14px; + color: #555; +} + +/* Details grid: items+totals left, address right */ +.order-details { + display: grid; + grid-template-columns: 1fr; + gap: 32px; +} + +@media (width >= 768px) { + .order-details { + grid-template-columns: 3fr 2fr; + } +} + +.order-details h3 { + font-size: 16px; + margin: 0 0 16px; + padding-bottom: 8px; + border-bottom: 1px solid #e0e0e0; +} + +/* Items */ +.order-items { + margin-bottom: 24px; +} + +.order-item { + display: grid; + grid-template-columns: 64px 1fr auto; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid #f0f0f0; + align-items: start; +} + +.order-item:last-child { + border-bottom: none; +} + +.order-item-image { + width: 64px; + height: 64px; + display: flex; + align-items: center; + justify-content: center; + background: #f9f9f9; + border-radius: 4px; +} + +.order-item-image img { + max-width: 56px; + max-height: 56px; + object-fit: contain; +} + +.order-item-name { + margin: 0; + font-weight: 600; + font-size: 14px; + line-height: 1.4; +} + +.order-item-variant { + margin: 2px 0 0; + font-size: 13px; + color: #555; +} + +.order-item-qty { + margin: 2px 0 0; + font-size: 13px; + color: #555; +} + +.order-item-price { + font-weight: 600; + font-size: 14px; + text-align: right; + white-space: nowrap; +} + +/* Totals */ +.order-totals { + padding-top: 12px; + border-top: 1px solid #e0e0e0; +} + +.order-totals-row { + display: flex; + justify-content: space-between; + padding: 4px 0; + font-size: 14px; +} + +.order-totals-total { + padding-top: 12px; + margin-top: 8px; + border-top: 1px solid #e0e0e0; + font-size: 16px; +} + +/* Shipping address & contact */ +.order-shipping-address, +.order-contact { + margin-bottom: 24px; +} + +.order-shipping-address p, +.order-contact p { + margin: 0; + font-size: 14px; + line-height: 1.6; + color: #333; +} + +/* Actions */ +.order-actions { + text-align: center; + margin-top: 40px; + padding-top: 32px; + border-top: 1px solid #e0e0e0; +} + +/* Cancelled state */ +.order-cancelled { + text-align: center; + padding: 80px 24px; +} + +.order-cancelled h2 { + margin: 0 0 8px; +} + +.order-cancelled p { + margin: 0 0 24px; + color: #555; +} diff --git a/blocks/order-complete/order-complete.js b/blocks/order-complete/order-complete.js new file mode 100644 index 00000000..3cd4888e --- /dev/null +++ b/blocks/order-complete/order-complete.js @@ -0,0 +1,285 @@ +import { getConfig, formatPrice } from '../../scripts/commerce-config.js'; + +/** + * Order confirmation / cancellation page. + * + * Success: Chase / PayPal → API → redirect here with ?orderId=... + * Cancel: Chase / PayPal → API → redirect here with ?orderId=...&reason=... + * reason values: customer_cancelled | payment_failed | declined + * + * Order display data comes from sessionStorage (saved before the payment redirect). + */ +export default async function decorate(block) { + const config = getConfig(); + const currencyCode = typeof config.currency === 'function' ? config.currency(config.getLocale()) : config.currency; + const params = Object.fromEntries(new URLSearchParams(window.location.search).entries()); + + // cancelled or failed payment + if (params.reason) { + const container = document.createElement('div'); + container.className = 'order-result order-cancelled'; + + const heading = document.createElement('h2'); + heading.textContent = 'Payment not completed'; + container.appendChild(heading); + + const msg = document.createElement('p'); + msg.textContent = params.reason === 'customer_cancelled' + ? 'You cancelled the payment.' + : 'Payment could not be processed. Please try again.'; + container.appendChild(msg); + + const link = document.createElement('p'); + const returnLink = document.createElement('a'); + returnLink.href = getConfig().getOrderPath('checkout'); + returnLink.className = 'button emphasis'; + returnLink.textContent = 'Return to checkout'; + link.appendChild(returnLink); + container.appendChild(link); + + block.replaceChildren(container); + return; + } + + // success flow — read from sessionStorage + const orderId = params.orderId || params.id; + const email = params.email || sessionStorage.getItem('checkout_email'); + const orderData = sessionStorage.getItem('checkout_order'); + const previewData = sessionStorage.getItem('checkout_preview'); + const cartItemsData = sessionStorage.getItem('checkout_cart_items'); + + if (!orderId) { + window.location.href = '/'; + return; + } + + let order; + let preview; + let cartItems; + try { + order = orderData ? JSON.parse(orderData) : null; + preview = previewData ? JSON.parse(previewData) : null; + cartItems = cartItemsData ? JSON.parse(cartItemsData) : null; + } catch { + order = null; + preview = null; + cartItems = null; + } + + // clear checkout session data + sessionStorage.removeItem('checkout_email'); + sessionStorage.removeItem('checkout_order'); + sessionStorage.removeItem('checkout_preview'); + sessionStorage.removeItem('checkout_cart_items'); + + // clear the cart + try { + const { default: cart } = await import('../../scripts/cart.js'); + cart.clear(); + } catch { + // cart may not be available + } + + // build confirmation page + const container = document.createElement('div'); + container.className = 'order-result order-confirmed'; + + // header section + const headerSection = document.createElement('div'); + headerSection.className = 'order-header'; + + const checkmark = document.createElement('div'); + checkmark.className = 'order-checkmark'; + checkmark.innerHTML = ''; + headerSection.appendChild(checkmark); + + const heading = document.createElement('h2'); + heading.textContent = 'Thank you for your order!'; + headerSection.appendChild(heading); + + const orderIdEl = document.createElement('p'); + orderIdEl.className = 'order-id'; + const orderIdLabel = document.createElement('span'); + orderIdLabel.textContent = 'Order ID: '; + const orderIdValue = document.createElement('strong'); + orderIdValue.textContent = orderId; + orderIdEl.append(orderIdLabel, orderIdValue); + headerSection.appendChild(orderIdEl); + + if (email) { + const emailEl = document.createElement('p'); + emailEl.className = 'order-email'; + emailEl.textContent = `A confirmation will be sent to ${email}.`; + headerSection.appendChild(emailEl); + } + + container.appendChild(headerSection); + + // two-column layout: items + totals on left, shipping on right + const detailsGrid = document.createElement('div'); + detailsGrid.className = 'order-details'; + + // left column: items + totals + const leftCol = document.createElement('div'); + leftCol.className = 'order-details-left'; + + // items + const displayItems = cartItems || order?.items; + if (displayItems?.length) { + const itemsSection = document.createElement('div'); + itemsSection.className = 'order-items'; + + const itemsHeading = document.createElement('h3'); + itemsHeading.textContent = 'Items ordered'; + itemsSection.appendChild(itemsHeading); + + displayItems.forEach((item) => { + const itemEl = document.createElement('div'); + itemEl.className = 'order-item'; + + if (item.image) { + const imgWrapper = document.createElement('div'); + imgWrapper.className = 'order-item-image'; + const img = document.createElement('img'); + img.src = item.image; + img.alt = item.name || ''; + imgWrapper.appendChild(img); + itemEl.appendChild(imgWrapper); + } + + const details = document.createElement('div'); + details.className = 'order-item-details'; + + const name = document.createElement('p'); + name.className = 'order-item-name'; + name.textContent = item.name || item.sku; + details.appendChild(name); + + if (item.variant) { + const variant = document.createElement('p'); + variant.className = 'order-item-variant'; + variant.textContent = item.variant; + details.appendChild(variant); + } + + const qty = document.createElement('p'); + qty.className = 'order-item-qty'; + qty.textContent = `Qty: ${item.quantity}`; + details.appendChild(qty); + + itemEl.appendChild(details); + + const price = document.createElement('div'); + price.className = 'order-item-price'; + const unitPrice = parseFloat(item.price?.final || item.price) || 0; + price.textContent = formatPrice(unitPrice * item.quantity, currencyCode); + itemEl.appendChild(price); + + itemsSection.appendChild(itemEl); + }); + leftCol.appendChild(itemsSection); + } + + // totals + if (preview) { + const totalsSection = document.createElement('div'); + totalsSection.className = 'order-totals'; + + const rows = [ + ['Subtotal', formatPrice(parseFloat(preview.subtotal), currencyCode)], + ['Shipping', preview.shippingMethod?.rate === 0 ? 'Free' : formatPrice(parseFloat(preview.shippingMethod?.rate || 0), currencyCode)], + ['Tax', formatPrice(parseFloat(preview.taxAmount), currencyCode)], + ]; + + rows.forEach(([label, value]) => { + const row = document.createElement('div'); + row.className = 'order-totals-row'; + const labelEl = document.createElement('span'); + labelEl.textContent = label; + const valueEl = document.createElement('span'); + valueEl.textContent = value; + row.append(labelEl, valueEl); + totalsSection.appendChild(row); + }); + + const totalRow = document.createElement('div'); + totalRow.className = 'order-totals-row order-totals-total'; + const totalLabel = document.createElement('strong'); + totalLabel.textContent = 'Total'; + const totalValue = document.createElement('strong'); + totalValue.textContent = formatPrice(parseFloat(preview.total), currencyCode); + totalRow.append(totalLabel, totalValue); + totalsSection.appendChild(totalRow); + + leftCol.appendChild(totalsSection); + } + + detailsGrid.appendChild(leftCol); + + // right column: shipping address + const rightCol = document.createElement('div'); + rightCol.className = 'order-details-right'; + + if (order?.shipping) { + const addrSection = document.createElement('div'); + addrSection.className = 'order-shipping-address'; + + const addrHeading = document.createElement('h3'); + addrHeading.textContent = 'Shipping address'; + addrSection.appendChild(addrHeading); + + const addr = order.shipping; + const lines = [ + addr.name, + addr.company, + addr.address1, + addr.address2, + `${addr.city}, ${addr.state} ${addr.zip}`, + addr.country?.toUpperCase(), + ].filter(Boolean); + + lines.forEach((line) => { + const p = document.createElement('p'); + p.textContent = line; + addrSection.appendChild(p); + }); + + rightCol.appendChild(addrSection); + } + + if (order?.customer) { + const contactSection = document.createElement('div'); + contactSection.className = 'order-contact'; + + const contactHeading = document.createElement('h3'); + contactHeading.textContent = 'Contact'; + contactSection.appendChild(contactHeading); + + const contactEmail = document.createElement('p'); + contactEmail.textContent = order.customer.email; + contactSection.appendChild(contactEmail); + + if (order.customer.phone) { + const contactPhone = document.createElement('p'); + contactPhone.textContent = order.customer.phone; + contactSection.appendChild(contactPhone); + } + + rightCol.appendChild(contactSection); + } + + detailsGrid.appendChild(rightCol); + container.appendChild(detailsGrid); + + // continue shopping + const actions = document.createElement('div'); + actions.className = 'order-actions'; + const continueLink = document.createElement('a'); + continueLink.href = '/'; + continueLink.className = 'button emphasis'; + continueLink.textContent = 'Continue shopping'; + actions.appendChild(continueLink); + container.appendChild(actions); + + block.replaceChildren(container); +} diff --git a/blocks/order-summary/order-summary.css b/blocks/order-summary/order-summary.css new file mode 100644 index 00000000..8990dad9 --- /dev/null +++ b/blocks/order-summary/order-summary.css @@ -0,0 +1,278 @@ +/* Two-column layout: section containing order-summary becomes flex row. + Pull horizontal spacing onto the section itself and strip it from the + inner block-wrapper divs, which the site's global styles would otherwise + pad with --horizontal-spacing (up to 64px each side = 128px wasted per item). */ +.section:has(.order-summary-wrapper) { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 24px 48px; + padding-block: 40px; + padding-inline: var(--horizontal-spacing); +} + +.section:has(.order-summary-wrapper):has(.checkout-progress-wrapper) { + padding-block-start: 10px; +} + +.section:has(.order-summary-wrapper) > .default-content-wrapper h1 { + margin-top: 0; + padding-top: 0; +} + +.section:has(.order-summary-wrapper) > .default-content-wrapper { + flex-basis: 100%; + margin: 0; + padding: 0; +} + +.order-summary-wrapper { + width: var(--commerce-summary-width); + flex-shrink: 0; + position: sticky; + top: var(--commerce-sticky-top); +} + +.cart-wrapper, +.checkout-wrapper { + flex: 1; + min-width: 0; +} + +.order-summary-header-total { + display: none; + font-size: var(--commerce-font-size-base); + font-weight: var(--commerce-font-weight-bold); +} + +.order-summary-toggle { + display: none; + background: none; + border: none; + box-shadow: none; + outline: none; + cursor: pointer; + padding: 4px; + color: var(--commerce-color-text); + line-height: 0; + -webkit-tap-highlight-color: transparent; +} + +.order-summary-toggle:hover, +.order-summary-toggle:focus, +.order-summary-toggle:active { + background: none; + border: none; + box-shadow: none; + outline: none; +} + +.order-summary-toggle svg { + transition: transform 0.3s ease; +} + +.order-summary-toggle[aria-expanded="true"] svg { + transform: rotate(180deg); +} + +/* Reset the site's per-wrapper padding/margin so flex items size correctly */ +.section:has(.order-summary-wrapper) > .cart-wrapper, +.section:has(.order-summary-wrapper) > .checkout-wrapper, +.section:has(.order-summary-wrapper) > .order-summary-wrapper { + padding: 0; + margin: 0; +} + +/* Fallback for browsers / authors who add commerce-layout class to section */ +.section.commerce-layout { + display: flex; + align-items: flex-start; + gap: 48px; + padding-block: 40px; + padding-inline: var(--horizontal-spacing); +} + +.section.commerce-layout > .cart-wrapper, +.section.commerce-layout > .checkout-wrapper, +.section.commerce-layout > .order-summary-wrapper { + padding: 0; + margin: 0; +} + +@media (width <= 999px) { + .section:has(.order-summary-wrapper), + .section.commerce-layout { + flex-direction: column; + padding-inline: var(--horizontal-spacing); + } + + .checkout-progress-wrapper { + order: -1; + } + + .order-summary-wrapper { + width: 100%; + position: static; + order: -1; + } + + .order-summary-toggle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .order-summary-header-total { + display: block; + } + + .order-summary.is-collapsed .order-summary-header { + border-bottom-color: transparent; + } +} + +.order-summary { + background-color: var(--commerce-color-background); + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); + overflow: hidden; +} + +.order-summary-header { + display: flex; + align-items: center; + gap: 12px; + padding: 20px 24px 12px; + border-bottom: 1px solid var(--commerce-color-border); + transition: border-bottom-color 0.35s ease; +} + +.order-summary-header h3 { + flex: 1; + margin: 0; + font-size: var(--heading-size-l); +} + +.order-summary-content { + overflow: hidden; + padding: var(--commerce-card-padding); + transition: height 0.35s ease, padding-top 0.35s ease, padding-bottom 0.35s ease; +} + +/* Items list */ +.order-summary-items { + display: flex; + flex-direction: column; + margin-bottom: 24px; +} + +/* First item has no top padding (the card header provides the gap) */ +.order-summary-items .cart-item:first-child { + padding-top: 0; +} + +/* Discount */ +.order-summary-discount { + display: flex; + gap: var(--commerce-gap-sm); + margin-bottom: var(--commerce-card-padding); + padding-bottom: var(--commerce-card-padding); + border-bottom: 1px solid var(--commerce-color-border); +} + +.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); +} + +.discount-input:focus { + outline: none; + border-color: var(--commerce-input-border-focus); +} + +.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; +} + +.discount-apply:hover { + background: var(--commerce-color-text); +} + +/* Totals */ +.order-summary-totals { + display: flex; + flex-direction: column; + gap: var(--commerce-gap-sm); +} + +.order-summary-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: var(--commerce-font-size-sm); +} + +.order-summary-row.order-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); +} + +.order-summary-final-amount { + display: flex; + align-items: center; + gap: 8px; +} + +.order-summary-discount-amount { + color: var(--commerce-color-success, #2d7a3a); +} + +.order-summary-final-amount .currency { + font-size: var(--commerce-font-size-xs); + font-weight: 400; + color: var(--commerce-color-text-muted); +} + +.order-summary-final-amount strong { + font-size: var(--commerce-font-size-total); +} + +/* Loading state */ +.order-summary-content.loading { + position: relative; + opacity: 0.4; + pointer-events: none; +} + +.order-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: order-summary-spin 0.7s linear infinite; +} + +@keyframes order-summary-spin { + to { transform: rotate(360deg); } +} diff --git a/blocks/order-summary/order-summary.js b/blocks/order-summary/order-summary.js new file mode 100644 index 00000000..6f404c52 --- /dev/null +++ b/blocks/order-summary/order-summary.js @@ -0,0 +1,310 @@ +import { loadCSS } from '../../scripts/aem.js'; +import cart from '../../scripts/cart.js'; +import { getConfig, formatPrice } from '../../scripts/commerce-config.js'; +import buildCartItem from '../../scripts/commerce/cart-item.js'; +import { parsePreview } from '../../scripts/commerce-api.js'; + +function getStrings() { + return getConfig().getStrings(); +} + +function getCurrencyCode() { + const { currency, getLocale } = getConfig(); + return typeof currency === 'function' ? currency(getLocale()) : currency; +} + +function buildTemplate(s) { + return /* html */` +
+
+

${s.orderSummary}

+ + +
+
+
+
+ + +
+
+
+ ${s.subtotal} + +
+
+
+ ${s.shipping} + +
+
+ ${s.estimatedTaxes} + +
+
+ ${s.total} +
+ + +
+
+
+
+
+`; +} + +/** + * If the order-summary is authored in its own section (separate from the + * cart/checkout block), move its wrapper into the adjacent form section so + * the CSS :has(.order-summary-wrapper) two-column layout rule fires correctly. + * @param {HTMLDivElement} block + */ +function colocateWithForm(block) { + const wrapper = block.closest('.order-summary-wrapper'); + const mySection = wrapper?.closest('.section'); + if (!wrapper || !mySection) return; + + // Already co-located — nothing to do + if (mySection.querySelector('.checkout-wrapper, .cart-wrapper')) return; + + const main = mySection.closest('main') || document; + const target = [...main.querySelectorAll('.section')] + .find((s) => s !== mySection && s.querySelector('.checkout-wrapper, .cart-wrapper')); + if (!target) return; + + target.appendChild(wrapper); + // Remove the now-empty section to avoid stray margins + if (!mySection.children.length) mySection.remove(); +} + +function initMobileCollapse(block) { + const summary = block.querySelector('.order-summary'); + const toggle = block.querySelector('.order-summary-toggle'); + const content = block.querySelector('.order-summary-content'); + if (!summary || !toggle || !content) return; + + const mq = window.matchMedia('(max-width: 999px)'); + + // Measure natural padding once before any inline overrides. + const cs = window.getComputedStyle(content); + const naturalPT = parseFloat(cs.paddingTop); + const naturalPB = parseFloat(cs.paddingBottom); + + const setCollapsed = (instant) => { + if (instant) content.style.transition = 'none'; + content.style.height = '0'; + content.style.paddingTop = '0'; + content.style.paddingBottom = '0'; + if (instant) { + content.getBoundingClientRect(); + content.style.transition = ''; + } + }; + + const setExpanded = (instant) => { + if (instant) content.style.transition = 'none'; + content.style.height = ''; + content.style.paddingTop = ''; + content.style.paddingBottom = ''; + if (instant) { + content.getBoundingClientRect(); + content.style.transition = ''; + } + }; + + const expand = () => { + // paddingTop/Bottom are currently '0' inline; scrollHeight is content-only. + const targetHeight = content.scrollHeight + naturalPT + naturalPB; + content.style.height = `${targetHeight}px`; + content.style.paddingTop = `${naturalPT}px`; + content.style.paddingBottom = `${naturalPB}px`; + content.addEventListener('transitionend', (e) => { + if (e.propertyName === 'height') setExpanded(false); + }, { once: true }); + }; + + toggle.addEventListener('click', () => { + if (summary.classList.contains('is-collapsed')) { + summary.classList.remove('is-collapsed'); + toggle.setAttribute('aria-expanded', 'true'); + expand(); + } else { + // Lock current pixel values so the transition has an explicit start point. + content.style.height = `${content.scrollHeight}px`; + content.style.paddingTop = `${naturalPT}px`; + content.style.paddingBottom = `${naturalPB}px`; + content.getBoundingClientRect(); + summary.classList.add('is-collapsed'); + toggle.setAttribute('aria-expanded', 'false'); + content.getBoundingClientRect(); + setCollapsed(false); + } + }); + + mq.addEventListener('change', (e) => { + if (e.matches) { + summary.classList.add('is-collapsed'); + toggle.setAttribute('aria-expanded', 'false'); + setCollapsed(true); + } else { + summary.classList.remove('is-collapsed'); + toggle.setAttribute('aria-expanded', 'true'); + setExpanded(true); + } + }); + + summary.classList.add('is-collapsed'); + toggle.setAttribute('aria-expanded', 'false'); + setCollapsed(true); + if (!mq.matches) { + summary.classList.remove('is-collapsed'); + toggle.setAttribute('aria-expanded', 'true'); + setExpanded(true); + } +} + +/** + * @param {HTMLDivElement} block + */ +export default async function decorate(block) { + await loadCSS('/styles/commerce-tokens.css'); + const s = getStrings(); + colocateWithForm(block); + block.innerHTML = buildTemplate(s); + + const itemsList = block.querySelector('.order-summary-items'); + const subtotalEl = block.querySelector('.order-summary-subtotal'); + const shippingEl = block.querySelector('.order-summary-shipping'); + const taxesEl = block.querySelector('.order-summary-taxes'); + const grandTotalEl = block.querySelector('.order-summary-grand-total'); + const headerTotalEl = block.querySelector('.order-summary-header-total'); + const currencyEl = block.querySelector('.currency'); + const discountInput = block.querySelector('.discount-input'); + const discountApply = block.querySelector('.discount-apply'); + const discountsEl = block.querySelector('.order-summary-discounts'); + currencyEl.textContent = getCurrencyCode(); + + const showPendingDiscount = (code) => { + discountsEl.innerHTML = ''; + const row = document.createElement('div'); + row.className = 'order-summary-row order-summary-discount-item order-summary-discount-pending'; + const label = document.createElement('span'); + label.textContent = `${s.discount} (${code})`; + const amount = document.createElement('span'); + amount.className = 'order-summary-discount-amount'; + amount.textContent = '--'; + row.append(label, amount); + discountsEl.appendChild(row); + }; + + const savedCoupon = sessionStorage.getItem('checkout_coupon_code') || ''; + if (savedCoupon) { + discountInput.value = savedCoupon; + showPendingDiscount(savedCoupon); + } + + discountApply.addEventListener('click', () => { + const code = discountInput.value.trim(); + if (code) { + sessionStorage.setItem('checkout_coupon_code', code); + showPendingDiscount(code); + discountApply.textContent = s.applied; + discountApply.disabled = true; + setTimeout(() => { + discountApply.textContent = s.apply; + discountApply.disabled = false; + }, 2000); + } else { + sessionStorage.removeItem('checkout_coupon_code'); + discountsEl.innerHTML = ''; + } + document.dispatchEvent(new CustomEvent('checkout:coupon-apply')); + }); + + const renderItems = () => { + itemsList.innerHTML = ''; + + cart.items.forEach((item) => { + const itemEl = buildCartItem( + item, + { + onQtyChange: (sku, qty) => cart.updateItem(sku, qty), + onRemove: (sku) => cart.removeItem(sku), + currencyCode: getCurrencyCode(), + }, + { remove: s.remove, removeItem: s.removeItem }, + ); + itemsList.appendChild(itemEl); + }); + }; + + const updateTotals = () => { + const currency = getCurrencyCode(); + const subtotal = formatPrice(cart.subtotal, currency); + subtotalEl.textContent = subtotal; + shippingEl.textContent = '--'; + taxesEl.textContent = '--'; + grandTotalEl.textContent = subtotal; + headerTotalEl.textContent = subtotal; + }; + + renderItems(); + updateTotals(); + + const wrapper = block.closest('.order-summary-wrapper'); + const syncVisibility = () => { + wrapper?.toggleAttribute('hidden', cart.items.length === 0); + }; + + document.addEventListener('cart:change', () => { + renderItems(); + updateTotals(); + syncVisibility(); + }); + + const summaryContent = block.querySelector('.order-summary-content'); + document.addEventListener('checkout:preview-loading', () => { + summaryContent?.classList.add('loading'); + }); + + document.addEventListener('checkout:preview', (e) => { + summaryContent?.classList.remove('loading'); + const { preview } = e.detail || {}; + if (!preview) return; + + const { + subtotal, taxAmount, shippingRate, total, discounts, + } = parsePreview(preview, cart.subtotal); + + const currency = getCurrencyCode(); + subtotalEl.textContent = formatPrice(subtotal, currency); + + discountsEl.innerHTML = ''; + discounts.filter((d) => !d.freeShipping && d.amount > 0).forEach((d) => { + const row = document.createElement('div'); + row.className = 'order-summary-row order-summary-discount-item'; + const label = document.createElement('span'); + label.textContent = d.name || s.discount; + const amount = document.createElement('span'); + amount.className = 'order-summary-discount-amount'; + amount.textContent = `-${formatPrice(d.amount, currency)}`; + row.append(label, amount); + discountsEl.appendChild(row); + }); + + shippingEl.textContent = shippingRate === 0 + ? s.free + : formatPrice(parseFloat(shippingRate), currency); + taxesEl.textContent = formatPrice(taxAmount, currency); + grandTotalEl.textContent = formatPrice(total, currency); + headerTotalEl.textContent = formatPrice(total, currency); + }); + + syncVisibility(); + initMobileCollapse(block); +} diff --git a/blocks/pdp/add-to-cart.js b/blocks/pdp/add-to-cart.js index 039f7b4c..958ce2f1 100644 --- a/blocks/pdp/add-to-cart.js +++ b/blocks/pdp/add-to-cart.js @@ -191,16 +191,6 @@ export default function renderAddToCart(ph, block, parent) { addToCartButton.textContent = ph.adding || 'Adding...'; addToCartButton.setAttribute('aria-disabled', 'true'); - // import required modules for cart functionality - const { cartApi } = await import('../../scripts/minicart/api.js'); - const { updateMagentoCacheSections, getMagentoCache } = await import('../../scripts/storage/util.js'); - - // cCheck and update customer cache if needed - const currentCache = getMagentoCache(); - if (!currentCache?.customer) { - await updateMagentoCacheSections(['customer']); - } - // get selected quantity and product SKU const quantity = document.querySelector('.quantity-container select')?.value || 1; const sku = getMetadata('sku'); @@ -224,6 +214,41 @@ export default function renderAddToCart(ph, block, parent) { } try { + if (window.useEdgeCheckout) { + const cartApi = (await import('../../scripts/cart.js')).default; + + const { sku: variantSku, price, name } = selectedVariant; + const item = { + sku: variantSku ?? sku, + parentSku: variantSku ? sku : undefined, + quantity: parseInt(quantity, 10), + price, + name, + url: selectedVariant.url, + path: new URL(selectedVariant.url).pathname, + image: selectedVariant.image[0], + variant: window.selectedVariant?.options?.color || '', + selectedOptions, + }; + await cartApi.addItem(item); + + // reenable button + addToCartButton.textContent = 'Add to Cart'; + addToCartButton.removeAttribute('aria-disabled'); + document.dispatchEvent(new CustomEvent('pdp:add-to-cart', { detail: { item } })); + return; + } + + // import required modules for cart functionality + const { cartApi } = await import('../../scripts/minicart/api.js'); + const { updateMagentoCacheSections, getMagentoCache } = await import('../../scripts/storage/util.js'); + + // cCheck and update customer cache if needed + const currentCache = getMagentoCache(); + if (!currentCache?.customer) { + await updateMagentoCacheSections(['customer']); + } + // add product to cart with selected options and quantity await cartApi.addToCart(sku, selectedOptions, quantity); diff --git a/icons/paypal.svg b/icons/paypal.svg new file mode 100644 index 00000000..eb4d8cdc --- /dev/null +++ b/icons/paypal.svg @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/package.json b/package.json index 1faf161b..4c6c07d7 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "lint:js": "eslint .", "lint:css": "stylelint blocks/**/*.css styles/*.css widgets/**/*.css", "lint": "npm run lint:js && npm run lint:css", + "lint:fix": "npm run lint:js -- --fix && npm run lint:css -- --fix", "build:json": "npm run build:json:models && npm run build:json:definitions && npm run build:json:filters", "build:json:models": "merge-json-cli -i \"ue/models/component-models.json\" -o \"component-models.json\"", "build:json:definitions": "merge-json-cli -i \"ue/models/component-definition.json\" -o \"component-definition.json\"", diff --git a/scripts/auth-api.js b/scripts/auth-api.js new file mode 100644 index 00000000..e55c6548 --- /dev/null +++ b/scripts/auth-api.js @@ -0,0 +1,162 @@ +import { getConfig } from './commerce-config.js'; +import { mintRecaptchaToken, RECAPTCHA_ACTIONS, RECAPTCHA_HEADER } from './recaptcha.js'; + +/** sessionStorage key for the JWT issued after OTP verification */ +export const AUTH_TOKEN_KEY = 'auth_token'; + +/** localStorage key for the serialised user object { email, roles } */ +export const AUTH_USER_KEY = 'auth_user'; + +/** CustomEvent name dispatched on document when auth state changes */ +export const AUTH_EVENT = 'commerce:auth-state-changed'; + +/** + * Dispatches a `commerce:auth-state-changed` CustomEvent on document. + * @param {boolean} loggedIn - Whether the user is now authenticated + * @param {string|null} email - The user's email, or null when logged out + */ +function dispatchAuthEvent(loggedIn, email) { + document.dispatchEvent(new CustomEvent(AUTH_EVENT, { detail: { loggedIn, email } })); +} + +/** + * Initiates the OTP login flow for the given email address. + * Sends a POST to /auth/login, which triggers the API to email a 6-digit + * one-time code to the user. The returned `hash` and `exp` values must be + * passed back to `verifyCode` to complete authentication. + * + * @param {string} email - The user's email address + * @param {string} [country] - ISO 3166-1 alpha-2 country code (e.g. 'us', 'ca') + * @param {string} [locale] - BCP-47 locale (e.g. 'en-US', 'fr-CA') + * @returns {Promise<{ email: string, hash: string, exp: number }>} + * @throws {Error} If the request fails or the API returns an error + */ +export async function login(email, country, locale) { + const headers = { 'Content-Type': 'application/json' }; + const recaptchaToken = await mintRecaptchaToken(RECAPTCHA_ACTIONS.AUTH_LOGIN); + if (recaptchaToken) headers[RECAPTCHA_HEADER] = recaptchaToken; + const resp = await fetch(`${getConfig().apiOrigin}/auth/login`, { + method: 'POST', + headers, + body: JSON.stringify({ email, country, locale }), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || `Login failed: ${resp.status}`); + } + return resp.json(); +} + +/** + * Completes the OTP login flow by verifying the code the user received. + * Sends a POST to /auth/callback with the code and the `hash`/`exp` values + * returned by `login`. On success, stores the JWT in sessionStorage (cleared + * when the browser tab closes) and the user object in localStorage (persists + * across sessions for UI restoration), then dispatches an auth state event. + * + * @param {string} email - The user's email address + * @param {string} code - The 6-digit OTP code from the user's email + * @param {string} hash - The HMAC hash returned by `login` + * @param {number} exp - The expiry timestamp returned by `login` + * @returns {Promise<{ success: boolean, token: string, email: string, roles: string[] }>} + * @throws {Error} If the code is invalid, expired, or the request fails + */ +export async function verifyCode(email, code, hash, exp) { + const resp = await fetch(`${getConfig().apiOrigin}/auth/callback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, code, hash, exp, + }), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || `Verification failed: ${resp.status}`); + } + const data = await resp.json(); + if (data.token) { + sessionStorage.setItem(AUTH_TOKEN_KEY, data.token); + } + localStorage.setItem(AUTH_USER_KEY, JSON.stringify({ + email: data.email, + roles: data.roles, + })); + dispatchAuthEvent(true, data.email); + return data; +} + +/** + * Logs the current user out. + * Makes a best-effort POST to /auth/logout to invalidate the token server-side, + * then unconditionally clears the JWT from sessionStorage and the user object + * from localStorage regardless of whether the server call succeeds. + * Dispatches an auth state event on completion. + * + * @returns {Promise} + */ +export async function logout() { + const token = sessionStorage.getItem(AUTH_TOKEN_KEY); + try { + await fetch(`${getConfig().apiOrigin}/auth/logout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + } catch { /* best-effort */ } + sessionStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(AUTH_USER_KEY); + dispatchAuthEvent(false, null); +} + +/** + * Returns whether the current user has an active session. + * Checks for the presence of a JWT in sessionStorage. Note that this does not + * validate the token with the server — it only checks local storage state. + * + * @returns {boolean} + */ +export function isLoggedIn() { + return !!sessionStorage.getItem(AUTH_TOKEN_KEY); +} + +/** + * Returns the stored user object for the current session, or null if not + * logged in. The object is written to localStorage by `verifyCode` and + * persists across browser sessions for UI restoration purposes, but a missing + * JWT in sessionStorage means the user is effectively logged out. + * + * @returns {{ email: string, roles: string[] }|null} + */ +export function getUser() { + const raw = localStorage.getItem(AUTH_USER_KEY); + if (!raw) return null; + try { return JSON.parse(raw); } catch { return null; } +} + +/** + * Wrapper around `fetch` that attaches the current user's Bearer token to the + * Authorization header. If the server responds with 401, the local session is + * cleared and an auth state event is dispatched so the UI can react. + * + * @param {string} url - The URL to fetch + * @param {RequestInit} [options={}] - Standard fetch options (method, body, etc.) + * @returns {Promise} + * @throws {Error} If the user is not authenticated (no token in sessionStorage) + */ +export async function authFetch(url, options = {}) { + const token = sessionStorage.getItem(AUTH_TOKEN_KEY); + if (!token) throw new Error('Not authenticated'); + + const headers = new Headers(options.headers || {}); + headers.set('Authorization', `Bearer ${token}`); + + const resp = await fetch(url, { ...options, headers }); + if (resp.status === 401) { + sessionStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(AUTH_USER_KEY); + dispatchAuthEvent(false, null); + } + return resp; +} diff --git a/scripts/body-scroll-lock.js b/scripts/body-scroll-lock.js new file mode 100644 index 00000000..cdf3eb41 --- /dev/null +++ b/scripts/body-scroll-lock.js @@ -0,0 +1,37 @@ +let lockCount = 0; +/** @type {number} */ +let scrollY = 0; + +/** + * Freezes document scroll behind overlays (e.g. ``). Ref-counted so nested + * overlays do not restore scroll until the last one closes. + */ +export function lockBodyScroll() { + if (typeof document === 'undefined') return; + if (lockCount === 0) { + scrollY = window.scrollY; + const { body } = document; + body.style.overflow = 'hidden'; + body.style.position = 'fixed'; + body.style.top = `-${scrollY}px`; + body.style.left = '0'; + body.style.right = '0'; + body.style.width = '100%'; + } + lockCount += 1; +} + +/** Call when an overlay is fully closed; pairs with {@link lockBodyScroll}. */ +export function unlockBodyScroll() { + if (typeof document === 'undefined') return; + lockCount = Math.max(0, lockCount - 1); + if (lockCount !== 0) return; + const { body } = document; + body.style.removeProperty('overflow'); + body.style.removeProperty('position'); + body.style.removeProperty('top'); + body.style.removeProperty('left'); + body.style.removeProperty('right'); + body.style.removeProperty('width'); + window.scrollTo(0, scrollY); +} diff --git a/scripts/cart.js b/scripts/cart.js new file mode 100644 index 00000000..1ca27ab9 --- /dev/null +++ b/scripts/cart.js @@ -0,0 +1,200 @@ +import { getConfig } from './commerce-config.js'; + +const debounce = (func, wait) => { + let timeout; + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; +}; + +export class Cart { + static get STORAGE_KEY() { + return `cart:${getConfig().getLocale()}`; + } + + static STORAGE_VERSION = 1; + + /** @type {Record} */ + #items = {}; + + constructor() { + this.#restore(); + this.#persistNow(); + } + + #restore() { + const cart = localStorage.getItem(Cart.STORAGE_KEY); + if (cart) { + const parsed = JSON.parse(cart); + if (parsed.version !== Cart.STORAGE_VERSION) { + localStorage.removeItem(Cart.STORAGE_KEY); + return; + } + this.#items = parsed.items.reduce((acc, item) => { + acc[item.sku] = item; + return acc; + }, {}); + document.dispatchEvent( + new CustomEvent('cart:change', { + detail: { + cart: this, + action: 'restore', + }, + }), + ); + } + } + + #persistNow() { + const expires = new Date(Date.now() + 30 * 864e5).toUTCString(); + document.cookie = `cart_items_count=${this.itemCount}; expires=${expires}; path=/`; + localStorage.setItem(Cart.STORAGE_KEY, JSON.stringify(this)); + } + + #persist = debounce(() => { + this.#persistNow(); + }, 300); + + #maybeSendEmptyEvent() { + if (this.itemCount === 0) { + document.dispatchEvent( + new CustomEvent('cart:change', { + detail: { + cart: this, + action: 'empty', + }, + }), + ); + } + } + + get items() { + return Object.values(this.#items); + } + + get itemCount() { + return Object.values(this.#items).reduce( + (acc, item) => acc + item.quantity, + 0, + ); + } + + get subtotal() { + return Object.values(this.#items).reduce( + (acc, item) => acc + item.quantity * (typeof item.price === 'string' + ? parseFloat(item.price) + : item.price / 100), + 0, + ); + } + + clear() { + this.#items = {}; + this.#persistNow(); + document.dispatchEvent( + new CustomEvent('cart:change', { + detail: { + cart: this, + action: 'clear', + }, + }), + ); + this.#maybeSendEmptyEvent(); + } + + /** + * @param {CartItem} item + */ + addItem(item) { + const existing = this.#items[item.sku]; + if (existing) { + existing.quantity += item.quantity; + } else { + this.#items[item.sku] = item; + } + document.dispatchEvent( + new CustomEvent('cart:change', { + detail: { + cart: this, + item, + action: 'add', + }, + }), + ); + this.#persist(); + } + + /** + * @param {string} sku + * @param {number} quantity + */ + updateItem(sku, quantity) { + if (!this.#items[sku]) { + throw new Error(`Item with sku ${sku} not found`); + } + this.#items[sku].quantity = quantity; + document.dispatchEvent( + new CustomEvent('cart:change', { + detail: { + cart: this, + item: this.#items[sku], + action: 'update', + }, + }), + ); + this.#maybeSendEmptyEvent(); + this.#persist(); + } + + /** + * @param {string} sku + */ + removeItem(sku) { + const item = this.#items[sku]; + delete this.#items[sku]; + document.dispatchEvent( + new CustomEvent('cart:change', { + detail: { + cart: this, + item, + action: 'remove', + }, + }), + ); + this.#maybeSendEmptyEvent(); + this.#persist(); + } + + /** + * Returns cart items in API-compatible format. + * @returns {Array<{sku: string, path: string, quantity: number, name: string, + * price: {final: string, currency: string}, imageUrl?: string, productUrl?: string}>} + */ + getItemsForAPI() { + const { currency, getLocale } = getConfig(); + const currencyCode = typeof currency === 'function' ? currency(getLocale()) : currency; + return this.items.map((item) => ({ + sku: item.sku, + path: item.path || new URL(item.url, window.location.origin).pathname, + quantity: item.quantity, + name: item.name, + price: { + final: String(item.price), + currency: currencyCode, + }, + ...(item.image ? { imageUrl: item.image } : {}), + ...(item.url ? { productUrl: item.url } : {}), + })); + } + + toJSON() { + return { + version: Cart.STORAGE_VERSION, + items: Object.values(this.#items), + }; + } +} + +window.cart = new Cart(); +export default window.cart; diff --git a/scripts/commerce-api.js b/scripts/commerce-api.js new file mode 100644 index 00000000..64e44982 --- /dev/null +++ b/scripts/commerce-api.js @@ -0,0 +1,270 @@ +import { getConfig } from './commerce-config.js'; +import { AUTH_TOKEN_KEY } from './auth-api.js'; +import { mintRecaptchaToken, RECAPTCHA_ACTIONS, RECAPTCHA_HEADER } from './recaptcha.js'; +import { getLocaleAndLanguage } from './scripts.js'; + +/** + * Error thrown when the Commerce API returns a non-2xx response. + * Carries the HTTP status code and the parsed response body so callers + * can inspect the error detail without re-parsing the response. + */ +class CommerceApiError extends Error { + constructor(status, body, errorHeader) { + super(body?.message || `API error ${status}`); + this.status = status; + this.body = body; + this.errorHeader = errorHeader || null; + } +} + +/** + * Makes an authenticated POST request to the Commerce API. + * Attaches a Bearer token from sessionStorage if one is present, so orders + * created while a user is logged in are associated with their account. + * When `recaptchaAction` is provided AND no Bearer token is available, + * mints a reCAPTCHA Enterprise token and attaches it as `X-Recaptcha-Token`. + * Bypass if authenticated. + * + * @param {string} path - API path relative to ORDERS_API_ORIGIN (e.g. '/orders') + * @param {Object} body - Request payload, serialised as JSON + * @param {string} [recaptchaAction] - One of RECAPTCHA_ACTIONS, or undefined to skip + * @returns {Promise} Parsed JSON response body + * @throws {CommerceApiError} If the response status is not 2xx + */ +async function post(path, body, recaptchaAction) { + const headers = { 'Content-Type': 'application/json' }; + const token = sessionStorage.getItem(AUTH_TOKEN_KEY); + if (token) headers.Authorization = `Bearer ${token}`; + + if (recaptchaAction && !token) { + const recaptchaToken = await mintRecaptchaToken(recaptchaAction); + if (recaptchaToken) headers[RECAPTCHA_HEADER] = recaptchaToken; + } + + const resp = await fetch(`${getConfig().apiOrigin}${path}`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + const data = await resp.json(); + if (!resp.ok) { + throw new CommerceApiError(resp.status, data, resp.headers.get('x-error')); + } + return data; +} + +/** + * Fetches available shipping rates for a given destination and cart contents. + * Used to populate the shipping method selector on the checkout form before + * the order is previewed or placed. + * + * @param {string} country - ISO 3166-1 alpha-2 country code (e.g. 'ca', 'us') + * @param {string} state - State or province code (e.g. 'QC', 'CA') + * @param {Array<{sku: string, path: string, quantity: number, price: Object}>} items + * Cart items in API format + * @returns {Promise<{ rates: Array<{ id: string, label: string, rate: number }> }>} + * @throws {CommerceApiError} + */ +export async function estimateShipping(country, state, items) { + return post('/estimate/shipping', { + country, + shipping: { country, state }, + items, + }); +} + +/** + * Fetches fully computed totals for all shipping methods at an address during Apple Pay + * express checkout. Called inside `onshippingcontactselected` where only a partial address + * (city, state, country, zip — no street) is available. + * + * @param {string} country - ISO 3166-1 alpha-2 country code (e.g. 'us', 'ca') + * @param {string} state - State or province code (e.g. 'QC', 'CA') + * @param {string} zip - Postal/ZIP code + * @param {Array<{sku: string, path: string, quantity: number, price: Object}>} items + * @returns {Promise<{ subtotal: number, shippingMethods: Array }>} + * @throws {CommerceApiError} + */ +export async function estimateExpressCheckout(country, state, zip, items) { + return post('/estimate/order', { + country, + shipping: { country, state, zip }, + items, + }); +} + +/** + * Previews an order to lock in tax, discounts, and shipping cost, and + * obtain an `estimateToken` that must be included when placing the order. + * Call this after the user selects a shipping method and before payment. + * + * @param {Object} orderBody - Full order body including customer, shipping, items, + * and shippingMethod.id + * @returns {Promise<{ subtotal: number, taxAmount: number, taxRate: number, + * shippingMethod: Object, discounts: Array, total: number, estimateToken: string }>} + * @throws {CommerceApiError} + */ +export async function previewOrder(orderBody) { + return post('/orders/preview', orderBody, RECAPTCHA_ACTIONS.ORDERS_PREVIEW); +} + +/** + * Places an order. The request body must include the `estimateToken` returned + * by `previewOrder` to confirm the quoted totals. If the user is logged in, + * the order is automatically associated with their account via the Bearer token. + * + * @param {Object} orderBody - Full order body including customer, shipping, items, + * shippingMethod, and estimateToken + * @returns {Promise<{ order: Object }>} + * @throws {CommerceApiError} + */ +export async function createOrder(orderBody) { + return post('/orders', orderBody, RECAPTCHA_ACTIONS.ORDERS_CREATE); +} + +/** + * Initiates a payment for a placed order. + * The idempotency key ensures that retrying the same request (e.g. after a + * network failure) does not result in a duplicate charge. + * + * @param {string} orderId - The order ID returned by `createOrder` + * @param {string} idempotencyKey - A unique key for this payment attempt (e.g. a UUID) + * @param {string} [fraudToken] - Optional fraud provider session token + * @param {string} [provider='chase'] - Payment provider ('chase', 'paypal', 'chase-wallet') + * @param {string} [paymentMethod='card'] - Payment method ('card', 'paypal', 'apple-pay') + * @param {Object} [extra={}] - Additional provider-specific fields (e.g. token, billingContact) + * @returns {Promise<{ orderId: string, paymentAttemptId: string, status: string, + * action?: string, redirectUrl?: string, transactionId?: string, reason?: string }>} + * @throws {CommerceApiError} + */ +export async function initiatePayment(orderId, idempotencyKey, fraudToken, provider = 'chase', paymentMethod = 'card', extra = {}) { + return post(`/orders/${orderId}/payments`, { + provider, + paymentMethod, + idempotencyKey, + ...(fraudToken ? { fraudToken } : {}), + ...extra, + }); +} + +/** + * Makes an authenticated HTTP request to the Commerce API. + * Attaches a Bearer token from sessionStorage when present. + * No reCAPTCHA support — PayPal session endpoints are Cloudflare-rate-limited. + * + * @param {string} path - API path relative to config.apiOrigin + * @param {object|null} body - Request payload; omitted when null + * @param {string} method - HTTP verb (GET, POST, PATCH, etc.) + * @returns {Promise} Parsed JSON response body + * @throws {CommerceApiError} If the response status is not 2xx + */ +async function request(path, body, method) { + const headers = {}; + if (body !== null) headers['Content-Type'] = 'application/json'; + const token = sessionStorage.getItem(AUTH_TOKEN_KEY); + if (token) headers.Authorization = `Bearer ${token}`; + + const resp = await fetch(`${getConfig().apiOrigin}${path}`, { + method, + headers, + ...(body !== null ? { body: JSON.stringify(body) } : {}), + }); + const data = await resp.json(); + if (!resp.ok) { + throw new CommerceApiError(resp.status, data, resp.headers.get('x-error')); + } + return data; +} + +/** + * Creates a PayPal order via the commerce API session endpoint. + * Called in the PayPal SDK's createOrder callback before a shipping address is known. + * + * @param {Array} items - Cart line items in API format + * @param {object} config - Commerce config with getLocale(), getLanguage(), currency + * @returns {Promise<{paypalOrderId: string}>} + * @throws {CommerceApiError} + */ +export async function createPayPalSession(items, config) { + const currency = typeof config.currency === 'function' + ? config.currency(config.getLocale()) + : config.currency; + const { locale: country, language: locale } = getLocaleAndLanguage(false, true); + return request('/payments/paypal/session', { + items, + currency, + country, + locale, + }, 'POST'); +} + +/** + * Patches a PayPal order with updated shipping address or selected option data. + * Called in onShippingAddressChange and onShippingOptionsChange SDK callbacks. + * + * @param {string} paypalOrderId - PayPal order ID from createPayPalSession + * @param {object} data - Patch payload ({ type, address?, items?, + * selectedOptionId?, total?, taxAmount?, shippingRate? }) + * @returns {Promise} API response + * @throws {CommerceApiError} + */ +export async function patchPayPalSession(paypalOrderId, data) { + return request(`/payments/paypal/session/${paypalOrderId}`, data, 'PATCH'); +} + +/** + * Retrieves the normalized payer and shipping details from a completed PayPal order. + * Called in onApprove after the buyer has authenticated. + * + * @param {string} paypalOrderId - PayPal order ID from createPayPalSession + * @param {string} [country] - Store country code for provider config resolution + * @param {string} [locale] - Store locale for provider config resolution + * @returns {Promise<{payer: object, shippingAddress: object, + * selectedOptionId: string|undefined}>} + * @throws {CommerceApiError} + */ +export async function getPayPalSession(paypalOrderId, country, locale) { + const params = new URLSearchParams(); + if (country) params.set('country', country); + if (locale) params.set('locale', locale); + const qs = params.size ? `?${params}` : ''; + return request(`/payments/paypal/session/${paypalOrderId}${qs}`, null, 'GET'); +} + +/** + * Requests an Apple Pay merchant session from the Commerce API. + * Called inside `ApplePaySession.onvalidatemerchant` — the API makes a mutual-TLS + * POST to Apple's gateway and returns the opaque merchant session object. + * + * @param {string} validationUrl - The URL provided by Apple in the onvalidatemerchant event + * @param {string} [country] - ISO country code (e.g. 'us', 'ca') for provider config resolution + * @param {string} [locale] - BCP-47 locale string (e.g. 'en-US') for provider config resolution + * @returns {Promise<{ merchantSession: Object }>} + * @throws {CommerceApiError} + */ +export async function validateApplePayMerchant(validationUrl, country, locale) { + return post('/payments/apple-pay/validate-merchant', { + validationUrl, + ...(country ? { country } : {}), + ...(locale ? { locale } : {}), + }); +} + +/** + * Normalises a checkout:preview payload into scalar price values. + * @param {Object} preview + * @param {number} cartSubtotal - fallback when preview.subtotal is absent + * @returns {{ subtotal: number, taxAmount: number, shippingRate: number, total: number }} + */ +export function parsePreview(preview, cartSubtotal) { + const subtotal = parseFloat(preview.subtotal) || cartSubtotal; + const taxAmount = parseFloat(preview.taxAmount) || 0; + const rawRate = preview.shippingMethod?.rate ?? 0; + const discounts = preview.discounts ?? []; + const hasFreeShipping = discounts.some((d) => d.freeShipping); + const shippingRate = hasFreeShipping ? 0 : rawRate; + const total = parseFloat(preview.total) || (subtotal + taxAmount + shippingRate); + return { + subtotal, taxAmount, shippingRate, total, discounts, + }; +} diff --git a/scripts/commerce-config.js b/scripts/commerce-config.js new file mode 100644 index 00000000..beaa706c --- /dev/null +++ b/scripts/commerce-config.js @@ -0,0 +1,98 @@ +const { hostname } = window.location; + +function resolveApiOrigin(org, site) { + const isProduction = !hostname.endsWith('.aem.page') + && !hostname.endsWith('.aem.live') + && !hostname.endsWith('.aem.network') + && hostname !== 'localhost' + && !hostname.startsWith('127.') + && !hostname.startsWith('integration.'); + const base = isProduction + ? 'https://api.adobecommerce.live' + : 'https://api-stage.adobecommerce.live'; + return `${base}/${org}/sites/${site}`; +} + +const defaults = { + org: undefined, + site: undefined, + getLocale: () => window.location.pathname.split('/').filter(Boolean)[0] || 'us', + getLanguage: () => window.location.pathname.split('/').filter(Boolean)[1] || 'en_us', + getOrderPath(page) { + return `/${this.getLocale()}/${this.getLanguage()}/order/${page}`; + }, + getAccountPath(page) { + const base = `/${this.getLocale()}/${this.getLanguage()}/account`; + return page ? `${base}/${page}` : base; + }, + currency: (locale) => (locale === 'ca' ? 'CAD' : 'USD'), + cardProvider: 'chase', + getFraudToken() { + try { return sessionStorage.getItem('forter_token') || undefined; } catch { return undefined; } + }, + strings: { + 'en-us': { + stepCart: 'Cart', + stepCheckout: 'Checkout', + stepConfirmation: 'Confirmation', + checkoutStepsLabel: 'Checkout steps', + remove: 'Remove', + removeItem: 'Remove item', + continueShopping: 'Continue shopping', + apply: 'Apply', + applied: 'Applied', + discount: 'Discount', + subtotal: 'Subtotal', + shipping: 'Shipping', + estimatedTaxes: 'Estimated taxes', + total: 'Total', + free: 'Free', + or: 'or', + orderSummary: 'Order summary', + discountPlaceholder: 'Discount code or gift card', + }, + 'fr-ca': { + stepCart: 'Panier', + stepCheckout: 'Caisse', + stepConfirmation: 'Confirmation', + checkoutStepsLabel: 'Étapes de la caisse', + remove: 'Retirer', + removeItem: "Retirer l'article", + continueShopping: 'Continuer vos achats', + apply: 'Appliquer', + applied: 'Appliqué', + discount: 'Rabais', + subtotal: 'Sous-total', + shipping: 'Livraison', + estimatedTaxes: 'Taxes estimées', + total: 'Total', + free: 'Gratuit', + or: 'ou', + orderSummary: 'Récapitulatif de commande', + discountPlaceholder: 'Code de réduction ou carte-cadeau', + }, + }, + getStrings() { + const key = this.getLanguage().toLowerCase().replace('_', '-'); + return this.strings[key] || this.strings['en-us']; + }, +}; + +/** + * Formats a numeric amount as a localized currency string. + * @param {number} amount + * @param {string} currencyCode - ISO 4217 code, e.g. 'USD', 'CAD' + * @returns {string} + */ +export function formatPrice(amount, currencyCode) { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: currencyCode || 'USD', + }).format(amount); +} + +export function getConfig() { + const merged = { ...defaults, ...(window.CommerceConfig || {}) }; + merged.apiOrigin = resolveApiOrigin(merged.org, merged.site); + return merged; +} diff --git a/scripts/commerce/cart-item.css b/scripts/commerce/cart-item.css new file mode 100644 index 00000000..d28e0da0 --- /dev/null +++ b/scripts/commerce/cart-item.css @@ -0,0 +1,158 @@ +/* Shared cart item component — used by cart, order-summary, and minicart */ + +.cart-item { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 12px; + align-items: flex-start; + padding: 16px 0; + border-bottom: 1px solid var(--commerce-color-border, #e5e7eb); +} + +.cart-item:last-child { + border-bottom: none; +} + +/* Image */ +.cart-item-image { + width: var(--commerce-item-image-size); + height: var(--commerce-item-image-size); + border: 1px solid var(--commerce-color-border, #e5e7eb); + border-radius: var(--commerce-radius-badge, 8px); + overflow: hidden; + background: var(--commerce-color-background); + flex-shrink: 0; +} + +.cart-item-image img, +.cart-item-image picture img { + width: 100%; + height: 100%; + object-fit: contain; + padding: 4px; + box-sizing: border-box; +} + +/* Details column */ +.cart-item-details { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.cart-item-name { + margin: 0 0 2px; + font-size: var(--commerce-font-size-sm, 14px); + font-weight: 600; + line-height: 1.4; +} + +.cart-item-name a { + text-decoration: none; + color: var(--commerce-color-text); +} + +.cart-item-name a:hover { + text-decoration: underline; +} + +.cart-item-variant { + margin: 0; + font-size: var(--commerce-font-size-xs); + color: var(--commerce-color-text-muted, #6b7280); +} + +/* Actions row */ +.cart-item-actions { + display: flex; + align-items: center; + margin-top: 10px; +} + +/* Qty control */ +.cart-item-qty-control { + display: flex; + align-items: center; + border: 1px solid var(--commerce-color-border, #e5e7eb); + border-radius: 6px; + overflow: hidden; +} + +.cart-item-qty-control button { + width: 28px; + height: 28px; + border: none; + border-radius: 0; + background: var(--commerce-color-background); + font-size: 14px; + font-weight: 600; + cursor: pointer; + color: var(--commerce-color-text); + display: flex; + align-items: center; + justify-content: center; +} + +.cart-item-qty-control button:hover { + background: var(--commerce-color-surface); +} + +.cart-item-qty-control .qty-input { + width: 32px; + height: 28px; + border: none; + border-left: 1px solid var(--commerce-color-border, #e5e7eb); + border-right: 1px solid var(--commerce-color-border, #e5e7eb); + text-align: center; + font-size: var(--commerce-font-size-xs); + font-weight: 600; + padding: 0; + background: transparent; + appearance: textfield; +} + +.cart-item-qty-control .qty-input::-webkit-inner-spin-button, +.cart-item-qty-control .qty-input::-webkit-outer-spin-button { + appearance: none; + margin: 0; +} + +/* Right column: price + remove */ +.cart-item-right { + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: space-between; + align-self: stretch; + gap: 4px; +} + +.cart-item-price { + font-size: var(--commerce-font-size-sm, 14px); + font-weight: 600; + white-space: nowrap; +} + +.cart-item-per-unit { + font-size: 12px; + color: var(--commerce-color-text-muted, #6b7280); + white-space: nowrap; +} + +.cart-item-remove { + background: none; + border: none; + cursor: pointer; + color: var(--commerce-color-text-muted, #6b7280); + padding: 0; + display: flex; + align-items: center; + gap: 4px; + font-size: var(--commerce-font-size-xs); +} + +.cart-item-remove:hover { + color: var(--commerce-color-text); +} diff --git a/scripts/commerce/cart-item.js b/scripts/commerce/cart-item.js new file mode 100644 index 00000000..2817fc49 --- /dev/null +++ b/scripts/commerce/cart-item.js @@ -0,0 +1,108 @@ +import { loadCSS, createOptimizedPicture } from '../aem.js'; +import { formatPrice } from '../commerce-config.js'; + +loadCSS('/scripts/commerce/cart-item.css'); + +const TRASH_ICON = /* html */``; + +/** + * @param {Object} item Cart item — sku, name, image, price, quantity, url?, variant? + * @param {{ onQtyChange: Function, onRemove: Function, currencyCode?: string }} callbacks + * @param {{ remove?: string, removeItem?: string }} [strings] + * @returns {HTMLElement} + */ +export default function buildCartItem(item, { onQtyChange, onRemove, currencyCode = 'USD' }, strings = {}) { + const { remove = 'Remove', removeItem = 'Remove item' } = strings; + + const el = document.createElement('div'); + el.className = `cart-item cart-item-${item.sku}`; + el.innerHTML = /* html */` +
+
+

+
+
+ + + +
+
+
+
+
+
+ +
`; + + // Image + el.querySelector('.cart-item-image').appendChild( + createOptimizedPicture(item.image, item.name || '', true), + ); + + // Name — link if url provided + const nameEl = el.querySelector('.cart-item-name'); + if (item.url) { + const a = document.createElement('a'); + a.textContent = item.name; + try { + a.href = new URL(item.url).pathname; + } catch { + a.href = item.url; + } + nameEl.appendChild(a); + } else { + nameEl.textContent = item.name; + } + + // Variant + if (item.variant) { + const variantEl = document.createElement('p'); + variantEl.className = 'cart-item-variant'; + variantEl.textContent = item.variant; + el.querySelector('.cart-item-actions').before(variantEl); + } + + // Price + const price = typeof item.price === 'string' ? parseFloat(item.price) : item.price; + const priceEl = el.querySelector('.cart-item-price'); + const perUnitEl = el.querySelector('.cart-item-per-unit'); + + const updatePrice = (qty) => { + priceEl.textContent = formatPrice(price * qty, currencyCode); + perUnitEl.textContent = qty > 1 ? `${formatPrice(price, currencyCode)} each` : ''; + }; + updatePrice(item.quantity); + + // Qty + const qtyInput = el.querySelector('.qty-input'); + qtyInput.value = item.quantity; + + const handleQtyChange = (newQty) => { + if (newQty < 1) { + onRemove(item.sku); + el.remove(); + return; + } + qtyInput.value = newQty; + updatePrice(newQty); + onQtyChange(item.sku, newQty); + }; + + el.querySelector('.qty-dec').addEventListener('click', () => handleQtyChange(+qtyInput.value - 1)); + el.querySelector('.qty-inc').addEventListener('click', () => handleQtyChange(+qtyInput.value + 1)); + qtyInput.addEventListener('change', (e) => handleQtyChange(+e.target.value)); + + el.querySelector('.cart-item-remove').addEventListener('click', (ev) => { + ev.preventDefault(); + onRemove(item.sku); + el.remove(); + }); + + return el; +} diff --git a/scripts/forter-snippet.js b/scripts/forter-snippet.js new file mode 100644 index 00000000..9a474e8b --- /dev/null +++ b/scripts/forter-snippet.js @@ -0,0 +1,22 @@ +/** + * Forter anti-fraud bootstrap (vendor snippet). Injected at end of body via loadDelayed. + * @see https://www.forter.com/ + */ +const FORTER_SITE_ID = 'f2bc58b1eab5'; + +const FORTER_IIFE = `(function () { + var merchantConfig = { + csp: false, + }; + + var siteId = '${FORTER_SITE_ID}'; +function t(t,e){for(var n=t.split(""),r=0;r=u&&d>0;){var w=s.slice(-u).join(".");document.cookie=escape(t)+"="+escape(e)+c+"; path="+f+"; domain="+w;var l=D.read(t);null!=l&&l==e||(w="."+w,document.cookie=escape(t)+"="+escape(e)+c+"; path="+f+"; domain="+w),h=-1===document.cookie.indexOf(t+"="+e),u++,d--}},read:function(t){var e=null;try{for(var n=escape(t)+"=",r=document.cookie.split(";"),o=32,i=0;i>8&255]+n[t>>16&255]+n[t>>24&255]+a+n[255&e]+n[e>>8&255]+a+n[e>>16&15|64]+n[e>>24&255]+a+n[63&r|128]+n[r>>8&255]+a+n[r>>16&255]+n[r>>24&255]+n[255&o]+n[o>>8&255]+n[o>>16&255]+n[o>>24&255]},i=function(){if(window.Uint32Array&&window.crypto&&window.crypto.getRandomValues){var t=new window.Uint32Array(4);return window.crypto.getRandomValues(t),{d0:t[0],d1:t[1],d2:t[2],d3:t[3]}}return{d0:4294967296*Math.random()>>>0,d1:4294967296*Math.random()>>>0,d2:4294967296*Math.random()>>>0,d3:4294967296*Math.random()>>>0}},a=function(){var t="",e=function(t,e){for(var n="",r=t;r>0;--r)n+=e.charAt(1e3*Math.random()%e.length);return n};return t+=e(2,"0123456789"),t+=e(1,"123456789"),t+=e(8,"0123456789")};return t.safeGenerateNoDash=function(){try{var t=i();return o(t.d0,t.d1,t.d2,t.d3,!1)}catch(t){try{return e+a()}catch(t){}}},t.isValidNumericalToken=function(t){return t&&t.toString().length<=11&&t.length>=9&&parseInt(t,10).toString().length<=11&&parseInt(t,10).toString().length>=9},t.isValidUUIDToken=function(t){return t&&32===t.toString().length&&/^[a-z0-9]+$/.test(t)},t.isValidFGUToken=function(t){return 0==t.indexOf(e)&&t.length>=12},t}(),z={uDF:"UDF",dUAL:"dUAL",uAS:"UAS",uDS:"UDS",uDAD:"UDAD",uFP:"UFP",mLd:"1",eTlu:"2",eUoe:"3",uS:"4",uF:"9",tmos:["T5","T10","T15"],tmosSecs:[5,10,15],bIR:"43",uB:"u",uBr:"b",cP:"c",nIL:"i",s:"s"};try{var $=X();try{$.id&&(Q.isValidNumericalToken($.id)||Q.isValidUUIDToken($.id)||Q.isValidFGUToken($.id))?window.ftr__ncd=!1:($.id=Q.safeGenerateNoDash(),window.ftr__ncd=!0),$.ts=window.ftr__startScriptLoad,H($),window.ftr__snp_cwc=!!D.read(A),window.ftr__snp_cwc||(I=V);for(var J="for"+"ter"+".co"+"m",K="ht"+"tps://c"+"dn9."+J,W="ht"+"tps://"+$.id+"-"+siteId+".cd"+"n."+J,Y="http"+"s://cd"+"n3."+J,Z=[K,W,Y],tt=0;tt=0){var o=n.getResponseHeader(b);x=window.ftr__altd2=t(atob(o),-_-1)}if(r.indexOf(q.toLowerCase())<0)return;var i=n.getResponseHeader(q),a=t(atob(i),-_-1);if(a){var c=a.split(":");if(c&&2===c.length){for(var f=c[0],u=c[1],s="",d=0,h=0;d<20;++d)s+=d%3>0&&h<12?siteId.charAt(h++):$.id.charAt(d);var w=u.split(",");if(w.length>1){var l=w[0],g=w[1];M=f+"/"+l+"."+s+"."+g}}}e()}catch(t){}},function(t,e){n&&n(t,e)},r)},window.ftr__radd=function(t,e){function n(e){try{var n=e.response,r=function(t){function e(t,n,i){try{if(i>=r)return{name:"",nextOffsetToProcess:n,error:"Max pointer dereference depth exceeded"};for(var a=[],c=n,f=t.getUint8(c),u=0;u0)){if(0!==f)return{name:"",nextOffsetToProcess:c,error:"Unexpected length at the end of name: "+f.toString()};return{name:a.join("."),nextOffsetToProcess:c+1}}for(var w="",l=1;l<=f;l++)w+=String.fromCharCode(t.getUint8(c+l));a.push(w),c+=f+1,f=t.getUint8(c)}return{name:"",nextOffsetToProcess:c,error:"Max iterations exceeded"}}catch(t){return{name:"",nextOffsetToProcess:n,error:"Unexpected error while parsing response: "+t.toString()}}}var n,r=4,o=100,i=16,a=new DataView(t),c=a.getUint16(0),f=a.getUint16(2),u=a.getUint16(4),s=a.getUint16(6),d=a.getUint16(8),h=a.getUint16(10),w=12,l=[],g=0;for(g=0;g=o)throw new Error("Max iterations exceeded while reading TXT data");w+=A,_.push({name:y,type:U,class:T,ttl:x,data:S})}return{transactionId:c,flags:f,questionCount:u,answerCount:s,authorityCount:d,additionalCount:h,questions:l,answers:_}}(n);if(!r)throw new Error("Error parsing DNS response");if(!("answers"in r))throw new Error("Unexpected response");var o=r.answers;if(0===o.length)throw new Error("No answers found");var i=o[0].data;i=i.replace(/^"(.*)"$/,"$1");var a=function(t){var e=40,n=32,r=126;try{for(var o=atob(t),i="",a=0;a0&&r<12?siteId.charAt(r++):$.id.charAt(n);return t.replace("/PRM1","").replace("/PRM2","/main.").replace("/PRM3",e).replace("/PRM4",".js")}(f),T=window.ftr__altd3=u,t()}catch(t){}}function r(t,n){e&&e(t,n)}if(U)return window.ftr__altd3=T,void t();window.ftr__config.m.dr==="N"+"D"+"R"&&e(new Error("N"+"D"+"R"),!1),F&&k||e(new Error("D"+"P"+"P"),!1),U=!0;try{var o=function(t){for(var e=new Uint8Array([0,0]),n=new Uint8Array([1,0]),r=new Uint8Array([0,1]),o=new Uint8Array([0,0]),i=new Uint8Array([0,0]),a=new Uint8Array([0,0]),c=t.split("."),f=[],u=0;u {}, + + isAvailable: () => true, + + renderExpressButton: () => {}, + + renderCheckoutButton(container, callbacks) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'button affirm-button'; + btn.textContent = 'Pay with Affirm'; + container.appendChild(btn); + + btn.addEventListener('click', async () => { + callbacks.clearError(); + btn.disabled = true; + + const state = callbacks.getState(); + if (!state.currentEstimateToken) { + await callbacks.updatePreview(); + if (!callbacks.getState().currentEstimateToken) { + callbacks.showError('Unable to calculate totals. Please try again.'); + btn.disabled = false; + return; + } + } + + const formData = callbacks.getFormData(); + const email = formData.get('email') || ''; + + let createdOrder; + try { + const orderBody = callbacks.buildOrderJSON(formData); + createdOrder = await callbacks.createOrder(orderBody); + callbacks.saveCheckoutSession( + email, + callbacks.getCart(), + callbacks.getState().currentPreview, + createdOrder.order ?? createdOrder, + ); + } catch (err) { + callbacks.showError(err.body?.message || 'Unable to place order. Please try again.'); + btn.disabled = false; + return; + } + + const idempotencyKey = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`; + let payment; + try { + payment = await callbacks.initiatePayment( + createdOrder.order?.id ?? createdOrder.id, + idempotencyKey, + undefined, + 'affirm', + 'bnpl', + ); + } catch (err) { + const message = err.body?.error === 'ORDER_TOTAL_OUT_OF_RANGE' + ? 'Affirm is not available for this order total.' + : err.body?.message || 'Affirm is unavailable. Please try another payment method.'; + callbacks.showError(message); + btn.disabled = false; + return; + } + + if (!payment.checkoutObject) { + callbacks.showError('Unexpected payment response. Please try again.'); + btn.disabled = false; + return; + } + + const { locale, language: affirmLocale } = getLocaleAndLanguage(true, true); + const affirmCountryCode = locale === 'ca' ? 'CAN' : 'USA'; + + // eslint-disable-next-line no-underscore-dangle + window._affirm_config = { + public_api_key: payment.checkoutObject.merchant.public_api_key, + script: payment.affirmJsUrl, + locale: affirmLocale, + country_code: affirmCountryCode, + }; + + /* eslint-disable */ + (function(m,g,n,d,a,e,h,c){var b=m[n]||{},k=document.createElement(e),p=document.getElementsByTagName(e)[0],l=function(a,b,c){return function(){a[b]._.push([c,arguments])}};b[d]=l(b,d,"set");var f=b[d];b[a]={};b[a]._=[];f._=[];b._=[];b[a][h]=l(b,a,h);b[c]=function(){b._.push([h,arguments])};a=0;for(c="set add save post open empty reset on off trigger ready setProduct".split(" ");a { + affirm.ui.error.on('close', () => { + btn.disabled = false; + }); + affirm.checkout(payment.checkoutObject); + const openOpts = payment.checkoutMode === 'modal' ? { + onSuccess: ({ checkout_token: checkoutToken }) => { + const confirmUrl = payment.checkoutObject.merchant.user_confirmation_url; + window.location.href = `${confirmUrl}&checkout_token=${encodeURIComponent(checkoutToken)}`; + }, + onFail: () => { + btn.disabled = false; + }, + } : {}; + affirm.checkout.open(openOpts); + }); + /* eslint-enable no-undef */ + }); + }, +}; diff --git a/scripts/payments/apple-pay.js b/scripts/payments/apple-pay.js new file mode 100644 index 00000000..faeb0ed5 --- /dev/null +++ b/scripts/payments/apple-pay.js @@ -0,0 +1,331 @@ +import { validateApplePayMerchant, estimateExpressCheckout } from '../commerce-api.js'; + +const APPLE_PAY_SDK_URL = 'https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js'; + +async function loadSdk() { + if (document.querySelector(`script[src="${APPLE_PAY_SDK_URL}"]`)) return; + await new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = APPLE_PAY_SDK_URL; + script.crossOrigin = 'anonymous'; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); +} + +function createApplePayButton(locale) { + const btn = document.createElement('apple-pay-button'); + btn.setAttribute('buttonstyle', 'black'); + btn.setAttribute('type', 'buy'); + btn.setAttribute('locale', locale || 'en-US'); + return btn; +} + +function startExpressSession(btn, config, callbacks) { + btn.addEventListener('click', async () => { + const cart = callbacks.getCart(); + const locale = config.getLocale(); + const language = config.getLanguage(); + const bcp47 = `${language.split('_')[0]}-${(language.split('_')[1] || locale).toUpperCase()}`; + + let lastShippingContact = null; + let lastShippingMethodId = null; + + const request = { + countryCode: locale.toUpperCase(), + currencyCode: typeof config.currency === 'function' ? config.currency(locale) : config.currency, + supportedNetworks: ['visa', 'masterCard', 'amex', 'discover'], + merchantCapabilities: ['supports3DS'], + requiredShippingContactFields: ['name', 'email', 'phone', 'postalAddress'], + total: { label: config.site || 'Store', amount: cart.subtotal.toFixed(2) }, + }; + + const session = new window.ApplePaySession(3, request); + + session.onvalidatemerchant = async (e) => { + try { + const { merchantSession } = await validateApplePayMerchant(e.validationURL, locale, bcp47); + session.completeMerchantValidation(merchantSession); + } catch { + session.abort(); + } + }; + + session.onshippingcontactselected = async (e) => { + lastShippingContact = e.shippingContact; + const contact = e.shippingContact; + try { + const result = await estimateExpressCheckout( + contact.countryCode, + contact.administrativeArea, + contact.postalCode, + cart.getItemsForAPI(), + ); + const methods = result.shippingMethods || []; + if (!methods.length) { + session.completeShippingContactSelection({ + errors: [new window.ApplePayError('addressUnserviceable')], + newTotal: { label: config.site || 'Store', amount: '0.00' }, + newShippingMethods: [], + newLineItems: [], + }); + return; + } + const defaultMethod = methods[0]; + session.completeShippingContactSelection({ + newShippingMethods: methods.map((m) => ({ + identifier: String(m.id), + label: m.label, + detail: m.eta || '', + amount: String(m.rate), + })), + newTotal: { label: config.site || 'Store', amount: String(defaultMethod.total) }, + newLineItems: [ + { label: 'Subtotal', amount: String(result.subtotal) }, + { label: 'Tax', amount: String(defaultMethod.taxAmount) }, + { label: 'Shipping', amount: String(defaultMethod.rate) }, + ], + }); + } catch { + session.completeShippingContactSelection({ + errors: [new window.ApplePayError('addressUnserviceable')], + newTotal: { label: config.site || 'Store', amount: '0.00' }, + newShippingMethods: [], + newLineItems: [], + }); + } + }; + + session.onshippingmethodselected = async (e) => { + try { + const contact = lastShippingContact; + const countryCode = contact?.countryCode?.toLowerCase(); + const previewResult = await callbacks.previewOrderDirect({ + items: cart.getItemsForAPI(), + shippingMethod: { id: e.shippingMethod.identifier }, + locale: bcp47, + ...(countryCode ? { + country: countryCode, + shipping: { + country: countryCode, + state: contact.administrativeArea, + zip: contact.postalCode || '', + }, + } : {}), + }); + lastShippingMethodId = e.shippingMethod.identifier; + callbacks.getState().currentEstimateToken = previewResult.estimateToken; + session.completeShippingMethodSelection({ + newTotal: { label: config.site || 'Store', amount: String(previewResult.total) }, + newLineItems: [ + { label: 'Subtotal', amount: String(previewResult.subtotal) }, + { label: 'Tax', amount: String(previewResult.taxAmount) }, + { label: 'Shipping', amount: String(previewResult.shippingMethod?.rate ?? 0) }, + ], + }); + } catch { + session.abort(); + } + }; + + session.onpaymentauthorized = async (e) => { + const { payment } = e; + const contact = payment.shippingContact; + try { + const shippingAddr = { + name: `${contact.givenName} ${contact.familyName}`.trim(), + address1: (contact.addressLines || [])[0] || '', + address2: (contact.addressLines || [])[1] || '', + city: contact.locality, + state: contact.administrativeArea, + zip: contact.postalCode, + country: contact.countryCode?.toLowerCase() || locale, + phone: contact.phoneNumber || '', + email: contact.emailAddress || '', + }; + + const orderBody = { + customer: { + firstName: contact.givenName || '', + lastName: contact.familyName || '', + email: contact.emailAddress || '', + phone: contact.phoneNumber || '', + }, + shipping: shippingAddr, + billing: shippingAddr, + items: cart.getItemsForAPI(), + shippingMethod: { id: lastShippingMethodId || e.payment.shippingMethod?.identifier || '' }, + estimateToken: callbacks.getState().currentEstimateToken, + country: locale, + locale: bcp47, + }; + + const createdOrder = await callbacks.createOrder(orderBody); + const fraudToken = (() => { + try { return sessionStorage.getItem('forter_token') || undefined; } catch { return undefined; } + })(); + const idempotencyKey = crypto.randomUUID?.() || `${Date.now()}`; + const result = await callbacks.initiatePayment( + createdOrder.order?.id ?? createdOrder.id, + idempotencyKey, + fraudToken, + 'chase-wallet', + 'apple-pay', + { token: payment.token, billingContact: payment.billingContact }, + ); + + if (result.status === 'completed') { + session.completePayment(window.ApplePaySession.STATUS_SUCCESS); + callbacks.onComplete(createdOrder); + } else { + session.completePayment(window.ApplePaySession.STATUS_FAILURE); + callbacks.showError(result.reason || 'Apple Pay payment failed.'); + } + } catch (err) { + session.completePayment(window.ApplePaySession.STATUS_FAILURE); + const msg = err?.errorHeader === 'recaptcha score too low' + ? 'Payment was declined for security reasons. Please refresh the page and try again.' + : 'Apple Pay payment failed. Please try again.'; + callbacks.showError(msg); + } + }; + + session.begin(); + }); +} + +/** + * Begins an Apple Pay checkout session synchronously within a user gesture, + * then returns a Promise that resolves/rejects when the session completes. + * + * IMPORTANT: This must be called synchronously from a trusted click handler. + * session.begin() fires inside the Promise executor (which is synchronous), + * preserving the user gesture required by Apple Pay. + * + * @param {Object} config + * @param {Object} callbacks + * @returns {Promise} Resolves with 'success' or 'cancel' + */ +export function beginCheckoutSession(config, callbacks) { + return new Promise((resolve, reject) => { + const state = callbacks.getState(); + + if (!window.ApplePaySession) { + reject(new Error('not-available')); + return; + } + + if (!state.currentPreview) { + reject(new Error('no-preview')); + return; + } + + const locale = config.getLocale(); + const language = config.getLanguage(); + const bcp47 = `${language.split('_')[0]}-${(language.split('_')[1] || locale).toUpperCase()}`; + + const request = { + countryCode: locale.toUpperCase(), + currencyCode: typeof config.currency === 'function' ? config.currency(locale) : config.currency, + supportedNetworks: ['visa', 'masterCard', 'amex', 'discover'], + merchantCapabilities: ['supports3DS'], + requiredShippingContactFields: [], + total: { + label: config.site || 'Store', + amount: parseFloat(state.currentPreview.total).toFixed(2), + }, + }; + + let session; + try { + session = new window.ApplePaySession(3, request); + } catch { + reject(new Error('not-available')); + return; + } + + session.onvalidatemerchant = async (e) => { + try { + const { merchantSession } = await validateApplePayMerchant(e.validationURL, locale, bcp47); + session.completeMerchantValidation(merchantSession); + } catch { + session.abort(); + reject(new Error('merchant-validation-failed')); + } + }; + + session.oncancel = () => resolve('cancel'); + + session.onpaymentauthorized = async (e) => { + const formData = callbacks.getFormData(); + try { + const orderBody = callbacks.buildOrderJSON(formData); + const createdOrder = await callbacks.createOrder(orderBody); + + const fraudToken = (() => { + try { return sessionStorage.getItem('forter_token') || undefined; } catch { return undefined; } + })(); + const idempotencyKey = crypto.randomUUID?.() || `${Date.now()}`; + const result = await callbacks.initiatePayment( + createdOrder.order?.id ?? createdOrder.id, + idempotencyKey, + fraudToken, + 'chase-wallet', + 'apple-pay', + { token: e.payment.token, billingContact: e.payment.billingContact }, + ); + + if (result.status === 'completed') { + session.completePayment(window.ApplePaySession.STATUS_SUCCESS); + const email = formData.get('email') || ''; + const cart = callbacks.getCart(); + const preview = callbacks.getState().currentPreview; + callbacks.saveCheckoutSession?.(email, cart, preview, createdOrder.order ?? createdOrder); + callbacks.onComplete(createdOrder); + resolve('success'); + } else { + session.completePayment(window.ApplePaySession.STATUS_FAILURE); + reject(new Error(result.reason || 'payment-failed')); + } + } catch (err) { + session.completePayment(window.ApplePaySession.STATUS_FAILURE); + const reason = err?.errorHeader === 'recaptcha score too low' ? 'recaptcha-blocked' : 'payment-failed'; + reject(new Error(reason)); + } + }; + + try { + session.begin(); // synchronous — user gesture still active here + } catch { + reject(new Error('not-available')); + } + }); +} + +/** @type {import('./types').PaymentProvider} */ +export default { + id: 'apple-pay', + label: 'Apple Pay', + supportsExpress: true, + hidesBilling: true, + + load: async () => loadSdk(), + + isAvailable: () => Boolean(window.ApplePaySession), + + renderExpressButton(container, callbacks) { + const config = callbacks.getConfig(); + const locale = config.getLocale(); + const language = config.getLanguage(); + const bcp47 = `${language.split('_')[0]}-${(language.split('_')[1] || locale).toUpperCase()}`; + const btn = createApplePayButton(bcp47); + startExpressSession(btn, config, callbacks); + container.appendChild(btn); + }, + + renderCheckoutButton() { + // Apple Pay is initiated directly from the form's submit button gesture + // via callbacks.beginApplePay — no button rendered here. + }, +}; diff --git a/scripts/payments/chase.js b/scripts/payments/chase.js new file mode 100644 index 00000000..fc302ebc --- /dev/null +++ b/scripts/payments/chase.js @@ -0,0 +1,9 @@ +/** @type {import('./types').PaymentProvider} */ +export default { + id: 'chase', + supportsExpress: false, + load: async () => {}, + isAvailable: () => true, + renderExpressButton: () => {}, + renderCheckoutButton: () => {}, +}; diff --git a/scripts/payments/google-pay.js b/scripts/payments/google-pay.js new file mode 100644 index 00000000..67840c9c --- /dev/null +++ b/scripts/payments/google-pay.js @@ -0,0 +1,25 @@ +/** + * Google Pay provider stub. + * + * Implementation requires: + * - Load: https://pay.google.com/gp/p/js/pay.js + * - isAvailable: new google.payments.api.PaymentsClient({environment:'PRODUCTION'}) + * .isReadyToPay({apiVersion:2, apiVersionMinor:0, allowedPaymentMethods:[...]}) + * - renderExpressButton: client.createButton({onClick, buttonType:'buy'}) + * - renderCheckoutButton: same + * - Payment token: passed to initiatePayment as provider='google-pay', paymentMethod='google-pay' + * - Merchant config (merchantId, merchantName) must come from CommerceConfig + * or the initiatePayment response + * + * @type {import('./types').PaymentProvider} + */ +export default { + id: 'google-pay', + label: 'Google Pay', + supportsExpress: true, + hidesBilling: true, + load: async () => { throw new Error('Google Pay is not implemented'); }, + isAvailable: () => false, + renderExpressButton: () => {}, + renderCheckoutButton: () => {}, +}; diff --git a/scripts/payments/paypal.js b/scripts/payments/paypal.js new file mode 100644 index 00000000..a1d9cb9f --- /dev/null +++ b/scripts/payments/paypal.js @@ -0,0 +1,385 @@ +import { + createPayPalSession, + patchPayPalSession, + getPayPalSession, +} from '../commerce-api.js'; +import { getLocaleAndLanguage } from '../scripts.js'; + +let sdkLoadPromise = null; + +const PAY_LATER_LABELS = { + fr: 'Payer plus tard', + de: 'Später bezahlen', + es: 'Pagar después', + it: 'Paga dopo', + nl: 'Nu kopen, later betalen', + pt: 'Pague depois', + pl: 'Zapłać później', + zh: '先买后付', + ja: '後払い', +}; + +function getPayLaterLabel(language) { + const lang = (language || 'en').split('_')[0].toLowerCase(); + return PAY_LATER_LABELS[lang] || 'Pay Later'; +} + +function loadSdk(clientId, currency, locale, intent = 'capture') { + if (sdkLoadPromise) return sdkLoadPromise; + sdkLoadPromise = new Promise((resolve, reject) => { + if (window.paypal) { resolve(); return; } + const script = document.createElement('script'); + const [lang, country] = locale.split('_'); + const normalizedLocale = country ? `${lang}_${country.toUpperCase()}` : locale; + const params = new URLSearchParams({ + 'client-id': clientId, + currency, + components: 'buttons,messages', + locale: normalizedLocale, + intent, + commit: 'false', + 'enable-funding': 'paylater', + }); + script.src = `https://www.paypal.com/sdk/js?${params}`; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('PayPal SDK failed to load')); + document.head.appendChild(script); + }); + return sdkLoadPromise; +} + +const PAYPAL_WORDMARK = /* html */` + + PayPal +`; + +function payLaterWordmark(label) { + return /* html */` + + PayPal + +${label}`; +} + +function createButton(innerHTML, className) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = className; + btn.innerHTML = innerHTML; + return btn; +} + +function showNotConfiguredDialog() { + const dialog = document.createElement('dialog'); + dialog.className = 'paypal-not-configured-dialog'; + dialog.innerHTML = /* html */` +

PayPal Express Checkout is not configured yet.

+ + `; + dialog.querySelector('button').addEventListener('click', () => dialog.close()); + dialog.addEventListener('close', () => dialog.remove()); + document.body.appendChild(dialog); + dialog.showModal(); +} + +/** @type {import('./types').PaymentProvider} */ +export default { + id: 'paypal', + label: 'PayPal', + supportsExpress: true, + hidesBilling: true, + + load: async (config) => { + const clientId = window.CommerceConfig?.paypal?.clientId; + if (!clientId) return; + const currency = typeof config.currency === 'function' + ? config.currency(config.getLocale()) + : (config.currency || 'USD'); + const locale = config.getLanguage().replace('-', '_'); + const intent = (window.CommerceConfig?.paypal?.intent || 'capture').toLowerCase(); + try { + await loadSdk(clientId, currency, locale, intent); + } catch { /* fall back to stub buttons */ } + }, + + isAvailable: () => !!window.paypal, + + /** + * Renders the PayPal Express Checkout button (and Pay Later button if + * eligible) into the provided container element. + * + * 1. Guard: if window.paypal is not loaded, render stub dialog buttons + * 2. Declare closure state: lastShippingMethods, lastShippingAddress + * 3. Build buttonConfig with createOrder, onShippingAddressChange, + * onShippingOptionsChange, onApprove, onError, onCancel + * 4. Render primary PayPal button + * 5. Render Pay Later button only if isEligible() + * + * @param {HTMLElement} container + * @param {object} callbacks + */ + renderExpressButton(container, callbacks) { + if (!window.paypal) { + // No SDK — stub buttons with localized Pay Later label + const label = getPayLaterLabel(callbacks.getConfig().getLanguage()); + const paypalBtn = createButton(PAYPAL_WORDMARK, 'paypal-express-btn'); + paypalBtn.addEventListener('click', showNotConfiguredDialog); + container.appendChild(paypalBtn); + + const payLaterWrapper = document.createElement('div'); + payLaterWrapper.className = 'paypal-paylater-wrapper'; + const payLaterStub = createButton( + payLaterWordmark(label), + 'paypal-express-btn paylater-express-btn', + ); + payLaterStub.addEventListener('click', showNotConfiguredDialog); + payLaterWrapper.appendChild(payLaterStub); + container.appendChild(payLaterWrapper); + return; + } + + let lastShippingMethods = []; + let lastShippingAddress = null; + + const expressConfig = callbacks.getConfig(); + const currency = typeof expressConfig.currency === 'function' + ? expressConfig.currency(expressConfig.getLocale()) + : (expressConfig.currency || 'USD'); + + const buttonConfig = { + style: { + layout: 'horizontal', + color: 'gold', + shape: 'rect', + label: 'paypal', + tagline: false, + height: 55, + }, + + createOrder: async () => { + const config = callbacks.getConfig(); + const cart = callbacks.getCart(); + const state = callbacks.getState(); + const { paypalOrderId } = await createPayPalSession(cart.getItemsForAPI(), config); + state.paypalSessionId = paypalOrderId; + return paypalOrderId; + }, + + onShippingAddressChange: async (data, actions) => { + lastShippingAddress = data.shippingAddress; + const state = callbacks.getState(); + const cart = callbacks.getCart(); + const config = callbacks.getConfig(); + try { + const result = await patchPayPalSession(state.paypalSessionId, { + type: 'address', + country: config.getLocale(), + locale: getLocaleAndLanguage(false, true).language, + currency, + address: { + country: data.shippingAddress.countryCode, + state: data.shippingAddress.state, + zip: data.shippingAddress.postalCode, + }, + items: cart.getItemsForAPI(), + }); + if (!result.shippingMethods?.length) { + return actions.reject(data.errors.ADDRESS_ERROR); + } + lastShippingMethods = result.shippingMethods; + // Preview with the default (first) method so estimateToken is always set + // even when the user never changes the shipping option (onShippingOptionsChange + // only fires on an explicit option change, not on initial address selection). + const countryCode = data.shippingAddress.countryCode?.toLowerCase(); + const [defaultMethod] = lastShippingMethods; + const preview = await callbacks.previewOrderDirect({ + items: cart.getItemsForAPI(), + shippingMethod: { id: String(defaultMethod.id) }, + ...(countryCode ? { + country: countryCode, + shipping: { + country: countryCode, + state: data.shippingAddress.state, + zip: data.shippingAddress.postalCode || '', + }, + } : {}), + }); + state.currentEstimateToken = preview.estimateToken; + } catch { + return actions.reject(data.errors.ADDRESS_ERROR); + } + return undefined; + }, + + onShippingOptionsChange: async (data, actions) => { + const selectedId = data.selectedShippingOption?.id; + const method = lastShippingMethods.find((m) => m.id === selectedId); + if (!method) return actions.reject(data.errors.METHOD_UNAVAILABLE); + const state = callbacks.getState(); + const cart = callbacks.getCart(); + const config = callbacks.getConfig(); + await patchPayPalSession(state.paypalSessionId, { + type: 'option', + country: config.getLocale(), + locale: getLocaleAndLanguage(false, true).language, + currency, + selectedOptionId: method.id, + total: method.total, + taxAmount: method.taxAmount, + shippingRate: method.rate, + }); + const countryCode = lastShippingAddress?.countryCode?.toLowerCase(); + const preview = await callbacks.previewOrderDirect({ + items: cart.getItemsForAPI(), + shippingMethod: { id: String(method.id) }, + ...(countryCode ? { + country: countryCode, + shipping: { + country: countryCode, + state: lastShippingAddress.state, + zip: lastShippingAddress.postalCode || '', + }, + } : {}), + }); + state.currentEstimateToken = preview.estimateToken; + return undefined; + }, + + onApprove: async () => { + try { + const state = callbacks.getState(); + const cart = callbacks.getCart(); + const config = callbacks.getConfig(); + const session = await getPayPalSession( + state.paypalSessionId, + config.getLocale(), + getLocaleAndLanguage(false, true).language, + ); + const orderBody = { + customer: { + firstName: session.payer.firstName, + lastName: session.payer.lastName, + email: session.payer.email, + phone: '', + }, + shipping: { ...session.shippingAddress, email: session.payer.email }, + billing: { ...session.shippingAddress, email: session.payer.email }, + items: cart.getItemsForAPI(), + shippingMethod: { id: session.selectedOptionId }, + estimateToken: state.currentEstimateToken, + country: session.shippingAddress.country, + locale: getLocaleAndLanguage(false, true).language, + }; + const createdOrder = await callbacks.createOrder(orderBody); + const fraudToken = (() => { + try { return sessionStorage.getItem('forter_token') || undefined; } catch { return undefined; } + })(); + const idempotencyKey = crypto.randomUUID?.() || `${Date.now()}`; + const result = await callbacks.initiatePayment( + createdOrder.order?.id ?? createdOrder.id, + idempotencyKey, + fraudToken, + 'paypal-express', + 'paypal', + { paypalOrderId: state.paypalSessionId }, + ); + if (result.status === 'completed') { + callbacks.onComplete(createdOrder); + } else { + callbacks.showError(result.reason || 'PayPal payment failed. Please try again.'); + } + } catch { + callbacks.showError('PayPal payment failed. Please try again.'); + } + }, + + onError: () => { + callbacks.showError('PayPal encountered an error. Please try again.'); + }, + + onCancel: () => { + // User dismissed the PayPal sheet intentionally — no action needed. + }, + }; + + window.paypal.Buttons(buttonConfig).render(container); + + const payLaterBtn = window.paypal.Buttons({ + ...buttonConfig, + fundingSource: window.paypal.FUNDING.PAYLATER, + style: { ...buttonConfig.style, color: 'silver' }, + }); + if (payLaterBtn.isEligible()) { + const wrapper = document.createElement('div'); + wrapper.className = 'paypal-paylater-wrapper'; + container.appendChild(wrapper); + payLaterBtn.render(wrapper); + } + }, + + renderCheckoutButton(container, callbacks) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'button paypal-redirect-btn'; + btn.textContent = 'Continue with PayPal'; + container.appendChild(btn); + + btn.addEventListener('click', async () => { + callbacks.clearError(); + btn.disabled = true; + + const state = callbacks.getState(); + if (!state.currentEstimateToken) { + await callbacks.updatePreview(); + if (!callbacks.getState().currentEstimateToken) { + callbacks.showError('Unable to calculate totals. Please try again.'); + btn.disabled = false; + return; + } + } + + const formData = callbacks.getFormData(); + const email = formData.get('email') || ''; + + let createdOrder; + try { + const orderBody = callbacks.buildOrderJSON(formData); + createdOrder = await callbacks.createOrder(orderBody); + callbacks.saveCheckoutSession( + email, + callbacks.getCart(), + callbacks.getState().currentPreview, + createdOrder.order ?? createdOrder, + ); + } catch (err) { + callbacks.showError(err.body?.message || 'Unable to place order. Please try again.'); + btn.disabled = false; + return; + } + + const idempotencyKey = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`; + const fraudToken = (() => { + try { return sessionStorage.getItem('forter_token') || undefined; } catch { return undefined; } + })(); + + try { + const payment = await callbacks.initiatePayment( + createdOrder.order?.id ?? createdOrder.id, + idempotencyKey, + fraudToken, + 'paypal', + 'paypal', + ); + if (payment.action === 'redirect' && payment.redirectUrl) { + window.location.href = payment.redirectUrl; + } else { + callbacks.showError('Unexpected payment response. Please try again.'); + btn.disabled = false; + } + } catch (err) { + callbacks.showError(err.body?.message || 'Something went wrong. Please try again.'); + btn.disabled = false; + } + }); + }, +}; diff --git a/scripts/recaptcha.js b/scripts/recaptcha.js new file mode 100644 index 00000000..1e642de7 --- /dev/null +++ b/scripts/recaptcha.js @@ -0,0 +1,138 @@ +import { loadScript } from './aem.js'; + +/** + * Google reCAPTCHA Enterprise integration for the protected unauthenticated + * write endpoints on the Commerce API: + * + * POST /auth/login → action 'auth_login' + * POST /orders/preview → action 'orders_preview' + * POST /orders → action 'orders_create' + * + * When `RECAPTCHA_SITE_KEY` is empty (e.g. a + * dev environment without the secret provisioned) this module degrades + * gracefully and the request goes out without a token. + */ + +/** + * reCAPTCHA Enterprise site key. Public — safe to expose client-side. + * + * Empty string disables the integration (graceful degradation). + */ +// TODO: Sitekey for api-stage only for Vitamix. +export const RECAPTCHA_SITE_KEY = '6LfaNtosAAAAABpYKw5c-zu9FWjxxfZJDpVu4JjG'; + +/** Action name constants mirroring the server's RECAPTCHA_ACTIONS. */ +export const RECAPTCHA_ACTIONS = Object.freeze({ + AUTH_LOGIN: 'auth_login', + ORDERS_PREVIEW: 'orders_preview', + ORDERS_CREATE: 'orders_create', +}); + +export const RECAPTCHA_HEADER = 'X-Recaptcha-Token'; + +/** + * Maximum time (ms) to wait for the SDK script to load before giving up. + */ +const SDK_LOAD_TIMEOUT_MS = 5000; + +let sdkPromise = null; + +/** + * Lazily inject the reCAPTCHA Enterprise SDK and wait until it is fully + * initialized — not just until the script tag has loaded. Returns the same + * promise on every call so concurrent callers share a single load. + * + * Important: reCAPTCHA Enterprise has two-stage initialization. After the + * script's `onload` fires, `grecaptcha.enterprise.ready` is available but + * `grecaptcha.enterprise.execute` is not. `execute` only becomes callable + * once the SDK's internal bootstrap completes, which is signalled via the + * `enterprise.ready(callback)` queue. We chain both stages so this promise + * represents "SDK is ready to mint tokens", letting callers skip race-prone + * existence checks. Rejects on load error or after {@link SDK_LOAD_TIMEOUT_MS} + * so callers don't hang indefinitely on a third-party outage. + * + * @param {string} [siteKey=RECAPTCHA_SITE_KEY] + * @returns {Promise} + */ +export function loadRecaptchaSdk(siteKey = RECAPTCHA_SITE_KEY) { + if (!siteKey) return Promise.resolve(); + if (typeof window === 'undefined') return Promise.resolve(); + if (window.grecaptcha?.enterprise?.execute) return Promise.resolve(); + if (sdkPromise) return sdkPromise; + + const src = `https://www.google.com/recaptcha/enterprise.js?render=${encodeURIComponent(siteKey)}`; + const load = loadScript(src, { async: 'true', defer: 'true' }) + .then(() => new Promise((resolve, reject) => { + if (!window.grecaptcha?.enterprise?.ready) { + reject(new Error('grecaptcha.enterprise.ready unavailable after SDK load')); + return; + } + window.grecaptcha.enterprise.ready(resolve); + })); + + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('reCAPTCHA SDK load timed out')), + SDK_LOAD_TIMEOUT_MS, + ); + }); + + sdkPromise = Promise.race([load, timeout]) + .then(() => { clearTimeout(timer); }) + .catch((err) => { + clearTimeout(timer); + sdkPromise = null; + throw err; + }); + + return sdkPromise; +} + +/** + * Mint a single-use reCAPTCHA Enterprise token for the given action. + * + * Lazily ensures the SDK is loaded — first call awaits the script load + * (~150 KB), subsequent calls are instant. + * + * The third parameter is an optional injection point for tests. Pass an + * object with the same shape as `window.grecaptcha` to bypass the real + * SDK; leave undefined in production to use the lazy-loaded SDK. + * + * @param {string} action - One of {@link RECAPTCHA_ACTIONS}. + * @param {string} [siteKey=RECAPTCHA_SITE_KEY] - Override for tests. + * @param {object} [grecaptcha] - Override for tests; production reads window.grecaptcha. + * @returns {Promise} A token string, or '' on any failure. + */ +export async function mintRecaptchaToken( + action, + siteKey = RECAPTCHA_SITE_KEY, // eslint-disable-line default-param-last + grecaptcha, +) { + if (!siteKey) return ''; + let sdk = grecaptcha; + if (sdk === undefined) { + if (typeof window === 'undefined') return ''; + // Always await loadRecaptchaSdk — it short-circuits when the SDK is + // already fully initialized, and otherwise chains through both the + // script load AND enterprise.ready() so callers don't race the + // SDK's two-stage init. + try { + await loadRecaptchaSdk(siteKey); + } catch { + return ''; + } + sdk = window.grecaptcha; + } + if (!sdk?.enterprise?.execute) return ''; + try { + // ready() is a no-op queue when SDK is already initialized (production + // path via loadRecaptchaSdk), but kept as a safety net for the test + // injection path where the caller passes a fresh mock SDK. + await new Promise((resolve) => { sdk.enterprise.ready(resolve); }); + const token = await sdk.enterprise.execute(siteKey, { action }); + return token || ''; + } catch { + return ''; + } +} diff --git a/scripts/scripts.js b/scripts/scripts.js index be07d521..f2b6eb08 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -18,11 +18,38 @@ import { getMetadata, } from './aem.js'; -const isProdHost = window.location.hostname.includes('vitamix.com'); +const { hostname } = window.location; + +// Locale+language pairs enabled for edge checkout on production. +// Format: '/' (e.g., 'ca/fr_ca'). Add pairs as each region goes live. +const EDGE_CHECKOUT_LOCALES = ['ca/fr_ca', 'ca/en_us', 'us/en_us']; + +const isEdgeHost = hostname.includes('localhost') || hostname.includes('edge-accounts') || hostname.includes('edge-orders') || hostname.includes('integration.vitamix.com') || hostname.includes('uat.vitamix.com'); +const isProdHost = hostname.includes('vitamix.com'); + +// Affirm public API key — safe to expose client-side (used for PDP promo widgets). +// Checkout gets its key from the server's checkout object so it always matches the +// environment of the merchant config. +export const AFFIRM_PUBLIC_KEY = isProdHost ? 'LIVE_PUBLIC_KEY' : 'GH4VQBRG3LHDS5CM'; export const FORMS_ENDPOINT = isProdHost ? '' : 'https://main--vitamix--aemsites.aem.network'; +const PAYPAL_CLIENT_IDS = { + us: 'AaBQdCVqIp15uFQaHrJmTUDBZ-xJrOYPs99NtZ-iLN5oij-ustZq304ikTHJKwqqSL4yN0v9GLireQLN', + ca: 'AVlGkhsI0_EFNFx8jnRsv0dSROcLzzLYtTdMoNieyjZeAWlIXdFpocB6eyhHvuDOZ3F2YFmQDkLE03Rp', +}; +const siteLocale = window.location.pathname.split('/').filter(Boolean)[0] || 'us'; + +window.CommerceConfig = { + org: 'aemsites', + site: 'vitamix', + paypal: { + clientId: PAYPAL_CLIENT_IDS[siteLocale] ?? PAYPAL_CLIENT_IDS.us, + intent: 'authorize', + }, +}; + /** * Load fonts.css and set a session storage flag. */ @@ -116,22 +143,50 @@ export function formatServings(servingsString) { /** * Gets the locale and language from the window.location.pathname. + * @param {boolean} [forceEnCA] - Remap en_us → en_ca for Canadian English paths. + * @param {boolean} [bcp47] - Return language as a BCP-47 tag (e.g. 'en-US') instead of + * the underscore form used in URL paths (e.g. 'en_us'). * @returns {Object} Object with locale and language. */ -export function getLocaleAndLanguage(forceEnCA = false) { +export function getLocaleAndLanguage(forceEnCA = false, bcp47 = false) { const pathSegments = window.location.pathname.split('/').filter(Boolean); const locale = pathSegments[0] || 'us'; // fallback to 'us' if not found - const language = pathSegments[1] || 'en_us'; // fallback to 'en_us' if not found + let language = pathSegments[1] || 'en_us'; // fallback to 'en_us' if not found // Commerce backend uses the language code en_ca for the Canada english store view. // On the frontend they are incorrectly using the en_us language code. if (forceEnCA && locale === 'ca' && language === 'en_us') { - return { locale, language: 'en_ca' }; + language = 'en_ca'; + } + + if (bcp47) { + language = language.replace('_', '-').replace(/-([a-z]{2})$/, (_, r) => `-${r.toUpperCase()}`); } return { locale, language }; } +/** + * Returns the path for an order-flow page (cart, checkout, complete, cancel) + * scoped to the current locale and language. + * @param {'cart'|'checkout'|'complete'|'cancel'} page + * @returns {string} + */ +export function getOrderPath(page) { + const { locale, language } = getLocaleAndLanguage(); + return `/${locale}/${language}/order/${page}`; +} + +/** + * Returns the path for an account page scoped to the current locale and language. + * @param {string} [page] - Optional sub-page (e.g., 'orders', 'order-detail') + * @returns {string} + */ +export function getAccountPath(page) { + const { locale, language } = getLocaleAndLanguage(); + return page ? `/${locale}/${language}/account/${page}` : `/${locale}/${language}/account`; +} + /** * Gets the form submission URL for the current locale and language. * @returns {string} The form submission URL @@ -159,15 +214,23 @@ function setAffiliateCoupon() { const urlParams = new URLSearchParams(window.location.search); const { cjdata, cjevent, COUPON } = Object.fromEntries(urlParams); - if (!cjdata || !cjevent || !COUPON) return; - - const { locale, language } = getLocaleAndLanguage(); - const loginUrl = new URL(`https://www.vitamix.com/${locale}/${language}/checkout/cart`); - Object.entries({ cjdata, cjevent, COUPON }).forEach(([key, value]) => { - loginUrl.searchParams.set(key, value); - }); + if (cjevent) { + localStorage.setItem('cjevent', JSON.stringify({ value: cjevent, ts: Date.now() })); + } - fetch(loginUrl.toString()); + if (COUPON) { + sessionStorage.setItem('checkout_coupon_code', COUPON); + + // TODO: remove once all locales migrate off Magento — applies the coupon to the PHP cart + const { locale, language } = getLocaleAndLanguage(); + if (!EDGE_CHECKOUT_LOCALES.includes(`${locale}/${language}`)) { + const cartUrl = new URL(`https://www.vitamix.com/${locale}/${language}/checkout/cart`); + if (cjdata) cartUrl.searchParams.set('cjdata', cjdata); + if (cjevent) cartUrl.searchParams.set('cjevent', cjevent); + cartUrl.searchParams.set('COUPON', COUPON); + fetch(cartUrl.toString()); + } + } } /** @@ -1276,9 +1339,15 @@ async function simulatePDPPreview() { * @param {Element} doc The container element */ async function loadEager(doc) { - const locale = window.location.pathname.split('/')[2]; - const language = locale ? locale.split('_')[0] : 'en'; - document.documentElement.lang = language; + const { locale, language } = getLocaleAndLanguage(); + document.documentElement.lang = language ? language.split('_')[0] : 'en'; + + // Dev/staging hosts: edge checkout enabled for all locales. + // Production: edge checkout enabled only for locale+language pairs in EDGE_CHECKOUT_LOCALES. + window.useEdgeCheckout = isEdgeHost || EDGE_CHECKOUT_LOCALES.includes(`${locale}/${language}`); + if (localStorage.getItem('useEdgeCheckout') !== null) { + window.useEdgeCheckout = localStorage.getItem('useEdgeCheckout') === 'true'; + } /* simulation date */ const params = new URLSearchParams(window.location.search); @@ -1404,8 +1473,8 @@ async function loadLazy(doc) { function decorateExternalLinks() { const externalLinks = document.querySelectorAll('a[href^="https://"]'); externalLinks.forEach((link) => { - const { hostname } = new URL(link.href); - if (!link.href.includes('vitamix') || hostname === 'localhost') { + const { hostname: linkHostname } = new URL(link.href); + if (!link.href.includes('vitamix') || linkHostname === 'localhost') { link.setAttribute('target', '_blank'); link.setAttribute('rel', 'noopener'); } @@ -1454,6 +1523,16 @@ async function loadDelayed() { console.error('Error loading link checker', e); } + const { default: injectForterSnippet } = await import('./forter-snippet.js'); + injectForterSnippet(); + + document.addEventListener('ftr:tokenReady', (evt) => { + const token = evt.detail; + try { + sessionStorage.setItem('forter_token', token); + } catch { /* ignore */ } + }); + const initContentScore = async () => { const CONTENT_SCORE = 'https://tools.aem.live/tools/content-score/src/scripts.js'; const { init } = await import(CONTENT_SCORE); diff --git a/scripts/slide-panel.js b/scripts/slide-panel.js new file mode 100644 index 00000000..0e8cd3e3 --- /dev/null +++ b/scripts/slide-panel.js @@ -0,0 +1,65 @@ +import { lockBodyScroll, unlockBodyScroll } from './body-scroll-lock.js'; + +/** + * Creates a reusable slide-out dialog panel. + * @param {string} id - Element ID for the dialog + * @param {string} title - Header title text + * @param {string} className - CSS class for the dialog + * @returns {{ dialog: HTMLDialogElement, content: HTMLElement, open: Function, close: Function }} + */ +export default function createSlidePanel(id, title, className) { + const dialog = document.createElement('dialog'); + dialog.id = id; + dialog.className = className; + dialog.setAttribute('aria-expanded', 'false'); + + const headerRow = document.createElement('div'); + headerRow.className = 'slide-panel-header'; + + const titleEl = document.createElement('h2'); + titleEl.textContent = title; + headerRow.append(titleEl); + + const closeBtn = document.createElement('button'); + closeBtn.className = 'slide-panel-close'; + closeBtn.textContent = '\u00D7'; + closeBtn.setAttribute('aria-label', 'Close'); + headerRow.append(closeBtn); + + const content = document.createElement('div'); + content.className = 'slide-panel-content'; + + dialog.append(headerRow, content); + + dialog.addEventListener('close', () => { + unlockBodyScroll(); + }); + + function open() { + lockBodyScroll(); + dialog.showModal(); + dialog.setAttribute('aria-expanded', 'true'); + } + + function close() { + dialog.setAttribute('aria-expanded', 'false'); + setTimeout(() => { + dialog.close(); + }, 300); + } + + closeBtn.addEventListener('click', close); + + dialog.addEventListener('click', (e) => { + const rect = dialog.getBoundingClientRect(); + const inside = rect.top <= e.clientY + && e.clientY <= rect.top + rect.height + && rect.left <= e.clientX + && e.clientX <= rect.left + rect.width; + if (!inside) close(); + }); + + return { + dialog, content, open, close, + }; +} diff --git a/styles/commerce-tokens.css b/styles/commerce-tokens.css new file mode 100644 index 00000000..b88699bd --- /dev/null +++ b/styles/commerce-tokens.css @@ -0,0 +1,66 @@ +:root { + /* ── Colour: brand ─────────────────────────────────────────── */ + --commerce-color-primary: var(--link-color, #ea580c); + --commerce-color-primary-hover: color-mix(in srgb, var(--commerce-color-primary) 85%, black); + --commerce-color-primary-text: #fff; + + /* ── Colour: text ──────────────────────────────────────────── */ + --commerce-color-text: var(--text-color, #1a1a1a); + --commerce-color-text-muted: #6b7280; + --commerce-color-text-price: var(--commerce-color-text); + + /* ── Colour: semantic ──────────────────────────────────────── */ + --commerce-color-error: #dc2626; + --commerce-color-error-bg: #fef2f2; + --commerce-color-error-border: #fecaca; + --commerce-color-success: #16a34a; + --commerce-color-discount: #16a34a; + + /* ── Colour: surface ───────────────────────────────────────── */ + --commerce-color-background: var(--background-color, #fff); + --commerce-color-surface: #f9fafb; + --commerce-color-border: #e5e7eb; + + /* ── Typography ────────────────────────────────────────────── */ + --commerce-font-family: var(--body-font-family, sans-serif); + --commerce-font-size-base: 1rem; + --commerce-font-size-sm: 0.875rem; + --commerce-font-size-xs: 0.8125rem; + --commerce-font-size-price: 1rem; + --commerce-font-size-total: 1.25rem; + --commerce-font-weight-bold: 600; + + /* ── Buttons ───────────────────────────────────────────────── */ + --commerce-button-radius: 6px; + --commerce-button-height: 48px; + --commerce-button-font-size: 1rem; + --commerce-button-font-weight: var(--commerce-font-weight-bold); + + /* ── Inputs ────────────────────────────────────────────────── */ + --commerce-input-radius: 6px; + --commerce-input-border: var(--commerce-color-border); + --commerce-input-border-focus: var(--commerce-color-primary); + --commerce-input-background: var(--commerce-color-background); + --commerce-input-height: 52px; + + /* ── Shape ─────────────────────────────────────────────────── */ + --commerce-radius-card: 12px; + --commerce-radius-badge: 4px; + + /* ── Spacing ───────────────────────────────────────────────── */ + --commerce-gap: 1.5rem; + --commerce-gap-sm: 0.75rem; + --commerce-card-padding: 24px; + + /* ── Layout ────────────────────────────────────────────────── */ + --commerce-max-width: 1300px; + --commerce-summary-width: 420px; + --commerce-form-max-width: 600px; + --commerce-item-image-size: 80px; + --commerce-sticky-top: 100px; + + /* ── Checkout progress step indicator ─────────────────────── */ + --commerce-step-color-active: #40596b; + --commerce-step-color-complete: var(--commerce-color-primary); + --commerce-step-color-inactive: var(--commerce-color-border); +} diff --git a/styles/styles.css b/styles/styles.css index 95524cd1..6338c119 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -71,6 +71,10 @@ body.pdp-template { --site-width: 1440px; } +body.cart-template { + --site-width: 1440px; +} + @media (width >= 900px) { :host, :root { @@ -898,3 +902,26 @@ body.corp main .default-content-wrapper h1 { body.corp main .default-content-wrapper h2 { font-size: var(--font-size-500); } + +/* + * reCAPTCHA Enterprise badge — hidden globally. Google's Terms of Service + * permit hiding the badge as long as attribution text is shown near every + * form that uses reCAPTCHA. The required `.recaptcha-notice` block is + * rendered inside the auth panel and the checkout form for that purpose. + */ +.grecaptcha-badge { + visibility: hidden !important; +} + +.recaptcha-notice { + font-size: var(--font-size-100); + color: #666; + margin-top: 12px; + line-height: 1.4; +} + +.recaptcha-notice a { + color: inherit; + text-decoration: underline; +} + diff --git a/tests/pdp/integration.spec.js b/tests/pdp/integration.spec.js index 348784a8..3ad561c0 100644 --- a/tests/pdp/integration.spec.js +++ b/tests/pdp/integration.spec.js @@ -626,17 +626,21 @@ test.describe('PDP Integration Tests', () => { pageUrl, } = config; - await page.route('**/bin/vitamix/newslettersubscription**', async (route) => { - const url = route.request().url(); - const urlObj = new URL(url); - - // Check the query parameters - expect(urlObj.searchParams.get('email')).toBe('test@test.com'); - expect(urlObj.searchParams.get('mobile')).toBe('1234567890'); - expect(urlObj.searchParams.get('sms_optin')).toBe(smsOptin ? '1' : '0'); - expect(urlObj.searchParams.get('lead_source')).toBe(leadSource); - expect(urlObj.searchParams.get('pageUrl')).toContain(pageUrl); - expect(urlObj.searchParams.get('actionUrl')).toBe('/us/en_us/rest/V1/vitamix-api/newslettersubscribe'); + await page.route('**/us/en_us/forms**', async (route) => { + expect(route.request().method()).toBe('POST'); + const body = route.request().postDataJSON(); + + expect(body.formId).toBe('us/en_us/newsletter'); + expect(body.email).toBe('test@test.com'); + expect(body.mobile).toBe('1234567890'); + expect(body.emailOptIn).toBe(true); + expect(body.leadSource).toBe(leadSource); + expect(body.pageUrl).toContain(pageUrl); + if (smsOptin) { + expect(body.smsOptIn).toBeTruthy(); + } else { + expect(body.smsOptIn).toBeFalsy(); + } console.log('✓ Newsletter subscription request intercepted with correct parameters'); await route.fulfill({ diff --git a/tests/scripts/commerce-api-paypal.spec.js b/tests/scripts/commerce-api-paypal.spec.js new file mode 100644 index 00000000..ddc9bbb2 --- /dev/null +++ b/tests/scripts/commerce-api-paypal.spec.js @@ -0,0 +1,279 @@ +import { test, expect } from '@playwright/test'; + +/** + * Unit tests for the PayPal session helpers in scripts/commerce-api.js. + * + * The functions are inlined here with injectable dependencies because + * scripts/commerce-api.js imports auth-api.js and recaptcha.js, which in + * turn import aem.js — a browser-only module that sets window.hlx at load + * time and cannot be imported in the Node test runner. This mirrors the + * pattern used in pricing.spec.js and recaptcha.spec.js. + * + * Keep these copies in sync with the real implementations. + */ + +// --------------------------------------------------------------------------- +// Inlined implementations with injectable deps +// --------------------------------------------------------------------------- + +class CommerceApiError extends Error { + constructor(status, body, errorHeader) { + super(body?.message || `API error ${status}`); + this.status = status; + this.body = body; + this.errorHeader = errorHeader || null; + } +} + +/** + * @param {string} path + * @param {object|null} body + * @param {string} method + * @param {{ apiOrigin: string, token: string|null, fetchFn: Function }} deps + */ +async function request(path, body, method, { apiOrigin, token, fetchFn }) { + const headers = {}; + if (body !== null) headers['Content-Type'] = 'application/json'; + if (token) headers.Authorization = `Bearer ${token}`; + const resp = await fetchFn(`${apiOrigin}${path}`, { + method, + headers, + ...(body !== null ? { body: JSON.stringify(body) } : {}), + }); + const data = await resp.json(); + if (!resp.ok) throw new CommerceApiError(resp.status, data, resp.headers.get('x-error')); + return data; +} + +async function createPayPalSession(items, config, deps) { + const currency = typeof config.currency === 'function' + ? config.currency(config.getLocale()) + : config.currency; + return request('/payments/paypal/session', { + items, + currency, + locale: config.getLanguage().replace('-', '_'), + }, 'POST', deps); +} + +async function patchPayPalSession(paypalOrderId, data, deps) { + return request(`/payments/paypal/session/${paypalOrderId}`, data, 'PATCH', deps); +} + +async function getPayPalSession(paypalOrderId, deps) { + return request(`/payments/paypal/session/${paypalOrderId}`, null, 'GET', deps); +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +const ORIGIN = 'https://api.example.com'; +const PAYPAL_ORDER_ID = 'PP-ORDER-001'; +const SAMPLE_ITEMS = [ + { sku: 'SKU-001', quantity: 2, price: { final: '49.99', currency: 'USD' } }, +]; + +function makeDeps({ token = null, responseBody = {}, status = 200 } = {}) { + const headers = new Map([['x-error', null]]); + const fetchFn = async () => ({ + ok: status >= 200 && status < 300, + status, + json: async () => responseBody, + headers: { get: (k) => headers.get(k) ?? null }, + }); + return { apiOrigin: ORIGIN, token, fetchFn }; +} + +function makeCapturingDeps({ token = null, responseBody = {}, status = 200 } = {}) { + let captured; + const fetchFn = async (url, init) => { + captured = { url, init }; + return { + ok: status >= 200 && status < 300, + status, + json: async () => responseBody, + headers: { get: () => null }, + }; + }; + return { + deps: { apiOrigin: ORIGIN, token, fetchFn }, + getCapture: () => captured, + }; +} + +// --------------------------------------------------------------------------- +// createPayPalSession +// --------------------------------------------------------------------------- + +test.describe('createPayPalSession', () => { + const CONFIG = { + currency: 'USD', + getLocale: () => 'en-US', + getLanguage: () => 'en-US', + }; + + test('POSTs to /payments/paypal/session', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: { paypalOrderId: 'PP-001' } }); + await createPayPalSession(SAMPLE_ITEMS, CONFIG, deps); + expect(getCapture().url).toBe(`${ORIGIN}/payments/paypal/session`); + expect(getCapture().init.method).toBe('POST'); + }); + + test('sends items, currency, and locale in the request body', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: { paypalOrderId: 'PP-001' } }); + await createPayPalSession(SAMPLE_ITEMS, CONFIG, deps); + const body = JSON.parse(getCapture().init.body); + expect(body.items).toEqual(SAMPLE_ITEMS); + expect(body.currency).toBe('USD'); + expect(body.locale).toBe('en_US'); + }); + + test('converts locale hyphen to underscore', async () => { + const config = { ...CONFIG, getLanguage: () => 'fr-CA' }; + const { deps, getCapture } = makeCapturingDeps({ responseBody: { paypalOrderId: 'PP-001' } }); + await createPayPalSession(SAMPLE_ITEMS, config, deps); + const body = JSON.parse(getCapture().init.body); + expect(body.locale).toBe('fr_CA'); + }); + + test('resolves currency via function when config.currency is a function', async () => { + const config = { + currency: (locale) => (locale === 'en-US' ? 'USD' : 'CAD'), + getLocale: () => 'en-US', + getLanguage: () => 'en-US', + }; + const { deps, getCapture } = makeCapturingDeps({ responseBody: { paypalOrderId: 'PP-001' } }); + await createPayPalSession(SAMPLE_ITEMS, config, deps); + const body = JSON.parse(getCapture().init.body); + expect(body.currency).toBe('USD'); + }); + + test('returns the parsed response body', async () => { + const deps = makeDeps({ responseBody: { paypalOrderId: 'PP-NEW-001' } }); + const result = await createPayPalSession(SAMPLE_ITEMS, CONFIG, deps); + expect(result.paypalOrderId).toBe('PP-NEW-001'); + }); + + test('throws CommerceApiError on non-2xx response', async () => { + const deps = makeDeps({ status: 422, responseBody: { message: 'invalid items' } }); + await expect(createPayPalSession(SAMPLE_ITEMS, CONFIG, deps)) + .rejects.toBeInstanceOf(CommerceApiError); + }); +}); + +// --------------------------------------------------------------------------- +// patchPayPalSession +// --------------------------------------------------------------------------- + +test.describe('patchPayPalSession', () => { + const PATCH_DATA = { type: 'address', currency: 'USD', address: { country: 'us' } }; + + test('makes PATCH request to /payments/paypal/session/:paypalOrderId', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: {} }); + await patchPayPalSession(PAYPAL_ORDER_ID, PATCH_DATA, deps); + expect(getCapture().url).toBe(`${ORIGIN}/payments/paypal/session/${PAYPAL_ORDER_ID}`); + expect(getCapture().init.method).toBe('PATCH'); + }); + + test('sends the data object as the request body', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: {} }); + await patchPayPalSession(PAYPAL_ORDER_ID, PATCH_DATA, deps); + expect(JSON.parse(getCapture().init.body)).toEqual(PATCH_DATA); + }); + + test('returns the parsed response body', async () => { + const deps = makeDeps({ responseBody: { shippingMethods: [], subtotal: '99.98' } }); + const result = await patchPayPalSession(PAYPAL_ORDER_ID, PATCH_DATA, deps); + expect(result.subtotal).toBe('99.98'); + }); + + test('throws CommerceApiError on non-2xx response', async () => { + const deps = makeDeps({ status: 502, responseBody: { message: 'paypal_token_error' } }); + let err; + try { + await patchPayPalSession(PAYPAL_ORDER_ID, PATCH_DATA, deps); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(CommerceApiError); + expect(err.status).toBe(502); + }); +}); + +// --------------------------------------------------------------------------- +// getPayPalSession +// --------------------------------------------------------------------------- + +test.describe('getPayPalSession', () => { + test('makes GET request to /payments/paypal/session/:paypalOrderId', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: {} }); + await getPayPalSession(PAYPAL_ORDER_ID, deps); + expect(getCapture().url).toBe(`${ORIGIN}/payments/paypal/session/${PAYPAL_ORDER_ID}`); + expect(getCapture().init.method).toBe('GET'); + }); + + test('sends no body on GET', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: {} }); + await getPayPalSession(PAYPAL_ORDER_ID, deps); + expect(getCapture().init.body).toBeUndefined(); + }); + + test('returns normalized payer and shipping details', async () => { + const responseBody = { + payer: { email_address: 'buyer@example.com' }, + shippingAddress: { country: 'us' }, + selectedOptionId: 'method-standard', + }; + const deps = makeDeps({ responseBody }); + const result = await getPayPalSession(PAYPAL_ORDER_ID, deps); + expect(result.payer.email_address).toBe('buyer@example.com'); + expect(result.selectedOptionId).toBe('method-standard'); + }); + + test('throws CommerceApiError on non-2xx response', async () => { + const deps = makeDeps({ status: 404, responseBody: { message: 'not found' } }); + await expect(getPayPalSession(PAYPAL_ORDER_ID, deps)) + .rejects.toBeInstanceOf(CommerceApiError); + }); +}); + +// --------------------------------------------------------------------------- +// request helper behaviour (tested via the wrappers above) +// --------------------------------------------------------------------------- + +test.describe('request helper', () => { + test('attaches Authorization header when token is present', async () => { + const { deps, getCapture } = makeCapturingDeps({ token: 'my-token', responseBody: {} }); + await getPayPalSession(PAYPAL_ORDER_ID, deps); + expect(getCapture().init.headers.Authorization).toBe('Bearer my-token'); + }); + + test('omits Authorization header when token is absent', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: {} }); + await getPayPalSession(PAYPAL_ORDER_ID, deps); + expect(getCapture().init.headers.Authorization).toBeUndefined(); + }); + + test('sets Content-Type: application/json when body is non-null', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: {} }); + await patchPayPalSession( + PAYPAL_ORDER_ID, + { type: 'address', currency: 'USD' }, + deps, + ); + expect(getCapture().init.headers['Content-Type']).toBe('application/json'); + }); + + test('omits Content-Type when body is null (GET)', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: {} }); + await getPayPalSession(PAYPAL_ORDER_ID, deps); + expect(getCapture().init.headers['Content-Type']).toBeUndefined(); + }); + + test('does not attach a reCAPTCHA token (PayPal endpoints are rate-limited)', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: {} }); + await getPayPalSession(PAYPAL_ORDER_ID, deps); + expect(getCapture().init.headers['X-Recaptcha-Token']).toBeUndefined(); + }); +}); diff --git a/tests/scripts/paypal-express.spec.js b/tests/scripts/paypal-express.spec.js new file mode 100644 index 00000000..c3bc8e65 --- /dev/null +++ b/tests/scripts/paypal-express.spec.js @@ -0,0 +1,651 @@ +import { test, expect } from '@playwright/test'; + +/** + * Unit tests for renderExpressButton callbacks in scripts/payments/paypal.js. + * + * The callbacks are inlined here with injectable dependencies because paypal.js + * imports commerce-api.js → auth-api.js → aem.js, a browser-only module that + * cannot be loaded in the Node test runner. + * + * Keep these copies in sync with the real implementations in paypal.js. + */ + +// --------------------------------------------------------------------------- +// Inlined callback logic with injectable deps +// --------------------------------------------------------------------------- + +/** @param {{ createPayPalSessionFn: Function }} deps */ +async function handleCreateOrder(callbacks, deps) { + const config = callbacks.getConfig(); + const state = callbacks.getState(); + const cart = callbacks.getCart(); + const { paypalOrderId } = await deps.createPayPalSessionFn(cart.getItemsForAPI(), config); + state.paypalSessionId = paypalOrderId; + return paypalOrderId; +} + +/** + * @param {object} data + * @param {object} actions + * @param {{ lastShippingMethods: Array, lastShippingAddress: object|null }} closureState + * @param {object} callbacks + * @param {{ patchPayPalSessionFn: Function }} deps + */ +async function handleShippingAddressChange(data, actions, closureState, callbacks, deps) { + closureState.lastShippingAddress = data.shippingAddress; + const state = callbacks.getState(); + const cart = callbacks.getCart(); + try { + const result = await deps.patchPayPalSessionFn(state.paypalSessionId, { + type: 'address', + address: { + country: data.shippingAddress.countryCode, + state: data.shippingAddress.state, + zip: data.shippingAddress.postalCode, + }, + items: cart.getItemsForAPI(), + }); + if (!result.shippingMethods?.length) { + return actions.reject(data.errors.ADDRESS_ERROR); + } + closureState.lastShippingMethods = result.shippingMethods; + } catch { + return actions.reject(data.errors.ADDRESS_ERROR); + } + return undefined; +} + +/** + * @param {object} data + * @param {object} actions + * @param {{ lastShippingMethods: Array, lastShippingAddress: object|null }} closureState + * @param {object} callbacks + * @param {{ patchPayPalSessionFn: Function }} deps + */ +async function handleShippingOptionsChange(data, actions, closureState, callbacks, deps) { + const selectedId = data.selectedShippingOption?.id; + const method = closureState.lastShippingMethods.find((m) => m.id === selectedId); + if (!method) return actions.reject(data.errors.METHOD_UNAVAILABLE); + const state = callbacks.getState(); + const cart = callbacks.getCart(); + await deps.patchPayPalSessionFn(state.paypalSessionId, { + type: 'option', + selectedOptionId: method.id, + total: method.total, + taxAmount: method.taxAmount, + shippingRate: method.rate, + }); + const countryCode = closureState.lastShippingAddress?.countryCode?.toLowerCase(); + const preview = await callbacks.previewOrderDirect({ + items: cart.getItemsForAPI(), + shippingMethod: { id: method.id }, + ...(countryCode ? { + country: countryCode, + shipping: { + country: countryCode, + state: closureState.lastShippingAddress.state, + zip: closureState.lastShippingAddress.postalCode || '', + }, + } : {}), + }); + state.currentEstimateToken = preview.estimateToken; + return undefined; +} + +/** @param {object} callbacks @param {{ getPayPalSessionFn: Function }} deps */ +async function handleApprove(callbacks, deps) { + try { + const state = callbacks.getState(); + const session = await deps.getPayPalSessionFn(state.paypalSessionId); + const cart = callbacks.getCart(); + const config = callbacks.getConfig(); + const orderBody = { + customer: { + firstName: session.payer.firstName, + lastName: session.payer.lastName, + email: session.payer.email, + phone: '', + }, + shipping: session.shippingAddress, + billing: session.shippingAddress, + items: cart.getItemsForAPI(), + shippingMethod: { id: session.selectedOptionId }, + estimateToken: state.currentEstimateToken, + country: session.shippingAddress.country, + locale: config.getLanguage(), + }; + const createdOrder = await callbacks.createOrder(orderBody); + const fraudToken = (() => { + try { + // eslint-disable-next-line no-undef + return (typeof sessionStorage !== 'undefined' && sessionStorage.getItem('forter_token')) + || undefined; + } catch { return undefined; } + })(); + const idempotencyKey = crypto.randomUUID?.() || `${Date.now()}`; + const result = await callbacks.initiatePayment( + createdOrder.order?.id ?? createdOrder.id, + idempotencyKey, + fraudToken, + 'paypal-express', + 'paypal', + { paypalOrderId: state.paypalSessionId }, + ); + if (result.status === 'completed') { + callbacks.onComplete(createdOrder); + } else { + callbacks.showError(result.reason || 'PayPal payment failed. Please try again.'); + } + } catch { + callbacks.showError('PayPal payment failed. Please try again.'); + } +} + +/** + * Mirrors the URLSearchParams construction in loadSdk() for parameter tests. + * + * @param {string} clientId + * @param {string} currency + * @param {string} locale + * @returns {string} + */ +function buildSdkUrl(clientId, currency, locale) { + const params = new URLSearchParams({ + 'client-id': clientId, + currency, + components: 'buttons,messages', + locale, + commit: 'false', + 'enable-funding': 'paylater', + }); + return `https://www.paypal.com/sdk/js?${params}`; +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +const ITEMS = [{ sku: 'SKU-001', quantity: 1, price: { final: '99.00', currency: 'USD' } }]; +const SESSION_ID = 'PP-SESSION-001'; + +function makeState(overrides = {}) { + return { + paypalSessionId: SESSION_ID, + currentEstimateToken: 'est-token-123', + ...overrides, + }; +} + +function makeCallbacks(state = makeState(), overrides = {}) { + return { + getConfig: () => ({ + getLanguage: () => 'en-US', + currency: 'USD', + getLocale: () => 'en-US', + }), + getState: () => state, + getCart: () => ({ getItemsForAPI: () => ITEMS }), + createOrder: async () => ({ order: { id: 'ORD-001' } }), + initiatePayment: async () => ({ status: 'completed' }), + onComplete: () => {}, + showError: () => {}, + previewOrderDirect: async () => ({ estimateToken: 'est-new-456' }), + ...overrides, + }; +} + +function makeActions() { + let rejected = null; + return { + reject: (reason) => { rejected = reason; return undefined; }, + getRejected: () => rejected, + }; +} + +// --------------------------------------------------------------------------- +// createOrder callback +// --------------------------------------------------------------------------- + +test.describe('createOrder callback', () => { + test('calls createPayPalSession with cart items and config', async () => { + const state = makeState({ paypalSessionId: undefined }); + const callbacks = makeCallbacks(state); + let capturedItems; + let capturedConfig; + const deps = { + createPayPalSessionFn: async (items, config) => { + capturedItems = items; + capturedConfig = config; + return { paypalOrderId: 'PP-NEW-001' }; + }, + }; + await handleCreateOrder(callbacks, deps); + expect(capturedItems).toEqual(ITEMS); + expect(capturedConfig.currency).toBe('USD'); + }); + + test('stores paypalOrderId in state.paypalSessionId', async () => { + const state = makeState({ paypalSessionId: undefined }); + const callbacks = makeCallbacks(state); + const deps = { + createPayPalSessionFn: async () => ({ paypalOrderId: 'PP-NEW-001' }), + }; + await handleCreateOrder(callbacks, deps); + expect(state.paypalSessionId).toBe('PP-NEW-001'); + }); + + test('returns the paypalOrderId string to the caller', async () => { + const state = makeState({}); + const callbacks = makeCallbacks(state); + const deps = { + createPayPalSessionFn: async () => ({ paypalOrderId: 'PP-RETURN-001' }), + }; + const result = await handleCreateOrder(callbacks, deps); + expect(result).toBe('PP-RETURN-001'); + }); +}); + +// --------------------------------------------------------------------------- +// onShippingAddressChange callback +// --------------------------------------------------------------------------- + +test.describe('onShippingAddressChange callback', () => { + const ADDR_DATA = { + shippingAddress: { countryCode: 'US', state: 'CA', postalCode: '90210' }, + errors: { ADDRESS_ERROR: 'ADDRESS_ERROR' }, + }; + + test('patches with type=address and normalized field names', async () => { + const state = makeState(); + const callbacks = makeCallbacks(state); + const closureState = { lastShippingMethods: [], lastShippingAddress: null }; + let patchBody; + const deps = { + patchPayPalSessionFn: async (id, body) => { + patchBody = body; + return { shippingMethods: [{ id: 'std' }] }; + }, + }; + await handleShippingAddressChange(ADDR_DATA, makeActions(), closureState, callbacks, deps); + expect(patchBody.type).toBe('address'); + expect(patchBody.address.country).toBe('US'); + expect(patchBody.address.state).toBe('CA'); + expect(patchBody.address.zip).toBe('90210'); + expect(patchBody.items).toEqual(ITEMS); + }); + + test('stores shippingMethods and shippingAddress in closure state on success', async () => { + const state = makeState(); + const callbacks = makeCallbacks(state); + const closureState = { lastShippingMethods: [], lastShippingAddress: null }; + const methods = [{ id: 'std', label: 'Standard' }]; + const deps = { + patchPayPalSessionFn: async () => ({ shippingMethods: methods }), + }; + await handleShippingAddressChange(ADDR_DATA, makeActions(), closureState, callbacks, deps); + expect(closureState.lastShippingMethods).toEqual(methods); + expect(closureState.lastShippingAddress).toEqual(ADDR_DATA.shippingAddress); + }); + + test('calls actions.reject with ADDRESS_ERROR when shippingMethods is empty', async () => { + const state = makeState(); + const callbacks = makeCallbacks(state); + const closureState = { lastShippingMethods: [], lastShippingAddress: null }; + const actions = makeActions(); + const deps = { + patchPayPalSessionFn: async () => ({ shippingMethods: [] }), + }; + await handleShippingAddressChange(ADDR_DATA, actions, closureState, callbacks, deps); + expect(actions.getRejected()).toBe('ADDRESS_ERROR'); + }); + + test('calls actions.reject with ADDRESS_ERROR when patchPayPalSession throws', async () => { + const state = makeState(); + const callbacks = makeCallbacks(state); + const closureState = { lastShippingMethods: [], lastShippingAddress: null }; + const actions = makeActions(); + const deps = { + patchPayPalSessionFn: async () => { throw new Error('network error'); }, + }; + await handleShippingAddressChange(ADDR_DATA, actions, closureState, callbacks, deps); + expect(actions.getRejected()).toBe('ADDRESS_ERROR'); + }); +}); + +// --------------------------------------------------------------------------- +// onShippingOptionsChange callback +// --------------------------------------------------------------------------- + +test.describe('onShippingOptionsChange callback', () => { + const METHOD = { + id: 'std', + label: 'Standard', + total: '14.99', + taxAmount: '1.20', + rate: '5.99', + }; + const OPT_DATA = { + selectedShippingOption: { id: 'std' }, + errors: { METHOD_UNAVAILABLE: 'METHOD_UNAVAILABLE' }, + }; + + test('patches with type=option and amounts from the selected method', async () => { + const state = makeState(); + const callbacks = makeCallbacks(state); + const closureState = { + lastShippingMethods: [METHOD], + lastShippingAddress: { countryCode: 'US', state: 'CA', postalCode: '90210' }, + }; + let patchBody; + const deps = { + patchPayPalSessionFn: async (id, body) => { patchBody = body; return {}; }, + }; + await handleShippingOptionsChange(OPT_DATA, makeActions(), closureState, callbacks, deps); + expect(patchBody.type).toBe('option'); + expect(patchBody.selectedOptionId).toBe('std'); + expect(patchBody.total).toBe('14.99'); + expect(patchBody.taxAmount).toBe('1.20'); + expect(patchBody.shippingRate).toBe('5.99'); + }); + + test('calls previewOrderDirect with lowercased country and shipping from closure address', async () => { + const state = makeState(); + let previewArg; + const callbacks = makeCallbacks(state, { + previewOrderDirect: async (arg) => { previewArg = arg; return { estimateToken: 'tok' }; }, + }); + const closureState = { + lastShippingMethods: [METHOD], + lastShippingAddress: { countryCode: 'US', state: 'CA', postalCode: '90210' }, + }; + const deps = { patchPayPalSessionFn: async () => ({}) }; + await handleShippingOptionsChange(OPT_DATA, makeActions(), closureState, callbacks, deps); + expect(previewArg.country).toBe('us'); + expect(previewArg.shipping.country).toBe('us'); + expect(previewArg.shipping.state).toBe('CA'); + expect(previewArg.shipping.zip).toBe('90210'); + expect(previewArg.shippingMethod.id).toBe('std'); + }); + + test('calls previewOrderDirect without country/shipping when lastShippingAddress is null', async () => { + const state = makeState(); + let previewArg; + const callbacks = makeCallbacks(state, { + previewOrderDirect: async (arg) => { previewArg = arg; return { estimateToken: 'tok' }; }, + }); + const closureState = { lastShippingMethods: [METHOD], lastShippingAddress: null }; + const deps = { patchPayPalSessionFn: async () => ({}) }; + await handleShippingOptionsChange(OPT_DATA, makeActions(), closureState, callbacks, deps); + expect(previewArg.country).toBeUndefined(); + expect(previewArg.shipping).toBeUndefined(); + expect(previewArg.shippingMethod.id).toBe('std'); + }); + + test('stores preview.estimateToken in state.currentEstimateToken', async () => { + const state = makeState({ currentEstimateToken: null }); + const callbacks = makeCallbacks(state, { + previewOrderDirect: async () => ({ estimateToken: 'new-tok' }), + }); + const closureState = { + lastShippingMethods: [METHOD], + lastShippingAddress: { countryCode: 'US', state: 'CA', postalCode: '90210' }, + }; + const deps = { patchPayPalSessionFn: async () => ({}) }; + await handleShippingOptionsChange(OPT_DATA, makeActions(), closureState, callbacks, deps); + expect(state.currentEstimateToken).toBe('new-tok'); + }); + + test('calls actions.reject with METHOD_UNAVAILABLE when method is not in lastShippingMethods', async () => { + const state = makeState(); + const callbacks = makeCallbacks(state); + const closureState = { lastShippingMethods: [{ id: 'express' }], lastShippingAddress: null }; + const actions = makeActions(); + const deps = { patchPayPalSessionFn: async () => ({}) }; + await handleShippingOptionsChange( + { selectedShippingOption: { id: 'std' }, errors: { METHOD_UNAVAILABLE: 'METHOD_UNAVAILABLE' } }, + actions, + closureState, + callbacks, + deps, + ); + expect(actions.getRejected()).toBe('METHOD_UNAVAILABLE'); + }); +}); + +// --------------------------------------------------------------------------- +// onApprove callback +// --------------------------------------------------------------------------- + +test.describe('onApprove callback', () => { + const SESSION = { + payer: { firstName: 'John', lastName: 'Doe', email: 'john@example.com' }, + shippingAddress: { + address1: '123 Main St', city: 'Los Angeles', state: 'CA', zip: '90210', country: 'us', + }, + selectedOptionId: 'std', + }; + + test('calls getPayPalSession with state.paypalSessionId', async () => { + const state = makeState({ paypalSessionId: 'PP-TEST-001' }); + let capturedId; + const callbacks = makeCallbacks(state); + const deps = { + getPayPalSessionFn: async (id) => { capturedId = id; return SESSION; }, + }; + await handleApprove(callbacks, deps); + expect(capturedId).toBe('PP-TEST-001'); + }); + + test('builds orderBody from session payer, shippingAddress, and estimateToken', async () => { + const state = makeState({ paypalSessionId: 'PP-TEST-001', currentEstimateToken: 'est-tok' }); + let orderBody; + const callbacks = makeCallbacks(state, { + createOrder: async (body) => { orderBody = body; return { order: { id: 'ORD-001' } }; }, + }); + const deps = { getPayPalSessionFn: async () => SESSION }; + await handleApprove(callbacks, deps); + expect(orderBody.customer.firstName).toBe('John'); + expect(orderBody.customer.lastName).toBe('Doe'); + expect(orderBody.customer.email).toBe('john@example.com'); + expect(orderBody.customer.phone).toBe(''); + expect(orderBody.shipping).toEqual(SESSION.shippingAddress); + expect(orderBody.billing).toEqual(SESSION.shippingAddress); + expect(orderBody.shippingMethod.id).toBe('std'); + expect(orderBody.estimateToken).toBe('est-tok'); + expect(orderBody.country).toBe('us'); + }); + + test('calls initiatePayment with provider=paypal-express, paymentMethod=paypal, paypalOrderId', async () => { + const state = makeState({ paypalSessionId: 'PP-SESSION-XYZ' }); + let capturedArgs; + const callbacks = makeCallbacks(state, { + initiatePayment: async (...args) => { capturedArgs = args; return { status: 'completed' }; }, + }); + const deps = { getPayPalSessionFn: async () => SESSION }; + await handleApprove(callbacks, deps); + expect(capturedArgs[3]).toBe('paypal-express'); + expect(capturedArgs[4]).toBe('paypal'); + expect(capturedArgs[5]).toEqual({ paypalOrderId: 'PP-SESSION-XYZ' }); + }); + + test('calls onComplete with createdOrder when payment status is completed', async () => { + const state = makeState(); + let completedOrder; + const callbacks = makeCallbacks(state, { + onComplete: (order) => { completedOrder = order; }, + initiatePayment: async () => ({ status: 'completed' }), + }); + const deps = { getPayPalSessionFn: async () => SESSION }; + await handleApprove(callbacks, deps); + expect(completedOrder).toBeDefined(); + expect(completedOrder.order.id).toBe('ORD-001'); + }); + + test('calls showError with result.reason when payment status is not completed', async () => { + const state = makeState(); + let errorMsg; + const callbacks = makeCallbacks(state, { + showError: (msg) => { errorMsg = msg; }, + initiatePayment: async () => ({ status: 'failed', reason: 'payment_declined' }), + }); + const deps = { getPayPalSessionFn: async () => SESSION }; + await handleApprove(callbacks, deps); + expect(errorMsg).toBe('payment_declined'); + }); + + test('calls showError with fallback message when result.reason is absent', async () => { + const state = makeState(); + let errorMsg; + const callbacks = makeCallbacks(state, { + showError: (msg) => { errorMsg = msg; }, + initiatePayment: async () => ({ status: 'failed' }), + }); + const deps = { getPayPalSessionFn: async () => SESSION }; + await handleApprove(callbacks, deps); + expect(errorMsg).toBe('PayPal payment failed. Please try again.'); + }); + + test('calls showError when getPayPalSession throws', async () => { + const state = makeState(); + let errorMsg; + const callbacks = makeCallbacks(state, { + showError: (msg) => { errorMsg = msg; }, + }); + const deps = { + getPayPalSessionFn: async () => { throw new Error('network error'); }, + }; + await handleApprove(callbacks, deps); + expect(errorMsg).toBe('PayPal payment failed. Please try again.'); + }); + + test('calls showError when createOrder throws', async () => { + const state = makeState(); + let errorMsg; + const callbacks = makeCallbacks(state, { + createOrder: async () => { throw new Error('order failed'); }, + showError: (msg) => { errorMsg = msg; }, + }); + const deps = { getPayPalSessionFn: async () => SESSION }; + await handleApprove(callbacks, deps); + expect(errorMsg).toBe('PayPal payment failed. Please try again.'); + }); +}); + +// --------------------------------------------------------------------------- +// SDK load parameters +// --------------------------------------------------------------------------- + +test.describe('SDK load parameters', () => { + test('includes commit=false in the SDK URL', () => { + const url = buildSdkUrl('test-client-id', 'USD', 'en_US'); + expect(url).toContain('commit=false'); + }); + + test('includes messages in the components parameter', () => { + const url = buildSdkUrl('test-client-id', 'USD', 'en_US'); + const params = new URLSearchParams(url.split('?')[1]); + expect(params.get('components')).toContain('messages'); + }); + + test('includes paylater in the enable-funding parameter', () => { + const url = buildSdkUrl('test-client-id', 'USD', 'en_US'); + const params = new URLSearchParams(url.split('?')[1]); + expect(params.get('enable-funding')).toContain('paylater'); + }); + + test('does NOT include venmo in the enable-funding parameter', () => { + const url = buildSdkUrl('test-client-id', 'USD', 'en_US'); + const params = new URLSearchParams(url.split('?')[1]); + expect(params.get('enable-funding')).not.toContain('venmo'); + }); +}); + +// --------------------------------------------------------------------------- +// Button style config +// --------------------------------------------------------------------------- + +test.describe('button style config', () => { + const BASE_STYLE = { + layout: 'horizontal', color: 'gold', shape: 'rect', label: 'paypal', disableMaxHeight: true, + }; + + test('primary button has gold color, paypal label, and horizontal layout', () => { + expect(BASE_STYLE.color).toBe('gold'); + expect(BASE_STYLE.label).toBe('paypal'); + expect(BASE_STYLE.layout).toBe('horizontal'); + }); + + test('Pay Later button overrides color to silver and label to pay_later', () => { + const payLaterStyle = { ...BASE_STYLE, color: 'silver', label: 'pay_later' }; + expect(payLaterStyle.color).toBe('silver'); + expect(payLaterStyle.label).toBe('pay_later'); + expect(payLaterStyle.layout).toBe('horizontal'); + }); +}); + +// --------------------------------------------------------------------------- +// Pay Later eligibility +// --------------------------------------------------------------------------- + +test.describe('Pay Later eligibility', () => { + function makePaypalMock(isEligible) { + return { + Buttons: () => ({ isEligible: () => isEligible, render: () => {} }), + FUNDING: { PAYLATER: 'paylater' }, + }; + } + + test('renders Pay Later button when isEligible() returns true', () => { + const paypal = makePaypalMock(true); + const btn = paypal.Buttons({ fundingSource: paypal.FUNDING.PAYLATER }); + expect(btn.isEligible()).toBe(true); + }); + + test('does not render Pay Later button when isEligible() returns false', () => { + const paypal = makePaypalMock(false); + const btn = paypal.Buttons({ fundingSource: paypal.FUNDING.PAYLATER }); + expect(btn.isEligible()).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Stub fallback (no window.paypal) +// --------------------------------------------------------------------------- + +test.describe('stub fallback', () => { + function dispatchRender(windowPaypal, onSdk, onStub) { + if (!windowPaypal) { + onStub(); + return 'stub'; + } + onSdk(); + return 'sdk'; + } + + test('renders stub buttons and skips SDK when window.paypal is undefined', () => { + let stubCalled = false; + let sdkCalled = false; + const result = dispatchRender( + undefined, + () => { sdkCalled = true; }, + () => { stubCalled = true; }, + ); + expect(result).toBe('stub'); + expect(stubCalled).toBe(true); + expect(sdkCalled).toBe(false); + }); + + test('renders SDK buttons when window.paypal is defined', () => { + let stubCalled = false; + let sdkCalled = false; + const mockPaypal = { Buttons: () => ({ render: () => {}, isEligible: () => false }) }; + const result = dispatchRender( + mockPaypal, + () => { sdkCalled = true; }, + () => { stubCalled = true; }, + ); + expect(result).toBe('sdk'); + expect(sdkCalled).toBe(true); + expect(stubCalled).toBe(false); + }); +}); diff --git a/tests/scripts/recaptcha.spec.js b/tests/scripts/recaptcha.spec.js new file mode 100644 index 00000000..c27973bb --- /dev/null +++ b/tests/scripts/recaptcha.spec.js @@ -0,0 +1,105 @@ +import { test, expect } from '@playwright/test'; + +/** + * Unit tests for mintRecaptchaToken (from scripts/recaptcha.js). + * + * The function is inlined here because scripts/recaptcha.js imports + * loadScript from aem.js, and aem.js cannot be imported in the Node + * test runner — it sets `window.hlx = ...` at module load. This mirrors + * the existing pattern in pricing.spec.js for the same reason. + * + * Keep this copy in sync with the real module's behavior. The contract: + * returns '' on any failure (missing site key, missing SDK, execute() + * rejection, falsy token); otherwise returns the token from + * grecaptcha.enterprise.execute(siteKey, { action }). + */ + +async function mintRecaptchaToken(action, siteKey, grecaptcha) { + if (!siteKey) return ''; + if (!grecaptcha?.enterprise?.execute) return ''; + try { + await new Promise((resolve) => { grecaptcha.enterprise.ready(resolve); }); + const token = await grecaptcha.enterprise.execute(siteKey, { action }); + return token || ''; + } catch { + return ''; + } +} + +function makeFakeGrecaptcha({ token = 'fake-token', execute, ready } = {}) { + return { + enterprise: { + ready: ready || ((cb) => cb()), + execute: execute || (async () => token), + }, + }; +} + +test.describe('mintRecaptchaToken', () => { + test('returns empty string when site key is empty (graceful degradation)', async () => { + const grecaptcha = makeFakeGrecaptcha(); + const result = await mintRecaptchaToken('auth_login', '', grecaptcha); + expect(result).toBe(''); + }); + + test('returns empty string when grecaptcha is undefined', async () => { + const result = await mintRecaptchaToken('auth_login', 'site-key', undefined); + expect(result).toBe(''); + }); + + test('returns empty string when grecaptcha.enterprise is missing', async () => { + const result = await mintRecaptchaToken('auth_login', 'site-key', {}); + expect(result).toBe(''); + }); + + test('returns the token from grecaptcha.enterprise.execute on happy path', async () => { + const grecaptcha = makeFakeGrecaptcha({ token: 'real-token-123' }); + const result = await mintRecaptchaToken('auth_login', 'site-key', grecaptcha); + expect(result).toBe('real-token-123'); + }); + + test('passes the action and site key to grecaptcha.enterprise.execute', async () => { + let observedSiteKey; + let observedAction; + const grecaptcha = makeFakeGrecaptcha({ + execute: async (siteKey, opts) => { + observedSiteKey = siteKey; + observedAction = opts.action; + return 'token'; + }, + }); + await mintRecaptchaToken('orders_create', 'site-key-abc', grecaptcha); + expect(observedSiteKey).toBe('site-key-abc'); + expect(observedAction).toBe('orders_create'); + }); + + test('waits for grecaptcha.enterprise.ready before executing', async () => { + const callOrder = []; + const grecaptcha = makeFakeGrecaptcha({ + ready: (cb) => { + callOrder.push('ready'); + setTimeout(cb, 10); + }, + execute: async () => { + callOrder.push('execute'); + return 'token'; + }, + }); + await mintRecaptchaToken('auth_login', 'site-key', grecaptcha); + expect(callOrder).toEqual(['ready', 'execute']); + }); + + test('returns empty string when execute throws (fail-open)', async () => { + const grecaptcha = makeFakeGrecaptcha({ + execute: async () => { throw new Error('execute failed'); }, + }); + const result = await mintRecaptchaToken('auth_login', 'site-key', grecaptcha); + expect(result).toBe(''); + }); + + test('returns empty string when execute returns falsy', async () => { + const grecaptcha = makeFakeGrecaptcha({ execute: async () => '' }); + const result = await mintRecaptchaToken('auth_login', 'site-key', grecaptcha); + expect(result).toBe(''); + }); +}); diff --git a/widgets/account/account-address-book.js b/widgets/account/account-address-book.js new file mode 100644 index 00000000..2053c9ad --- /dev/null +++ b/widgets/account/account-address-book.js @@ -0,0 +1,311 @@ +/* eslint-disable import/prefer-default-export -- module is address-book wiring only */ +/* eslint-disable no-alert -- minimal user feedback for address errors */ +import getStatesProvincesOptions from '../forms/states-provinces.js'; +import { + createCustomerAddress, + deleteCustomerAddress, + getCustomerAddressById, + getCustomerAddresses, + renderAccountAddressList, + unwrapAddressDetail, + unwrapPayload, + updateCustomerAddress, +} from './account-api.js'; + +/** + * @param {HTMLSelectElement} select + * @param {{ value: string, label: string }[]} options + * @param {string} emptyLabel + */ +function fillRegionSelect(select, options, emptyLabel) { + if (!select) return; + select.innerHTML = ''; + const opt0 = document.createElement('option'); + opt0.value = ''; + opt0.textContent = emptyLabel; + select.append(opt0); + options.forEach((o) => { + const opt = document.createElement('option'); + opt.value = o.value; + opt.textContent = o.label; + select.append(opt); + }); +} + +/** + * @param {string} name "John Q Public" + * @returns {{ first: string, last: string }} + */ +function splitFullName(name) { + const s = String(name || '').trim(); + if (!s) return { first: '', last: '' }; + const i = s.indexOf(' '); + if (i === -1) return { first: s, last: '' }; + return { first: s.slice(0, i).trim(), last: s.slice(i + 1).trim() }; +} + +/** + * @param {HTMLFormElement} form + * @param {string} accountEmail + * @returns {Record} + */ +function readAddressForm(form, accountEmail) { + const fd = new FormData(form); + const first = String(fd.get('firstName') || '').trim(); + const last = String(fd.get('lastName') || '').trim(); + const phone = String(fd.get('phone') || '').replace(/\D/g, ''); + const country = String(fd.get('country') || 'us').toLowerCase(); + const body = { + name: `${first} ${last}`.trim() || (accountEmail || '').split('@')[0] || '—', + address1: String(fd.get('address1') || '').trim(), + address2: String(fd.get('address2') || '').trim(), + city: String(fd.get('city') || '').trim(), + state: String(fd.get('state') || '').trim(), + zip: String(fd.get('zip') || '').trim(), + country, + phone, + email: String(fd.get('email') || '').trim() || accountEmail, + isDefault: fd.get('isDefault') === 'on', + default: fd.get('isDefault') === 'on', + }; + return body; +} + +/** + * @param {HTMLFormElement} form + * @param {Record} addr + * @param {string} accountEmail + */ +function fillAddressForm(form, addr, accountEmail) { + const { first, last } = splitFullName(typeof addr.name === 'string' ? addr.name : ''); + /** @type {HTMLInputElement | null} */ + (form.querySelector('[name="firstName"]')).value = first; + /** @type {HTMLInputElement | null} */ + (form.querySelector('[name="lastName"]')).value = last; + /** @type {HTMLInputElement | null} */ + (form.querySelector('[name="address1"]')).value = String(addr.address1 ?? ''); + /** @type {HTMLInputElement | null} */ + (form.querySelector('[name="address2"]')).value = String(addr.address2 ?? ''); + /** @type {HTMLInputElement | null} */ + (form.querySelector('[name="city"]')).value = String(addr.city ?? ''); + /** @type {HTMLInputElement | null} */ + (form.querySelector('[name="zip"]')).value = String(addr.zip ?? ''); + /** @type {HTMLInputElement | null} */ + (form.querySelector('[name="phone"]')).value = String(addr.phone ?? ''); + /** @type {HTMLInputElement | null} */ + (form.querySelector('[name="email"]')).value = String(addr.email ?? accountEmail ?? ''); + /** @type {HTMLInputElement | null} */ + const defCb = form.querySelector('input[name="isDefault"]'); + if (defCb) defCb.checked = addr.isDefault === true || addr.default === true; + const countrySel = form.querySelector('[name="country"]'); + if (countrySel) { + const c = String(addr.country || 'us').toLowerCase(); + countrySel.value = ['us', 'ca', 'mx'].includes(c) ? c : 'us'; + } +} + +/** + * @param {HTMLElement} widget + * @param {string} customerEmail + * @param {string} lang - `en` | `fr` | `es` + * @param {string} marketLocale - path locale `us` | `ca` | `mx` + * @param {Record} copy + */ +export function wireAccountAddressBook(widget, customerEmail, lang, marketLocale, copy) { + if (widget.dataset.addressBookWired === '1') return; + widget.dataset.addressBookWired = '1'; + + const ab = /** @type {Record} */ (copy.addressBook || {}); + const dialog = widget.querySelector('.account-address-dialog'); + const form = widget.querySelector('.account-address-form'); + const addBtn = widget.querySelector('.account-address-add'); + const titleEl = widget.querySelector('.account-address-dialog-title'); + const errEl = widget.querySelector('.account-address-form-error'); + const saveBtn = widget.querySelector('.account-address-save'); + const cancelBtn = widget.querySelector('.account-address-cancel'); + const countrySelect = form?.querySelector('[name="country"]'); + const stateSelect = form?.querySelector('[name="state"]'); + const zipLabel = form?.querySelector('[for="account-addr-zip"] .label-text'); + const stateLabel = form?.querySelector('[for="account-addr-state"] .label-text'); + + if (!dialog || !form) return; + + const setFormCopy = () => { + const countries = /** @type {Record} */ (copy.countries || {}); + const setLab = (forId, text) => { + const lab = form.querySelector(`[for="${forId}"] .label-text`); + if (lab) lab.textContent = text; + }; + setLab('account-addr-first', ab.firstName || 'First name'); + setLab('account-addr-last', ab.lastName || 'Last name'); + setLab('account-addr-email', ab.email || 'Email'); + setLab('account-addr-line1', ab.address1 || 'Street address'); + setLab('account-addr-line2', ab.address2 || 'Address line 2'); + setLab('account-addr-city', ab.city || 'City'); + setLab('account-addr-country', ab.country || 'Country'); + setLab('account-addr-phone', ab.phone || 'Phone'); + setLab('account-addr-zip', ab.zipUs || 'ZIP code'); + setLab('account-addr-state', ab.stateUs || 'State'); + const defSpan = form.querySelector('.account-addr-default-label'); + if (defSpan) defSpan.textContent = ab.defaultAddress || 'Default address'; + if (saveBtn) saveBtn.textContent = ab.save || 'Save'; + if (cancelBtn) cancelBtn.textContent = ab.cancel || 'Cancel'; + if (addBtn) addBtn.textContent = ab.add || 'Add address'; + ['us', 'ca', 'mx'].forEach((code) => { + const opt = countrySelect?.querySelector(`option[value="${code}"]`); + if (opt) opt.textContent = countries[code] || code.toUpperCase(); + }); + }; + setFormCopy(); + + if (!customerEmail) return; + + /** @type {string | null} */ + let editingId = null; + + const defaultCountry = () => { + const m = String(marketLocale || 'us').toLowerCase(); + return ['us', 'ca', 'mx'].includes(m) ? m : 'us'; + }; + + const setZipStateLabels = (country) => { + const c = country.toLowerCase(); + if (zipLabel) { + if (c === 'us') zipLabel.textContent = ab.zipUs || 'ZIP code'; + else zipLabel.textContent = ab.postalOther || 'Postal code'; + } + if (stateLabel) { + if (c === 'us') stateLabel.textContent = ab.stateUs || 'State'; + else if (c === 'ca') stateLabel.textContent = ab.stateCa || 'Province'; + else stateLabel.textContent = ab.stateMx || 'State'; + } + }; + + const loadRegions = async () => { + if (!stateSelect || !countrySelect) return; + const country = String(countrySelect.value || 'us').toUpperCase(); + setZipStateLabels(country.toLowerCase()); + const opts = await getStatesProvincesOptions(country, lang).catch(() => []); + fillRegionSelect(stateSelect, opts, ab.regionPlaceholder || 'Select…'); + }; + + const closeDialog = () => { + dialog.close(); + editingId = null; + }; + + const showAddDialog = () => { + editingId = null; + if (errEl) { + errEl.hidden = true; + errEl.textContent = ''; + } + form.reset(); + if (countrySelect) countrySelect.value = defaultCountry(); + const em = /** @type {HTMLInputElement | null} */ (form.querySelector('[name="email"]')); + if (em) em.value = customerEmail; + if (titleEl) titleEl.textContent = ab.dialogAddTitle || 'New address'; + loadRegions().then(() => dialog.showModal()); + }; + + const showEditDialog = (raw) => { + if (errEl) { + errEl.hidden = true; + errEl.textContent = ''; + } + fillAddressForm(form, raw, customerEmail); + const pendingState = String(raw.state ?? ''); + if (titleEl) titleEl.textContent = ab.dialogEditTitle || 'Edit address'; + loadRegions().then(() => { + if (stateSelect && pendingState) { + stateSelect.value = pendingState; + } + dialog.showModal(); + }); + }; + + addBtn?.addEventListener('click', () => { + showAddDialog(); + }); + + cancelBtn?.addEventListener('click', () => closeDialog()); + + const reloadList = async () => { + const payload = await getCustomerAddresses(customerEmail); + const list = unwrapPayload(payload); + await renderAccountAddressList(widget, list ?? payload, copy); + }; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + if (errEl) { + errEl.hidden = true; + errEl.textContent = ''; + } + const body = readAddressForm(form, customerEmail); + if (!body.address1 || !body.city || !body.state || !body.zip) { + if (errEl) { + errEl.hidden = false; + errEl.textContent = ab.validationRequired || 'Please fill all required fields.'; + } + return; + } + if (saveBtn) saveBtn.disabled = true; + try { + if (editingId) { + await updateCustomerAddress(customerEmail, editingId, body); + } else { + await createCustomerAddress(customerEmail, body); + } + closeDialog(); + await reloadList(); + } catch (err) { + if (errEl) { + errEl.hidden = false; + errEl.textContent = ab.saveError || (err instanceof Error ? err.message : String(err)); + } + } finally { + if (saveBtn) saveBtn.disabled = false; + } + }); + + widget.addEventListener('click', async (e) => { + const editBtn = e.target.closest('.account-address-edit'); + const delBtn = e.target.closest('.account-address-delete'); + const li = editBtn?.closest('.account-address-item') || delBtn?.closest('.account-address-item'); + const id = li?.dataset.addressId; + if (!id || !customerEmail) return; + + if (editBtn) { + editingId = id; + const liRaw = /** @type {unknown} */ (li).accountAddressRaw; + let raw = /** @type {Record | undefined} */ (liRaw); + const street = raw && typeof raw.address1 === 'string' ? raw.address1.trim() : ''; + const minimal = !raw || !street; + if (minimal) { + try { + const fetched = await getCustomerAddressById(customerEmail, id); + raw = /** @type {Record | undefined} */ (unwrapAddressDetail(fetched)); + } catch { + raw = undefined; + } + } + if (!raw || typeof raw !== 'object') { + window.alert(ab.loadDetailError || 'Could not load address.'); + return; + } + showEditDialog(raw); + } + + if (delBtn) { + const ok = window.confirm(ab.deleteConfirm || 'Remove this address?'); + if (!ok) return; + try { + await deleteCustomerAddress(customerEmail, id); + await reloadList(); + } catch (err) { + window.alert(ab.deleteError || (err instanceof Error ? err.message : String(err))); + } + } + }); +} diff --git a/widgets/account/account-api.js b/widgets/account/account-api.js new file mode 100644 index 00000000..f8b000cf --- /dev/null +++ b/widgets/account/account-api.js @@ -0,0 +1,1174 @@ +import { createOptimizedPicture } from '../../scripts/aem.js'; +import { authFetch } from '../../scripts/auth-api.js'; +import { formatPrice, getConfig } from '../../scripts/commerce-config.js'; +import { FORMS_ENDPOINT, getLocaleAndLanguage } from '../../scripts/scripts.js'; + +/** + * GET URL for the signed-in user's forms profile (customer + newsletter opt-in status). + * @returns {string} + */ +function getFormsProfileUrl() { + const { locale, language } = getLocaleAndLanguage(); + return `${FORMS_ENDPOINT}/${locale}/${language}/forms/profile`; +} + +/* eslint-disable no-console -- VITAMIX_ACCOUNT_API_* payload logs for copy/paste integration */ + +/** + * GET forms profile: customer record + newsletter opt-in status (requires Bearer token). + * + * @returns {Promise<{ + * customer: unknown, + * profile: { + * emailOptInStatus?: boolean, + * smsOptInStatus?: boolean, + * emailAddress?: string, + * mobile?: string | null, + * } | null, + * }>} + */ +export async function fetchFormsProfile() { + const url = getFormsProfileUrl(); + const resp = await authFetch(url, { method: 'GET' }); + const text = await resp.text(); + console.log('VITAMIX_ACCOUNT_API_FORMS_PROFILE'); + console.log(`HTTP_${resp.status}`); + console.log(text); + if (!resp.ok) { + throw new Error(`Profile request failed (${resp.status})`); + } + if (!text.trim()) { + return { customer: null, profile: null }; + } + let payload = JSON.parse(text); + if (payload && typeof payload === 'object' && 'data' in payload && payload.data !== undefined) { + payload = payload.data; + } + const root = payload && typeof payload === 'object' ? /** @type {Record} */ (payload) : {}; + /** + * @type {{ + * emailOptInStatus?: boolean, + * smsOptInStatus?: boolean, + * emailAddress?: string, + * mobile?: string | null, + * } | null} + */ + const profile = root.profile && typeof root.profile === 'object' + ? root.profile + : null; + return { + customer: root.customer ?? null, + profile, + }; +} + +/** + * Base URL for customer-scoped APIs: + * `{apiOrigin}/customers/{encodedEmail}` (apiOrigin is e.g. …/aemsites/sites/vitamix). + * The email path segment is URL-encoded (e.g. `@` → `%40`) per RFC 3986. + * + * @param {string} customerEmail + * @returns {string} + */ +export function getCustomerApiBase(customerEmail) { + const origin = getConfig().apiOrigin.replace(/\/$/, ''); + return `${origin}/customers/${encodeURIComponent(customerEmail)}`; +} + +/** + * Logs a fixed tag line then the raw response body string for copy/paste. + * + * @param {string} tag + * @param {Response} resp + * @param {{ throwIfNotOk?: boolean }} [options] + * @returns {Promise} Parsed JSON or null + */ +async function readResponseAndLog(tag, resp, options = {}) { + const text = await resp.text(); + console.log(tag); + console.log(`HTTP_${resp.status}`); + console.log(text); + if (options.throwIfNotOk && !resp.ok) { + throw new Error(`Request failed (${resp.status})`); + } + if (!text.trim()) { + return null; + } + try { + return JSON.parse(text); + } catch { + return null; + } +} + +/** + * GET logged-in customer record. + * @param {string} customerEmail + * @returns {Promise} + */ +export async function getLoggedInCustomer(customerEmail) { + const url = getCustomerApiBase(customerEmail); + const resp = await authFetch(url, { method: 'GET' }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_CUSTOMER', resp); +} + +/** + * GET customer addresses list. + * @param {string} customerEmail + * @returns {Promise} + */ +export async function getCustomerAddresses(customerEmail) { + const url = `${getCustomerApiBase(customerEmail)}/addresses`; + const resp = await authFetch(url, { method: 'GET' }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ADDRESSES', resp); +} + +/** + * GET single address by id. + * @param {string} customerEmail + * @param {string} addressId + * @returns {Promise} + */ +export async function getCustomerAddressById(customerEmail, addressId) { + const url = `${getCustomerApiBase(customerEmail)}/addresses/${encodeURIComponent(addressId)}`; + const resp = await authFetch(url, { method: 'GET' }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ADDRESS_BY_ID', resp); +} + +/** + * DELETE address. + * @param {string} customerEmail + * @param {string} addressId + * @returns {Promise} + */ +export async function deleteCustomerAddress(customerEmail, addressId) { + const url = `${getCustomerApiBase(customerEmail)}/addresses/${encodeURIComponent(addressId)}`; + const resp = await authFetch(url, { method: 'DELETE' }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ADDRESS_DELETE', resp, { throwIfNotOk: true }); +} + +/** + * POST new address (stub for future UI). + * @param {string} customerEmail + * @param {Record} body + * @returns {Promise} + */ +export async function createCustomerAddress(customerEmail, body) { + const url = `${getCustomerApiBase(customerEmail)}/addresses`; + const resp = await authFetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ADDRESS_CREATE', resp, { throwIfNotOk: true }); +} + +/** + * PUT replace customer address. + * @param {string} customerEmail + * @param {string} addressId + * @param {Record} body + * @returns {Promise} + */ +export async function updateCustomerAddress(customerEmail, addressId, body) { + const url = `${getCustomerApiBase(customerEmail)}/addresses/${encodeURIComponent(addressId)}`; + const resp = await authFetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ADDRESS_UPDATE', resp, { throwIfNotOk: true }); +} + +/** + * GET customer orders list. + * @param {string} customerEmail + * @returns {Promise} + */ +export async function getCustomerOrders(customerEmail) { + const url = `${getCustomerApiBase(customerEmail)}/orders`; + const resp = await authFetch(url, { method: 'GET' }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ORDERS', resp); +} + +/** + * GET single order (stub; id may be numeric or friendly per API). + * @param {string} customerEmail + * @param {string} orderId + * @returns {Promise} + */ +export async function getCustomerOrderById(customerEmail, orderId) { + const url = `${getCustomerApiBase(customerEmail)}/orders/${encodeURIComponent(orderId)}`; + const resp = await authFetch(url, { method: 'GET' }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ORDER_BY_ID', resp, { throwIfNotOk: true }); +} + +/** + * Fetches customer, addresses, and orders in parallel for the account drawer. + * + * @param {string} customerEmail + * @returns {Promise<{ customer: unknown, addresses: unknown, orders: unknown }>} + */ +export async function fetchAccountBundle(customerEmail) { + const [customer, addresses, orders] = await Promise.all([ + getLoggedInCustomer(customerEmail), + getCustomerAddresses(customerEmail), + getCustomerOrders(customerEmail), + ]); + return { customer, addresses, orders }; +} + +/** @param {unknown} iso */ +function formatIsoForUi(iso) { + if (iso == null || iso === '') return '—'; + const s = typeof iso === 'string' ? iso : String(iso); + const d = new Date(s); + return Number.isNaN(d.getTime()) ? s : d.toLocaleString(); +} + +/** + * API returns `{ "customer": { ... } }` at the root of the customer GET response. + * + * @param {unknown} payload + * @returns {unknown} + */ +function unwrapCustomerResponse(payload) { + const afterData = (payload && typeof payload === 'object' && 'data' in payload + && /** @type {Record} */ (payload).data !== undefined) + ? /** @type {Record} */ (payload).data + : payload; + if (afterData && typeof afterData === 'object' && 'customer' in afterData + && /** @type {Record} */ (afterData).customer != null) { + return /** @type {Record} */ (afterData).customer; + } + return afterData; +} + +/** @param {unknown} payload */ +function normalizeAddressArray(payload) { + if (Array.isArray(payload)) return payload; + if (payload && typeof payload === 'object') { + const o = /** @type {Record} */ (payload); + if (Array.isArray(o.addresses)) return o.addresses; + if (Array.isArray(o.items)) return o.items; + if (Array.isArray(o.data)) return o.data; + } + return []; +} + +/** + * Turns ISO 3166-1 alpha-2 codes (e.g. `us`, `CA`) into a region name in the store language + * (from URL path, same as {@link getLocaleAndLanguage}). + * + * @param {unknown} raw + * @returns {string} + */ +function formatCountryLabel(raw) { + if (raw == null) return ''; + const s = String(raw).trim(); + if (!s) return ''; + const iso = s.length === 2 && /^[a-zA-Z]{2}$/.test(s) ? s.toUpperCase() : null; + if (!iso) return s; + + let uiLocale = 'en-US'; + try { + const { language } = getLocaleAndLanguage(true, true); + if (language && typeof language === 'string') uiLocale = language; + } catch { + /* no window */ + } + + if (typeof Intl !== 'undefined' && typeof Intl.DisplayNames === 'function') { + try { + const label = new Intl.DisplayNames([uiLocale], { type: 'region' }).of(iso); + if (label && typeof label === 'string') return label; + } catch { + /* bad locale tag */ + } + } + return iso; +} + +/** @param {unknown} payload */ +function normalizeOrderArray(payload) { + if (Array.isArray(payload)) return payload; + if (payload && typeof payload === 'object') { + const o = /** @type {Record} */ (payload); + if (Array.isArray(o.orders)) return o.orders; + if (Array.isArray(o.items)) return o.items; + if (Array.isArray(o.data)) return o.data; + } + return []; +} + +/** + * @param {Record} addr + * @param {Record} [addressBookCopy] + * @returns {{ badge: string, lines: string[] }} + */ +function mapAddressToDisplay(addr, addressBookCopy = {}) { + const defBadge = addressBookCopy.defaultBadge || 'Default'; + const addrBadge = addressBookCopy.addressBadge || 'Address'; + const id = addr.id != null ? String(addr.id) : ''; + const hasStreet = typeof addr.address1 === 'string' && addr.address1.length > 0; + + /* List endpoint: { id, email, isDefault } only — hydrate before display when possible */ + if (!hasStreet && id) { + const isDef = addr.default === true || addr.isDefault === true; + const badge = isDef ? defBadge : addrBadge; + const emailLine = typeof addr.email === 'string' ? addr.email : ''; + const lines = [emailLine].filter((x) => String(x).length); + return { + badge, + lines: lines.length ? lines : [addressBookCopy.listSummaryFallback || 'Address'], + }; + } + + const badgeParts = []; + if (addr.default === true || addr.isDefault === true) badgeParts.push(defBadge); + const badge = badgeParts.join(' · ') || addrBadge; + const name = typeof addr.name === 'string' ? addr.name : ''; + const line1 = typeof addr.address1 === 'string' ? addr.address1 : ''; + const line2 = [addr.city, addr.state, addr.zip].filter((x) => x != null && String(x).length).join(', '); + const countryRaw = typeof addr.country === 'string' ? addr.country : ''; + const country = countryRaw ? formatCountryLabel(countryRaw) : ''; + const lines = [name, line1, line2, country].filter((x) => String(x).length); + return { badge, lines: lines.length ? lines : [JSON.stringify(addr)] }; +} + +/** + * @param {HTMLElement} widget + * @param {unknown} customer + * @param {string} email + * @param {Record} copy + */ +function applyOverviewPanel(widget, customer, email, copy) { + const overview = widget.querySelector('.account-panel[data-section="overview"]'); + if (!overview) return; + const container = overview.querySelector('.account-overview-rows'); + if (!container) return; + container.innerHTML = ''; + const ov = /** @type {Record} */ (copy.overviewLabels || {}); + const countries = /** @type {Record} */ (copy.countries || {}); + const lc = String( + /** @type {{ accountLocale?: string }} */ (copy).accountLocale + || (typeof window !== 'undefined' + ? (window.location.pathname.split('/').filter(Boolean)[0] || 'us') + : 'us'), + ).toLowerCase(); + + const pushRow = (label, value) => { + if (value == null || String(value).length === 0) return; + const wrap = document.createElement('div'); + wrap.className = 'account-mock-row'; + const lab = document.createElement('span'); + lab.className = 'account-mock-label'; + lab.textContent = label; + const val = document.createElement('span'); + val.className = 'account-mock-value'; + val.textContent = String(value); + wrap.append(lab, val); + container.append(wrap); + }; + + if (customer && typeof customer === 'object') { + const c = /** @type {Record} */ (customer); + const name = typeof c.name === 'string' ? c.name + : [c.firstName, c.lastName].filter((x) => x != null && String(x).length).join(' '); + if (name) pushRow(ov.displayName || 'Name', name); + if (email) pushRow(ov.email || 'Email', email); + if (c.createdAt) pushRow(ov.memberSince || 'Member since', formatIsoForUi(c.createdAt)); + if (c.status != null || c.state != null) { + pushRow(ov.accountStatus || 'Account status', String(c.status ?? c.state)); + } + } + const regionLabel = countries[lc] || lc.toUpperCase(); + pushRow(ov.storeRegion || 'Store region', regionLabel); +} + +function pickOrderTotal(order) { + if (order.total != null) return String(order.total); + if (order.grandTotal != null) return String(order.grandTotal); + if (order.totalDue != null) return String(order.totalDue); + return '—'; +} + +/** + * Last segment after the final `-` (e.g. `…661Z-K1770KLK` → `K1770KLK`). If there is no hyphen, + * returns the trimmed string unchanged. + * + * @param {string} raw + * @returns {string} + */ +export function getOrderNumberTailSegment(raw) { + const s = String(raw ?? '').trim(); + if (!s) return ''; + const i = s.lastIndexOf('-'); + if (i === -1) return s; + const tail = s.slice(i + 1).trim(); + return tail || s; +} + +/** + * Short order number for UI: tail segment, uppercased, hyphen after the first 4 characters + * when longer (e.g. `ydm3csej` → `YDM3-CSEJ`). + * + * @param {string} raw + * @returns {string} + */ +export function formatOrderNumberForDisplay(raw) { + const tail = getOrderNumberTailSegment(raw); + const u = tail.toUpperCase(); + if (u.length <= 4) return u; + return `${u.slice(0, 4)}-${u.slice(4)}`; +} + +/** + * @param {Record} order + * @param {{ placed?: string, total?: string, state?: string }} orderLabels + */ +function mapOrderToDisplay(order, orderLabels) { + const fetchId = order.friendlyId || order.orderId || order.id || order.number || '—'; + const displaySource = order.id || order.friendlyId || order.orderId || order.number || fetchId; + const dateRaw = order.createdAt || order.created_at || order.date || order.placedAt; + const dateDisplay = formatIsoForUi(dateRaw); + const total = pickOrderTotal(order); + const state = order.state != null ? String(order.state) : ''; + const totalLabel = orderLabels.total || 'Total'; + const stateLabel = orderLabels.state || 'Status'; + const metaSecond = total !== '—' + ? `${totalLabel}: ${total}` + : `${stateLabel}: ${state || '—'}`; + const orderId = String(fetchId); + return { + orderId, + displayOrderNumber: formatOrderNumberForDisplay(String(displaySource)), + metaFirst: `${orderLabels.placed || 'Placed'}: ${dateDisplay}`, + metaSecond, + }; +} + +/** + * Builds label/value rows from a customer object (best-effort; API shape may vary). + * + * @param {unknown} customer + * @param {string} fallbackEmail + * @param {Record} [labels] + * @returns {Array<{ label: string, value: string }>} + */ +function buildInfoRowsFromCustomer(customer, fallbackEmail, labels = {}) { + if (!customer || typeof customer !== 'object') return []; + const c = /** @type {Record} */ (customer); + const rows = []; + const push = (key, val) => { + const label = labels[key]; + if (!label || val == null || !String(val).length) return; + rows.push({ label, value: String(val) }); + }; + push('email', c.email ?? fallbackEmail); + const name = typeof c.name === 'string' ? c.name + : [c.firstName, c.lastName].filter((x) => x != null && String(x).length).join(' '); + push('name', name); + push('phone', c.phone ?? c.telephone); + push('customerId', c.id ?? c.customerId ?? c.uid); + push('preferredLanguage', c.locale ?? c.language); + push('accountStatus', c.status ?? c.state); + if (c.createdAt) push('created', formatIsoForUi(c.createdAt)); + if (c.updatedAt) push('updated', formatIsoForUi(c.updatedAt)); + return rows.filter((r) => r.value.length); +} + +/** + * Updates the information panel when customer JSON is available. + * + * @param {HTMLElement} widget + * @param {unknown} customer + * @param {string} email + * @param {Record} copy + */ +function applyCustomerToWidget(widget, customer, email, copy) { + const information = widget.querySelector('.account-panel[data-section="information"]'); + if (!information) return; + const labels = /** @type {Record} */ (copy.customerInfo || {}); + const rows = buildInfoRowsFromCustomer(customer, email, labels); + const container = information.querySelector('.account-mock-rows'); + if (!container) return; + container.innerHTML = ''; + if (!rows.length) return; + rows.forEach((row) => { + const wrap = document.createElement('div'); + wrap.className = 'account-mock-row'; + const lab = document.createElement('span'); + lab.className = 'account-mock-label'; + lab.textContent = row.label; + const val = document.createElement('span'); + val.className = 'account-mock-value'; + val.textContent = row.value; + wrap.append(lab, val); + container.append(wrap); + }); +} + +/** + * @param {HTMLElement} widget + * @param {unknown} ordersPayload + * @param {{ placed?: string, total?: string, state?: string }} orderMockLabels + * @param {Record} copy + */ +function applyOrdersToWidget(widget, ordersPayload, orderMockLabels, copy = {}) { + const list = widget.querySelector('.account-order-mock-list'); + const emptyEl = widget.querySelector('.account-orders-empty'); + if (!list) return; + list.innerHTML = ''; + const raw = normalizeOrderArray(ordersPayload); + if (!raw.length) { + if (emptyEl) { + emptyEl.hidden = false; + emptyEl.textContent = String(copy.ordersEmpty || 'No orders yet.'); + } + return; + } + if (emptyEl) emptyEl.hidden = true; + raw.forEach((item) => { + if (!item || typeof item !== 'object') return; + const o = mapOrderToDisplay(/** @type {Record} */ (item), orderMockLabels); + const li = document.createElement('li'); + li.className = 'account-order-mock-row'; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'account-order-mock-item'; + btn.dataset.orderId = o.orderId; + const idEl = document.createElement('span'); + idEl.className = 'account-order-mock-id'; + idEl.textContent = o.displayOrderNumber; + idEl.title = o.orderId; + const meta = document.createElement('div'); + meta.className = 'account-order-mock-meta'; + const s1 = document.createElement('span'); + s1.textContent = o.metaFirst; + const s2 = document.createElement('span'); + s2.textContent = o.metaSecond; + meta.append(s1, s2); + btn.append(idEl, meta); + li.append(btn); + list.append(li); + }); +} + +/** If API wraps payload in `{ data: ... }`, unwrap one level. */ +export function unwrapPayload(payload) { + if (payload == null) return payload; + if (typeof payload === 'object' && 'data' in payload && payload.data !== undefined) { + return /** @type {Record} */ (payload).data; + } + return payload; +} + +/** + * GET single-address response may be `{ "address": { ... } }`. + * + * @param {unknown} payload + * @returns {unknown} + */ +export function unwrapAddressDetail(payload) { + const d = unwrapPayload(payload); + if (d && typeof d === 'object' && 'address' in d + && /** @type {Record} */ (d).address != null) { + return /** @type {Record} */ (d).address; + } + return d; +} + +/** + * When the addresses list API returns stubs only (`id`, `email`, `isDefault`), fetch each + * full record so the list can show a normal postal summary. + * + * @param {unknown[]} items + * @param {string} customerEmail + * @returns {Promise} + */ +async function hydrateAccountAddressListItems(items, customerEmail) { + if (!customerEmail || !Array.isArray(items)) return items; + return Promise.all( + items.map(async (item) => { + if (!item || typeof item !== 'object') return item; + const stub = /** @type {Record} */ (item); + const id = stub.id != null ? String(stub.id) : ''; + const street = typeof stub.address1 === 'string' ? stub.address1.trim() : ''; + if (!id || street) return stub; + try { + const payload = await getCustomerAddressById(customerEmail, id); + const detail = unwrapAddressDetail(payload); + if (detail && typeof detail === 'object') { + const d = /** @type {Record} */ (detail); + return { ...d, ...stub, id: stub.id ?? d.id }; + } + } catch { + /* keep stub for minimal display */ + } + return stub; + }), + ); +} + +/** + * @param {HTMLElement} widget + * @param {unknown} addressesPayload + * @param {Record} [copy] + * @returns {Promise} + */ +export async function renderAccountAddressList(widget, addressesPayload, copy = {}) { + const listEl = widget.querySelector('.account-address-list'); + const emptyEl = widget.querySelector('.account-address-empty'); + const ab = /** @type {Record} */ (copy.addressBook || {}); + if (!listEl) return; + listEl.innerHTML = ''; + let raw = normalizeAddressArray(addressesPayload); + if (!raw.length) { + if (emptyEl) { + emptyEl.hidden = false; + emptyEl.textContent = ab.emptyList || 'No saved addresses yet.'; + } + return; + } + if (emptyEl) emptyEl.hidden = true; + + const emailEl = widget.querySelector('.account-email-muted'); + const customerEmail = (emailEl?.textContent || '').trim(); + const needsHydration = raw.some((item) => { + if (!item || typeof item !== 'object') return false; + const a = /** @type {Record} */ (item); + const id = a.id != null ? String(a.id) : ''; + const street = typeof a.address1 === 'string' ? a.address1.trim() : ''; + return Boolean(id && !street); + }); + if (customerEmail && needsHydration) { + raw = await hydrateAccountAddressListItems(raw, customerEmail); + } + + raw + .filter((x) => x && typeof x === 'object') + .forEach((item) => { + const addr = /** @type {Record} */ (item); + const mapped = mapAddressToDisplay(addr, ab); + const li = document.createElement('li'); + li.className = 'account-address-item'; + const aid = addr.id != null ? String(addr.id) : ''; + li.dataset.addressId = aid; + /** @type {any} */ (li).accountAddressRaw = addr; + + const head = document.createElement('div'); + head.className = 'account-address-item-head'; + const badge = document.createElement('div'); + badge.className = 'account-address-badge'; + badge.textContent = mapped.badge; + const actions = document.createElement('div'); + actions.className = 'account-address-actions'; + const editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'button link-style account-address-edit'; + editBtn.textContent = ab.edit || 'Edit'; + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'button link-style account-address-delete'; + delBtn.textContent = ab.remove || 'Remove'; + actions.append(editBtn, delBtn); + head.append(badge, actions); + + const lines = document.createElement('p'); + lines.className = 'account-address-lines'; + lines.textContent = mapped.lines.join('\n'); + li.append(head, lines); + listEl.append(li); + }); +} + +/** + * @param {HTMLElement} widget + * @param {unknown} addressesPayload + * @param {Record} [copy] + */ +async function applyAddressesToWidget(widget, addressesPayload, copy = {}) { + await renderAccountAddressList(widget, addressesPayload, copy); +} + +/** + * Single-order GET may return `{ "order": { ... } }` or a bare object. + * + * @param {unknown} payload + * @returns {unknown} + */ +function unwrapOrderDetail(payload) { + const afterData = unwrapPayload(payload); + if (afterData && typeof afterData === 'object' && 'order' in afterData + && /** @type {Record} */ (afterData).order != null) { + return /** @type {Record} */ (afterData).order; + } + return afterData; +} + +/** + * @param {Record} addr + * @returns {string} + */ +function formatOrderAddressBlock(addr) { + if (!addr || typeof addr !== 'object') return ''; + const a = /** @type {Record} */ (addr); + const lines = [ + typeof a.name === 'string' ? a.name : '', + typeof a.address1 === 'string' ? a.address1 : '', + typeof a.address2 === 'string' ? a.address2 : '', + ].filter(Boolean); + const cityLine = [a.city, a.state, a.zip] + .filter((x) => x != null && String(x).length) + .join(', '); + if (cityLine) lines.push(cityLine); + if (a.country != null && String(a.country).length) { + lines.push(formatCountryLabel(String(a.country))); + } + return lines.join('\n'); +} + +/** + * @param {Record} order + * @param {Record} od + * @returns {string} + */ +function buildOrderPaymentLine(order, od) { + const rootMethod = order.paymentMethod != null ? String(order.paymentMethod) : ''; + const pay = order.payment; + if (!pay || typeof pay !== 'object') { + return rootMethod; + } + const p = /** @type {Record} */ (pay); + const method = p.method != null ? String(p.method) : ''; + const last4 = p.cardLastFour != null ? String(p.cardLastFour) : ''; + let cardPart = ''; + if (last4) { + const tpl = od.paymentCard; + cardPart = tpl && tpl.includes('{{lastFour}}') + ? tpl.replace('{{lastFour}}', last4) + : `Card ending ${last4}`; + } + const tail = [method, cardPart].filter(Boolean).join(' · '); + if (!tail) return rootMethod; + return rootMethod ? `${rootMethod} · ${tail}` : tail; +} + +/** + * @param {Record} item + * @param {string} currencyCode + * @param {string} qtyLabel + * @returns {HTMLElement} + */ +function buildReadOnlyOrderLineItem(item, currencyCode, qtyLabel) { + const qty = Number(item.quantity) || 0; + const priceObj = item.price; + const finalStr = priceObj && typeof priceObj === 'object' && priceObj != null && 'final' in priceObj + ? String(/** @type {{ final?: unknown }} */ (priceObj).final) + : String(item.price ?? '0'); + const unit = parseFloat(finalStr) || 0; + const lineTotal = unit * qty; + + const el = document.createElement('div'); + el.className = 'cart-item'; + + const imgWrap = document.createElement('div'); + imgWrap.className = 'cart-item-image'; + const imageUrl = typeof item.imageUrl === 'string' ? item.imageUrl : ''; + if (imageUrl) { + const name = typeof item.name === 'string' ? item.name : ''; + imgWrap.appendChild(createOptimizedPicture(imageUrl, name, true)); + } + + const details = document.createElement('div'); + details.className = 'cart-item-details'; + const nameP = document.createElement('p'); + nameP.className = 'cart-item-name'; + const productUrl = typeof item.productUrl === 'string' ? item.productUrl : ''; + const displayName = typeof item.name === 'string' ? item.name : String(item.sku || ''); + if (productUrl) { + const a = document.createElement('a'); + a.textContent = displayName; + try { + a.href = new URL(productUrl).pathname; + } catch { + a.href = productUrl; + } + nameP.appendChild(a); + } else { + nameP.textContent = displayName; + } + const qtyP = document.createElement('p'); + qtyP.className = 'cart-item-variant'; + const sku = item.sku != null ? String(item.sku) : ''; + qtyP.textContent = sku ? `${qtyLabel} ${qty} · SKU ${sku}` : `${qtyLabel} ${qty}`; + details.append(nameP, qtyP); + + const right = document.createElement('div'); + right.className = 'cart-item-right'; + const priceEl = document.createElement('div'); + priceEl.className = 'cart-item-price'; + priceEl.textContent = formatPrice(lineTotal, currencyCode); + right.appendChild(priceEl); + if (qty > 1) { + const each = document.createElement('div'); + each.className = 'cart-item-per-unit'; + each.textContent = `${formatPrice(unit, currencyCode)} each`; + right.appendChild(each); + } + + el.append(imgWrap, details, right); + return el; +} + +/** + * Renders checkout-style order summary (shared `order-summary` + `cart-item` classes). + * + * @param {HTMLElement} container + * @param {Record} order + * @param {{ + * orderMock?: { placed?: string, state?: string }, + * orderDetail?: Record, + * }} [copySlice] + */ +function renderOrderDetailReadout(container, order, copySlice = {}) { + container.innerHTML = ''; + if (!order || typeof order !== 'object') return; + + const o = /** @type {Record} */ (order); + const od = /** @type {Record} */ (copySlice.orderDetail || {}); + const om = /** @type {{ placed?: string, state?: string }} */ (copySlice.orderMock || {}); + const s = getConfig().getStrings(); + + const meta = document.createElement('div'); + meta.className = 'account-order-detail-meta account-mock-rows'; + + const pushMetaRow = (label, value) => { + if (value == null || String(value).length === 0) return; + const wrap = document.createElement('div'); + wrap.className = 'account-mock-row'; + const lab = document.createElement('span'); + lab.className = 'account-mock-label'; + lab.textContent = label; + const val = document.createElement('span'); + val.className = 'account-mock-value'; + val.textContent = String(value); + wrap.append(lab, val); + meta.append(wrap); + }; + + const placedRaw = o.createdAt ?? o.placedAt; + if (placedRaw) { + pushMetaRow(om.placed || od.placedOn || 'Placed', formatIsoForUi(placedRaw)); + } + if (o.state != null) { + const stateStr = String(o.state).replaceAll('_', ' '); + pushMetaRow(om.state || od.status || 'Status', stateStr); + } + + const items = Array.isArray(o.items) ? o.items.filter((x) => x && typeof x === 'object') : []; + const firstPrice = items[0] && /** @type {Record} */ (items[0]).price; + const currency = firstPrice && typeof firstPrice === 'object' && firstPrice != null && 'currency' in firstPrice + ? String(/** @type {{ currency?: unknown }} */ (firstPrice).currency || 'USD') + : 'USD'; + + let subtotalNum = 0; + items.forEach((raw) => { + const it = /** @type {Record} */ (raw); + const q = Number(it.quantity) || 0; + const pObj = it.price; + const finalStr = pObj && typeof pObj === 'object' && pObj != null && 'final' in pObj + ? String(/** @type {{ final?: unknown }} */ (pObj).final) + : String(it.price ?? '0'); + const unit = parseFloat(finalStr) || 0; + subtotalNum += unit * q; + }); + + const estimates = o.estimates && typeof o.estimates === 'object' + ? /** @type {Record} */ (o.estimates) + : {}; + const discounts = Array.isArray(estimates.discounts) ? estimates.discounts : []; + const freeShip = discounts.some((d) => d && typeof d === 'object' + && /** @type {Record} */ (d).freeShipping === true); + const shipMeta = estimates.shippingMethod && typeof estimates.shippingMethod === 'object' + ? /** @type {Record} */ (estimates.shippingMethod) + : null; + const rateRaw = shipMeta?.rate; + const rateNum = typeof rateRaw === 'number' ? rateRaw : parseFloat(String(rateRaw ?? NaN)); + let shipAmount = 0; + let shipText = '—'; + if (freeShip) { + shipText = s.free; + shipAmount = 0; + } else if (Number.isFinite(rateNum)) { + shipAmount = rateNum; + shipText = formatPrice(rateNum, currency); + } + + const taxObj = estimates.tax && typeof estimates.tax === 'object' + ? /** @type {Record} */ (estimates.tax) + : null; + const taxNum = taxObj != null + ? parseFloat(String(taxObj.totalTax ?? taxObj.amount ?? NaN)) + : NaN; + const taxText = Number.isFinite(taxNum) ? formatPrice(taxNum, currency) : '—'; + const taxAmount = Number.isFinite(taxNum) ? taxNum : 0; + + const totalNum = subtotalNum + shipAmount + taxAmount; + + const summaryRoot = document.createElement('div'); + summaryRoot.className = 'order-summary'; + summaryRoot.innerHTML = ` +
+

+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+ `; + + const h3 = summaryRoot.querySelector('h3'); + if (h3) h3.textContent = s.orderSummary; + + const lblSub = summaryRoot.querySelector('.od-lbl-sub'); + const valSub = summaryRoot.querySelector('.od-val-sub'); + const lblShip = summaryRoot.querySelector('.od-lbl-ship'); + const valShip = summaryRoot.querySelector('.od-val-ship'); + const lblTax = summaryRoot.querySelector('.od-lbl-tax'); + const valTax = summaryRoot.querySelector('.od-val-tax'); + const lblTotal = summaryRoot.querySelector('.od-lbl-total'); + const valTotal = summaryRoot.querySelector('.od-val-total'); + const curEl = summaryRoot.querySelector('.currency'); + + if (lblSub) lblSub.textContent = s.subtotal; + if (valSub) valSub.textContent = formatPrice(subtotalNum, currency); + if (lblShip) lblShip.textContent = s.shipping; + if (valShip) valShip.textContent = shipText; + if (lblTax) lblTax.textContent = s.estimatedTaxes; + if (valTax) valTax.textContent = taxText; + if (lblTotal) lblTotal.textContent = s.total; + if (valTotal) valTotal.textContent = formatPrice(totalNum, currency); + if (curEl) curEl.textContent = currency; + + const itemsRoot = summaryRoot.querySelector('.order-summary-items'); + const qtyLabel = `${od.qty || 'Qty.'} `; + if (itemsRoot) { + items.forEach((raw) => { + const lineItem = /** @type {Record} */ (raw); + const row = buildReadOnlyOrderLineItem(lineItem, currency, qtyLabel.trim()); + itemsRoot.appendChild(row); + }); + } + + const addrWrap = document.createElement('div'); + addrWrap.className = 'account-order-detail-addresses'; + + const shipBody = formatOrderAddressBlock( + o.shipping && typeof o.shipping === 'object' ? /** @type {Record} */ (o.shipping) : {}, + ); + const billBody = formatOrderAddressBlock( + o.billing && typeof o.billing === 'object' ? /** @type {Record} */ (o.billing) : {}, + ); + + if (shipBody) { + const sec = document.createElement('div'); + sec.className = 'account-order-detail-address-block'; + const h = document.createElement('h6'); + h.className = 'account-order-detail-address-title'; + h.textContent = od.shippingAddress || 'Shipping address'; + const p = document.createElement('p'); + p.className = 'account-address-lines'; + p.textContent = shipBody; + sec.append(h, p); + addrWrap.append(sec); + } + if (billBody) { + const sec = document.createElement('div'); + sec.className = 'account-order-detail-address-block'; + const h = document.createElement('h6'); + h.className = 'account-order-detail-address-title'; + h.textContent = od.billingAddress || 'Billing address'; + const p = document.createElement('p'); + p.className = 'account-address-lines'; + p.textContent = billBody; + sec.append(h, p); + addrWrap.append(sec); + } + + const payLine = buildOrderPaymentLine(o, od); + + container.append(meta, summaryRoot); + if (addrWrap.childElementCount) { + container.append(addrWrap); + } + if (payLine) { + const foot = document.createElement('div'); + foot.className = 'account-order-detail-foot account-mock-rows'; + const wrap = document.createElement('div'); + wrap.className = 'account-mock-row'; + const lab = document.createElement('span'); + lab.className = 'account-mock-label'; + lab.textContent = od.payment || 'Payment'; + const val = document.createElement('span'); + val.className = 'account-mock-value'; + val.textContent = payLine; + wrap.append(lab, val); + foot.append(wrap); + container.append(foot); + } +} + +/** + * One-time: order row opens detail panel, fetches GET …/orders/:id, logs payloads, shows readout. + * Customer email is read from `.account-email-muted` on each click so it stays in sync with the UI. + * + * @param {HTMLElement} widget + * @param {{ orderMock?: object, orderDetail?: Record }} [copySlice] + */ +export function wireOrderDetailInteractions(widget, copySlice = {}) { + if (widget.dataset.orderDetailWired === '1') return; + widget.dataset.orderDetailWired = '1'; + + const ordersPanel = widget.querySelector('.account-panel[data-section="orders"]'); + if (!ordersPanel) return; + + const listPanel = ordersPanel.querySelector('.account-orders-list-panel'); + const detailPanel = ordersPanel.querySelector('.account-order-detail-panel'); + const list = ordersPanel.querySelector('.account-order-mock-list'); + const back = ordersPanel.querySelector('.account-order-detail-back'); + const heading = ordersPanel.querySelector('.account-order-detail-heading'); + const readout = ordersPanel.querySelector('.account-order-detail-readout'); + const errEl = ordersPanel.querySelector('.account-order-detail-error'); + const od = copySlice.orderDetail || {}; + + if (back) back.textContent = od.back || '← All orders'; + + const showList = () => { + if (listPanel) listPanel.hidden = false; + if (detailPanel) detailPanel.hidden = true; + if (errEl) { + errEl.hidden = true; + errEl.textContent = ''; + } + }; + + const showDetail = () => { + if (listPanel) listPanel.hidden = true; + if (detailPanel) detailPanel.hidden = false; + }; + + back?.addEventListener('click', showList); + + const mo = new MutationObserver(() => { + if (ordersPanel.hidden) showList(); + }); + mo.observe(ordersPanel, { attributes: true, attributeFilter: ['hidden'] }); + + list?.addEventListener('click', async (e) => { + const btn = e.target.closest('button.account-order-mock-item'); + if (!btn) return; + const { orderId } = btn.dataset; + const emailEl = widget.querySelector('.account-email-muted'); + const customerEmail = (emailEl?.textContent || '').trim(); + if (!orderId || !customerEmail) return; + + showDetail(); + if (heading) { + heading.textContent = formatOrderNumberForDisplay(orderId); + heading.title = orderId; + } + if (readout) { + readout.innerHTML = ''; + const loadP = document.createElement('p'); + loadP.className = 'account-order-detail-loading'; + loadP.textContent = od.loading || 'Loading…'; + readout.append(loadP); + } + if (errEl) { + errEl.hidden = true; + errEl.textContent = ''; + } + + try { + const payload = await getCustomerOrderById(customerEmail, orderId); + const display = unwrapOrderDetail(payload); + console.log('VITAMIX_ACCOUNT_API_ORDER_DETAIL_PARSED'); + console.log(JSON.stringify(display)); + if (readout && display && typeof display === 'object') { + const orderRecord = /** @type {Record} */ (display); + renderOrderDetailReadout(readout, orderRecord, copySlice); + } else if (readout) { + readout.innerHTML = ''; + } + } catch (err) { + if (readout) readout.innerHTML = ''; + if (errEl) { + errEl.hidden = false; + errEl.textContent = od.error || (err instanceof Error ? err.message : String(err)); + } + console.log('VITAMIX_ACCOUNT_API_ORDER_DETAIL_EXCEPTION'); + console.log(err instanceof Error ? err.message : String(err)); + } + }); +} + +/** + * After fetchAccountBundle, maps API payloads onto the account widget DOM. + * + * @param {HTMLElement} widget + * @param {{ customer: unknown, addresses: unknown, orders: unknown }} data + * @param {{ + * orderMock?: { placed?: string, total?: string, state?: string }, + * orderDetail?: Record, + * }} [copySlice] + */ +export async function applyAccountDataToWidget(widget, data, copySlice = {}) { + const email = /** @type {HTMLParagraphElement | null} */ (widget.querySelector('.account-email-muted'))?.textContent?.trim() || ''; + + let customer = unwrapCustomerResponse(data.customer); + if (Array.isArray(customer) && customer.length === 1) { + [customer] = customer; + } + + const addresses = unwrapPayload(data.addresses); + const orders = unwrapPayload(data.orders); + + if (customer && typeof customer === 'object') { + applyOverviewPanel(widget, customer, email, copySlice); + applyCustomerToWidget(widget, customer, email, copySlice); + } else { + applyOverviewPanel(widget, null, email, copySlice); + } + if (addresses != null) { + await applyAddressesToWidget(widget, addresses, copySlice); + } else { + await renderAccountAddressList(widget, [], copySlice); + } + if (orders != null) { + applyOrdersToWidget(widget, orders, copySlice.orderMock || {}, copySlice); + } else { + applyOrdersToWidget(widget, [], copySlice.orderMock || {}, copySlice); + } +} diff --git a/widgets/account/account.css b/widgets/account/account.css new file mode 100644 index 00000000..13f63318 --- /dev/null +++ b/widgets/account/account.css @@ -0,0 +1,655 @@ +/* OTP account drawer — left nav + full-width main; minimal main content */ + +.account-widget { + font-family: var(--commerce-font-family); + color: var(--commerce-color-text); + margin-top: 1em; + text-align: left; +} + +.account-layout { + display: flex; + flex-direction: column; + gap: var(--commerce-gap); +} + +.account-sidebar { + min-width: 0; +} + +.account-summary-card { + display: flex; + flex-direction: column; + background-color: var(--commerce-color-background); + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); + overflow: hidden; + min-height: 0; + box-shadow: none; +} + +.account-summary-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--commerce-color-border); +} + +.account-summary-header h3 { + margin: 0; + font-size: var(--heading-size-l, 1.25rem); + font-weight: var(--commerce-font-weight-bold); +} + +.account-email-muted { + margin: 8px 0 0; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); +} + +.account-nav-shell { + position: relative; + flex: 1; + min-height: 0; +} + +.account-nav-mobile { + display: none; + padding: 12px 16px; + border-bottom: 1px solid var(--commerce-color-border); + background: var(--commerce-color-background); +} + +.account-sidebar-footer { + padding: 12px 16px 16px; + margin-top: auto; + border-top: 1px solid var(--commerce-color-border); +} + +.account-widget-mobile-nav-select .account-nav-mobile { + display: block; +} + +.account-widget-mobile-nav-select .account-nav { + display: none; +} + +/* On mobile compact nav, sign out is only in the select — hide duplicate footer button */ +.account-widget-mobile-nav-select .account-sidebar-footer { + display: none; +} + +.account-nav-select { + width: 100%; + box-sizing: border-box; + min-height: var(--commerce-input-height, 48px); + padding: 12px 16px; + font-size: var(--commerce-font-size-sm); + font-family: inherit; + color: var(--commerce-color-text); + background: var(--commerce-input-background); + border: 1px solid var(--commerce-input-border); + border-radius: var(--commerce-input-radius); + cursor: pointer; + appearance: auto; +} + +.account-nav-select:focus { + outline: none; + border-color: var(--commerce-input-border-focus); +} + +.account-nav-list { + list-style: none; + margin: 0; + padding: 0; +} + +.account-nav-list li { + border-bottom: 1px solid var(--commerce-color-border); +} + +.account-nav-list li:last-child { + border-bottom: none; +} + +.account-nav-item { + display: block; + width: 100%; + padding: 14px 24px; + text-align: left; + font-size: var(--commerce-font-size-sm); + font-weight: 500; + font-family: inherit; + color: var(--commerce-color-text); + background: var(--commerce-color-background); + border: none; + cursor: pointer; + transition: background 0.15s, color 0.15s; + box-sizing: border-box; +} + +.account-nav-item:hover { + background: var(--commerce-color-surface); +} + +.account-nav-item.is-active { + background: var(--commerce-color-surface); + font-weight: var(--commerce-font-weight-bold); + border-left: 3px solid var(--commerce-color-primary); + padding-left: calc(24px - 3px); +} + +.account-logout { + width: 100%; + box-sizing: border-box; + min-height: var(--commerce-button-height); + padding: 10px 16px; + font-size: var(--commerce-font-size-sm); + font-weight: var(--commerce-font-weight-bold); + color: var(--commerce-color-error); + background: transparent; + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-button-radius); + cursor: pointer; + font-family: inherit; + text-align: left; + transition: background 0.15s, border-color 0.15s; +} + +.account-logout:hover { + background: var(--commerce-color-error-bg); + border-color: var(--commerce-color-error-border); +} + +.account-logout:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.account-main { + display: flex; + flex-direction: column; + gap: 0; + min-width: 0; + flex: 1; + text-align: left; +} + +.account-panels { + flex: 1; + min-height: 0; + min-width: 0; +} + +.account-panel { + display: none; + text-align: left; +} + +.account-panel.is-visible { + display: block; +} + +/* Main column: flat, left-aligned, uses drawer width — no popover card */ +.account-panel-body { + padding: 0 8px 24px 0; + margin: 0; + text-align: left; + background: transparent; + border: none; + border-radius: 0; + max-width: 100%; +} + +.account-panel-title { + margin: 0 0 16px; + padding-bottom: 8px; + font-size: var(--commerce-font-size-base); + font-weight: var(--commerce-font-weight-bold); + border-bottom: 1px solid var(--commerce-color-border); +} + +.account-panel-intro { + margin: 0 0 20px; + max-width: 52rem; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); + line-height: 1.55; +} + +.account-mock-rows { + margin: 0; + max-width: 40rem; + display: flex; + flex-direction: column; + gap: 0; +} + +.account-mock-row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + padding: 14px 0; + border-bottom: 1px solid var(--commerce-color-border); + font-size: var(--commerce-font-size-sm); +} + +.account-mock-row:last-child { + border-bottom: none; +} + +.account-mock-label { + color: var(--commerce-color-text-muted); + font-weight: 400; + font-size: var(--commerce-font-size-xs); +} + +.account-mock-value { + text-align: left; + font-weight: 500; + color: var(--commerce-color-text); +} + +.account-communications { + margin-top: 28px; + padding-top: 20px; + border-top: 1px solid var(--commerce-color-border); + max-width: 40rem; +} + +.account-communications-title { + margin: 0 0 8px; + font-size: var(--commerce-font-size-base); + font-weight: var(--commerce-font-weight-bold); + color: var(--commerce-color-text); +} + +.account-communications-question { + margin: 0 0 16px; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); + line-height: 1.55; + min-height: 1.55em; +} + +.account-communications-question-copy:not(.is-visible) { + display: none; +} + +.account-shimmer { + display: none; + border-radius: var(--commerce-input-radius, 4px); + background: linear-gradient( + 90deg, + var(--commerce-color-border, #e0e0e0) 0%, + var(--commerce-color-background, #f5f5f5) 45%, + var(--commerce-color-border, #e0e0e0) 90% + ); + background-size: 200% 100%; + animation: account-shimmer 1.2s ease-in-out infinite; +} + +.account-shimmer.is-visible { + display: block; +} + +.account-communications-question-shimmer { + width: min(100%, 28rem); + height: 1.1em; +} + +.account-communications-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + min-height: 48px; +} + +.account-communications-btn-shimmer { + width: 140px; + height: var(--commerce-button-height, 48px); +} + +@keyframes account-shimmer { + 0% { + background-position: 200% 0; + } + + 100% { + background-position: -200% 0; + } +} + +/* `button.button { display: inline-block }` in styles.css overrides the [hidden] attribute */ +.account-communications-actions > .account-communications-btn-shimmer, +.account-communications-actions > .button { + display: none !important; +} + +.account-communications-actions > .account-communications-btn-shimmer.is-visible { + display: block !important; +} + +.account-communications-actions > .button.is-visible { + display: inline-block !important; +} + +.account-communications-error { + margin: 12px 0 0; + font-size: var(--commerce-font-size-sm); + line-height: 1.5; + color: var(--commerce-color-danger, #b00020); +} + +.account-address-list { + list-style: none; + margin: 0; + padding: 0; + max-width: 42rem; +} + +.account-address-toolbar { + margin: 0 0 16px; +} + +.account-address-item-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.account-address-actions { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + flex-shrink: 0; +} + +.account-address-dialog { + max-width: min(100vw - 32px, 560px); + width: 100%; + padding: 0; + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); + background: var(--commerce-color-background); +} + +.account-address-dialog::backdrop { + background: rgb(0 0 0 / 45%); +} + +.account-address-dialog-inner { + padding: 20px 24px 24px; +} + +.account-address-dialog-title { + margin: 0 0 16px; + font-size: var(--commerce-font-size-lg); +} + +.account-address-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px 16px; +} + +.account-addr-field-full { + grid-column: 1 / -1; +} + +.account-addr-field label { + display: block; + margin-bottom: 4px; + font-size: var(--commerce-font-size-xs); + color: var(--commerce-color-text-muted); +} + +.account-addr-field .required { + color: var(--commerce-color-error-text, #b00020); +} + +.account-addr-field input:not([type='checkbox'], [type='radio']), +.account-addr-field select { + width: 100%; + box-sizing: border-box; + padding: 10px 12px; + 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); +} + +.account-addr-field-checkbox label { + display: flex; + flex-flow: row nowrap; + align-items: flex-start; + justify-content: flex-start; + gap: 10px; + margin: 0; + cursor: pointer; + color: var(--commerce-color-text); + font-size: var(--commerce-font-size-sm); +} + +.account-addr-field-checkbox input[type='checkbox'] { + width: auto; + min-width: 1rem; + height: auto; + margin: 0.2em 0 0; + flex-shrink: 0; + cursor: pointer; +} + +.account-addr-field-checkbox .account-addr-default-label { + flex: 1 1 auto; + min-width: 0; + line-height: 1.35; +} + +.account-address-form-error { + margin: 12px 0 0; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-error-text, #b00020); +} + +.account-address-form-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 20px; +} + +.account-orders-empty { + margin: 0 0 12px; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); +} + +.account-address-item { + margin: 0 0 20px; + padding-bottom: 20px; + border-bottom: 1px solid var(--commerce-color-border); +} + +.account-address-item:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.account-address-badge { + margin: 0 0 8px; + font-size: var(--commerce-font-size-xs); + font-weight: var(--commerce-font-weight-bold); + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--commerce-color-text-muted); +} + +.account-address-lines { + margin: 0; + font-size: var(--commerce-font-size-sm); + line-height: 1.65; + color: var(--commerce-color-text); + white-space: pre-line; +} + +.account-orders-stack { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 42rem; +} + +.account-order-mock-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0; +} + +.account-order-mock-row { + margin: 0; + padding: 0; +} + +.account-order-mock-item { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + margin: 0; + padding: 14px 0; + border: none; + border-bottom: 1px solid var(--commerce-color-border); + border-radius: 0; + background: transparent; + font: inherit; + text-align: left; + color: inherit; + cursor: pointer; +} + +.account-order-mock-item:hover { + background: color-mix(in srgb, var(--commerce-color-text) 4%, transparent); +} + +.account-order-mock-row:last-child .account-order-mock-item { + border-bottom: none; +} + +.account-order-mock-id { + font-weight: var(--commerce-font-weight-bold); + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text); +} + +.account-order-mock-meta { + display: flex; + flex-wrap: wrap; + gap: 12px 24px; + font-size: var(--commerce-font-size-xs); + color: var(--commerce-color-text-muted); +} + +.account-order-detail-back { + margin: 0 0 12px; + padding: 0; + border: none; + background: none; + font: inherit; + color: var(--commerce-color-primary); + cursor: pointer; + text-decoration: underline; +} + +.account-order-detail-heading { + margin: 0 0 12px; + font-size: var(--commerce-font-size-md); + font-weight: var(--commerce-font-weight-bold); +} + +.account-order-detail-readout { + max-width: 42rem; +} + +.account-order-detail-readout .order-summary { + margin-top: 0; +} + +.account-order-detail-meta { + margin-bottom: 20px; +} + +.account-order-detail-loading { + margin: 0; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); +} + +.account-order-detail-addresses { + display: grid; + gap: 20px; + margin-top: 24px; + max-width: 42rem; +} + +@media (width >= 640px) { + .account-order-detail-addresses { + grid-template-columns: 1fr 1fr; + } +} + +.account-order-detail-address-title { + margin: 0 0 8px; + font-size: var(--commerce-font-size-xs); + font-weight: var(--commerce-font-weight-bold); + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--commerce-color-text-muted); +} + +.account-order-detail-foot { + margin-top: 20px; + max-width: 42rem; +} + +.account-order-detail-error { + margin: 12px 0 0; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-error-text, #b00020); +} + +@media (width >= 768px) { + .account-layout { + display: grid; + grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); + gap: clamp(1rem, 3vw, 2.5rem); + align-items: start; + } + + .account-sidebar { + position: sticky; + top: 0; + } + + .account-panel-body { + padding-right: clamp(8px, 2vw, 32px); + } + + .account-widget-mobile-nav-select .account-nav-mobile { + display: none; + } + + .account-widget-mobile-nav-select .account-nav { + display: block; + } + + .account-widget-mobile-nav-select .account-sidebar-footer { + display: block; + } +} diff --git a/widgets/account/account.html b/widgets/account/account.html new file mode 100644 index 00000000..f8a2316a --- /dev/null +++ b/widgets/account/account.html @@ -0,0 +1,164 @@ + diff --git a/widgets/account/account.js b/widgets/account/account.js new file mode 100644 index 00000000..b4118cff --- /dev/null +++ b/widgets/account/account.js @@ -0,0 +1,345 @@ +import { loadCSS } from '../../scripts/aem.js'; +import { fetchFormsProfile, renderAccountAddressList } from './account-api.js'; +import { getFormSubmissionUrl, getLocaleAndLanguage } from '../../scripts/scripts.js'; +import { getUser, logout } from '../../scripts/auth-api.js'; + +/** Select option value for sign out (not a content section). */ +const LOGOUT_SELECT_VALUE = '__logout__'; + +/** + * @param {string} lang + * @returns {Promise>} + */ +async function loadCopy(lang) { + const jsonPath = new URL('./account.json', import.meta.url).pathname; + const url = `${window.hlx?.codeBasePath || ''}${jsonPath}`; + const resp = await fetch(url); + const data = await resp.json(); + const key = data[lang] ? lang : 'en'; + return data[key]; +} + +/** + * @param {HTMLElement} widget + */ +export default async function decorate(widget) { + const base = window.hlx?.codeBasePath || ''; + await Promise.all([ + loadCSS(`${base}/styles/commerce-tokens.css`), + loadCSS(`${base}/widgets/account/account.css`), + loadCSS(`${base}/blocks/order-summary/order-summary.css`), + loadCSS(`${base}/scripts/commerce/cart-item.css`), + ]); + + const { locale, language } = getLocaleAndLanguage(); + const lang = (language || 'en_us').split('_')[0]; + const copy = await loadCopy(lang); + const email = getUser()?.email || ''; + const dialog = widget.closest('dialog'); + const titleEl = dialog?.querySelector('.slide-panel-header h2'); + if (titleEl && copy.modalTitle) titleEl.textContent = copy.modalTitle; + + const nav = copy.nav || {}; + const navButtons = widget.querySelectorAll('.account-nav-item'); + const panelEls = widget.querySelectorAll('.account-panel'); + const navSelect = widget.querySelector('.account-nav-select'); + + navButtons.forEach((btn) => { + const key = btn.dataset.section; + if (key && nav[key]) btn.textContent = nav[key]; + }); + + const buildNavSelectOptions = () => { + if (!navSelect) return; + navSelect.setAttribute('aria-label', copy.sectionSelectAria || 'Choose section'); + navSelect.innerHTML = ''; + navButtons.forEach((btn) => { + const { section } = btn.dataset; + if (!section) return; + const opt = document.createElement('option'); + opt.value = section; + opt.textContent = btn.textContent || section; + navSelect.append(opt); + }); + const logoutOpt = document.createElement('option'); + logoutOpt.value = LOGOUT_SELECT_VALUE; + logoutOpt.textContent = copy.logout || 'Log out'; + navSelect.append(logoutOpt); + }; + buildNavSelectOptions(); + + const greetingEl = widget.querySelector('.account-greeting'); + const emailEl = widget.querySelector('.account-email-muted'); + if (greetingEl) { + const local = email ? email.split('@')[0] : ''; + greetingEl.textContent = local ? `${copy.greeting}, ${local}` : copy.greeting; + } + if (emailEl) emailEl.textContent = email || ''; + + const panels = copy.panels || {}; + const overview = widget.querySelector('.account-panel[data-section="overview"]'); + if (overview) { + const p = panels.overview || {}; + const t = overview.querySelector('.account-panel-title'); + const intro = overview.querySelector('.account-panel-intro'); + if (t) t.textContent = p.title || ''; + if (intro) intro.textContent = p.intro || ''; + } + + const information = widget.querySelector('.account-panel[data-section="information"]'); + if (information) { + const p = panels.information || {}; + const t = information.querySelector('.account-panel-title'); + if (t) t.textContent = p.title || ''; + const comm = /** @type {Record} */ (copy.communications || {}); + const commRoot = information.querySelector('.account-communications'); + const commTitle = information.querySelector('.account-communications-title'); + const commQuestionCopy = information.querySelector('.account-communications-question-copy'); + const commQuestionShimmer = information.querySelector('.account-communications-question-shimmer'); + const commBtnShimmer = information.querySelector('.account-communications-btn-shimmer'); + const commSubscribe = information.querySelector('.account-communications-subscribe'); + const commUnsubscribe = information.querySelector('.account-communications-unsubscribe'); + const commError = information.querySelector('.account-communications-error'); + if (commTitle) commTitle.textContent = comm.title || 'Communications'; + if (commSubscribe) commSubscribe.textContent = comm.subscribe || 'Subscribe'; + if (commUnsubscribe) commUnsubscribe.textContent = comm.unsubscribe || 'Unsubscribe'; + + /** @type {boolean} */ + let emailOptInStatus = false; + + /** + * Buttons use a class for visibility — site `button.button { display: inline-block }` + * overrides `[hidden]`. + */ + const COMM_VISIBLE = 'is-visible'; + + const applyCommCopy = () => { + if (!commQuestionCopy) return; + const subscribed = emailOptInStatus === true; + commQuestionCopy.textContent = subscribed + ? (comm.questionSubscribed + || 'You are currently subscribed to periodic emails and newsletters from Vitamix.') + : (comm.question + || 'Would you like us to send you periodic emails and newsletters from Vitamix?'); + }; + + const setCommBusy = (busy) => { + commQuestionShimmer?.classList.toggle(COMM_VISIBLE, busy); + commBtnShimmer?.classList.toggle(COMM_VISIBLE, busy); + commQuestionCopy?.classList.toggle(COMM_VISIBLE, !busy); + if (busy) { + commSubscribe?.classList.remove(COMM_VISIBLE); + commUnsubscribe?.classList.remove(COMM_VISIBLE); + } + }; + + const hideCommError = () => { + if (commError) { + commError.hidden = true; + commError.textContent = ''; + } + }; + + const showCommError = (message) => { + if (!commError) return; + commError.textContent = message; + commError.hidden = false; + }; + + const applyCommOptInUi = () => { + applyCommCopy(); + const subscribed = emailOptInStatus === true; + commSubscribe?.classList.toggle(COMM_VISIBLE, !subscribed); + commUnsubscribe?.classList.toggle(COMM_VISIBLE, subscribed); + if (commSubscribe) commSubscribe.disabled = false; + if (commUnsubscribe) commUnsubscribe.disabled = false; + }; + + const submitNewsletterPreference = async (emailOptIn) => { + const trimmed = (email || '').trim(); + if (!trimmed) return; + const country = window.location.pathname.split('/')[1] || 'us'; + const leadSource = `sub-em-account-${country}`; + const payload = { + formId: `${locale}/${language}/newsletter`, + pageUrl: window.location.href, + email: trimmed, + mobile: '', + smsOptIn: false, + emailOptIn, + leadSource, + }; + hideCommError(); + setCommBusy(true); + try { + const url = getFormSubmissionUrl(); + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + showCommError(comm.error || 'Something went wrong. Please try again.'); + setCommBusy(false); + applyCommOptInUi(); + return; + } + emailOptInStatus = emailOptIn; + setCommBusy(false); + applyCommOptInUi(); + } catch { + showCommError(comm.error || 'Something went wrong. Please try again.'); + setCommBusy(false); + applyCommOptInUi(); + } + }; + + const loadCommunicationsProfile = async () => { + setCommBusy(true); + try { + const { profile } = await fetchFormsProfile(); + if (profile && typeof profile.emailOptInStatus === 'boolean') { + emailOptInStatus = profile.emailOptInStatus; + } + } catch { + /* keep default not subscribed */ + } finally { + setCommBusy(false); + applyCommOptInUi(); + } + }; + + if (commRoot && email) { + commRoot.hidden = false; + commSubscribe?.addEventListener('click', () => { + submitNewsletterPreference(true); + }); + commUnsubscribe?.addEventListener('click', () => { + submitNewsletterPreference(false); + }); + loadCommunicationsProfile(); + } + } + + const address = widget.querySelector('.account-panel[data-section="address"]'); + if (address) { + const p = panels.address || {}; + const t = address.querySelector('.account-panel-title'); + const addBtn = address.querySelector('.account-address-add'); + if (t) t.textContent = p.title || ''; + const ab = /** @type {Record} */ (copy.addressBook || {}); + if (addBtn) { + addBtn.textContent = ab.add || 'Add address'; + addBtn.hidden = !email; + addBtn.disabled = !email; + } + await renderAccountAddressList(widget, [], copy); + } + + const orders = widget.querySelector('.account-panel[data-section="orders"]'); + if (orders) { + const p = panels.orders || {}; + const t = orders.querySelector('.account-panel-title'); + const emptyEl = orders.querySelector('.account-orders-empty'); + if (t) t.textContent = p.title || ''; + if (emptyEl) { + emptyEl.hidden = false; + emptyEl.textContent = String(copy.ordersEmpty || ''); + } + } + + const mq = window.matchMedia('(min-width: 768px)'); + let activeSection = 'overview'; + + const syncMobileNavMode = () => { + if (mq.matches) { + widget.classList.remove('account-widget-mobile-nav-select'); + return; + } + if (activeSection !== 'overview') { + widget.classList.add('account-widget-mobile-nav-select'); + } else { + widget.classList.remove('account-widget-mobile-nav-select'); + } + }; + + const logoutBtn = widget.querySelector('.account-logout'); + const doLogout = async () => { + if (!logoutBtn) return; + logoutBtn.disabled = true; + try { + await logout(); + } catch { + /* best-effort */ + } finally { + logoutBtn.disabled = false; + } + if (dialog?.closeModal) dialog.closeModal(); + else dialog?.close(); + }; + + const selectSection = (section) => { + if (!section || section === LOGOUT_SELECT_VALUE) return; + activeSection = section; + navButtons.forEach((b) => { + const on = b.dataset.section === section; + b.classList.toggle('is-active', on); + if (on) b.setAttribute('aria-current', 'page'); + else b.removeAttribute('aria-current'); + }); + if (navSelect) navSelect.value = section; + panelEls.forEach((panel) => { + const show = panel.dataset.section === section; + panel.classList.toggle('is-visible', show); + panel.hidden = !show; + }); + syncMobileNavMode(); + }; + + navButtons.forEach((btn) => { + btn.addEventListener('click', () => selectSection(btn.dataset.section)); + }); + if (navSelect) { + navSelect.addEventListener('change', async () => { + if (navSelect.value === LOGOUT_SELECT_VALUE) { + navSelect.value = activeSection; + await doLogout(); + return; + } + selectSection(navSelect.value); + }); + } + mq.addEventListener('change', syncMobileNavMode); + syncMobileNavMode(); + + if (logoutBtn) { + logoutBtn.textContent = copy.logout || 'Log out'; + logoutBtn.addEventListener('click', async () => { + await doLogout(); + }); + } + + const { + fetchAccountBundle, + applyAccountDataToWidget, + wireOrderDetailInteractions, + } = await import('./account-api.js'); + const { wireAccountAddressBook } = await import('./account-address-book.js'); + + const copyWithLocale = { ...copy, accountLocale: locale || 'us' }; + + if (email) { + try { + const data = await fetchAccountBundle(email); + await applyAccountDataToWidget(widget, data, copyWithLocale); + } catch (err) { + // eslint-disable-next-line no-console -- integration: copy API bundle errors + console.log('VITAMIX_ACCOUNT_API_BUNDLE_EXCEPTION'); + // eslint-disable-next-line no-console + console.log(err instanceof Error ? err.message : String(err)); + } + wireAccountAddressBook(widget, email, lang, String(locale || 'us').toLowerCase(), copy); + } + + wireOrderDetailInteractions(widget, copy); +} diff --git a/widgets/account/account.json b/widgets/account/account.json new file mode 100644 index 00000000..3b2b6753 --- /dev/null +++ b/widgets/account/account.json @@ -0,0 +1,326 @@ +{ + "en": { + "modalTitle": "Account", + "nav": { + "overview": "My Account", + "information": "Account Information", + "address": "Addresses", + "orders": "Order History" + }, + "greeting": "Welcome back", + "panels": { + "overview": { + "title": "My Account", + "intro": "Your account summary loads from the account service when you are signed in." + }, + "information": { + "title": "Account Information" + }, + "address": { + "title": "Addresses" + }, + "orders": { + "title": "Order History" + } + }, + "overviewLabels": { + "displayName": "Name", + "email": "Email", + "memberSince": "Member since", + "accountStatus": "Account status", + "storeRegion": "Store region" + }, + "customerInfo": { + "email": "Email", + "name": "Name", + "phone": "Phone", + "customerId": "Customer ID", + "preferredLanguage": "Preferred language", + "accountStatus": "Account status", + "created": "Member since", + "updated": "Last updated" + }, + "countries": { + "us": "United States", + "ca": "Canada", + "mx": "Mexico" + }, + "addressBook": { + "add": "Add address", + "edit": "Edit", + "remove": "Remove", + "save": "Save", + "cancel": "Cancel", + "deleteConfirm": "Remove this address?", + "dialogAddTitle": "New address", + "dialogEditTitle": "Edit address", + "firstName": "First name", + "lastName": "Last name", + "email": "Email", + "address1": "Street address", + "address2": "Apt, suite, etc. (optional)", + "city": "City", + "country": "Country", + "phone": "Phone", + "defaultAddress": "Default address", + "zipUs": "ZIP code", + "postalOther": "Postal code", + "stateUs": "State", + "stateCa": "Province", + "stateMx": "State", + "regionPlaceholder": "Select state or province", + "emptyList": "No saved addresses yet.", + "listSummaryFallback": "Address", + "validationRequired": "Please fill all required fields.", + "saveError": "Could not save address.", + "deleteError": "Could not remove address.", + "loadDetailError": "Could not load address.", + "defaultBadge": "Default", + "addressBadge": "Address" + }, + "ordersEmpty": "No orders yet.", + "logout": "Log out", + "sectionSelectAria": "Account menu", + "orderMock": { + "placed": "Placed", + "total": "Total", + "state": "Status" + }, + "communications": { + "title": "Communications", + "question": "Would you like us to send you periodic emails and newsletters from Vitamix?", + "questionSubscribed": "You are currently subscribed to periodic emails and newsletters from Vitamix.", + "subscribe": "Subscribe", + "unsubscribe": "Unsubscribe", + "error": "Something went wrong. Please try again." + }, + "orderDetail": { + "back": "← All orders", + "loading": "Loading order…", + "error": "Could not load this order.", + "placedOn": "Placed", + "status": "Status", + "qty": "Qty.", + "shippingAddress": "Shipping address", + "billingAddress": "Billing address", + "payment": "Payment", + "paymentCard": "Card ending {{lastFour}}" + } + }, + "fr": { + "modalTitle": "Compte", + "nav": { + "overview": "Mon compte", + "information": "Informations du compte", + "address": "Adresses", + "orders": "Historique des commandes" + }, + "greeting": "Bon retour", + "panels": { + "overview": { + "title": "Mon compte", + "intro": "Le résumé de votre compte est chargé depuis le service compte lorsque vous êtes connecté." + }, + "information": { + "title": "Informations du compte" + }, + "address": { + "title": "Adresses" + }, + "orders": { + "title": "Historique des commandes" + } + }, + "overviewLabels": { + "displayName": "Nom", + "email": "Courriel", + "memberSince": "Membre depuis", + "accountStatus": "Statut du compte", + "storeRegion": "Boutique" + }, + "customerInfo": { + "email": "Courriel", + "name": "Nom", + "phone": "Téléphone", + "customerId": "ID client", + "preferredLanguage": "Langue préférée", + "accountStatus": "Statut du compte", + "created": "Membre depuis", + "updated": "Dernière mise à jour" + }, + "countries": { + "us": "États-Unis", + "ca": "Canada", + "mx": "Mexique" + }, + "addressBook": { + "add": "Ajouter une adresse", + "edit": "Modifier", + "remove": "Supprimer", + "save": "Enregistrer", + "cancel": "Annuler", + "deleteConfirm": "Supprimer cette adresse ?", + "dialogAddTitle": "Nouvelle adresse", + "dialogEditTitle": "Modifier l'adresse", + "firstName": "Prénom", + "lastName": "Nom", + "email": "Courriel", + "address1": "Adresse", + "address2": "Appartement, bureau, etc. (facultatif)", + "city": "Ville", + "country": "Pays", + "phone": "Téléphone", + "defaultAddress": "Adresse par défaut", + "zipUs": "Code ZIP", + "postalOther": "Code postal", + "stateUs": "État", + "stateCa": "Province", + "stateMx": "État", + "regionPlaceholder": "Sélectionnez l'état ou la province", + "emptyList": "Aucune adresse enregistrée.", + "listSummaryFallback": "Adresse", + "validationRequired": "Veuillez remplir tous les champs obligatoires.", + "saveError": "Impossible d'enregistrer l'adresse.", + "deleteError": "Impossible de supprimer l'adresse.", + "loadDetailError": "Impossible de charger l'adresse.", + "defaultBadge": "Par défaut", + "addressBadge": "Adresse" + }, + "ordersEmpty": "Aucune commande pour le moment.", + "logout": "Se déconnecter", + "sectionSelectAria": "Menu du compte", + "orderMock": { + "placed": "Passée le", + "total": "Total", + "state": "Statut" + }, + "communications": { + "title": "Communications", + "question": "Souhaitez-vous recevoir de temps à autre des courriels et bulletins Vitamix ?", + "questionSubscribed": "Vous êtes actuellement abonné aux courriels et bulletins périodiques de Vitamix.", + "subscribe": "S'abonner", + "unsubscribe": "Se désabonner", + "error": "Une erreur s'est produite. Veuillez réessayer." + }, + "orderDetail": { + "back": "← Toutes les commandes", + "loading": "Chargement de la commande…", + "error": "Impossible de charger cette commande.", + "placedOn": "Passée le", + "status": "Statut", + "qty": "Qté", + "shippingAddress": "Adresse de livraison", + "billingAddress": "Adresse de facturation", + "payment": "Paiement", + "paymentCard": "Carte se terminant par {{lastFour}}" + } + }, + "es": { + "modalTitle": "Cuenta", + "nav": { + "overview": "Mi cuenta", + "information": "Información de la cuenta", + "address": "Direcciones", + "orders": "Historial de pedidos" + }, + "greeting": "Bienvenido de nuevo", + "panels": { + "overview": { + "title": "Mi cuenta", + "intro": "El resumen de su cuenta se carga desde el servicio de cuenta cuando ha iniciado sesión." + }, + "information": { + "title": "Información de la cuenta" + }, + "address": { + "title": "Direcciones" + }, + "orders": { + "title": "Historial de pedidos" + } + }, + "overviewLabels": { + "displayName": "Nombre", + "email": "Correo", + "memberSince": "Miembro desde", + "accountStatus": "Estado de la cuenta", + "storeRegion": "Tienda" + }, + "customerInfo": { + "email": "Correo", + "name": "Nombre", + "phone": "Teléfono", + "customerId": "ID de cliente", + "preferredLanguage": "Idioma preferido", + "accountStatus": "Estado de la cuenta", + "created": "Miembro desde", + "updated": "Última actualización" + }, + "countries": { + "us": "Estados Unidos", + "ca": "Canadá", + "mx": "México" + }, + "addressBook": { + "add": "Añadir dirección", + "edit": "Editar", + "remove": "Eliminar", + "save": "Guardar", + "cancel": "Cancelar", + "deleteConfirm": "¿Eliminar esta dirección?", + "dialogAddTitle": "Nueva dirección", + "dialogEditTitle": "Editar dirección", + "firstName": "Nombre", + "lastName": "Apellido", + "email": "Correo", + "address1": "Dirección", + "address2": "Apartamento, suite, etc. (opcional)", + "city": "Ciudad", + "country": "País", + "phone": "Teléfono", + "defaultAddress": "Dirección predeterminada", + "zipUs": "Código ZIP", + "postalOther": "Código postal", + "stateUs": "Estado", + "stateCa": "Provincia", + "stateMx": "Estado", + "regionPlaceholder": "Seleccione estado o provincia", + "emptyList": "No hay direcciones guardadas.", + "listSummaryFallback": "Dirección", + "validationRequired": "Complete todos los campos obligatorios.", + "saveError": "No se pudo guardar la dirección.", + "deleteError": "No se pudo eliminar la dirección.", + "loadDetailError": "No se pudo cargar la dirección.", + "defaultBadge": "Predeterminada", + "addressBadge": "Dirección" + }, + "ordersEmpty": "Aún no hay pedidos.", + "logout": "Cerrar sesión", + "sectionSelectAria": "Menú de la cuenta", + "orderMock": { + "placed": "Realizado", + "total": "Total", + "state": "Estado" + }, + "communications": { + "title": "Comunicaciones", + "question": "¿Desea que le enviemos correos periódicos y boletines de Vitamix?", + "questionSubscribed": "Actualmente está suscrito a correos y boletines periódicos de Vitamix.", + "subscribe": "Suscribirse", + "unsubscribe": "Cancelar suscripción", + "error": "Algo salió mal. Inténtelo de nuevo." + }, + "orderDetail": { + "back": "← Todos los pedidos", + "loading": "Cargando pedido…", + "error": "No se pudo cargar este pedido.", + "placedOn": "Realizado", + "status": "Estado", + "qty": "Cant.", + "shippingAddress": "Dirección de envío", + "billingAddress": "Dirección de facturación", + "payment": "Pago", + "paymentCard": "Tarjeta terminada en {{lastFour}}" + } + } +} diff --git a/widgets/forms/consult-expert.js b/widgets/forms/consult-expert.js index cbe23e39..fcc8e784 100644 --- a/widgets/forms/consult-expert.js +++ b/widgets/forms/consult-expert.js @@ -54,6 +54,7 @@ export default async function decorate(widget) { const vitamixInternationalPath = `/${locale}/${language}/vitamix-international`; const countryCode = (locale || 'us').toUpperCase(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang).catch(() => ({})); const stateOptions = await getStatesProvincesOptions(countryCode, lang).catch(() => []); const labels = copy.labels || {}; @@ -131,6 +132,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { const resp = await fetch(getFormSubmissionUrl(), { method: 'POST', @@ -138,16 +140,24 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Sheet logger responded with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didNavigate = true; const thankYouPath = `/${locale}/${language}/consult-expert-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Consult expert form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/contact-foundation.js b/widgets/forms/contact-foundation.js index caad8d2d..7b9ca08b 100644 --- a/widgets/forms/contact-foundation.js +++ b/widgets/forms/contact-foundation.js @@ -41,6 +41,7 @@ export default async function decorate(widget) { const { locale, language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang); const labels = copy.labels || {}; const inputHints = copy.inputPlaceholders || {}; @@ -92,6 +93,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { const resp = await fetch(getFormSubmissionUrl(), { method: 'POST', @@ -99,16 +101,24 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Sheet logger responded with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didNavigate = true; const thankYouPath = `/${locale}/${language}/contact-foundation-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Contact foundation form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/contact-us.js b/widgets/forms/contact-us.js index 64c5978c..d512410d 100644 --- a/widgets/forms/contact-us.js +++ b/widgets/forms/contact-us.js @@ -41,6 +41,7 @@ export default async function decorate(widget) { const { locale, language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang); const labels = copy.labels || {}; const inputHints = copy.inputPlaceholders || {}; @@ -88,6 +89,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { const resp = await fetch(getFormSubmissionUrl(), { method: 'POST', @@ -95,16 +97,24 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Forms API submission failed with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didNavigate = true; const thankYouPath = `/${locale}/${language}/contact-us-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Contact us form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/create-account.js b/widgets/forms/create-account.js index dbf980bf..bab5a336 100644 --- a/widgets/forms/create-account.js +++ b/widgets/forms/create-account.js @@ -29,6 +29,7 @@ export default async function decorate(widget) { const { locale, language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang); const labels = copy.labels || {}; const inputHints = copy.inputPlaceholders || {}; @@ -99,6 +100,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { const resp = await fetch(SHEET_LOGGER_URL, { method: 'POST', @@ -106,16 +108,24 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Sheet logger responded with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didNavigate = true; const thankYouPath = `/${locale}/${language}/create-account-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Create account form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/edit-account.js b/widgets/forms/edit-account.js index 1057730d..71502edf 100644 --- a/widgets/forms/edit-account.js +++ b/widgets/forms/edit-account.js @@ -30,6 +30,7 @@ export default async function decorate(widget) { const { locale, language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang); const labels = copy.labels || {}; const inputHints = copy.inputPlaceholders || {}; @@ -93,6 +94,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { const resp = await fetch(SHEET_LOGGER_URL, { method: 'POST', @@ -100,16 +102,24 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Sheet logger responded with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didNavigate = true; const thankYouPath = `/${locale}/${language}/edit-account-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Edit account form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/login.js b/widgets/forms/login.js index 27a7beaa..aa944194 100644 --- a/widgets/forms/login.js +++ b/widgets/forms/login.js @@ -32,6 +32,7 @@ export default async function decorate(widget) { const { language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang); const labels = copy.labels || {}; const inputHints = copy.inputPlaceholders || {}; @@ -90,10 +91,15 @@ export default async function decorate(widget) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, pageUrl: window.location.href, resend: true }), }); - if (!resp.ok) throw new Error(`Sheet logger responded with ${resp.status}`); + if (!resp.ok) { + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + } } catch (err) { // eslint-disable-next-line no-console console.error('Login resend failed', err); + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); } resendBtn.disabled = false; resendBtn.textContent = originalText; @@ -113,6 +119,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didAdvance = false; try { const resp = await fetch(SHEET_LOGGER_URL, { method: 'POST', @@ -120,8 +127,11 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Sheet logger responded with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didAdvance = true; form.setAttribute('hidden', ''); form.classList.add('is-hidden'); verifyEl.removeAttribute('hidden'); @@ -130,9 +140,14 @@ export default async function decorate(widget) { } catch (err) { // eslint-disable-next-line no-console console.error('Login form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didAdvance) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/manage-address.js b/widgets/forms/manage-address.js index e453ac7c..9268a4c0 100644 --- a/widgets/forms/manage-address.js +++ b/widgets/forms/manage-address.js @@ -46,6 +46,7 @@ export default async function decorate(widget) { const { locale, language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const countryCode = (locale || 'us').toUpperCase(); const copy = await loadFormCopy(lang); const provinceOptions = await getStatesProvincesOptions(countryCode, lang).catch(() => []); @@ -126,6 +127,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { const resp = await fetch(SHEET_LOGGER_URL, { method: 'POST', @@ -133,16 +135,24 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Sheet logger responded with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didNavigate = true; const thankYouPath = `/${locale}/${language}/manage-address-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Manage address form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/media-contact.js b/widgets/forms/media-contact.js index 1b5bbdfb..8e2eefb6 100644 --- a/widgets/forms/media-contact.js +++ b/widgets/forms/media-contact.js @@ -41,6 +41,7 @@ export default async function decorate(widget) { const { locale, language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang); const labels = copy.labels || {}; const inputHints = copy.inputPlaceholders || {}; @@ -99,6 +100,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { const resp = await fetch(getFormSubmissionUrl(), { method: 'POST', @@ -106,16 +108,24 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Forms API submission failed with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didNavigate = true; const thankYouPath = `/${locale}/${language}/corporate-information/media-center/media-request-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Media contact form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/order-status.css b/widgets/forms/order-status.css index 3e0f9c13..1df29978 100644 --- a/widgets/forms/order-status.css +++ b/widgets/forms/order-status.css @@ -39,21 +39,40 @@ border-color: var(--color-gray-900); } -/* Result JSON display below the form */ +/* Result display below the form */ .forms-order-status .order-status-result { margin-top: var(--spacing-300); padding: var(--spacing-200); background-color: var(--color-white); border: 1px solid var(--color-gray-300); border-radius: var(--rounding-m); - font-family: var(--font-family-mono, monospace); - font-size: var(--font-size-20); - white-space: pre-wrap; - word-break: break-word; - max-height: 20rem; - overflow: auto; + font-size: var(--font-size-60); } .forms-order-status .order-status-result:empty { display: none; } + +.forms-order-status .order-status-result-list { + margin: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-100); +} + +.forms-order-status .order-status-result-row { + display: flex; + gap: 0.35em; +} + +.forms-order-status .order-status-result-row dt { + font-weight: bold; +} + +.forms-order-status .order-status-result-row dt::after { + content: ':'; +} + +.forms-order-status .order-status-result-row dd { + margin: 0; +} diff --git a/widgets/forms/order-status.html b/widgets/forms/order-status.html index c190b13e..f38641a9 100644 --- a/widgets/forms/order-status.html +++ b/widgets/forms/order-status.html @@ -3,10 +3,11 @@ - +
- + \ No newline at end of file diff --git a/widgets/forms/order-status.js b/widgets/forms/order-status.js index 1b2f85d5..9ace1e61 100644 --- a/widgets/forms/order-status.js +++ b/widgets/forms/order-status.js @@ -1,7 +1,4 @@ -import { getLocaleAndLanguage } from '../../scripts/scripts.js'; - -/** Sheet logger endpoint for order status lookup */ -const SHEET_LOGGER_URL = 'https://sheet-logger.david8603.workers.dev/vitamix.com/forms-testing/order-status'; +import { getLocaleAndLanguage, getFormSubmissionUrl } from '../../scripts/scripts.js'; /** * Loads form copy from the widget's local JSON (same name as the script). @@ -18,9 +15,59 @@ async function loadFormCopy(lang) { return data[key]; } +/** + * Derives a status key from the API response. + * @param {Object|null} result - Parsed API response + * @returns {string} Status key matching a key in result.statuses + */ +function deriveStatus(result) { + if (!result?.succeeded) return 'unavailable'; + if (result.outcome === 'Cancelled') return 'cancelled'; + const deliveries = result.order?.delivery ?? []; + const shippedCount = deliveries.filter((d) => d.shipped).length; + if (shippedCount === 0) return deliveries.length ? 'processed' : 'received'; + if (shippedCount < deliveries.length) return 'partiallyShipped'; + return 'shipped'; +} + +/** + * Renders the order result as a formatted definition list into the given container. + * @param {Object|null} result - Parsed API response + * @param {Object} copy - Localised copy for the current language + * @param {HTMLElement} container - Element to render into + */ +function renderResult(result, copy, container) { + const resultLabels = copy.result?.labels ?? {}; + const resultStatuses = copy.result?.statuses ?? {}; + + const orderNumber = result?.order?.key ?? '—'; + const statusKey = deriveStatus(result); + const orderStatus = resultStatuses[statusKey] ?? statusKey; + + const rows = [ + [resultLabels.orderNumber ?? 'Order Number', orderNumber], + [resultLabels.orderStatus ?? 'Order Status', orderStatus], + ]; + + const dl = document.createElement('dl'); + dl.className = 'order-status-result-list'; + rows.forEach(([label, value]) => { + const div = document.createElement('div'); + div.className = 'order-status-result-row'; + const dt = document.createElement('dt'); + dt.textContent = label; + const dd = document.createElement('dd'); + dd.textContent = value; + div.append(dt, dd); + dl.append(div); + }); + + container.replaceChildren(dl); +} + /** * Decorates the order-status widget: applies copy from JSON and configures form. - * Submits POST with JSON to sheet-logger and displays the response JSON below the form. + * Submits POST with JSON to sheet-logger and displays the order result below the form. * @param {HTMLElement} widget - The widget root element */ export default async function decorate(widget) { @@ -28,8 +75,9 @@ export default async function decorate(widget) { const resultEl = widget.querySelector('.order-status-result'); if (!form || !resultEl) return; - const { language } = getLocaleAndLanguage(); + const { locale, language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang); const labels = copy.labels || {}; const inputHints = copy.inputPlaceholders || {}; @@ -46,6 +94,7 @@ export default async function decorate(widget) { e.preventDefault(); const data = new FormData(form); const payload = Object.fromEntries(data.entries()); + payload.formId = `${locale}/${language}/order-status`; payload.pageUrl = window.location.href; [...form.elements].forEach((el) => { el.disabled = true; }); @@ -58,11 +107,16 @@ export default async function decorate(widget) { resultEl.textContent = ''; try { - const resp = await fetch(SHEET_LOGGER_URL, { + const resp = await fetch(getFormSubmissionUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); + if (resp.status === 400) { + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; + } const text = await resp.text(); let result; try { @@ -70,15 +124,14 @@ export default async function decorate(widget) { } catch { result = { status: resp.status, ok: resp.ok, body: text }; } + renderResult(result, copy, resultEl); resultEl.hidden = false; - resultEl.textContent = JSON.stringify(result, null, 2); resultEl.classList.add('order-status-result-visible'); } catch (err) { // eslint-disable-next-line no-console console.error('Order status lookup failed', err); - resultEl.hidden = false; - resultEl.textContent = JSON.stringify({ error: err.message }, null, 2); - resultEl.classList.add('order-status-result-visible'); + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); } finally { [...form.elements].forEach((el) => { el.disabled = false; }); if (submitBtn) submitBtn.textContent = submitBtn.dataset.originalLabel || originalSubmitText; diff --git a/widgets/forms/order-status.json b/widgets/forms/order-status.json index 9e541464..6f7ec94f 100644 --- a/widgets/forms/order-status.json +++ b/widgets/forms/order-status.json @@ -7,6 +7,20 @@ }, "inputPlaceholders": { "orderNumber": "" + }, + "result": { + "labels": { + "orderNumber": "Order Number", + "orderStatus": "Order Status" + }, + "statuses": { + "received": "Order Received", + "processed": "Order Processed", + "partiallyShipped": "Partially Shipped", + "shipped": "Order Shipped", + "cancelled": "Order Cancelled", + "unavailable": "Status Unavailable" + } } }, "fr": { @@ -17,6 +31,20 @@ }, "inputPlaceholders": { "orderNumber": "" + }, + "result": { + "labels": { + "orderNumber": "Numéro de commande", + "orderStatus": "État de la commande" + }, + "statuses": { + "received": "Commande reçue", + "processed": "Commande traitée", + "partiallyShipped": "Partiellement expédiée", + "shipped": "Commande expédiée", + "cancelled": "Commande annulée", + "unavailable": "État indisponible" + } } }, "es": { @@ -27,6 +55,20 @@ }, "inputPlaceholders": { "orderNumber": "" + }, + "result": { + "labels": { + "orderNumber": "Número de pedido", + "orderStatus": "Estado del pedido" + }, + "statuses": { + "received": "Pedido recibido", + "processed": "Pedido procesado", + "partiallyShipped": "Parcialmente enviado", + "shipped": "Pedido enviado", + "cancelled": "Pedido cancelado", + "unavailable": "Estado no disponible" + } } } -} +} \ No newline at end of file diff --git a/widgets/forms/product-registration.js b/widgets/forms/product-registration.js index ee53e9d2..ba05d6f1 100644 --- a/widgets/forms/product-registration.js +++ b/widgets/forms/product-registration.js @@ -1,9 +1,6 @@ -import { getLocaleAndLanguage } from '../../scripts/scripts.js'; +import { getFormSubmissionUrl, getLocaleAndLanguage } from '../../scripts/scripts.js'; import getStatesProvincesOptions from './states-provinces.js'; -/** Sheet logger endpoint for product registration form */ -const SHEET_LOGGER_URL = 'https://sheet-logger.david8603.workers.dev/vitamix.com/forms-testing/product-registration'; - /** * Loads form copy from the widget's local JSON (same name as the script). * @param {string} lang - Language key (en, fr, es) @@ -45,6 +42,7 @@ export default async function decorate(widget) { const { locale, language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const countryCode = (locale || 'us').toUpperCase(); const copy = await loadFormCopy(lang); const provinceOptions = await getStatesProvincesOptions(countryCode, lang).catch(() => []); @@ -69,7 +67,14 @@ export default async function decorate(widget) { if (serialInput) serialInput.placeholder = inputHints.serialNumber ?? ''; const findLink = form.querySelector('.find-serial-link'); - if (findLink) findLink.textContent = labels.findYourSerialNumber ?? 'Find your serial number'; + if (findLink) { + findLink.textContent = labels.findYourSerialNumber ?? 'Find your serial number'; + findLink.addEventListener('click', async (e) => { + e.preventDefault(); + const { openModal } = await import('../../blocks/modal/modal.js'); + await openModal(`/${locale}/${language}/customer-service/product-registration-find-serial`); + }); + } const radioLegend = form.querySelector('.product-registration-radio-group .radio-legend'); if (radioLegend) radioLegend.textContent = labels.iPlanToUseIt ?? 'I plan to use it'; @@ -135,9 +140,17 @@ export default async function decorate(widget) { const emailConsentEl = form.querySelector('.email-consent-disclaimer'); if (emailConsentEl) emailConsentEl.textContent = labels.emailConsentDisclaimer ?? ''; form.querySelector('.click-here-prefix').textContent = labels.clickHereToConsult ?? 'Click here to consult our '; - form.querySelector('.privacy-policy-link').textContent = labels.privacyPolicyLinkText ?? 'privacy policy'; + const privacyPolicyLink = form.querySelector('.privacy-policy-link'); + if (privacyPolicyLink) { + privacyPolicyLink.textContent = labels.privacyPolicyLinkText ?? 'privacy policy'; + privacyPolicyLink.href = `/${locale}/${language}/privacy-statement`; + } form.querySelector('.and-our').textContent = labels.andOur ?? ' and our '; - form.querySelector('.terms-link').textContent = labels.termsOfUseLinkText ?? 'terms of use.'; + const termsOfUseLink = form.querySelector('.terms-link'); + if (termsOfUseLink) { + termsOfUseLink.textContent = labels.termsOfUseLinkText ?? 'terms of use.'; + termsOfUseLink.href = `/${locale}/${language}/legal-notice`; + } const submitBtn = form.querySelector('button[type="submit"]'); const clearBtn = form.querySelector('.clear-form-btn'); @@ -148,6 +161,7 @@ export default async function decorate(widget) { e.preventDefault(); const data = new FormData(form); const payload = Object.fromEntries(data.entries()); + payload.formId = `${locale}/${language}/product-registration`; payload.pageUrl = window.location.href; const submitButton = form.querySelector('button[type="submit"]'); @@ -158,23 +172,32 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { - const resp = await fetch(SHEET_LOGGER_URL, { + const resp = await fetch(getFormSubmissionUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Sheet logger responded with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } - const thankYouPath = `/${locale}/${language}/product-registration-thankyou`; + didNavigate = true; + const thankYouPath = `/${locale}/${language}/customer-service/product-registration-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Product registration form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/sales-advice.js b/widgets/forms/sales-advice.js index 91c8cbe4..db044959 100644 --- a/widgets/forms/sales-advice.js +++ b/widgets/forms/sales-advice.js @@ -55,6 +55,7 @@ export default async function decorate(widget) { const vitamixInternationalPath = `/${locale}/${language}/vitamix-international`; const countryCode = (locale || 'us').toUpperCase(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang).catch(() => ({})); const stateOptions = await getStatesProvincesOptions(countryCode, lang).catch(() => []); const labels = copy.labels || {}; @@ -158,6 +159,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { const resp = await fetch(getFormSubmissionUrl(), { method: 'POST', @@ -165,16 +167,24 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Forms API submission failed with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didNavigate = true; const thankYouPath = `/${locale}/${language}/commercial/sales-advice-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Sales advice form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/toast.css b/widgets/forms/toast.css new file mode 100644 index 00000000..35984269 --- /dev/null +++ b/widgets/forms/toast.css @@ -0,0 +1,48 @@ +.toast-container { + position: fixed; + top: 20px; + right: 20px; + z-index: 10000; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; + max-width: calc(100% - 40px); +} + +.toast { + min-width: 240px; + max-width: 420px; + padding: 12px 16px; + border-radius: 6px; + background: #2f3e4e; + color: #fff; + font-size: 14px; + line-height: 1.4; + box-shadow: 0 2px 8px rgb(0 0 0 / 20%); + opacity: 0; + transform: translateY(-8px); + transition: opacity 200ms ease, transform 200ms ease; + pointer-events: auto; + word-break: break-word; +} + +.toast-visible { + opacity: 1; + transform: translateY(0); +} + +.toast-info { background: #2f3e4e; } +.toast-success { background: #2f7a4c; } +.toast-error { background: #b3261e; } + +.form-field-error { + color: var(--color-red, #c8102e); + font-size: var(--font-size-20, 0.875rem); + margin: 0.25em 0 0; + line-height: 1.3; +} + +[aria-invalid="true"] { + border-color: var(--color-red, #c8102e); +} diff --git a/widgets/forms/util.js b/widgets/forms/util.js new file mode 100644 index 00000000..660081db --- /dev/null +++ b/widgets/forms/util.js @@ -0,0 +1,288 @@ +import { loadCSS } from '../../scripts/aem.js'; + +const TOAST_DURATION_MS = 5000; +const TOAST_EXIT_MS = 250; + +let toastContainer = null; +let formStylesLoaded = false; + +function ensureFormStyles() { + if (formStylesLoaded) return; + loadCSS(`${window.hlx?.codeBasePath || ''}/widgets/forms/toast.css`); + formStylesLoaded = true; +} + +function ensureToastContainer() { + ensureFormStyles(); + if (!toastContainer || !document.body.contains(toastContainer)) { + toastContainer = document.createElement('div'); + toastContainer.className = 'toast-container'; + toastContainer.setAttribute('role', 'status'); + toastContainer.setAttribute('aria-live', 'polite'); + document.body.append(toastContainer); + } + return toastContainer; +} + +/** + * Shows a dismissible toast message. + * @param {string} message - Text to display + * @param {'info'|'success'|'error'} [level] - Visual level; defaults to 'info' + */ +export function toast(message, level = 'info') { + if (!message) return; + const root = ensureToastContainer(); + const el = document.createElement('div'); + el.className = `toast toast-${level}`; + el.textContent = message; + root.append(el); + requestAnimationFrame(() => el.classList.add('toast-visible')); + setTimeout(() => { + el.classList.remove('toast-visible'); + setTimeout(() => el.remove(), TOAST_EXIT_MS); + }, TOAST_DURATION_MS); +} + +/** + * Extracts candidate error messages from a 400 response in priority order: + * 1. body.details[].message + * 2. body.error + * 3. 'x-error' response header + */ +function extractErrorMessages(response, body) { + if (Array.isArray(body?.details)) { + const messages = body.details + .map((d) => d?.message) + .filter((m) => typeof m === 'string' && m.trim()); + if (messages.length) return messages; + } + if (typeof body?.error === 'string' && body.error.trim()) { + return [body.error]; + } + const headerMessage = response?.headers?.get?.('x-error'); + if (headerMessage) return [headerMessage]; + return []; +} + +/** + * Finds the first input whose `name` attribute appears (case-insensitively) + * inside one of the given messages. + */ +/** + * Human-readable rewrites of field names: e.g. 'emailAddress' -> 'email address'. + */ +function splitCamel(name) { + return name + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .replace(/[-_]+/g, ' ') + .toLowerCase() + .trim(); +} + +/** + * Alternate phrases that may appear in error messages -> canonical input name. + */ +const FIELD_ALIASES = { + 'purchase date': 'purchasedOn', + 'date of purchase': 'purchasedOn', + 'date purchased': 'purchasedOn', + 'purchase location': 'purchasedFrom', + retailer: 'purchasedFrom', + store: 'purchasedFrom', + serial: 'serialNumber', + zip: 'zipCode', + postcode: 'postalCode', + surname: 'lastName', + 'family name': 'lastName', + 'given name': 'firstName', + forename: 'firstName', +}; + +function findMatchingInput(form, messages) { + const namedInputs = [...form.querySelectorAll('[name]')].filter((el) => el.name); + const inputsByName = new Map(namedInputs.map((el) => [el.name, el])); + const candidates = []; + namedInputs.forEach((input) => { + const lower = input.name.toLowerCase(); + candidates.push({ keyword: lower, input }); + const split = splitCamel(input.name); + if (split && split !== lower) candidates.push({ keyword: split, input }); + }); + Object.entries(FIELD_ALIASES).forEach(([alias, fieldName]) => { + const input = inputsByName.get(fieldName); + if (input) candidates.push({ keyword: alias.toLowerCase(), input }); + }); + candidates.sort((a, b) => b.keyword.length - a.keyword.length); + let match = null; + messages.find((message) => { + const lower = message.toLowerCase(); + const c = candidates.find((cand) => lower.includes(cand.keyword)); + if (c) match = { input: c.input, message }; + return !!c; + }); + return match; +} + +/** + * Returns the input's "field row" — the closest ancestor whose parent is the + * form or a fieldset. This is where we render an inline error message. + */ +function getFieldWrapper(input) { + let el = input; + while ( + el?.parentElement + && el.parentElement.tagName !== 'FORM' + && el.parentElement.tagName !== 'FIELDSET' + ) { + el = el.parentElement; + } + return el; +} + +function getOrCreateErrorElement(input) { + const wrapper = getFieldWrapper(input); + if (!wrapper) return null; + let errorEl = wrapper.querySelector(':scope > .form-field-error'); + if (!errorEl) { + errorEl = document.createElement('p'); + errorEl.className = 'form-field-error'; + errorEl.setAttribute('aria-live', 'polite'); + errorEl.hidden = true; + wrapper.appendChild(errorEl); + } + return errorEl; +} + +function showInlineError(input, message) { + const errorEl = getOrCreateErrorElement(input); + if (!errorEl) return; + errorEl.textContent = message; + errorEl.hidden = false; + input.setAttribute('aria-invalid', 'true'); +} + +function clearInlineError(input) { + const wrapper = getFieldWrapper(input); + const errorEl = wrapper?.querySelector(':scope > .form-field-error'); + if (errorEl) { + errorEl.textContent = ''; + errorEl.hidden = true; + } + input.removeAttribute('aria-invalid'); +} + +function applyInputError(input, message) { + if (typeof input.setCustomValidity !== 'function') return; + input.setCustomValidity(message); + showInlineError(input, message); + if (typeof input.scrollIntoView === 'function') { + input.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + if (typeof input.focus === 'function') input.focus(); + const clear = () => { + input.setCustomValidity(''); + clearInlineError(input); + input.removeEventListener('input', clear); + input.removeEventListener('change', clear); + }; + input.addEventListener('input', clear); + input.addEventListener('change', clear); +} + +/** + * Localized native validation messages, keyed by ValidityState flag. + * `default` is the fallback when none of the more specific keys apply. + */ +const VALIDATION_MESSAGES = { + en: { + valueMissing: 'This field is required.', + default: 'Please enter a valid value.', + }, + fr: { + valueMissing: 'Ce champ est obligatoire.', + default: 'Veuillez saisir une valeur valide.', + }, + es: { + valueMissing: 'Este campo es obligatorio.', + default: 'Por favor, introduzca un valor válido.', + }, +}; + +function pickValidationMessage(validity, messages) { + if (validity.customError) return null; + if (validity.valueMissing) return messages.valueMissing; + if (validity.valid) return null; + return messages.default; +} + +/** + * Installs localized native-validation messages on every form control. + * Listens for `invalid` events and replaces the browser's default message + * via setCustomValidity. Custom errors set by other code are not overridden. + * @param {HTMLFormElement} form + * @param {string} [lang] - Language key (en, fr, es); defaults to 'en' + */ +export function setupFormValidation(form, lang = 'en') { + ensureFormStyles(); + const messages = VALIDATION_MESSAGES[lang] || VALIDATION_MESSAGES.en; + [...form.querySelectorAll('input, select, textarea')].forEach((input) => { + input.addEventListener('invalid', (e) => { + e.preventDefault(); + const message = pickValidationMessage(input.validity, messages); + if (message) { + input.setCustomValidity(message); + showInlineError(input, message); + } + }); + const clear = () => { + if (input.validity.customError) input.setCustomValidity(''); + clearInlineError(input); + }; + input.addEventListener('input', clear); + input.addEventListener('change', clear); + }); + + form.addEventListener('submit', (e) => { + if (form.checkValidity()) return; + e.preventDefault(); + e.stopImmediatePropagation(); + const firstInvalid = form.querySelector(':invalid'); + if (firstInvalid) { + firstInvalid.focus(); + firstInvalid.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + }, true); +} + +/** + * Handles a non-ok form submission response. For a 400, extracts the preferred + * error message and applies it inline to a matching input or shows a toast. + * For any other non-ok status, shows a toast with the fallback message. + * @param {Response} response - The fetch Response + * @param {HTMLFormElement} form - Form whose inputs may be matched by field name + * @param {string} fallbackMessage - Shown when no structured error is found + * @returns {Promise} + */ +export async function handleFormSubmitError(response, form, fallbackMessage) { + if (response?.status === 400) { + let body = null; + try { + const text = await response.text(); + body = text ? JSON.parse(text) : null; + } catch { + body = null; + } + const messages = extractErrorMessages(response, body); + if (messages.length) { + const match = findMatchingInput(form, messages); + if (match) { + applyInputError(match.input, match.message); + return; + } + toast(messages[0], 'error'); + return; + } + } + if (fallbackMessage) toast(fallbackMessage, 'error'); +} diff --git a/widgets/forms/wellness-program.js b/widgets/forms/wellness-program.js index 348486de..154df14e 100644 --- a/widgets/forms/wellness-program.js +++ b/widgets/forms/wellness-program.js @@ -25,6 +25,7 @@ export default async function decorate(widget) { const { locale, language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang); const labels = copy.labels || {}; const inputHints = copy.inputPlaceholders || {}; @@ -78,6 +79,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { const resp = await fetch(getFormSubmissionUrl(), { method: 'POST', @@ -85,16 +87,24 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Forms API submission failed with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didNavigate = true; const thankYouPath = `/${locale}/${language}/wellness-program-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Wellness program form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } });