@cimplify/sdk 0.109.0 → 0.111.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/README.md +18 -0
- package/dist/advanced.d.mts +4 -4
- package/dist/advanced.d.ts +4 -4
- package/dist/advanced.js +22 -22
- package/dist/advanced.mjs +3 -3
- package/dist/{audio-XU2sFNyO.d.mts → audio-DhvyjqdA.d.mts} +2 -2
- package/dist/{audio-8Igdy2WK.d.ts → audio-m0w8cnxq.d.ts} +2 -2
- package/dist/avatars.js +21 -21
- package/dist/avatars.mjs +3 -3
- package/dist/{backend-L4d_Ha0J.d.mts → backend-gUCzs5Kg.d.mts} +16 -2
- package/dist/{backend-L4d_Ha0J.d.ts → backend-gUCzs5Kg.d.ts} +16 -2
- package/dist/{catalogue-DLLsfuNg.d.ts → catalogue-1QcoGDO6.d.ts} +2 -2
- package/dist/{catalogue-BeBFWlHF.d.mts → catalogue-FdFQiWrU.d.mts} +2 -2
- package/dist/{chunk-HF2MZKP2.mjs → chunk-22BD3RHN.mjs} +6 -3
- package/dist/{chunk-B54P6RL2.js → chunk-2AQ237Y4.js} +30 -30
- package/dist/{chunk-W3VVG4NL.js → chunk-2O2CG465.js} +14 -2
- package/dist/{chunk-UPHTPBNQ.mjs → chunk-2QTQ6BUH.mjs} +1 -1
- package/dist/{chunk-BS65NRDW.js → chunk-3S6WPBZL.js} +16 -16
- package/dist/{chunk-PEZYNYJE.js → chunk-3TUOUP5T.js} +3 -3
- package/dist/{chunk-EB6EAOM5.mjs → chunk-3VEH23I5.mjs} +7 -10
- package/dist/{chunk-SYEO2WV4.mjs → chunk-42JTCWIE.mjs} +3 -3
- package/dist/{chunk-FB6YH2WW.js → chunk-4C7I3ELN.js} +4 -4
- package/dist/{chunk-UQYO3OM7.js → chunk-4ZDR72SG.js} +1 -2
- package/dist/{chunk-HPKKCUHU.js → chunk-52YS45RR.js} +13 -13
- package/dist/{chunk-6YVELDFK.mjs → chunk-5BYDGGWO.mjs} +1 -1
- package/dist/{chunk-YN24DET4.js → chunk-5HFMY3D7.js} +10 -10
- package/dist/{chunk-4CVB2ZTC.js → chunk-5LH2QN3T.js} +32 -29
- package/dist/{chunk-4A24F4ZV.mjs → chunk-5YFBGGCK.mjs} +4 -1
- package/dist/{chunk-NLAT3DB2.mjs → chunk-6DAEZKIG.mjs} +1 -1
- package/dist/{chunk-F6CHJXXK.mjs → chunk-6DATW7TB.mjs} +3 -3
- package/dist/{chunk-4RYATNVK.mjs → chunk-6H254EVP.mjs} +6 -11
- package/dist/{chunk-32U4BVJA.mjs → chunk-6PJWXIFX.mjs} +1 -1
- package/dist/{chunk-OMTVH334.js → chunk-6QANXRUM.js} +203 -186
- package/dist/{chunk-BHAGIIBI.js → chunk-6WUBSMTX.js} +82 -85
- package/dist/{chunk-EPYFN2A3.js → chunk-7AAIAVAA.js} +24 -20
- package/dist/{chunk-HZC27ICH.mjs → chunk-7BHW5GTC.mjs} +3 -3
- package/dist/{chunk-MB425DVA.mjs → chunk-7IQQQENP.mjs} +2 -2
- package/dist/{chunk-DJUUL67Z.js → chunk-7KFTYENS.js} +296 -296
- package/dist/{chunk-XLQSNQMP.js → chunk-7SM6XSPZ.js} +339 -339
- package/dist/{chunk-NBH64BDP.mjs → chunk-A33OORT7.mjs} +13 -1
- package/dist/{chunk-G2SXLGFY.js → chunk-A7BU3BK4.js} +7 -7
- package/dist/{chunk-ZNWS7NOW.mjs → chunk-A7V4L2IT.mjs} +2 -2
- package/dist/{chunk-O3SITS4R.mjs → chunk-AGV33FWC.mjs} +4 -4
- package/dist/{chunk-Z2DE3EHS.js → chunk-AHNR3BXI.js} +7 -7
- package/dist/{chunk-UQ4BHMLS.mjs → chunk-AQ5RLBU2.mjs} +5 -5
- package/dist/{chunk-J4HU2YQB.js → chunk-AQOE6WCG.js} +2 -2
- package/dist/{chunk-X57B7VQC.js → chunk-AYJPHKYV.js} +22 -22
- package/dist/{chunk-6XTWY6MH.js → chunk-AZAXD44P.js} +126 -42
- package/dist/{chunk-6ETDAU62.mjs → chunk-B45X3HPO.mjs} +98 -39
- package/dist/{chunk-QDW4EPWS.mjs → chunk-BBGKQIJJ.mjs} +1 -1
- package/dist/{chunk-NHYTO4VU.js → chunk-BP4JLSUA.js} +111 -120
- package/dist/{chunk-7INQVOHY.mjs → chunk-BU6OK542.mjs} +25 -8
- package/dist/{chunk-MKIJQL5Q.js → chunk-CJVEJPIG.js} +69 -69
- package/dist/{chunk-W23VX5MY.js → chunk-CVSRWM6W.js} +5 -5
- package/dist/{chunk-A7DK7QAG.mjs → chunk-CYBMFMJD.mjs} +1 -1
- package/dist/{chunk-BOTUAHP2.mjs → chunk-DS2VU4WW.mjs} +5 -20
- package/dist/{chunk-HMMFNLXZ.js → chunk-DSAG7T5P.js} +2 -2
- package/dist/{chunk-7GG4KNTL.mjs → chunk-E65WCTIG.mjs} +8 -4
- package/dist/{chunk-WXDVEXTW.mjs → chunk-ECDK6QBF.mjs} +1 -1
- package/dist/{chunk-5IRV36MS.mjs → chunk-EIB4OWVQ.mjs} +1 -1
- package/dist/{chunk-JLJ4FD5H.js → chunk-EQUZYCGO.js} +18 -18
- package/dist/{chunk-Q2Y3VMKH.js → chunk-ETZDIISN.js} +151 -151
- package/dist/{chunk-AKTWH3KJ.mjs → chunk-F3UJ6ZRM.mjs} +2 -2
- package/dist/{chunk-J6HRY5EQ.js → chunk-F63N2ZTQ.js} +232 -232
- package/dist/{chunk-MUEFTPNN.js → chunk-FE6VCE4K.js} +34 -34
- package/dist/{chunk-GCKWKLZF.mjs → chunk-FT6GFYJL.mjs} +17 -3
- package/dist/{chunk-2UYTS7SL.mjs → chunk-GCX7TY4D.mjs} +16 -16
- package/dist/{chunk-QDLMIHUH.js → chunk-GDB6TCSD.js} +7 -7
- package/dist/{chunk-7S7LDFGH.js → chunk-GDZT5LZ5.js} +12 -12
- package/dist/{chunk-TB2LS4CE.mjs → chunk-GHS6MIL6.mjs} +7 -11
- package/dist/{chunk-7OHZ56N7.mjs → chunk-GXKI64OT.mjs} +4 -4
- package/dist/{chunk-HRSIIDOZ.js → chunk-H3OQKK6X.js} +41 -41
- package/dist/{chunk-56BM75QX.js → chunk-H5DFSCJ6.js} +2 -2
- package/dist/{chunk-LMK775I4.mjs → chunk-HAGEIC2L.mjs} +1 -1
- package/dist/{chunk-6FZDMICK.js → chunk-HAJ4JRFO.js} +15 -15
- package/dist/{chunk-AWR2G5QN.js → chunk-HDR72GB6.js} +6 -11
- package/dist/{chunk-PTT6KIM6.js → chunk-HSEO6TPU.js} +9 -9
- package/dist/{chunk-CQSA2D2O.js → chunk-I5FOHCLH.js} +4 -31
- package/dist/{chunk-TMFB55IR.js → chunk-J2U6PJSO.js} +3 -30
- package/dist/{chunk-W77VO6LP.js → chunk-J5AJNY2N.js} +13 -13
- package/dist/{chunk-UKA7LHIT.js → chunk-JR3TNEQC.js} +98 -98
- package/dist/{chunk-P2O34BIO.mjs → chunk-JURRXY2J.mjs} +1 -1
- package/dist/{chunk-PKMVOVUA.mjs → chunk-K3HW5I2A.mjs} +1 -1
- package/dist/{chunk-BXUGKBMK.mjs → chunk-KDQOR4QQ.mjs} +1 -14
- package/dist/{chunk-KVZSYGTX.mjs → chunk-KGHJH5KJ.mjs} +1 -1
- package/dist/{chunk-3AKWZF3X.mjs → chunk-KK6HYVBI.mjs} +1 -1
- package/dist/{chunk-K6GK5B5J.js → chunk-KMKWGXMK.js} +6 -6
- package/dist/{chunk-EH4UG7IL.mjs → chunk-KU7O4IXU.mjs} +4 -4
- package/dist/{chunk-IHRMR3WV.mjs → chunk-KVFNRJOR.mjs} +9 -9
- package/dist/{chunk-A36JRXXH.mjs → chunk-KWZHIPRN.mjs} +26 -9
- package/dist/{chunk-HBZQ5HSV.mjs → chunk-LIV3KXJG.mjs} +1 -1
- package/dist/{chunk-S75PBSSL.mjs → chunk-ME6HGG4I.mjs} +92 -8
- package/dist/{chunk-SNNEOS5Z.js → chunk-MHDUUSG6.js} +75 -75
- package/dist/{chunk-FKYZCXFM.js → chunk-MIPO6F72.js} +3 -3
- package/dist/{chunk-MFWIHTEN.js → chunk-MTIHEW2M.js} +5 -5
- package/dist/{chunk-UCR5HRYM.js → chunk-NFXCKTE2.js} +17 -3
- package/dist/{chunk-M53GR3ZV.mjs → chunk-NHJUGZUR.mjs} +43 -5
- package/dist/{chunk-FAYK2MN6.js → chunk-NZTKLY2W.js} +14 -18
- package/dist/{chunk-EQW6PFC2.js → chunk-O2ICABUD.js} +2 -2
- package/dist/{chunk-5PW6DVHF.js → chunk-OIVVQQ22.js} +55 -37
- package/dist/{chunk-GBJ5CW3A.js → chunk-PKOZXD2Q.js} +30 -30
- package/dist/{chunk-NGN3JE5A.js → chunk-PQTQKNKD.js} +16 -7
- package/dist/{chunk-DFZWHNRG.js → chunk-QOCHWR4V.js} +105 -42
- package/dist/{chunk-PCVVQNBD.js → chunk-QS6TRHMR.js} +5 -5
- package/dist/{chunk-DXZZ27CV.js → chunk-RKBLPGPV.js} +245 -207
- package/dist/{chunk-765P2G7G.mjs → chunk-RUVGVNOF.mjs} +1 -1
- package/dist/{chunk-NJ7ZWH4U.mjs → chunk-SAFJIGYB.mjs} +1 -1
- package/dist/{chunk-AOPH2IZV.js → chunk-SKQFLBDD.js} +60 -22
- package/dist/{chunk-BUUA5XJV.js → chunk-TWM6DLYF.js} +2 -2
- package/dist/{chunk-YPL5TKI4.mjs → chunk-U6UVWEAK.mjs} +1 -1
- package/dist/{chunk-6YQDHXPL.mjs → chunk-UCEPNO6N.mjs} +46 -9
- package/dist/{chunk-U7RULAEP.mjs → chunk-UG5BTOTF.mjs} +2 -2
- package/dist/{chunk-MITGBWV7.mjs → chunk-UGH37BQW.mjs} +1 -1
- package/dist/{chunk-IZDWAL53.mjs → chunk-UJDQQTBS.mjs} +8 -8
- package/dist/{chunk-URRZ2SMI.mjs → chunk-V7ZRH3PY.mjs} +2 -2
- package/dist/{chunk-3H76N7G5.mjs → chunk-VBGHBL3E.mjs} +8 -17
- package/dist/{chunk-AZC2LTUI.js → chunk-VVE3Y76O.js} +2 -2
- package/dist/{chunk-BRAA7U3I.mjs → chunk-W65JIRI4.mjs} +1 -1
- package/dist/{chunk-KU4EDEKA.js → chunk-WKWTDZS7.js} +9 -9
- package/dist/{chunk-BTUSVHIE.mjs → chunk-WLSU5QJT.mjs} +2 -2
- package/dist/{chunk-NTTHAXHI.js → chunk-X5A5FOWW.js} +11 -8
- package/dist/{chunk-ODL55PYL.mjs → chunk-XD2PMH46.mjs} +16 -7
- package/dist/{chunk-OADW5BSV.js → chunk-XK6UDWJS.js} +2 -2
- package/dist/{chunk-7ZTEVNXO.mjs → chunk-Y65LXQGA.mjs} +8 -8
- package/dist/{chunk-Z3PGQRTX.mjs → chunk-Y66WVMO5.mjs} +3 -30
- package/dist/{chunk-VZAJDN5C.mjs → chunk-Y7JWLB25.mjs} +1 -1
- package/dist/{chunk-ZJGPD42I.js → chunk-YCDXAJKU.js} +3 -3
- package/dist/{chunk-NCTLCSLE.mjs → chunk-Z3A3AFVU.mjs} +1 -1
- package/dist/{chunk-AEEF3T2E.mjs → chunk-ZCY6YKKP.mjs} +3 -3
- package/dist/{chunk-M5FWHML6.js → chunk-ZMIL4ZOA.js} +5 -18
- package/dist/{chunk-D3JMTFOX.mjs → chunk-ZR5ULMN2.mjs} +1 -1
- package/dist/{client-C9hsiaki.d.ts → client-CChON4-x.d.ts} +2 -2
- package/dist/{client-CO8lw80Q.d.mts → client-EeuGm5gj.d.mts} +2 -2
- package/dist/{courier-CJasuxTU.d.ts → courier-Cnn-edJ4.d.ts} +5 -5
- package/dist/{courier-C2Vo2VlH.d.mts → courier-FmGfkvIN.d.mts} +5 -5
- package/dist/{crowd-D1ljcic8.d.ts → crowd-DdHFbqKK.d.ts} +1 -1
- package/dist/{crowd-B37S1apB.d.mts → crowd-hOVSAmA3.d.mts} +1 -1
- package/dist/{danfo-BsaPzhGi.d.mts → danfo-BEBapFlX.d.mts} +3 -3
- package/dist/{danfo-CCv23CPy.d.ts → danfo-UQ_PTIIx.d.ts} +3 -3
- package/dist/{elements-Bsfk7CPw.d.ts → elements-ss9xZgRg.d.mts} +47 -1
- package/dist/{elements-Bsfk7CPw.d.mts → elements-ss9xZgRg.d.ts} +47 -1
- package/dist/engines/godot.d.mts +1 -1
- package/dist/engines/godot.d.ts +1 -1
- package/dist/engines/wasm.d.mts +1 -1
- package/dist/engines/wasm.d.ts +1 -1
- package/dist/fallback-KDB4Y2CE.mjs +6 -0
- package/dist/fallback-Y26TQENE.js +19 -0
- package/dist/{five-a-side-DP84gMuh.d.mts → five-a-side-Bv1AOUy3.d.mts} +4 -4
- package/dist/{five-a-side-DO8SDqMb.d.ts → five-a-side-EC51SJY1.d.ts} +4 -4
- package/dist/{fluid-BkxNBD2t.d.ts → fluid-ARr0qUIL.d.ts} +168 -4
- package/dist/{fluid-BZ42EMds.d.mts → fluid-DLlEj2cs.d.mts} +168 -4
- package/dist/game/conformance.d.mts +7 -7
- package/dist/game/conformance.d.ts +7 -7
- package/dist/game/conformance.js +39 -41
- package/dist/game/conformance.mjs +17 -19
- package/dist/game/heap.d.mts +3 -3
- package/dist/game/heap.d.ts +3 -3
- package/dist/game/heap.js +13 -15
- package/dist/game/heap.mjs +4 -6
- package/dist/game/kit/canvas.d.mts +33 -34
- package/dist/game/kit/canvas.d.ts +33 -34
- package/dist/game/kit/canvas.js +569 -570
- package/dist/game/kit/canvas.mjs +52 -53
- package/dist/game/kit/courier.d.mts +9 -9
- package/dist/game/kit/courier.d.ts +9 -9
- package/dist/game/kit/courier.js +20 -20
- package/dist/game/kit/courier.mjs +17 -17
- package/dist/game/kit/danfo.d.mts +7 -7
- package/dist/game/kit/danfo.d.ts +7 -7
- package/dist/game/kit/danfo.js +30 -30
- package/dist/game/kit/danfo.mjs +15 -15
- package/dist/game/kit/engine.d.mts +12 -12
- package/dist/game/kit/engine.d.ts +12 -12
- package/dist/game/kit/engine.js +40 -41
- package/dist/game/kit/engine.mjs +11 -12
- package/dist/game/kit/five-a-side.d.mts +7 -7
- package/dist/game/kit/five-a-side.d.ts +7 -7
- package/dist/game/kit/five-a-side.js +24 -24
- package/dist/game/kit/five-a-side.mjs +14 -14
- package/dist/game/kit/frontier.d.mts +7 -7
- package/dist/game/kit/frontier.d.ts +7 -7
- package/dist/game/kit/genesis.d.mts +7 -7
- package/dist/game/kit/genesis.d.ts +7 -7
- package/dist/game/kit/ludo.d.mts +8 -8
- package/dist/game/kit/ludo.d.ts +8 -8
- package/dist/game/kit/ludo.js +24 -24
- package/dist/game/kit/ludo.mjs +14 -14
- package/dist/game/kit/runner.d.mts +8 -8
- package/dist/game/kit/runner.d.ts +8 -8
- package/dist/game/kit/runner.js +22 -22
- package/dist/game/kit/runner.mjs +14 -14
- package/dist/game/kit/shootout.d.mts +9 -9
- package/dist/game/kit/shootout.d.ts +9 -9
- package/dist/game/kit/shootout.js +33 -34
- package/dist/game/kit/shootout.mjs +18 -19
- package/dist/game/kit/tycoon.d.mts +9 -9
- package/dist/game/kit/tycoon.d.ts +9 -9
- package/dist/game/kit/tycoon.js +38 -38
- package/dist/game/kit/tycoon.mjs +14 -14
- package/dist/game/kit/word.d.mts +7 -7
- package/dist/game/kit/word.d.ts +7 -7
- package/dist/game/kit/word.js +22 -22
- package/dist/game/kit/word.mjs +14 -14
- package/dist/game/kit/worlds.d.mts +10 -10
- package/dist/game/kit/worlds.d.ts +10 -10
- package/dist/game/kit/worlds.js +22 -22
- package/dist/game/kit/worlds.mjs +17 -17
- package/dist/game/kit.d.mts +31 -32
- package/dist/game/kit.d.ts +31 -32
- package/dist/game/kit.js +523 -524
- package/dist/game/kit.mjs +38 -39
- package/dist/game/prepared.d.mts +6 -7
- package/dist/game/prepared.d.ts +6 -7
- package/dist/game/prepared.js +23 -24
- package/dist/game/prepared.mjs +12 -13
- package/dist/{game-BZAfgKV6.d.mts → game-Bx8onkhX.d.mts} +5 -97
- package/dist/{game-UoJWopvD.d.ts → game-fgQy2YIm.d.ts} +5 -97
- package/dist/game.d.mts +56 -18
- package/dist/game.d.ts +56 -18
- package/dist/game.js +922 -927
- package/dist/game.mjs +16 -17
- package/dist/{graphics-DNIAV5Mi.d.ts → graphics-C6YjWYvP.d.ts} +1 -1
- package/dist/{graphics-CGJDdtdT.d.mts → graphics-Dk2yQnYN.d.mts} +1 -1
- package/dist/{guest-CMCZeyh5.d.mts → guest-DrsA00XD.d.mts} +2 -7
- package/dist/{guest-CB458Hgw.d.ts → guest-wIdfoT05.d.ts} +2 -7
- package/dist/{hit-stop-D2Ca-i9-.d.mts → hit-stop-BKkVwTq7.d.mts} +1 -1
- package/dist/{hit-stop-Ca1OYk15.d.ts → hit-stop-D-8pPYnM.d.ts} +1 -1
- package/dist/{hot-B9BTmDz6.d.ts → hot-Crn9XpHJ.d.ts} +1 -1
- package/dist/{hot-B_L-GEHZ.d.mts → hot-DH3nOhQD.d.mts} +1 -1
- package/dist/{index-DPeyo938.d.ts → index-B8ybCKJI.d.ts} +3 -3
- package/dist/index-BB2gjVvA.d.mts +153 -0
- package/dist/index-BEfmBDAH.d.ts +153 -0
- package/dist/{index-D8ZDzms9.d.mts → index-DHjNHb1c.d.mts} +3 -3
- package/dist/{index-DFDeJxFQ.d.ts → index-DQK8sTfu.d.ts} +2 -2
- package/dist/{index-C76o9Pj4.d.mts → index-DSVkhRmW.d.mts} +2 -2
- package/dist/index.d.mts +8 -8
- package/dist/index.d.ts +8 -8
- package/dist/index.js +189 -177
- package/dist/index.mjs +8 -8
- package/dist/interactives-authoring.js +12 -1
- package/dist/interactives-authoring.mjs +12 -1
- package/dist/interactives-canvas.global.js +14 -39
- package/dist/interactives-guest.d.mts +2 -2
- package/dist/interactives-guest.d.ts +2 -2
- package/dist/interactives-guest.js +10 -10
- package/dist/interactives-guest.mjs +6 -6
- package/dist/interactives-jsc.global.js +4 -23
- package/dist/interactives-meters.d.mts +11 -11
- package/dist/interactives-meters.d.ts +11 -11
- package/dist/interactives-meters.js +65 -66
- package/dist/interactives-meters.mjs +17 -18
- package/dist/interactives-production.d.mts +5 -5
- package/dist/interactives-production.d.ts +5 -5
- package/dist/interactives-production.js +15 -16
- package/dist/interactives-production.mjs +3 -4
- package/dist/interactives-record.d.mts +3 -9
- package/dist/interactives-record.d.ts +3 -9
- package/dist/interactives-record.js +45 -65
- package/dist/interactives-record.mjs +19 -39
- package/dist/interactives-runtime.d.mts +3 -3
- package/dist/interactives-runtime.d.ts +3 -3
- package/dist/interactives-runtime.js +101 -105
- package/dist/interactives-runtime.mjs +6 -6
- package/dist/interactives-studio-kernel.global.js +1 -1
- package/dist/interactives-studio.d.mts +7 -7
- package/dist/interactives-studio.d.ts +7 -7
- package/dist/interactives-studio.js +136 -82
- package/dist/interactives-studio.mjs +65 -11
- package/dist/interactives-web.d.mts +14 -14
- package/dist/interactives-web.d.ts +14 -14
- package/dist/interactives-web.js +201 -198
- package/dist/interactives-web.mjs +33 -34
- package/dist/interactives.d.mts +7 -28
- package/dist/interactives.d.ts +7 -28
- package/dist/interactives.js +267 -313
- package/dist/interactives.mjs +26 -24
- package/dist/{juice-Dfgc1B1q.d.ts → juice-CoHxlpKA.d.ts} +1 -1
- package/dist/{juice-CsnrmTOk.d.mts → juice-DQztFS43.d.mts} +1 -1
- package/dist/{launch-CCsmv8HC.d.mts → launch-T2f4_Di4.d.mts} +6 -6
- package/dist/{launch-8W_Tojpz.d.ts → launch-d6oreMzR.d.ts} +6 -6
- package/dist/{local-DA_HpPOj.d.mts → local-D8_I6UeL.d.mts} +0 -5
- package/dist/{local-_mD0x-p_.d.ts → local-VZkqsCzR.d.ts} +0 -5
- package/dist/{ludo-BVHycBP-.d.ts → ludo-B8ClkDqr.d.ts} +4 -4
- package/dist/{ludo-BRaTcI3E.d.mts → ludo-BOuohQ-M.d.mts} +4 -4
- package/dist/mock/cli.mjs +25 -10
- package/dist/mock/library.js +25 -10
- package/dist/mock/library.mjs +25 -10
- package/dist/mock/msw.js +25 -10
- package/dist/mock/msw.mjs +25 -10
- package/dist/{native-CcMUV7rL.d.mts → native-BodxSh03.d.mts} +2 -2
- package/dist/{native-3f4Ht82c.d.ts → native-DUHMT8gA.d.ts} +2 -2
- package/dist/{online-CCy8pwOn.d.ts → online-XeymeM9s.d.ts} +1 -1
- package/dist/{online-smW2BO3Y.d.mts → online-d7W_Al9a.d.mts} +1 -1
- package/dist/{play-BhHHX1jD.d.ts → play-BRRlfLhX.d.ts} +5 -5
- package/dist/{play-Bn7NfGNE.d.mts → play-ClrMd_e8.d.mts} +5 -5
- package/dist/{policy-Dvnzg8ki.d.mts → policy-CCw8tXLm.d.mts} +19 -19
- package/dist/{policy-b3MVXMQu.d.ts → policy-CMqOSQIu.d.ts} +19 -19
- package/dist/{ports-CNeZdgVm.d.mts → ports-B_0v_P3A.d.mts} +1 -1
- package/dist/{ports-Vno6MDaR.d.ts → ports-CGU_3e_0.d.ts} +1 -1
- package/dist/{prefetch-BFg6lJyD.d.ts → prefetch-BHnicOQj.d.ts} +3 -3
- package/dist/{prefetch-BOoKX3qx.d.mts → prefetch-CBXgYacv.d.mts} +3 -3
- package/dist/{price-BIKJla_G.d.mts → price-B-w4PwHm.d.mts} +1 -1
- package/dist/{price-D1gE7Ek6.d.ts → price-BEvQFNJv.d.ts} +1 -1
- package/dist/react.d.mts +35 -27
- package/dist/react.d.ts +35 -27
- package/dist/react.js +276 -248
- package/dist/react.mjs +147 -119
- package/dist/{runner-BAG8EglP.d.ts → runner-CQcLL7By.d.ts} +6 -6
- package/dist/{runner-CKtKtIzc.d.mts → runner-D46Yj-wE.d.mts} +6 -6
- package/dist/{runtime-DfZp1Kwg.d.mts → runtime-BRWfkf_q.d.mts} +1 -8
- package/dist/{runtime-yHPgIJV-.d.ts → runtime-DyuBMkKM.d.ts} +1 -8
- package/dist/{scheduling-CnRAhWQL.d.ts → scheduling-D9WwOxbo.d.ts} +1 -1
- package/dist/{scheduling-lHyYoCa7.d.mts → scheduling-DDmrYCr-.d.mts} +1 -1
- package/dist/{server-DxcxjER1.d.ts → server-BiKwxvnf.d.ts} +2 -2
- package/dist/{server-lU3Sznd4.d.mts → server-DPdtl2dd.d.mts} +2 -2
- package/dist/server.d.mts +4 -4
- package/dist/server.d.ts +4 -4
- package/dist/server.js +10 -10
- package/dist/server.mjs +4 -4
- package/dist/{shootout-rK-oblpb.d.ts → shootout-BJZeX6FG.d.ts} +6 -6
- package/dist/{shootout-D998OWa-.d.mts → shootout-tcZd1Rxt.d.mts} +6 -6
- package/dist/styles.css +1 -1
- package/dist/{template-Byaueo34.d.mts → template-AhCdB1kW.d.mts} +3 -3
- package/dist/{template-BXE2L654.d.ts → template-BkEGVTQj.d.ts} +3 -3
- package/dist/{terrain-vgYkJPNx.d.ts → terrain-BMxEc6Oc.d.ts} +3 -3
- package/dist/{terrain-CR0NQZPl.d.mts → terrain-C-XC1Oui.d.mts} +3 -3
- package/dist/testing/msw.d.mts +3 -3
- package/dist/testing/msw.d.ts +3 -3
- package/dist/testing/msw.js +3 -3
- package/dist/testing/msw.mjs +2 -2
- package/dist/testing/suite.d.mts +6 -6
- package/dist/testing/suite.d.ts +6 -6
- package/dist/testing/suite.js +25 -25
- package/dist/testing/suite.mjs +6 -6
- package/dist/testing.d.mts +5 -5
- package/dist/testing.d.ts +5 -5
- package/dist/testing.js +117 -117
- package/dist/testing.mjs +7 -7
- package/dist/{tick-transport-Q_8syS8J.d.mts → tick-transport-C8QEh8Uw.d.mts} +1 -1
- package/dist/{tick-transport-xpMcMTs2.d.ts → tick-transport-UjHY3xK1.d.ts} +1 -1
- package/dist/{tree-BsXFWKQ6.d.mts → tree-C-Io2Hbx.d.mts} +1 -1
- package/dist/{tree-DW9x29Vv.d.ts → tree-CP_qSW4H.d.ts} +1 -1
- package/dist/{tycoon-BMAE5Wea.d.mts → tycoon-B5KHk2Xx.d.mts} +5 -5
- package/dist/{tycoon-dWvWTbE4.d.ts → tycoon-hmHo-3HX.d.ts} +5 -5
- package/dist/{types-AkQEePZo.d.mts → types-BQkxPwZP.d.mts} +1 -1
- package/dist/{types-BWaZJDla.d.ts → types-D_f6UUxM.d.ts} +1 -1
- package/dist/utils.d.mts +3 -3
- package/dist/utils.d.ts +3 -3
- package/dist/utils.js +44 -32
- package/dist/utils.mjs +2 -2
- package/dist/{weave-delivery-3G03LdMM.d.mts → weave-delivery-CW-Kstut.d.mts} +10 -1
- package/dist/{weave-delivery-3G03LdMM.d.ts → weave-delivery-CW-Kstut.d.ts} +10 -1
- package/dist/{web-C8B9uVx_.d.ts → web-Dss74a7v.d.ts} +7 -7
- package/dist/{web-BqSEfX2o.d.mts → web-f9ZQPoyw.d.mts} +7 -7
- package/dist/{weight-BDABC8cN.d.ts → weight-CPGjsgjV.d.ts} +3 -3
- package/dist/{weight-Cee73u3d.d.mts → weight-tF1ODR1p.d.mts} +3 -3
- package/dist/{word-DB2CcI9q.d.mts → word-BcX9Jcmv.d.mts} +3 -3
- package/dist/{word-C1Q8oXuy.d.ts → word-C7sVPLHm.d.ts} +3 -3
- package/dist/{world-Bmh7K4tQ.d.ts → world-PvILHDR1.d.ts} +1 -1
- package/dist/{world-CPEjuctH.d.mts → world-qekkXjJv.d.mts} +1 -1
- package/dist/{worlds-ShY5h6Z_.d.ts → worlds-Br2Pb0Cs.d.ts} +6 -6
- package/dist/{worlds-CQgN3GnR.d.mts → worlds-D5ydYr7d.d.mts} +6 -6
- package/package.json +5 -3
- package/registry/cart-summary.json +1 -1
- package/registry/chat-widget.json +1 -1
- package/registry/price.json +1 -1
- package/registry/product-customizer.json +1 -1
- package/registry/search-input.json +1 -1
- package/dist/chunk-2LKAOU3L.mjs +0 -96
- package/dist/chunk-UWPQL2IQ.js +0 -104
- package/dist/fallback-Q5YYIMVJ.js +0 -20
- package/dist/fallback-XGBE3RXC.mjs +0 -7
- package/dist/index-C3KORE2U.d.mts +0 -86
- package/dist/index-Cv_rL5X0.d.ts +0 -86
- package/dist/split-Di9wBpNx.d.mts +0 -68
- package/dist/split-Di9wBpNx.d.ts +0 -68
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "chat-widget.tsx",
|
|
12
|
-
"content": "\"use client\";\n\nimport React, {\n useState,\n useRef,\n useCallback,\n type KeyboardEvent,\n} from \"react\";\nimport type { CimplifyClient } from \"../client\";\nimport { CimplifyError } from \"@cimplify/sdk\";\nimport type { ChatMessage, ChatUploadAttachment, ChatWidgetStarter } from \"../types/support\";\nimport { isOptimistic } from \"./chat-model\";\nimport {\n ChatKitProgram,\n ChatReplyCards,\n MessageBubble,\n Seam,\n chatTimeline,\n dayLabel,\n deliveryState,\n groupPosition,\n proseOf,\n sameDay,\n type GenUIActionEvent,\n} from \"./chat-kit\";\nimport { useChat } from \"./hooks/use-chat\";\nimport { useOptionalCimplifyClient } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nconst MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;\n\n/** Mirrors the server allowlist: images plus inert document types. */\nconst DOCUMENT_MIME_TYPES = new Set([\n \"application/pdf\",\n \"text/plain\",\n \"text/csv\",\n \"application/msword\",\n \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n \"application/vnd.ms-excel\",\n \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n]);\n\nconst ATTACHMENT_ACCEPT = [\"image/*\", ...DOCUMENT_MIME_TYPES].join(\",\");\n\nfunction isAllowedAttachment(file: File): boolean {\n return file.type.startsWith(\"image/\") || DOCUMENT_MIME_TYPES.has(file.type);\n}\nconst KEEP_DISMISSED_KEY = \"cimplify_keep_chat_dismissed\";\n/** How close to the bottom still counts as reading the live edge. */\nconst AT_BOTTOM_SLACK_PX = 48;\n\n/* ─── Public API ──────────────────────────────────────────── */\n\nexport interface ChatWidgetClassNames {\n root?: string;\n bubble?: string;\n panel?: string;\n header?: string;\n messages?: string;\n input?: string;\n welcome?: string;\n}\n\nexport interface ChatWidgetProps {\n client?: CimplifyClient;\n /** Business display name shown in the header */\n businessName?: string;\n /** Greeting text on the welcome screen */\n greeting?: string;\n /** Subtitle on the welcome screen */\n subtitle?: string;\n /** Conversation starters */\n starters?: ChatWidgetStarter[];\n /** Input placeholder */\n placeholder?: string;\n /** Position of the bubble */\n position?: \"bottom-right\" | \"bottom-left\";\n /** Start with the panel open */\n defaultOpen?: boolean;\n /** Poll interval in ms (default: 3000) */\n pollInterval?: number;\n /** Currency for structured product cards (e.g. \"GHS\"). */\n currency?: string;\n /**\n * Receives taps on GenUI buttons and structured cards\n * (`chat:view_product:{id}`, `chat:view_order:{id}`). Without it, cards\n * render read-only and program buttons no-op.\n */\n onGenUIAction?: (event: GenUIActionEvent) => void;\n className?: string;\n classNames?: ChatWidgetClassNames;\n}\n\nexport function ChatWidget({\n client: clientProp,\n businessName: businessNameProp,\n greeting = \"Hi there!\",\n subtitle = \"Ask us anything — we typically reply in a few seconds.\",\n starters,\n placeholder = \"Type a message\\u2026\",\n position = \"bottom-right\",\n defaultOpen = false,\n pollInterval,\n currency: currencyProp,\n onGenUIAction,\n className,\n classNames,\n}: ChatWidgetProps) {\n const context = useOptionalCimplifyClient();\n const client = clientProp ?? context?.client;\n // Provider-wrapped hosts get the real identity with zero config.\n const businessName = businessNameProp ?? context?.business?.name ?? \"Support\";\n const currency = currencyProp ?? context?.baseCurrency;\n\n const [open, setOpen] = useState(defaultOpen);\n const [readAt, setReadAt] = useState<string | null>(null);\n const [attachError, setAttachError] = useState<CimplifyError | null>(null);\n const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);\n\n const {\n messages,\n isLoading,\n isSending,\n awaitingReply,\n seenAt,\n error,\n isActive,\n humanRequested,\n claimedAs,\n signOut,\n send,\n react,\n requestHuman,\n startConversation,\n loadOlder,\n mayHaveOlder,\n openIfExisting,\n } = useChat({ client, starters, pollInterval });\n const showTyping = isSending || awaitingReply;\n\n const attachFile = useCallback(\n async (file: File) => {\n if (!client) return;\n setAttachError(null);\n if (!isAllowedAttachment(file)) {\n setAttachError(\n new CimplifyError(\"invalid_attachment\", \"Only photos and documents can be attached.\"),\n );\n return;\n }\n if (file.size > MAX_ATTACHMENT_BYTES) {\n setAttachError(new CimplifyError(\"attachment_too_large\", \"Files must be under 25MB.\"));\n return;\n }\n const uploaded = await client.uploads.upload(file);\n if (!uploaded.ok) {\n setAttachError(uploaded.error);\n return;\n }\n const attachment: ChatUploadAttachment = {\n upload_id: uploaded.value.id,\n url: uploaded.value.url,\n mime_type: uploaded.value.content_type,\n filename: uploaded.value.filename,\n size: uploaded.value.size_bytes,\n };\n await send(\"\", { attachments: [attachment] });\n },\n [client, send],\n );\n\n const handleSend = useCallback(\n (content: string) => {\n setAttachError(null);\n const target = replyTo;\n setReplyTo(null);\n return send(content, target ? { replyTo: target } : undefined);\n },\n [send, replyTo],\n );\n\n // Unread badge: incoming messages newer than the watermark snapshotted\n // when the panel last closed.\n const unread = open\n ? 0\n : messages.filter(\n (m) => m.sender_type !== \"customer\" && (!readAt || m.created_at > readAt),\n ).length;\n\n useState(() => {\n if (defaultOpen) void openIfExisting();\n });\n\n const handleOpen = useCallback(() => {\n setOpen(true);\n void openIfExisting();\n }, [openIfExisting]);\n\n const handleClose = useCallback(() => {\n const newest = messages[messages.length - 1];\n setReadAt(newest?.created_at ?? null);\n setOpen(false);\n }, [messages]);\n\n const isLeft = position === \"bottom-left\";\n\n return (\n <div\n className={cn(\n \"fixed bottom-6 z-[9999]\",\n isLeft ? \"left-6\" : \"right-6\",\n className,\n classNames?.root,\n )}\n >\n {/* Bubble */}\n {!open && (\n <Bubble\n unread={unread}\n onClick={handleOpen}\n className={classNames?.bubble}\n />\n )}\n\n {/* Panel */}\n {open && (\n <Panel\n client={client}\n businessName={businessName}\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n placeholder={placeholder}\n messages={messages}\n isLoading={isLoading}\n isSending={isSending}\n isTyping={showTyping}\n seenAt={seenAt}\n readAt={readAt}\n error={error ?? attachError}\n isActive={isActive}\n isLeft={isLeft}\n humanRequested={humanRequested}\n mayHaveOlder={mayHaveOlder}\n replyTo={replyTo}\n claimedAs={claimedAs}\n onSignOut={() => void signOut()}\n onClose={handleClose}\n onSend={handleSend}\n onReact={react}\n onReply={setReplyTo}\n onCancelReply={() => setReplyTo(null)}\n onRequestHuman={() => void requestHuman()}\n onLoadOlder={() => void loadOlder()}\n onAttach={attachFile}\n onStartConversation={startConversation}\n currency={currency}\n onGenUIAction={onGenUIAction}\n classNames={classNames}\n />\n )}\n </div>\n );\n}\n\n/* ─── Bubble ──────────────────────────────────────────────── */\n\nfunction Bubble({\n unread,\n onClick,\n className,\n}: {\n unread: number;\n onClick: () => void;\n className?: string;\n}) {\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-label=\"Open chat\"\n className={cn(\n \"relative flex h-14 w-14 items-center justify-center rounded-full\",\n \"bg-foreground text-background shadow-lg\",\n \"transition-transform duration-300 ease-out\",\n \"hover:scale-[1.08] active:scale-95\",\n className,\n )}\n >\n <ChatIcon className=\"h-6 w-6\" />\n {unread > 0 && (\n <span\n className={cn(\n \"absolute -right-0.5 -top-0.5 flex h-5 w-5 items-center justify-center\",\n \"rounded-full bg-destructive text-[10px] font-bold text-white\",\n \"ring-2 ring-background\",\n )}\n >\n {unread > 9 ? \"9+\" : unread}\n </span>\n )}\n </button>\n );\n}\n\n/* ─── Panel ───────────────────────────────────────────────── */\n\nfunction Panel({\n client,\n businessName,\n greeting,\n subtitle,\n starters,\n placeholder,\n messages,\n isLoading,\n isSending,\n isTyping,\n seenAt,\n readAt,\n error,\n isActive,\n isLeft,\n humanRequested,\n mayHaveOlder,\n replyTo,\n claimedAs,\n onSignOut,\n onClose,\n onSend,\n onReact,\n onReply,\n onCancelReply,\n onRequestHuman,\n onLoadOlder,\n onAttach,\n onStartConversation,\n currency,\n onGenUIAction,\n classNames,\n}: {\n client: CimplifyClient | undefined;\n businessName: string;\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n placeholder: string;\n messages: ChatMessage[];\n isLoading: boolean;\n isSending: boolean;\n isTyping: boolean;\n seenAt: string | null;\n readAt: string | null;\n error: CimplifyError | null;\n isActive: boolean;\n isLeft: boolean;\n humanRequested: boolean;\n mayHaveOlder: boolean;\n replyTo: ChatMessage | null;\n claimedAs: string | null;\n onSignOut: () => void;\n onClose: () => void;\n onSend: (content: string) => Promise<boolean>;\n onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n onReply: (message: ChatMessage) => void;\n onCancelReply: () => void;\n onRequestHuman: () => void;\n onLoadOlder: () => void;\n onAttach: (file: File) => Promise<void>;\n onStartConversation: (text?: string) => Promise<void>;\n currency?: string;\n onGenUIAction?: (event: GenUIActionEvent) => void;\n classNames?: ChatWidgetClassNames;\n}) {\n return (\n <div\n className={cn(\n \"flex flex-col overflow-hidden rounded-2xl bg-background shadow-2xl\",\n \"w-[400px] max-w-[calc(100vw-3rem)]\",\n \"h-[min(600px,calc(100vh-6rem))]\",\n \"animate-in fade-in slide-in-from-bottom-4 duration-300\",\n isLeft ? \"origin-bottom-left\" : \"origin-bottom-right\",\n classNames?.panel,\n )}\n >\n {/* Header */}\n <Header\n businessName={businessName}\n claimedAs={claimedAs}\n onSignOut={onSignOut}\n onClose={onClose}\n className={classNames?.header}\n />\n\n {/* Body */}\n {!isActive && !isLoading ? (\n <Welcome\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n onStarter={onStartConversation}\n className={classNames?.welcome}\n />\n ) : (\n <MessageList\n messages={messages}\n businessName={businessName}\n isLoading={isLoading}\n isSending={isTyping}\n seenAt={seenAt}\n readAt={readAt}\n humanRequested={humanRequested}\n mayHaveOlder={mayHaveOlder}\n onReact={onReact}\n onReply={onReply}\n onRequestHuman={onRequestHuman}\n onLoadOlder={onLoadOlder}\n currency={currency}\n onGenUIAction={onGenUIAction}\n className={classNames?.messages}\n />\n )}\n\n {isActive && !claimedAs && client && <KeepConversation client={client} />}\n\n {humanRequested && isActive && (\n <div className=\"flex items-center gap-2 border-t border-border bg-muted/50 px-4 py-2 text-[11px] text-muted-foreground\">\n <span className=\"h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-amber-500\" />\n Waiting for {businessName} — usually replies in minutes\n </div>\n )}\n\n {/* Error */}\n {error != null && (\n <div className=\"border-t border-destructive/20 bg-destructive/10 px-4 py-2 text-xs text-destructive\">\n {error.userMessage || \"Something went wrong. Please try again.\"}\n </div>\n )}\n\n {replyTo && (\n <div className=\"flex items-start gap-2 border-t border-border bg-muted/50 px-4 py-2\">\n <span className=\"w-[3px] shrink-0 self-stretch rounded-full bg-foreground/40\" />\n <div className=\"min-w-0 flex-1\">\n <div className=\"text-[11px] font-semibold text-foreground\">\n Replying to {replyTo.sender_type === \"customer\" ? \"yourself\" : businessName}\n </div>\n <div className=\"truncate text-[11px] text-muted-foreground\">\n {proseOf(replyTo.content) || \"Photo\"}\n </div>\n </div>\n <button\n type=\"button\"\n aria-label=\"Cancel reply\"\n onClick={onCancelReply}\n className=\"flex-shrink-0 p-0.5 text-muted-foreground hover:text-foreground\"\n >\n <CloseIcon className=\"h-3.5 w-3.5\" />\n </button>\n </div>\n )}\n\n {/* Always present: gating on an active conversation left a visitor who\n wants to type their own question with only the starter buttons. */}\n <ChatInput\n placeholder={placeholder}\n isSending={isSending}\n onSend={isActive ? onSend : async (text) => { await onStartConversation(text); return true; }}\n onAttach={onAttach}\n className={classNames?.input}\n />\n\n\n {/* Footer */}\n <div className=\"flex-shrink-0 border-t border-border bg-background px-4 py-1.5 text-center text-[10px] text-muted-foreground\">\n Powered by{\" \"}\n <span className=\"font-semibold text-foreground\">Cimplify</span>\n </div>\n </div>\n );\n}\n\n/* ─── Header ──────────────────────────────────────────────── */\n\nfunction Header({\n businessName,\n claimedAs,\n onSignOut,\n onClose,\n className,\n}: {\n businessName: string;\n claimedAs: string | null;\n onSignOut: () => void;\n onClose: () => void;\n className?: string;\n}) {\n return (\n <div\n className={cn(\n \"flex flex-shrink-0 items-center gap-3 bg-foreground px-5 py-4 text-background\",\n className,\n )}\n >\n <div className=\"flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] bg-background/15\">\n <ChatIcon className=\"h-5 w-5\" />\n </div>\n <div className=\"min-w-0 flex-1\">\n <div className=\"truncate text-[15px] font-semibold leading-tight\">\n {businessName}\n </div>\n {claimedAs ? (\n <div className=\"flex items-center gap-1 truncate text-xs text-background/60\">\n <span className=\"truncate\">Chatting as {claimedAs}</span>\n <span aria-hidden=\"true\">·</span>\n <button\n type=\"button\"\n onClick={onSignOut}\n className=\"flex-shrink-0 underline underline-offset-2 hover:text-background\"\n >\n Not you?\n </button>\n </div>\n ) : (\n <div className=\"flex items-center gap-1.5 text-xs text-background/60\">\n <span className=\"h-[7px] w-[7px] flex-shrink-0 rounded-full bg-emerald-400\" />\n Online\n </div>\n )}\n </div>\n <button\n type=\"button\"\n onClick={onClose}\n aria-label=\"Close chat\"\n className={cn(\n \"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg\",\n \"bg-background/10 text-background/70 transition-colors hover:bg-background/20 hover:text-background\",\n )}\n >\n <CloseIcon className=\"h-[18px] w-[18px]\" />\n </button>\n </div>\n );\n}\n\n/* ─── Welcome ─────────────────────────────────────────────── */\n\nfunction Welcome({\n greeting,\n subtitle,\n starters,\n onStarter,\n className,\n}: {\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n onStarter: (text?: string) => Promise<void>;\n className?: string;\n}) {\n const defaultStarters: ChatWidgetStarter[] = [\n { icon: \"\\ud83d\\udd0d\", text: \"Help me find a product\" },\n { icon: \"\\ud83d\\uded2\", text: \"Check my cart\" },\n { icon: \"\\ud83d\\udce6\", text: \"Track my order\" },\n ];\n\n const items = starters ?? defaultStarters;\n\n return (\n <div\n className={cn(\n \"flex flex-1 flex-col items-center justify-center overflow-y-auto bg-muted/50 px-8 py-10 text-center\",\n className,\n )}\n >\n <div className=\"mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-muted\">\n <ChatIcon className=\"h-7 w-7 text-muted-foreground\" />\n </div>\n <h3 className=\"mb-2 text-xl font-bold text-foreground\">{greeting}</h3>\n <p className=\"mb-6 max-w-[280px] text-sm leading-relaxed text-muted-foreground\">\n {subtitle}\n </p>\n <div className=\"flex w-full max-w-[300px] flex-col gap-2\">\n {items.map((s) => (\n <button\n key={s.text}\n type=\"button\"\n onClick={() => onStarter(s.text)}\n className={cn(\n \"flex items-center gap-3 rounded-xl border border-border bg-background px-4 py-3\",\n \"text-left text-[13px] font-medium text-foreground\",\n \"transition-all hover:-translate-y-px hover:border-foreground hover:shadow-sm\",\n )}\n >\n {s.icon && (\n <span className=\"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-muted text-[15px]\">\n {s.icon}\n </span>\n )}\n <span>{s.text}</span>\n </button>\n ))}\n </div>\n </div>\n );\n}\n\n/* ─── Messages ────────────────────────────────────────────── */\n\nconst QUICK_REACTIONS = [\"👍\", \"❤️\"] as const;\n\nfunction LetterAvatar({ name }: { name: string }) {\n return (\n <span className=\"flex size-6 items-center justify-center rounded-full bg-foreground text-[10px] font-bold text-background\">\n {name.charAt(0).toUpperCase()}\n </span>\n );\n}\n\nfunction MessageList({\n messages,\n businessName,\n isLoading,\n isSending,\n seenAt,\n readAt,\n humanRequested,\n mayHaveOlder,\n onReact,\n onReply,\n onRequestHuman,\n onLoadOlder,\n currency,\n onGenUIAction,\n className,\n}: {\n messages: ChatMessage[];\n businessName: string;\n isLoading: boolean;\n isSending: boolean;\n seenAt: string | null;\n readAt: string | null;\n humanRequested: boolean;\n mayHaveOlder: boolean;\n onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n onReply: (message: ChatMessage) => void;\n onRequestHuman: () => void;\n onLoadOlder: () => void;\n currency?: string;\n onGenUIAction?: (event: GenUIActionEvent) => void;\n className?: string;\n}) {\n const scrolledUpRef = useRef(false);\n const timeline = chatTimeline(messages, { readAt, rendersPrograms: true });\n const { bubbles, reactionsByTarget, firstUnreadId, unreadOnOpen, firstAgentMessageId } =\n timeline;\n const lastBubble = bubbles[bubbles.length - 1];\n const offerHuman = !humanRequested && lastBubble?.sender_type === \"bot\" && !isSending;\n\n const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {\n const node = event.currentTarget;\n scrolledUpRef.current =\n node.scrollHeight - node.scrollTop - node.clientHeight >= AT_BOTTOM_SLACK_PX;\n };\n\n return (\n <div\n onScroll={handleScroll}\n style={{ background: \"var(--color-chat-canvas, var(--color-muted, #f3f5f3))\" }}\n className={cn(\"flex flex-1 flex-col gap-0.5 overflow-y-auto px-3.5 py-3.5\", className)}\n >\n {isLoading && messages.length === 0 && (\n <div className=\"flex flex-1 items-center justify-center\">\n <TypingDots />\n </div>\n )}\n\n {mayHaveOlder && (\n <div className=\"flex justify-center pb-1\">\n <button\n type=\"button\"\n onClick={onLoadOlder}\n className=\"rounded-full border border-foreground/[0.06] bg-background px-3.5 py-[7px] text-xs font-semibold text-muted-foreground shadow-[0_1px_2px_rgba(15,23,42,0.04)]\"\n >\n Show earlier messages\n </button>\n </div>\n )}\n\n {bubbles.map((msg, i) => {\n const prev = bubbles[i - 1];\n const newDay = !prev || !sameDay(prev.created_at, msg.created_at);\n const group = groupPosition(timeline, i);\n const startsRun = group === \"single\" || group === \"first\";\n const delivery = deliveryState(msg, timeline, seenAt, isOptimistic);\n\n return (\n <div\n key={msg.id}\n className={cn(\"flex flex-col gap-1.5\", startsRun && i > 0 && \"mt-2.5\")}\n >\n {newDay && <Seam variant=\"chip\" label={dayLabel(msg.created_at)} />}\n {msg.id === firstUnreadId && (\n <Seam\n variant=\"unread\"\n label={`${unreadOnOpen} new message${unreadOnOpen === 1 ? \"\" : \"s\"}`}\n />\n )}\n {msg.id === firstAgentMessageId && <Seam label=\"A person joined\" />}\n {msg.sender_type === \"system\" ? (\n <Seam label=\"Passing you to the team\" />\n ) : (\n <div className=\"group relative\">\n {msg.sender_type !== \"customer\" && !isOptimistic(msg) && (\n <HoverRail message={msg} onReact={onReact} onReply={onReply} />\n )}\n <MessageBubble\n message={msg}\n reactions={reactionsByTarget.get(msg.id)}\n businessName={businessName}\n group={group}\n delivery={delivery}\n onReply={onReply}\n avatar={<LetterAvatar name={businessName} />}\n renderProgram={(source) => (\n <ChatKitProgram source={source} onAction={onGenUIAction} />\n )}\n renderCards={(target) => (\n <ChatReplyCards message={target} currency={currency} onAction={onGenUIAction} />\n )}\n onQuoteMiss={async () => {\n onLoadOlder();\n return true;\n }}\n />\n </div>\n )}\n </div>\n );\n })}\n\n {isSending && (\n <div className=\"mr-auto mt-1\">\n <TypingDots />\n </div>\n )}\n\n {offerHuman && (\n <div className=\"mt-2\">\n <button\n type=\"button\"\n onClick={onRequestHuman}\n className=\"rounded-full border border-border bg-background px-3 py-1.5 text-xs font-semibold text-foreground hover:border-foreground\"\n >\n Talk to a person\n </button>\n </div>\n )}\n\n <div\n key={`${messages.length}:${isSending}`}\n ref={(node) => {\n // Keyed so the callback re-fires per new message/typing change —\n // follows the conversation, but never yanks a reader out of history.\n if (node && !scrolledUpRef.current) node.scrollIntoView({ behavior: \"smooth\" });\n }}\n />\n </div>\n );\n}\n\nfunction HoverRail({\n message,\n onReact,\n onReply,\n}: {\n message: ChatMessage;\n onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n onReply: (message: ChatMessage) => void;\n}) {\n return (\n <div className=\"absolute -top-3 right-0 z-10 hidden gap-0.5 rounded-full border border-border bg-background px-1 py-0.5 shadow-sm group-hover:flex\">\n {QUICK_REACTIONS.map((emoji) => (\n <button\n key={emoji}\n type=\"button\"\n aria-label={`React ${emoji}`}\n onClick={() => void onReact(message, emoji)}\n className=\"flex h-6 w-6 items-center justify-center rounded-full text-[13px] hover:bg-muted\"\n >\n {emoji}\n </button>\n ))}\n <button\n type=\"button\"\n aria-label=\"Reply\"\n onClick={() => onReply(message)}\n className=\"flex h-6 w-6 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground\"\n >\n <ReplyIcon className=\"h-3.5 w-3.5\" />\n </button>\n </div>\n );\n}\n\n/* ─── Keep this conversation ──────────────────────────────── */\n\nfunction readKeepDismissed(): boolean {\n try {\n return typeof window !== \"undefined\" && window.localStorage.getItem(KEEP_DISMISSED_KEY) === \"1\";\n } catch {\n return false;\n }\n}\n\nfunction writeKeepDismissed(): void {\n try {\n window.localStorage.setItem(KEEP_DISMISSED_KEY, \"1\");\n } catch {}\n}\n\nfunction KeepConversation({ client }: { client: CimplifyClient }) {\n const [step, setStep] = useState<\"row\" | \"contact\" | \"code\">(\"row\");\n const [dismissed, setDismissed] = useState(readKeepDismissed);\n const [contact, setContact] = useState(\"\");\n const [code, setCode] = useState(\"\");\n const [challengeId, setChallengeId] = useState<string | null | undefined>(null);\n const [busy, setBusy] = useState(false);\n const [verifyError, setVerifyError] = useState<string | null>(null);\n\n if (dismissed) return null;\n\n const contactType = contact.includes(\"@\") ? \"email\" : \"phone\";\n\n const requestCode = async () => {\n const trimmed = contact.trim();\n if (!trimmed || busy) return;\n setBusy(true);\n setVerifyError(null);\n const result = await client.link.requestOtp({ contact: trimmed, contact_type: contactType });\n setBusy(false);\n if (result.ok) {\n setChallengeId(result.value.challenge_id);\n setCode(\"\");\n setStep(\"code\");\n } else {\n setVerifyError(result.error.userMessage || \"Couldn't send a code — try again.\");\n }\n };\n\n const verify = async (value: string) => {\n if (value.length !== 6 || busy || challengeId === null) return;\n setBusy(true);\n setVerifyError(null);\n const result = await client.link.verifyOtp({\n ...(challengeId === undefined ? {} : { challenge_id: challengeId }),\n contact: contact.trim(),\n contact_type: contactType,\n otp_code: value,\n });\n setBusy(false);\n if (!result.ok) {\n setCode(\"\");\n setVerifyError(result.error.userMessage || \"Invalid or expired code.\");\n }\n };\n\n return (\n <div className=\"flex-shrink-0 border-t border-border bg-muted/50 px-4 py-2.5\">\n {step === \"row\" && (\n <div className=\"flex items-center gap-2.5\">\n <button\n type=\"button\"\n onClick={() => setStep(\"contact\")}\n className=\"flex min-w-0 flex-1 flex-col text-left\"\n >\n <span className=\"text-xs font-semibold text-foreground\">Keep this conversation</span>\n <span className=\"truncate text-[11px] text-muted-foreground\">\n Verify your number and it follows you — any device\n </span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Dismiss\"\n onClick={() => {\n writeKeepDismissed();\n setDismissed(true);\n }}\n className=\"flex-shrink-0 p-1 text-muted-foreground hover:text-foreground\"\n >\n <CloseIcon className=\"h-3.5 w-3.5\" />\n </button>\n </div>\n )}\n\n {step === \"contact\" && (\n <div className=\"flex items-center gap-2\">\n <input\n type=\"text\"\n inputMode=\"email\"\n value={contact}\n onChange={(e) => setContact(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && void requestCode()}\n placeholder=\"Phone or email\"\n autoFocus\n className=\"h-9 min-w-0 flex-1 rounded-lg border border-border bg-background px-3 text-xs text-foreground outline-none placeholder:text-muted-foreground focus:border-foreground\"\n />\n <button\n type=\"button\"\n onClick={() => void requestCode()}\n disabled={!contact.trim() || busy}\n className=\"h-9 flex-shrink-0 rounded-lg bg-foreground px-3 text-xs font-semibold text-background disabled:opacity-50\"\n >\n {busy ? \"Sending…\" : \"Send code\"}\n </button>\n </div>\n )}\n\n {step === \"code\" && (\n <div className=\"flex items-center gap-2\">\n <input\n type=\"text\"\n inputMode=\"numeric\"\n autoComplete=\"one-time-code\"\n maxLength={6}\n value={code}\n onChange={(e) => {\n const digits = e.target.value.replace(/\\D/g, \"\").slice(0, 6);\n setCode(digits);\n if (digits.length === 6) void verify(digits);\n }}\n placeholder=\"6-digit code\"\n autoFocus\n className=\"h-9 min-w-0 flex-1 rounded-lg border border-border bg-background px-3 text-xs tracking-[0.2em] text-foreground outline-none placeholder:tracking-normal placeholder:text-muted-foreground focus:border-foreground\"\n />\n <button\n type=\"button\"\n onClick={() => void requestCode()}\n disabled={busy}\n className=\"h-9 flex-shrink-0 rounded-lg px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground disabled:opacity-50\"\n >\n Resend\n </button>\n </div>\n )}\n\n {verifyError && <p className=\"mt-1.5 text-[11px] text-destructive\">{verifyError}</p>}\n </div>\n );\n}\n\n/* ─── Input ───────────────────────────────────────────────── */\n\nfunction ChatInput({\n placeholder,\n isSending,\n onSend,\n onAttach,\n className,\n}: {\n placeholder: string;\n isSending: boolean;\n onSend: (content: string) => Promise<boolean>;\n onAttach: (file: File) => Promise<void>;\n className?: string;\n}) {\n const [value, setValue] = useState(\"\");\n const [uploading, setUploading] = useState(false);\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n const fileRef = useRef<HTMLInputElement>(null);\n\n const handleAttach = useCallback(\n async (file: File) => {\n setUploading(true);\n try {\n await onAttach(file);\n } finally {\n setUploading(false);\n }\n },\n [onAttach],\n );\n\n const handleSend = useCallback(async () => {\n const trimmed = value.trim();\n if (!trimmed || isSending) return;\n setValue(\"\");\n if (textareaRef.current) {\n textareaRef.current.style.height = \"auto\";\n }\n const sent = await onSend(trimmed);\n // A failed send must not eat the message — put it back to retry.\n if (!sent) setValue((current) => current || trimmed);\n }, [value, isSending, onSend]);\n\n const handleKeyDown = useCallback(\n (e: KeyboardEvent<HTMLTextAreaElement>) => {\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n handleSend();\n }\n },\n [handleSend],\n );\n\n const handleInput = useCallback(() => {\n const el = textareaRef.current;\n if (!el) return;\n el.style.height = \"auto\";\n el.style.height = `${Math.min(el.scrollHeight, 100)}px`;\n }, []);\n\n const hasContent = value.trim().length > 0;\n\n return (\n <div\n className={cn(\n \"flex flex-shrink-0 items-end gap-2 border-t border-border bg-background px-4 py-3\",\n className,\n )}\n >\n <input\n ref={fileRef}\n type=\"file\"\n accept={ATTACHMENT_ACCEPT}\n className=\"hidden\"\n aria-hidden=\"true\"\n tabIndex={-1}\n onChange={(e) => {\n const file = e.target.files?.[0];\n e.target.value = \"\";\n if (file) void handleAttach(file);\n }}\n />\n <button\n type=\"button\"\n aria-label=\"Attach a photo\"\n disabled={uploading}\n onClick={() => fileRef.current?.click()}\n className={cn(\n \"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[10px]\",\n \"bg-muted text-muted-foreground transition-colors hover:text-foreground\",\n uploading && \"animate-pulse\",\n )}\n >\n <ImageIcon className=\"h-[18px] w-[18px]\" />\n </button>\n <div\n className={cn(\n \"flex flex-1 items-end rounded-xl border-[1.5px] border-transparent bg-muted px-1\",\n \"transition-colors focus-within:border-foreground focus-within:bg-background\",\n )}\n >\n <textarea\n ref={textareaRef}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n onKeyDown={handleKeyDown}\n onInput={handleInput}\n placeholder={placeholder}\n rows={1}\n className={cn(\n \"max-h-[100px] min-h-[20px] flex-1 resize-none bg-transparent px-2.5 py-2\",\n \"text-sm text-foreground outline-none placeholder:text-muted-foreground\",\n )}\n />\n </div>\n\n <button\n type=\"button\"\n onClick={handleSend}\n disabled={!hasContent || isSending}\n aria-label=\"Send message\"\n className={cn(\n \"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[10px]\",\n \"transition-all duration-200\",\n hasContent && !isSending\n ? \"bg-foreground text-background hover:scale-105 active:scale-95\"\n : \"bg-muted text-muted-foreground\",\n )}\n >\n <SendIcon className=\"h-[18px] w-[18px]\" />\n </button>\n </div>\n );\n}\n\n/* ─── Typing dots ─────────────────────────────────────────── */\n\nfunction TypingDots() {\n return (\n <div className=\"flex items-center gap-1 px-4 py-3\">\n {[0, 1, 2].map((i) => (\n <span\n key={i}\n className=\"h-[7px] w-[7px] animate-bounce rounded-full bg-muted-foreground/40\"\n style={{ animationDelay: `${i * 160}ms` }}\n />\n ))}\n </div>\n );\n}\n\n/* ─── Icons (inline SVG — no icon library) ────────────────── */\n\nfunction ChatIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z\" />\n </svg>\n );\n}\n\nfunction CloseIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M18 6L6 18M6 6l12 12\" />\n </svg>\n );\n}\n\nfunction SendIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <line x1=\"22\" y1=\"2\" x2=\"11\" y2=\"13\" />\n <polygon points=\"22 2 15 22 11 13 2 9 22 2\" />\n </svg>\n );\n}\n\nfunction ImageIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\" />\n <circle cx=\"8.5\" cy=\"8.5\" r=\"1.5\" />\n <polyline points=\"21 15 16 10 5 21\" />\n </svg>\n );\n}\n\nfunction ReplyIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <polyline points=\"9 17 4 12 9 7\" />\n <path d=\"M20 18v-2a4 4 0 0 0-4-4H4\" />\n </svg>\n );\n}\n"
|
|
12
|
+
"content": "\"use client\";\n\nimport React, { useState, useRef, useCallback, type KeyboardEvent } from \"react\";\nimport type { CimplifyClient } from \"../client\";\nimport { CimplifyError } from \"@cimplify/sdk\";\nimport type { ChatMessage, ChatUploadAttachment, ChatWidgetStarter } from \"../types/support\";\nimport { isOptimistic } from \"./chat-model\";\nimport {\n ChatKitProgram,\n ChatReplyCards,\n MessageBubble,\n Seam,\n chatTimeline,\n dayLabel,\n deliveryState,\n groupPosition,\n proseOf,\n sameDay,\n type GenUIActionEvent,\n} from \"./chat-kit\";\nimport { useChat } from \"./hooks/use-chat\";\nimport { useOptionalCimplifyClient } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nconst MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;\n\n/** Mirrors the server allowlist: images plus inert document types. */\nconst DOCUMENT_MIME_TYPES = new Set([\n \"application/pdf\",\n \"text/plain\",\n \"text/csv\",\n \"application/msword\",\n \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n \"application/vnd.ms-excel\",\n \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n]);\n\nconst ATTACHMENT_ACCEPT = [\"image/*\", ...DOCUMENT_MIME_TYPES].join(\",\");\n\nfunction isAllowedAttachment(file: File): boolean {\n return file.type.startsWith(\"image/\") || DOCUMENT_MIME_TYPES.has(file.type);\n}\nconst KEEP_DISMISSED_KEY = \"cimplify_keep_chat_dismissed\";\n/** How close to the bottom still counts as reading the live edge. */\nconst AT_BOTTOM_SLACK_PX = 48;\n\n/* ─── Public API ──────────────────────────────────────────── */\n\nexport interface ChatWidgetClassNames {\n root?: string;\n bubble?: string;\n panel?: string;\n header?: string;\n messages?: string;\n input?: string;\n welcome?: string;\n}\n\nexport interface ChatWidgetProps {\n client?: CimplifyClient;\n /** Business display name shown in the header */\n businessName?: string;\n /** Greeting text on the welcome screen */\n greeting?: string;\n /** Subtitle on the welcome screen */\n subtitle?: string;\n /** Conversation starters */\n starters?: ChatWidgetStarter[];\n /** Input placeholder */\n placeholder?: string;\n /** Position of the bubble */\n position?: \"bottom-right\" | \"bottom-left\";\n /** Start with the panel open */\n defaultOpen?: boolean;\n /** Poll interval in ms (default: 3000) */\n pollInterval?: number;\n /** Currency for structured product cards (e.g. \"GHS\"). */\n currency?: string;\n /**\n * Receives taps on GenUI buttons and structured cards\n * (`chat:view_product:{id}`, `chat:view_order:{id}`). Without it, cards\n * render read-only and program buttons no-op.\n */\n onGenUIAction?: (event: GenUIActionEvent) => void;\n className?: string;\n classNames?: ChatWidgetClassNames;\n}\n\nexport function ChatWidget({\n client: clientProp,\n businessName: businessNameProp,\n greeting = \"Hi there!\",\n subtitle = \"Ask us anything — we typically reply in a few seconds.\",\n starters,\n placeholder = \"Type a message\\u2026\",\n position = \"bottom-right\",\n defaultOpen = false,\n pollInterval,\n currency: currencyProp,\n onGenUIAction,\n className,\n classNames,\n}: ChatWidgetProps) {\n const context = useOptionalCimplifyClient();\n const client = clientProp ?? context?.client;\n // Provider-wrapped hosts get the real identity with zero config.\n const businessName = businessNameProp ?? context?.business?.name ?? \"Support\";\n const currency = currencyProp ?? context?.baseCurrency ?? undefined;\n\n const [open, setOpen] = useState(defaultOpen);\n const [readAt, setReadAt] = useState<string | null>(null);\n const [attachError, setAttachError] = useState<CimplifyError | null>(null);\n const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);\n\n const {\n messages,\n isLoading,\n isSending,\n awaitingReply,\n seenAt,\n error,\n isActive,\n humanRequested,\n claimedAs,\n signOut,\n send,\n react,\n requestHuman,\n startConversation,\n loadOlder,\n mayHaveOlder,\n openIfExisting,\n } = useChat({ client, starters, pollInterval });\n const showTyping = isSending || awaitingReply;\n\n const attachFile = useCallback(\n async (file: File) => {\n if (!client) return;\n setAttachError(null);\n if (!isAllowedAttachment(file)) {\n setAttachError(\n new CimplifyError(\"invalid_attachment\", \"Only photos and documents can be attached.\"),\n );\n return;\n }\n if (file.size > MAX_ATTACHMENT_BYTES) {\n setAttachError(new CimplifyError(\"attachment_too_large\", \"Files must be under 25MB.\"));\n return;\n }\n const uploaded = await client.uploads.upload(file);\n if (!uploaded.ok) {\n setAttachError(uploaded.error);\n return;\n }\n const attachment: ChatUploadAttachment = {\n upload_id: uploaded.value.id,\n url: uploaded.value.url,\n mime_type: uploaded.value.content_type,\n filename: uploaded.value.filename,\n size: uploaded.value.size_bytes,\n };\n await send(\"\", { attachments: [attachment] });\n },\n [client, send],\n );\n\n const handleSend = useCallback(\n (content: string) => {\n setAttachError(null);\n const target = replyTo;\n setReplyTo(null);\n return send(content, target ? { replyTo: target } : undefined);\n },\n [send, replyTo],\n );\n\n // Unread badge: incoming messages newer than the watermark snapshotted\n // when the panel last closed.\n const unread = open\n ? 0\n : messages.filter((m) => m.sender_type !== \"customer\" && (!readAt || m.created_at > readAt))\n .length;\n\n useState(() => {\n if (defaultOpen) void openIfExisting();\n });\n\n const handleOpen = useCallback(() => {\n setOpen(true);\n void openIfExisting();\n }, [openIfExisting]);\n\n const handleClose = useCallback(() => {\n const newest = messages[messages.length - 1];\n setReadAt(newest?.created_at ?? null);\n setOpen(false);\n }, [messages]);\n\n const isLeft = position === \"bottom-left\";\n\n return (\n <div\n className={cn(\n \"fixed bottom-6 z-[9999]\",\n isLeft ? \"left-6\" : \"right-6\",\n className,\n classNames?.root,\n )}\n >\n {/* Bubble */}\n {!open && <Bubble unread={unread} onClick={handleOpen} className={classNames?.bubble} />}\n\n {/* Panel */}\n {open && (\n <Panel\n client={client}\n businessName={businessName}\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n placeholder={placeholder}\n messages={messages}\n isLoading={isLoading}\n isSending={isSending}\n isTyping={showTyping}\n seenAt={seenAt}\n readAt={readAt}\n error={error ?? attachError}\n isActive={isActive}\n isLeft={isLeft}\n humanRequested={humanRequested}\n mayHaveOlder={mayHaveOlder}\n replyTo={replyTo}\n claimedAs={claimedAs}\n onSignOut={() => void signOut()}\n onClose={handleClose}\n onSend={handleSend}\n onReact={react}\n onReply={setReplyTo}\n onCancelReply={() => setReplyTo(null)}\n onRequestHuman={() => void requestHuman()}\n onLoadOlder={() => void loadOlder()}\n onAttach={attachFile}\n onStartConversation={startConversation}\n currency={currency}\n onGenUIAction={onGenUIAction}\n classNames={classNames}\n />\n )}\n </div>\n );\n}\n\n/* ─── Bubble ──────────────────────────────────────────────── */\n\nfunction Bubble({\n unread,\n onClick,\n className,\n}: {\n unread: number;\n onClick: () => void;\n className?: string;\n}) {\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-label=\"Open chat\"\n className={cn(\n \"relative flex h-14 w-14 items-center justify-center rounded-full\",\n \"bg-foreground text-background shadow-lg\",\n \"transition-transform duration-300 ease-out\",\n \"hover:scale-[1.08] active:scale-95\",\n className,\n )}\n >\n <ChatIcon className=\"h-6 w-6\" />\n {unread > 0 && (\n <span\n className={cn(\n \"absolute -right-0.5 -top-0.5 flex h-5 w-5 items-center justify-center\",\n \"rounded-full bg-destructive text-[10px] font-bold text-white\",\n \"ring-2 ring-background\",\n )}\n >\n {unread > 9 ? \"9+\" : unread}\n </span>\n )}\n </button>\n );\n}\n\n/* ─── Panel ───────────────────────────────────────────────── */\n\nfunction Panel({\n client,\n businessName,\n greeting,\n subtitle,\n starters,\n placeholder,\n messages,\n isLoading,\n isSending,\n isTyping,\n seenAt,\n readAt,\n error,\n isActive,\n isLeft,\n humanRequested,\n mayHaveOlder,\n replyTo,\n claimedAs,\n onSignOut,\n onClose,\n onSend,\n onReact,\n onReply,\n onCancelReply,\n onRequestHuman,\n onLoadOlder,\n onAttach,\n onStartConversation,\n currency,\n onGenUIAction,\n classNames,\n}: {\n client: CimplifyClient | undefined;\n businessName: string;\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n placeholder: string;\n messages: ChatMessage[];\n isLoading: boolean;\n isSending: boolean;\n isTyping: boolean;\n seenAt: string | null;\n readAt: string | null;\n error: CimplifyError | null;\n isActive: boolean;\n isLeft: boolean;\n humanRequested: boolean;\n mayHaveOlder: boolean;\n replyTo: ChatMessage | null;\n claimedAs: string | null;\n onSignOut: () => void;\n onClose: () => void;\n onSend: (content: string) => Promise<boolean>;\n onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n onReply: (message: ChatMessage) => void;\n onCancelReply: () => void;\n onRequestHuman: () => void;\n onLoadOlder: () => void;\n onAttach: (file: File) => Promise<void>;\n onStartConversation: (text?: string) => Promise<void>;\n currency?: string;\n onGenUIAction?: (event: GenUIActionEvent) => void;\n classNames?: ChatWidgetClassNames;\n}) {\n return (\n <div\n className={cn(\n \"flex flex-col overflow-hidden rounded-2xl bg-background shadow-2xl\",\n \"w-[400px] max-w-[calc(100vw-3rem)]\",\n \"h-[min(600px,calc(100vh-6rem))]\",\n \"animate-in fade-in slide-in-from-bottom-4 duration-300\",\n isLeft ? \"origin-bottom-left\" : \"origin-bottom-right\",\n classNames?.panel,\n )}\n >\n {/* Header */}\n <Header\n businessName={businessName}\n claimedAs={claimedAs}\n onSignOut={onSignOut}\n onClose={onClose}\n className={classNames?.header}\n />\n\n {/* Body */}\n {!isActive && !isLoading ? (\n <Welcome\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n onStarter={onStartConversation}\n className={classNames?.welcome}\n />\n ) : (\n <MessageList\n messages={messages}\n businessName={businessName}\n isLoading={isLoading}\n isSending={isTyping}\n seenAt={seenAt}\n readAt={readAt}\n humanRequested={humanRequested}\n mayHaveOlder={mayHaveOlder}\n onReact={onReact}\n onReply={onReply}\n onRequestHuman={onRequestHuman}\n onLoadOlder={onLoadOlder}\n currency={currency}\n onGenUIAction={onGenUIAction}\n className={classNames?.messages}\n />\n )}\n\n {isActive && !claimedAs && client && <KeepConversation client={client} />}\n\n {humanRequested && isActive && (\n <div className=\"flex items-center gap-2 border-t border-border bg-muted/50 px-4 py-2 text-[11px] text-muted-foreground\">\n <span className=\"h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-amber-500\" />\n Waiting for {businessName} — usually replies in minutes\n </div>\n )}\n\n {/* Error */}\n {error != null && (\n <div className=\"border-t border-destructive/20 bg-destructive/10 px-4 py-2 text-xs text-destructive\">\n {error.userMessage || \"Something went wrong. Please try again.\"}\n </div>\n )}\n\n {replyTo && (\n <div className=\"flex items-start gap-2 border-t border-border bg-muted/50 px-4 py-2\">\n <span className=\"w-[3px] shrink-0 self-stretch rounded-full bg-foreground/40\" />\n <div className=\"min-w-0 flex-1\">\n <div className=\"text-[11px] font-semibold text-foreground\">\n Replying to {replyTo.sender_type === \"customer\" ? \"yourself\" : businessName}\n </div>\n <div className=\"truncate text-[11px] text-muted-foreground\">\n {proseOf(replyTo.content) || \"Photo\"}\n </div>\n </div>\n <button\n type=\"button\"\n aria-label=\"Cancel reply\"\n onClick={onCancelReply}\n className=\"flex-shrink-0 p-0.5 text-muted-foreground hover:text-foreground\"\n >\n <CloseIcon className=\"h-3.5 w-3.5\" />\n </button>\n </div>\n )}\n\n {/* Always present: gating on an active conversation left a visitor who\n wants to type their own question with only the starter buttons. */}\n <ChatInput\n placeholder={placeholder}\n isSending={isSending}\n onSend={\n isActive\n ? onSend\n : async (text) => {\n await onStartConversation(text);\n return true;\n }\n }\n onAttach={onAttach}\n className={classNames?.input}\n />\n\n {/* Footer */}\n <div className=\"flex-shrink-0 border-t border-border bg-background px-4 py-1.5 text-center text-[10px] text-muted-foreground\">\n Powered by <span className=\"font-semibold text-foreground\">Cimplify</span>\n </div>\n </div>\n );\n}\n\n/* ─── Header ──────────────────────────────────────────────── */\n\nfunction Header({\n businessName,\n claimedAs,\n onSignOut,\n onClose,\n className,\n}: {\n businessName: string;\n claimedAs: string | null;\n onSignOut: () => void;\n onClose: () => void;\n className?: string;\n}) {\n return (\n <div\n className={cn(\n \"flex flex-shrink-0 items-center gap-3 bg-foreground px-5 py-4 text-background\",\n className,\n )}\n >\n <div className=\"flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] bg-background/15\">\n <ChatIcon className=\"h-5 w-5\" />\n </div>\n <div className=\"min-w-0 flex-1\">\n <div className=\"truncate text-[15px] font-semibold leading-tight\">{businessName}</div>\n {claimedAs ? (\n <div className=\"flex items-center gap-1 truncate text-xs text-background/60\">\n <span className=\"truncate\">Chatting as {claimedAs}</span>\n <span aria-hidden=\"true\">·</span>\n <button\n type=\"button\"\n onClick={onSignOut}\n className=\"flex-shrink-0 underline underline-offset-2 hover:text-background\"\n >\n Not you?\n </button>\n </div>\n ) : (\n <div className=\"flex items-center gap-1.5 text-xs text-background/60\">\n <span className=\"h-[7px] w-[7px] flex-shrink-0 rounded-full bg-emerald-400\" />\n Online\n </div>\n )}\n </div>\n <button\n type=\"button\"\n onClick={onClose}\n aria-label=\"Close chat\"\n className={cn(\n \"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg\",\n \"bg-background/10 text-background/70 transition-colors hover:bg-background/20 hover:text-background\",\n )}\n >\n <CloseIcon className=\"h-[18px] w-[18px]\" />\n </button>\n </div>\n );\n}\n\n/* ─── Welcome ─────────────────────────────────────────────── */\n\nfunction Welcome({\n greeting,\n subtitle,\n starters,\n onStarter,\n className,\n}: {\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n onStarter: (text?: string) => Promise<void>;\n className?: string;\n}) {\n const defaultStarters: ChatWidgetStarter[] = [\n { icon: \"\\ud83d\\udd0d\", text: \"Help me find a product\" },\n { icon: \"\\ud83d\\uded2\", text: \"Check my cart\" },\n { icon: \"\\ud83d\\udce6\", text: \"Track my order\" },\n ];\n\n const items = starters ?? defaultStarters;\n\n return (\n <div\n className={cn(\n \"flex flex-1 flex-col items-center justify-center overflow-y-auto bg-muted/50 px-8 py-10 text-center\",\n className,\n )}\n >\n <div className=\"mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-muted\">\n <ChatIcon className=\"h-7 w-7 text-muted-foreground\" />\n </div>\n <h3 className=\"mb-2 text-xl font-bold text-foreground\">{greeting}</h3>\n <p className=\"mb-6 max-w-[280px] text-sm leading-relaxed text-muted-foreground\">{subtitle}</p>\n <div className=\"flex w-full max-w-[300px] flex-col gap-2\">\n {items.map((s) => (\n <button\n key={s.text}\n type=\"button\"\n onClick={() => onStarter(s.text)}\n className={cn(\n \"flex items-center gap-3 rounded-xl border border-border bg-background px-4 py-3\",\n \"text-left text-[13px] font-medium text-foreground\",\n \"transition-all hover:-translate-y-px hover:border-foreground hover:shadow-sm\",\n )}\n >\n {s.icon && (\n <span className=\"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-muted text-[15px]\">\n {s.icon}\n </span>\n )}\n <span>{s.text}</span>\n </button>\n ))}\n </div>\n </div>\n );\n}\n\n/* ─── Messages ────────────────────────────────────────────── */\n\nconst QUICK_REACTIONS = [\"👍\", \"❤️\"] as const;\n\nfunction LetterAvatar({ name }: { name: string }) {\n return (\n <span className=\"flex size-6 items-center justify-center rounded-full bg-foreground text-[10px] font-bold text-background\">\n {name.charAt(0).toUpperCase()}\n </span>\n );\n}\n\nfunction MessageList({\n messages,\n businessName,\n isLoading,\n isSending,\n seenAt,\n readAt,\n humanRequested,\n mayHaveOlder,\n onReact,\n onReply,\n onRequestHuman,\n onLoadOlder,\n currency,\n onGenUIAction,\n className,\n}: {\n messages: ChatMessage[];\n businessName: string;\n isLoading: boolean;\n isSending: boolean;\n seenAt: string | null;\n readAt: string | null;\n humanRequested: boolean;\n mayHaveOlder: boolean;\n onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n onReply: (message: ChatMessage) => void;\n onRequestHuman: () => void;\n onLoadOlder: () => void;\n currency?: string;\n onGenUIAction?: (event: GenUIActionEvent) => void;\n className?: string;\n}) {\n const scrolledUpRef = useRef(false);\n const timeline = chatTimeline(messages, { readAt, rendersPrograms: true });\n const { bubbles, reactionsByTarget, firstUnreadId, unreadOnOpen, firstAgentMessageId } = timeline;\n const lastBubble = bubbles[bubbles.length - 1];\n const offerHuman = !humanRequested && lastBubble?.sender_type === \"bot\" && !isSending;\n\n const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {\n const node = event.currentTarget;\n scrolledUpRef.current =\n node.scrollHeight - node.scrollTop - node.clientHeight >= AT_BOTTOM_SLACK_PX;\n };\n\n return (\n <div\n onScroll={handleScroll}\n style={{ background: \"var(--color-chat-canvas, var(--color-muted, #f3f5f3))\" }}\n className={cn(\"flex flex-1 flex-col gap-0.5 overflow-y-auto px-3.5 py-3.5\", className)}\n >\n {isLoading && messages.length === 0 && (\n <div className=\"flex flex-1 items-center justify-center\">\n <TypingDots />\n </div>\n )}\n\n {mayHaveOlder && (\n <div className=\"flex justify-center pb-1\">\n <button\n type=\"button\"\n onClick={onLoadOlder}\n className=\"rounded-full border border-foreground/[0.06] bg-background px-3.5 py-[7px] text-xs font-semibold text-muted-foreground shadow-[0_1px_2px_rgba(15,23,42,0.04)]\"\n >\n Show earlier messages\n </button>\n </div>\n )}\n\n {bubbles.map((msg, i) => {\n const prev = bubbles[i - 1];\n const newDay = !prev || !sameDay(prev.created_at, msg.created_at);\n const group = groupPosition(timeline, i);\n const startsRun = group === \"single\" || group === \"first\";\n const delivery = deliveryState(msg, timeline, seenAt, isOptimistic);\n\n return (\n <div key={msg.id} className={cn(\"flex flex-col gap-1.5\", startsRun && i > 0 && \"mt-2.5\")}>\n {newDay && <Seam variant=\"chip\" label={dayLabel(msg.created_at)} />}\n {msg.id === firstUnreadId && (\n <Seam\n variant=\"unread\"\n label={`${unreadOnOpen} new message${unreadOnOpen === 1 ? \"\" : \"s\"}`}\n />\n )}\n {msg.id === firstAgentMessageId && <Seam label=\"A person joined\" />}\n {msg.sender_type === \"system\" ? (\n <Seam label=\"Passing you to the team\" />\n ) : (\n <div className=\"group relative\">\n {msg.sender_type !== \"customer\" && !isOptimistic(msg) && (\n <HoverRail message={msg} onReact={onReact} onReply={onReply} />\n )}\n <MessageBubble\n message={msg}\n reactions={reactionsByTarget.get(msg.id)}\n businessName={businessName}\n group={group}\n delivery={delivery}\n onReply={onReply}\n avatar={<LetterAvatar name={businessName} />}\n renderProgram={(source) => (\n <ChatKitProgram source={source} onAction={onGenUIAction} />\n )}\n renderCards={(target) => (\n <ChatReplyCards message={target} currency={currency} onAction={onGenUIAction} />\n )}\n onQuoteMiss={async () => {\n onLoadOlder();\n return true;\n }}\n />\n </div>\n )}\n </div>\n );\n })}\n\n {isSending && (\n <div className=\"mr-auto mt-1\">\n <TypingDots />\n </div>\n )}\n\n {offerHuman && (\n <div className=\"mt-2\">\n <button\n type=\"button\"\n onClick={onRequestHuman}\n className=\"rounded-full border border-border bg-background px-3 py-1.5 text-xs font-semibold text-foreground hover:border-foreground\"\n >\n Talk to a person\n </button>\n </div>\n )}\n\n <div\n key={`${messages.length}:${isSending}`}\n ref={(node) => {\n // Keyed so the callback re-fires per new message/typing change —\n // follows the conversation, but never yanks a reader out of history.\n if (node && !scrolledUpRef.current) node.scrollIntoView({ behavior: \"smooth\" });\n }}\n />\n </div>\n );\n}\n\nfunction HoverRail({\n message,\n onReact,\n onReply,\n}: {\n message: ChatMessage;\n onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n onReply: (message: ChatMessage) => void;\n}) {\n return (\n <div className=\"absolute -top-3 right-0 z-10 hidden gap-0.5 rounded-full border border-border bg-background px-1 py-0.5 shadow-sm group-hover:flex\">\n {QUICK_REACTIONS.map((emoji) => (\n <button\n key={emoji}\n type=\"button\"\n aria-label={`React ${emoji}`}\n onClick={() => void onReact(message, emoji)}\n className=\"flex h-6 w-6 items-center justify-center rounded-full text-[13px] hover:bg-muted\"\n >\n {emoji}\n </button>\n ))}\n <button\n type=\"button\"\n aria-label=\"Reply\"\n onClick={() => onReply(message)}\n className=\"flex h-6 w-6 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground\"\n >\n <ReplyIcon className=\"h-3.5 w-3.5\" />\n </button>\n </div>\n );\n}\n\n/* ─── Keep this conversation ──────────────────────────────── */\n\nfunction readKeepDismissed(): boolean {\n try {\n return typeof window !== \"undefined\" && window.localStorage.getItem(KEEP_DISMISSED_KEY) === \"1\";\n } catch {\n return false;\n }\n}\n\nfunction writeKeepDismissed(): void {\n try {\n window.localStorage.setItem(KEEP_DISMISSED_KEY, \"1\");\n } catch {}\n}\n\nfunction KeepConversation({ client }: { client: CimplifyClient }) {\n const [step, setStep] = useState<\"row\" | \"contact\" | \"code\">(\"row\");\n const [dismissed, setDismissed] = useState(readKeepDismissed);\n const [contact, setContact] = useState(\"\");\n const [code, setCode] = useState(\"\");\n const [challengeId, setChallengeId] = useState<string | null | undefined>(null);\n const [busy, setBusy] = useState(false);\n const [verifyError, setVerifyError] = useState<string | null>(null);\n\n if (dismissed) return null;\n\n const contactType = contact.includes(\"@\") ? \"email\" : \"phone\";\n\n const requestCode = async () => {\n const trimmed = contact.trim();\n if (!trimmed || busy) return;\n setBusy(true);\n setVerifyError(null);\n const result = await client.link.requestOtp({ contact: trimmed, contact_type: contactType });\n setBusy(false);\n if (result.ok) {\n setChallengeId(result.value.challenge_id);\n setCode(\"\");\n setStep(\"code\");\n } else {\n setVerifyError(result.error.userMessage || \"Couldn't send a code — try again.\");\n }\n };\n\n const verify = async (value: string) => {\n if (value.length !== 6 || busy || challengeId === null) return;\n setBusy(true);\n setVerifyError(null);\n const result = await client.link.verifyOtp({\n ...(challengeId === undefined ? {} : { challenge_id: challengeId }),\n contact: contact.trim(),\n contact_type: contactType,\n otp_code: value,\n });\n setBusy(false);\n if (!result.ok) {\n setCode(\"\");\n setVerifyError(result.error.userMessage || \"Invalid or expired code.\");\n }\n };\n\n return (\n <div className=\"flex-shrink-0 border-t border-border bg-muted/50 px-4 py-2.5\">\n {step === \"row\" && (\n <div className=\"flex items-center gap-2.5\">\n <button\n type=\"button\"\n onClick={() => setStep(\"contact\")}\n className=\"flex min-w-0 flex-1 flex-col text-left\"\n >\n <span className=\"text-xs font-semibold text-foreground\">Keep this conversation</span>\n <span className=\"truncate text-[11px] text-muted-foreground\">\n Verify your number and it follows you — any device\n </span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Dismiss\"\n onClick={() => {\n writeKeepDismissed();\n setDismissed(true);\n }}\n className=\"flex-shrink-0 p-1 text-muted-foreground hover:text-foreground\"\n >\n <CloseIcon className=\"h-3.5 w-3.5\" />\n </button>\n </div>\n )}\n\n {step === \"contact\" && (\n <div className=\"flex items-center gap-2\">\n <input\n type=\"text\"\n inputMode=\"email\"\n value={contact}\n onChange={(e) => setContact(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && void requestCode()}\n placeholder=\"Phone or email\"\n autoFocus\n className=\"h-9 min-w-0 flex-1 rounded-lg border border-border bg-background px-3 text-xs text-foreground outline-none placeholder:text-muted-foreground focus:border-foreground\"\n />\n <button\n type=\"button\"\n onClick={() => void requestCode()}\n disabled={!contact.trim() || busy}\n className=\"h-9 flex-shrink-0 rounded-lg bg-foreground px-3 text-xs font-semibold text-background disabled:opacity-50\"\n >\n {busy ? \"Sending…\" : \"Send code\"}\n </button>\n </div>\n )}\n\n {step === \"code\" && (\n <div className=\"flex items-center gap-2\">\n <input\n type=\"text\"\n inputMode=\"numeric\"\n autoComplete=\"one-time-code\"\n maxLength={6}\n value={code}\n onChange={(e) => {\n const digits = e.target.value.replace(/\\D/g, \"\").slice(0, 6);\n setCode(digits);\n if (digits.length === 6) void verify(digits);\n }}\n placeholder=\"6-digit code\"\n autoFocus\n className=\"h-9 min-w-0 flex-1 rounded-lg border border-border bg-background px-3 text-xs tracking-[0.2em] text-foreground outline-none placeholder:tracking-normal placeholder:text-muted-foreground focus:border-foreground\"\n />\n <button\n type=\"button\"\n onClick={() => void requestCode()}\n disabled={busy}\n className=\"h-9 flex-shrink-0 rounded-lg px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground disabled:opacity-50\"\n >\n Resend\n </button>\n </div>\n )}\n\n {verifyError && <p className=\"mt-1.5 text-[11px] text-destructive\">{verifyError}</p>}\n </div>\n );\n}\n\n/* ─── Input ───────────────────────────────────────────────── */\n\nfunction ChatInput({\n placeholder,\n isSending,\n onSend,\n onAttach,\n className,\n}: {\n placeholder: string;\n isSending: boolean;\n onSend: (content: string) => Promise<boolean>;\n onAttach: (file: File) => Promise<void>;\n className?: string;\n}) {\n const [value, setValue] = useState(\"\");\n const [uploading, setUploading] = useState(false);\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n const fileRef = useRef<HTMLInputElement>(null);\n\n const handleAttach = useCallback(\n async (file: File) => {\n setUploading(true);\n try {\n await onAttach(file);\n } finally {\n setUploading(false);\n }\n },\n [onAttach],\n );\n\n const handleSend = useCallback(async () => {\n const trimmed = value.trim();\n if (!trimmed || isSending) return;\n setValue(\"\");\n if (textareaRef.current) {\n textareaRef.current.style.height = \"auto\";\n }\n const sent = await onSend(trimmed);\n // A failed send must not eat the message — put it back to retry.\n if (!sent) setValue((current) => current || trimmed);\n }, [value, isSending, onSend]);\n\n const handleKeyDown = useCallback(\n (e: KeyboardEvent<HTMLTextAreaElement>) => {\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n handleSend();\n }\n },\n [handleSend],\n );\n\n const handleInput = useCallback(() => {\n const el = textareaRef.current;\n if (!el) return;\n el.style.height = \"auto\";\n el.style.height = `${Math.min(el.scrollHeight, 100)}px`;\n }, []);\n\n const hasContent = value.trim().length > 0;\n\n return (\n <div\n className={cn(\n \"flex flex-shrink-0 items-end gap-2 border-t border-border bg-background px-4 py-3\",\n className,\n )}\n >\n <input\n ref={fileRef}\n type=\"file\"\n accept={ATTACHMENT_ACCEPT}\n className=\"hidden\"\n aria-hidden=\"true\"\n tabIndex={-1}\n onChange={(e) => {\n const file = e.target.files?.[0];\n e.target.value = \"\";\n if (file) void handleAttach(file);\n }}\n />\n <button\n type=\"button\"\n aria-label=\"Attach a photo\"\n disabled={uploading}\n onClick={() => fileRef.current?.click()}\n className={cn(\n \"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[10px]\",\n \"bg-muted text-muted-foreground transition-colors hover:text-foreground\",\n uploading && \"animate-pulse\",\n )}\n >\n <ImageIcon className=\"h-[18px] w-[18px]\" />\n </button>\n <div\n className={cn(\n \"flex flex-1 items-end rounded-xl border-[1.5px] border-transparent bg-muted px-1\",\n \"transition-colors focus-within:border-foreground focus-within:bg-background\",\n )}\n >\n <textarea\n ref={textareaRef}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n onKeyDown={handleKeyDown}\n onInput={handleInput}\n placeholder={placeholder}\n rows={1}\n className={cn(\n \"max-h-[100px] min-h-[20px] flex-1 resize-none bg-transparent px-2.5 py-2\",\n \"text-sm text-foreground outline-none placeholder:text-muted-foreground\",\n )}\n />\n </div>\n\n <button\n type=\"button\"\n onClick={handleSend}\n disabled={!hasContent || isSending}\n aria-label=\"Send message\"\n className={cn(\n \"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[10px]\",\n \"transition-all duration-200\",\n hasContent && !isSending\n ? \"bg-foreground text-background hover:scale-105 active:scale-95\"\n : \"bg-muted text-muted-foreground\",\n )}\n >\n <SendIcon className=\"h-[18px] w-[18px]\" />\n </button>\n </div>\n );\n}\n\n/* ─── Typing dots ─────────────────────────────────────────── */\n\nfunction TypingDots() {\n return (\n <div className=\"flex items-center gap-1 px-4 py-3\">\n {[0, 1, 2].map((i) => (\n <span\n key={i}\n className=\"h-[7px] w-[7px] animate-bounce rounded-full bg-muted-foreground/40\"\n style={{ animationDelay: `${i * 160}ms` }}\n />\n ))}\n </div>\n );\n}\n\n/* ─── Icons (inline SVG — no icon library) ────────────────── */\n\nfunction ChatIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z\" />\n </svg>\n );\n}\n\nfunction CloseIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M18 6L6 18M6 6l12 12\" />\n </svg>\n );\n}\n\nfunction SendIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <line x1=\"22\" y1=\"2\" x2=\"11\" y2=\"13\" />\n <polygon points=\"22 2 15 22 11 13 2 9 22 2\" />\n </svg>\n );\n}\n\nfunction ImageIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\" />\n <circle cx=\"8.5\" cy=\"8.5\" r=\"1.5\" />\n <polyline points=\"21 15 16 10 5 21\" />\n </svg>\n );\n}\n\nfunction ReplyIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <polyline points=\"9 17 4 12 9 7\" />\n <path d=\"M20 18v-2a4 4 0 0 0-4-4H4\" />\n </svg>\n );\n}\n"
|
|
13
13
|
}
|
|
14
14
|
]
|
|
15
15
|
}
|
package/registry/price.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"files": [
|
|
8
8
|
{
|
|
9
9
|
"path": "price.tsx",
|
|
10
|
-
"content": "\"use client\";\n\nimport React from \"react\";\nimport {
|
|
10
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport { formatCurrencyAmount } from \"@cimplify/sdk\";\nimport type { CurrencyCode } from \"@cimplify/sdk\";\nimport { useOptionalCimplify } from \"@cimplify/sdk/react\";\n\nexport interface PriceProps {\n /** The amount, denominated in `currency` (or the business base currency). */\n amount: number | string;\n /**\n * Currency the `amount` is denominated in. Pass the product/order currency\n * so the value renders correctly during SSR, before the provider has\n * loaded the business client-side. Falls back to the provider's base\n * currency. When neither is known the component renders nothing rather\n * than guessing a currency.\n */\n currency?: CurrencyCode | null;\n /** Optional CSS class name for the wrapping span. */\n className?: string;\n /** Optional prefix rendered before the formatted price (e.g. \"+\"). */\n prefix?: string;\n /**\n * Apply the provider's FX display-currency override when one is active.\n * Default true. The conversion is a post-hydration enhancement: it only\n * engages once an FX rate has loaded client-side, so SSR and first-paint\n * output always match and no currency flash occurs.\n */\n convert?: boolean;\n}\n\n/**\n * Price — renders a formatted price value.\n *\n * Currency is resolved from the `currency` prop (the amount's source currency),\n * falling back to the provider's base currency. With neither known it renders\n * null, a loading state, and never a guessed currency. Because the source\n * currency comes from data rather than the client-only provider bootstrap, an\n * explicit `currency` renders correctly during SSR.\n *\n * FX display-currency conversion (when a user picks a non-base display\n * currency) is layered on top after hydration, and only when the source is the\n * base currency the FX rate is computed from. It never affects the server or\n * first-paint render, so there is no hydration mismatch.\n */\nexport function Price({\n amount,\n currency,\n className,\n prefix,\n convert = true,\n}: PriceProps): React.ReactElement | null {\n const context = useOptionalCimplify();\n const sourceCurrency = currency ?? context?.baseCurrency ?? null;\n if (sourceCurrency === null) return null;\n\n const displayCurrency = context?.displayCurrency ?? null;\n const fxActive =\n convert &&\n context !== null &&\n context.fxRate != null &&\n displayCurrency !== null &&\n displayCurrency !== sourceCurrency &&\n sourceCurrency === context.baseCurrency;\n\n const formatted =\n fxActive && context !== null && displayCurrency !== null\n ? formatCurrencyAmount(context.convertPrice(amount), displayCurrency)\n : formatCurrencyAmount(amount, sourceCurrency);\n if (formatted === null) return null;\n\n return (\n <span className={className}>\n {prefix}\n {formatted}\n </span>\n );\n}\n"
|
|
11
11
|
}
|
|
12
12
|
]
|
|
13
13
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
{
|
|
16
16
|
"path": "product-customizer.tsx",
|
|
17
|
-
"content": "\"use client\";\n\nimport React, { useState, useCallback, useMemo, useEffect, useRef } from \"react\";\nimport { Button } from \"@base-ui/react/button\";\nimport type {\n ProductWithDetails,\n VariantView,\n AddOnOption,\n ComponentSelectionInput,\n CompositePriceResult,\n} from \"@cimplify/sdk\";\nimport type { ProductBillingPlan } from \"@cimplify/sdk\";\nimport type { BundleSelectionInput } from \"@cimplify/sdk\";\nimport {\n getProductCurrency,\n getUnitPriceAtQuantity,\n getVariantUnitPrice,\n parsePrice,\n} from \"@cimplify/sdk\";\nimport { isMultiDayService, serviceBillingMultiplier } from \"../utils/price-basis\";\nimport { formatDuration } from \"./utils/format-duration\";\nimport { useCart, useQuote } from \"@cimplify/sdk/react\";\nimport type { AddToCartOptions } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { QuantitySelector } from \"@cimplify/sdk/react\";\nimport { VariantSelector } from \"@cimplify/sdk/react\";\nimport { AddOnSelector } from \"@cimplify/sdk/react\";\nimport { StaffPicker } from \"@cimplify/sdk/react\";\nimport { BookingSummary } from \"./booking-summary\";\nimport type { Staff } from \"@cimplify/sdk\";\nimport { CompositeSelector } from \"@cimplify/sdk/react\";\nimport { BundleSelector } from \"@cimplify/sdk/react\";\nimport { BillingPlanSelector } from \"@cimplify/sdk/react\";\nimport { VolumePricing } from \"@cimplify/sdk/react\";\nimport { CustomerInputFields } from \"./customer-input-fields\";\nimport { DateSlotPicker } from \"@cimplify/sdk/react\";\nimport type { AvailableSlot } from \"@cimplify/sdk\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface ProductCustomizerClassNames {\n root?: string;\n actions?: string;\n submitButton?: string;\n submitButtonAdded?: string;\n validation?: string;\n specialInstructions?: string;\n depositInfo?: string;\n units?: string;\n allergens?: string;\n duration?: string;\n}\n\nexport interface ProductCustomizerProps {\n product: ProductWithDetails;\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n /** Lets the parent swap its gallery for `variant.images` on selection. */\n onVariantChange?: (variantId: string | undefined, variant: VariantView | undefined) => void;\n showSpecialInstructions?: boolean;\n /** Submit button verb; the running total is appended. Defaults to \"Add to Cart\". */\n submitLabel?: string;\n className?: string;\n classNames?: ProductCustomizerClassNames;\n}\n\ninterface VariantSelection {\n id?: string;\n variant?: VariantView;\n}\n\nconst REQUIRED_OPTIONS_MESSAGE = \"Please select all required options\";\nconst QUOTE_LOADING_MESSAGE = \"Updating price…\";\nconst QUOTE_UNAVAILABLE_MESSAGE = \"Price is unavailable. Please try again.\";\nconst CUSTOMIZER_VALIDATION_ID = \"cimplify-customizer-validation\";\n\nfunction initialVariantSelection(product: ProductWithDetails): VariantSelection {\n const variant =\n product.variants?.find((candidate) => candidate.is_default) ?? product.variants?.[0];\n return { id: variant?.id, variant };\n}\n\nexport function ProductCustomizer(props: ProductCustomizerProps): React.ReactElement {\n // A product change represents a new customization session. Keying the\n // stateful implementation resets it synchronously and avoids an effect that\n // briefly renders the previous product's variant and quote.\n return <ProductCustomizerContent key={props.product.id} {...props} />;\n}\n\nfunction ProductCustomizerContent({\n product,\n onAddToCart,\n onVariantChange,\n showSpecialInstructions = true,\n submitLabel,\n className,\n classNames,\n}: ProductCustomizerProps): React.ReactElement {\n const [quantity, setQuantity] = useState(product.min_order_quantity ?? 1);\n const [isAdded, setIsAdded] = useState(false);\n const [isSubmitting, setIsSubmitting] = useState(false);\n const [variantSelection, setVariantSelection] = useState<VariantSelection>(() =>\n initialVariantSelection(product),\n );\n const selectedVariantId = variantSelection.id;\n const selectedVariant = variantSelection.variant;\n const [selectedAddOnOptionIds, setSelectedAddOnOptionIds] = useState<string[]>([]);\n\n const [compositeSelections, setCompositeSelections] = useState<ComponentSelectionInput[]>([]);\n const [compositePrice, setCompositePrice] = useState<CompositePriceResult | null>(null);\n const [compositeReady, setCompositeReady] = useState(false);\n\n const [bundleSelections, setBundleSelections] = useState<BundleSelectionInput[]>([]);\n const [bundleTotalPrice, setBundleTotalPrice] = useState<number | null>(null);\n const [bundleReady, setBundleReady] = useState(false);\n const [selectedBillingPlan, setSelectedBillingPlan] = useState<ProductBillingPlan | null>(null);\n const [customerInputValues, setCustomerInputValues] = useState<Record<string, unknown>>({});\n const [specialInstructions, setSpecialInstructions] = useState(\"\");\n const [selectedSlot, setSelectedSlot] = useState<AvailableSlot | null>(null);\n const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);\n const [unitsCount, setUnitsCount] = useState(1);\n\n const cart = useCart();\n\n const productType = product.type || \"product\";\n const isComposite = productType === \"composite\";\n const isBundle = productType === \"bundle\";\n const isDigital = productType === \"digital\";\n const isService = productType === \"service\";\n // Whole-stay services can reserve N interchangeable units (rooms, kayaks,\n // pitches) per booking — mirrors desk's wizard, which gates the input the\n // same way; intraday party size is already the quantity.\n const offersUnits = isService && isMultiDayService(product);\n const isStandard = !isComposite && !isBundle;\n\n const hasVariants = isStandard && product.variants && product.variants.length > 0;\n const hasAddOns = isStandard && product.add_ons && product.add_ons.length > 0;\n\n // Staff offered for the chosen slot. SlotStaffInfo carries id + name; richer\n // profiles (avatars/bios) would come from a staff roster fetch.\n const slotStaff = useMemo<Staff[]>(\n () =>\n (selectedSlot?.available_staff ?? []).map((member) => ({\n id: member.staff_id,\n name: member.name,\n })),\n [selectedSlot],\n );\n const selectedStaffName = selectedStaffId\n ? (slotStaff.find((member) => member.id === selectedStaffId)?.name ?? null)\n : null;\n\n const selectedAddOnOptions = useMemo(() => {\n if (!product.add_ons) return [];\n const options: AddOnOption[] = [];\n for (const addOn of product.add_ons) {\n for (const option of addOn.options) {\n if (selectedAddOnOptionIds.includes(option.id)) {\n options.push(option);\n }\n }\n }\n return options;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const normalizedAddOnOptionIds = useMemo(() => {\n if (selectedAddOnOptionIds.length === 0) return [];\n return Array.from(\n new Set(selectedAddOnOptionIds.map((id) => id.trim()).filter(Boolean)),\n ).sort();\n }, [selectedAddOnOptionIds]);\n\n const localTotalPrice = useMemo(() => {\n if (isComposite && compositePrice) {\n return parsePrice(compositePrice.final_price) * quantity;\n }\n\n if (isBundle && bundleTotalPrice != null) {\n return bundleTotalPrice * quantity;\n }\n\n let price = getVariantUnitPrice(product, selectedVariant);\n\n for (const option of selectedAddOnOptions) {\n if (option.default_price) {\n price += parsePrice(option.default_price);\n }\n }\n\n return price * quantity;\n }, [\n product.default_price,\n selectedVariant,\n selectedAddOnOptions,\n quantity,\n isComposite,\n compositePrice,\n isBundle,\n bundleTotalPrice,\n ]);\n\n const requiredAddOnsSatisfied = useMemo(() => {\n if (!product.add_ons) return true;\n\n for (const addOn of product.add_ons) {\n if (addOn.is_required) {\n const selectedInGroup = selectedAddOnOptionIds.filter((id) =>\n addOn.options.some((opt) => opt.id === id),\n ).length;\n\n const minRequired = addOn.min_selections || 1;\n if (selectedInGroup < minRequired) {\n return false;\n }\n }\n }\n return true;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const quoteInput = useMemo(\n () => ({\n productId: product.id,\n quantity,\n variantId: selectedVariantId,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n compositeSelections:\n isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n }),\n [\n product.id,\n quantity,\n selectedVariantId,\n normalizedAddOnOptionIds,\n isBundle,\n bundleSelections,\n isComposite,\n compositeSelections,\n ],\n );\n\n const requiredInputsSatisfied = useMemo(() => {\n if (!product.input_fields || product.input_fields.length === 0) return true;\n return product.input_fields.every((field) => {\n if (!field.is_required) return true;\n const val = customerInputValues[field.id];\n return val !== undefined && val !== \"\" && val !== null;\n });\n }, [product.input_fields, customerInputValues]);\n\n const quoteEnabled = isComposite\n ? compositeReady && requiredInputsSatisfied\n : isBundle\n ? bundleReady && requiredInputsSatisfied\n : requiredAddOnsSatisfied && requiredInputsSatisfied;\n\n const {\n quote,\n isLoading: isQuoteLoading,\n error: quoteError,\n isExpired: isQuoteExpired,\n } = useQuote(quoteInput, {\n enabled: quoteEnabled,\n });\n\n const currency = quote?.currency ?? getProductCurrency(product);\n const quoteId = quote?.quote_id;\n const quotedUnitPrice = (quote?.quoted_total_price_info ?? quote?.final_price_info)?.final_price;\n const quotedTotalPrice = useMemo(() => {\n if (quotedUnitPrice === undefined || quotedUnitPrice === null) return undefined;\n return parsePrice(quotedUnitPrice) * quantity;\n }, [quotedUnitPrice, quantity]);\n\n const quoteIsReady =\n quoteEnabled && quote !== null && !isQuoteLoading && !quoteError && !isQuoteExpired;\n const validationMessage = !quoteEnabled\n ? REQUIRED_OPTIONS_MESSAGE\n : quoteError\n ? QUOTE_UNAVAILABLE_MESSAGE\n : !quoteIsReady\n ? QUOTE_LOADING_MESSAGE\n : null;\n\n // Services bill by basis × periods × units server-side, not by quantity —\n // mirror billing_multiplier so the preview matches the cart. Both totals\n // above already include × quantity, so divide it back out to the unit\n // price before applying the real multiplier.\n const displayTotalPrice = useMemo(() => {\n const total = quotedTotalPrice ?? localTotalPrice;\n const multiplier = serviceBillingMultiplier(product, {\n quantity,\n units: unitsCount,\n scheduledStart: selectedSlot?.start_time,\n scheduledEnd: selectedSlot?.end_time,\n });\n if (multiplier === null) return total;\n return (total / Math.max(1, quantity)) * multiplier;\n }, [quotedTotalPrice, localTotalPrice, product, quantity, unitsCount, selectedSlot]);\n\n const handleVariantChange = useCallback(\n (variantId: string | undefined, variant: VariantView | undefined) => {\n setVariantSelection({ id: variantId, variant });\n onVariantChange?.(variantId, variant);\n },\n [onVariantChange],\n );\n\n const addedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(addedTimerRef.current), []);\n\n const handleAddToCart = useCallback(async () => {\n if (\n isSubmitting ||\n !quoteIsReady ||\n !quote ||\n quotedUnitPrice === undefined ||\n quotedUnitPrice === null\n ) {\n return;\n }\n\n setIsSubmitting(true);\n\n const options: AddToCartOptions = {\n locationId: quote.location_id ?? undefined,\n variantId: selectedVariantId,\n variant: selectedVariant\n ? {\n id: selectedVariant.id,\n name: selectedVariant.name || \"\",\n price_adjustment: selectedVariant.price_adjustment,\n price_info: selectedVariant.price_info,\n }\n : undefined,\n quoteId,\n quotedUnitPrice,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n addOnOptions:\n selectedAddOnOptions.length > 0\n ? selectedAddOnOptions.map((opt) => ({\n id: opt.id,\n name: opt.name,\n add_on_id: opt.add_on_id,\n default_price: opt.default_price,\n }))\n : undefined,\n compositeSelections:\n isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n billingPlanId: selectedBillingPlan?.id,\n ...(isService && selectedSlot\n ? {\n scheduledStart: selectedSlot.start_time,\n scheduledEnd: selectedSlot.end_time,\n staffId: selectedStaffId ?? selectedSlot.available_staff?.[0]?.staff_id,\n resourceId: selectedSlot.available_resources?.[0]?.resource_id,\n units: unitsCount > 1 ? unitsCount : undefined,\n }\n : {}),\n customerInputs:\n Object.keys(customerInputValues).length > 0\n ? Object.entries(customerInputValues)\n .filter(([, v]) => v !== undefined && v !== \"\")\n .map(([fieldId, value]) => ({ field_id: fieldId, value }))\n : undefined,\n specialInstructions: specialInstructions.trim() || undefined,\n };\n\n try {\n if (onAddToCart) {\n await onAddToCart(product, quantity, options);\n } else {\n await cart.addItem(product, quantity, options);\n }\n setIsAdded(true);\n clearTimeout(addedTimerRef.current);\n addedTimerRef.current = setTimeout(() => {\n setIsAdded(false);\n setQuantity(product.min_order_quantity ?? 1);\n }, 2000);\n } catch {\n // Caller handles errors via onAddToCart or cart hook error state\n } finally {\n setIsSubmitting(false);\n }\n }, [\n product,\n quantity,\n selectedVariantId,\n selectedVariant,\n quoteId,\n quotedUnitPrice,\n normalizedAddOnOptionIds,\n selectedAddOnOptions,\n isComposite,\n compositeSelections,\n isBundle,\n bundleSelections,\n selectedBillingPlan,\n customerInputValues,\n specialInstructions,\n isService,\n selectedSlot,\n selectedStaffId,\n unitsCount,\n isSubmitting,\n onAddToCart,\n cart,\n quoteIsReady,\n quote,\n ]);\n\n return (\n <div data-cimplify-customizer className={cn(\"space-y-6\", className, classNames?.root)}>\n {isComposite && product.groups && product.composite_id && (\n <CompositeSelector\n compositeId={product.composite_id}\n groups={product.groups}\n currency={currency}\n onSelectionsChange={setCompositeSelections}\n onPriceChange={setCompositePrice}\n onReady={setCompositeReady}\n skipPriceFetch\n />\n )}\n\n {isBundle && product.components && (\n <BundleSelector\n components={product.components}\n bundlePrice={product.bundle_price}\n discountValue={product.discount_value}\n pricingType={product.pricing_type}\n currency={currency}\n onSelectionsChange={setBundleSelections}\n onPriceChange={setBundleTotalPrice}\n onReady={setBundleReady}\n />\n )}\n\n {isService && (\n <DateSlotPicker\n serviceId={product.id}\n selectedSlot={selectedSlot}\n onSlotSelect={(slot) => {\n setSelectedSlot(slot);\n setSelectedStaffId(null);\n }}\n participantCount={quantity}\n units={offersUnits ? unitsCount : undefined}\n currency={currency}\n // Without the resolved mode the picker renders intraday slots for\n // whole-stay services — stays need the stay-range treatment.\n schedulingMode={product.scheduling_mode ?? (offersUnits ? \"multi_day\" : \"intraday\")}\n durationUnit={product.duration_unit}\n durationValue={product.duration_value}\n />\n )}\n\n {offersUnits && (\n <div data-cimplify-customizer-units className={classNames?.units}>\n <label className=\"block text-xs font-medium uppercase tracking-wider text-muted-foreground mb-3\">\n Units\n </label>\n <QuantitySelector value={unitsCount} onChange={setUnitsCount} min={1} />\n </div>\n )}\n\n {isService && slotStaff.length > 0 && (\n <div data-cimplify-customizer-staff>\n <label className=\"block text-xs font-medium uppercase tracking-wider text-muted-foreground mb-3\">\n Choose your specialist\n </label>\n <StaffPicker\n staff={slotStaff}\n selectedStaffId={selectedStaffId}\n onStaffSelect={setSelectedStaffId}\n className=\"flex flex-wrap gap-2\"\n classNames={{\n option:\n \"inline-flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors hover:border-foreground/40 cursor-pointer data-[selected]:border-foreground data-[selected]:bg-foreground data-[selected]:text-background\",\n avatar: \"w-6 h-6 rounded-full object-cover\",\n }}\n />\n </div>\n )}\n\n {hasVariants && (\n <VariantSelector\n variants={product.variants!}\n variantAxes={product.variant_axes}\n basePrice={product.default_price}\n currency={currency}\n selectedVariantId={selectedVariantId}\n onVariantChange={handleVariantChange}\n productName={product.name}\n />\n )}\n\n {hasAddOns && (\n <AddOnSelector\n addOns={product.add_ons!}\n selectedOptions={selectedAddOnOptionIds}\n onOptionsChange={setSelectedAddOnOptionIds}\n currency={currency}\n />\n )}\n\n {/* Billing plans */}\n {product.billing_plans && product.billing_plans.length > 0 && (\n <BillingPlanSelector\n productId={product.id}\n plans={product.billing_plans}\n currency={currency}\n onPlanSelect={setSelectedBillingPlan}\n selectedPlanId={selectedBillingPlan?.id ?? null}\n showOneTimePurchase\n />\n )}\n\n {/* Volume pricing tiers */}\n {product.quantity_pricing && product.quantity_pricing.length > 1 && (\n <VolumePricing\n tiers={product.quantity_pricing}\n currentQuantity={quantity}\n currency={currency}\n />\n )}\n\n {/* Customer input fields */}\n {product.input_fields && product.input_fields.length > 0 && (\n <CustomerInputFields\n fields={product.input_fields}\n values={customerInputValues}\n onChange={setCustomerInputValues}\n currency={currency}\n />\n )}\n\n {/* Deposit info for service products */}\n {product.deposit_type && product.deposit_type !== \"none\" && product.deposit_amount && (\n <div\n data-cimplify-customizer-deposit\n className={cn(\"text-sm text-muted-foreground\", classNames?.depositInfo)}\n >\n {product.deposit_type === \"fixed\" ? (\n <span>\n Deposit required: <Price amount={product.deposit_amount} currency={currency} />\n </span>\n ) : (\n <span>Deposit required: {parsePrice(product.deposit_amount)}%</span>\n )}\n </div>\n )}\n\n {/* Allergens */}\n {product.allergies && product.allergies.length > 0 && (\n <div\n data-cimplify-customizer-allergens\n className={cn(\"flex flex-wrap gap-1.5\", classNames?.allergens)}\n >\n {product.allergies.map((allergen) => (\n <span\n key={allergen}\n data-cimplify-allergen-tag\n className=\"inline-block text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground\"\n >\n {allergen}\n </span>\n ))}\n </div>\n )}\n\n {/* Service duration */}\n {isService && product.duration_minutes != null && (\n <div\n data-cimplify-customizer-duration\n className={cn(\"text-sm text-muted-foreground\", classNames?.duration)}\n >\n Duration: {formatDuration(product.duration_minutes, product.duration_unit)}\n </div>\n )}\n\n {/* Special instructions */}\n {showSpecialInstructions && !isDigital && (\n <div data-cimplify-customizer-special-instructions>\n <textarea\n value={specialInstructions}\n onChange={(e) => setSpecialInstructions(e.target.value)}\n placeholder=\"Special instructions (e.g., no onions, extra sauce)\"\n rows={2}\n data-cimplify-customizer-textarea\n className={cn(\n \"w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring resize-none\",\n classNames?.specialInstructions,\n )}\n />\n </div>\n )}\n\n {isService && selectedSlot && (\n <BookingSummary\n slot={selectedSlot}\n total={displayTotalPrice}\n staffName={selectedStaffName}\n durationMinutes={product.duration_minutes}\n depositType={product.deposit_type}\n depositAmount={product.deposit_amount}\n currency={currency}\n />\n )}\n\n <div\n data-cimplify-customizer-actions\n className={cn(\"pt-4 border-t border-border\", classNames?.actions)}\n >\n {validationMessage && (\n <p\n id={CUSTOMIZER_VALIDATION_ID}\n data-cimplify-customizer-validation\n className={cn(\n \"text-sm mb-3\",\n quoteError ? \"text-destructive\" : \"text-muted-foreground\",\n classNames?.validation,\n )}\n >\n {validationMessage}\n </p>\n )}\n <div className=\"flex items-center gap-4\">\n <div className=\"flex flex-col gap-1\">\n <QuantitySelector\n value={quantity}\n onChange={setQuantity}\n min={product.min_order_quantity ?? 1}\n />\n {product.min_order_quantity && product.min_order_quantity > 1 && (\n <span\n data-cimplify-customizer-min-hint\n className=\"text-xs text-muted-foreground text-center\"\n >\n Min. {product.min_order_quantity}\n </span>\n )}\n </div>\n\n {product.quantity_pricing && product.quantity_pricing.length > 0 && (\n <div\n data-cimplify-customizer-unit-price\n className=\"text-sm text-muted-foreground text-right shrink-0\"\n >\n <Price\n amount={getUnitPriceAtQuantity(\n product.quantity_pricing,\n quantity,\n parsePrice(product.default_price),\n )}\n currency={currency}\n className=\"font-medium text-foreground\"\n />{\" \"}\n ea.\n </div>\n )}\n\n <Button\n onClick={handleAddToCart}\n disabled={isAdded || isSubmitting || !quoteIsReady}\n aria-describedby={validationMessage ? CUSTOMIZER_VALIDATION_ID : undefined}\n data-cimplify-customizer-submit\n className={cn(\n \"flex-1 h-14 text-base bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed rounded-full\",\n isAdded && classNames?.submitButtonAdded,\n classNames?.submitButton,\n )}\n >\n {isAdded ? (\n \"Added\"\n ) : !quoteEnabled ? (\n (submitLabel ?? \"Add to Cart\")\n ) : !quoteIsReady ? (\n quoteError ? (\n QUOTE_UNAVAILABLE_MESSAGE\n ) : (\n QUOTE_LOADING_MESSAGE\n )\n ) : (\n <>\n {submitLabel ?? \"Add to Cart\"} ·{\" \"}\n <Price amount={displayTotalPrice} currency={currency} />\n </>\n )}\n </Button>\n </div>\n </div>\n </div>\n );\n}\n"
|
|
17
|
+
"content": "\"use client\";\n\nimport React, { useState, useCallback, useMemo, useEffect, useRef } from \"react\";\nimport { Button } from \"@base-ui/react/button\";\nimport type {\n ProductWithDetails,\n VariantView,\n AddOnOption,\n ComponentSelectionInput,\n CompositePriceResult,\n} from \"@cimplify/sdk\";\nimport type { ProductBillingPlan } from \"@cimplify/sdk\";\nimport type { BundleSelectionInput } from \"@cimplify/sdk\";\nimport {\n getProductCurrency,\n getUnitPriceAtQuantity,\n getVariantUnitPrice,\n parsePrice,\n} from \"@cimplify/sdk\";\nimport { isMultiDayService, serviceBillingMultiplier } from \"../utils/price-basis\";\nimport { formatDuration } from \"./utils/format-duration\";\nimport { useCart, useQuote } from \"@cimplify/sdk/react\";\nimport type { AddToCartOptions } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { QuantitySelector } from \"@cimplify/sdk/react\";\nimport { VariantSelector } from \"@cimplify/sdk/react\";\nimport { AddOnSelector } from \"@cimplify/sdk/react\";\nimport { StaffPicker } from \"@cimplify/sdk/react\";\nimport { BookingSummary } from \"./booking-summary\";\nimport type { Staff } from \"@cimplify/sdk\";\nimport { CompositeSelector } from \"@cimplify/sdk/react\";\nimport { BundleSelector } from \"@cimplify/sdk/react\";\nimport { BillingPlanSelector } from \"@cimplify/sdk/react\";\nimport { VolumePricing } from \"@cimplify/sdk/react\";\nimport { CustomerInputFields } from \"./customer-input-fields\";\nimport { DateSlotPicker } from \"@cimplify/sdk/react\";\nimport type { AvailableSlot } from \"@cimplify/sdk\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface ProductCustomizerClassNames {\n root?: string;\n actions?: string;\n submitButton?: string;\n submitButtonAdded?: string;\n validation?: string;\n specialInstructions?: string;\n depositInfo?: string;\n units?: string;\n allergens?: string;\n duration?: string;\n}\n\nexport interface ProductCustomizerProps {\n product: ProductWithDetails;\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n /** Lets the parent swap its gallery for `variant.images` on selection. */\n onVariantChange?: (variantId: string | undefined, variant: VariantView | undefined) => void;\n showSpecialInstructions?: boolean;\n /** Submit button verb; the running total is appended. Defaults to \"Add to Cart\". */\n submitLabel?: string;\n className?: string;\n classNames?: ProductCustomizerClassNames;\n}\n\ninterface VariantSelection {\n id?: string;\n variant?: VariantView;\n}\n\nconst REQUIRED_OPTIONS_MESSAGE = \"Please select all required options\";\nconst QUOTE_LOADING_MESSAGE = \"Updating price…\";\nconst QUOTE_UNAVAILABLE_MESSAGE = \"Price is unavailable. Please try again.\";\nconst CUSTOMIZER_VALIDATION_ID = \"cimplify-customizer-validation\";\n\nfunction initialVariantSelection(product: ProductWithDetails): VariantSelection {\n const variant =\n product.variants?.find((candidate) => candidate.is_default) ?? product.variants?.[0];\n return { id: variant?.id, variant };\n}\n\nexport function ProductCustomizer(props: ProductCustomizerProps): React.ReactElement {\n // A product change represents a new customization session. Keying the\n // stateful implementation resets it synchronously and avoids an effect that\n // briefly renders the previous product's variant and quote.\n return <ProductCustomizerContent key={props.product.id} {...props} />;\n}\n\nfunction ProductCustomizerContent({\n product,\n onAddToCart,\n onVariantChange,\n showSpecialInstructions = true,\n submitLabel,\n className,\n classNames,\n}: ProductCustomizerProps): React.ReactElement {\n const [quantity, setQuantity] = useState(product.min_order_quantity ?? 1);\n const [isAdded, setIsAdded] = useState(false);\n const [isSubmitting, setIsSubmitting] = useState(false);\n const [variantSelection, setVariantSelection] = useState<VariantSelection>(() =>\n initialVariantSelection(product),\n );\n const selectedVariantId = variantSelection.id;\n const selectedVariant = variantSelection.variant;\n const [selectedAddOnOptionIds, setSelectedAddOnOptionIds] = useState<string[]>([]);\n\n const [compositeSelections, setCompositeSelections] = useState<ComponentSelectionInput[]>([]);\n const [compositePrice, setCompositePrice] = useState<CompositePriceResult | null>(null);\n const [compositeReady, setCompositeReady] = useState(false);\n\n const [bundleSelections, setBundleSelections] = useState<BundleSelectionInput[]>([]);\n const [bundleTotalPrice, setBundleTotalPrice] = useState<number | null>(null);\n const [bundleReady, setBundleReady] = useState(false);\n const [selectedBillingPlan, setSelectedBillingPlan] = useState<ProductBillingPlan | null>(null);\n const [customerInputValues, setCustomerInputValues] = useState<Record<string, unknown>>({});\n const [specialInstructions, setSpecialInstructions] = useState(\"\");\n const [selectedSlot, setSelectedSlot] = useState<AvailableSlot | null>(null);\n const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);\n const [unitsCount, setUnitsCount] = useState(1);\n\n const cart = useCart();\n\n const productType = product.type || \"product\";\n const isComposite = productType === \"composite\";\n const isBundle = productType === \"bundle\";\n const isDigital = productType === \"digital\";\n const isService = productType === \"service\";\n // Whole-stay services can reserve N interchangeable units (rooms, kayaks,\n // pitches) per booking — mirrors desk's wizard, which gates the input the\n // same way; intraday party size is already the quantity.\n const offersUnits = isService && isMultiDayService(product);\n const isStandard = !isComposite && !isBundle;\n\n const hasVariants = isStandard && product.variants && product.variants.length > 0;\n const hasAddOns = isStandard && product.add_ons && product.add_ons.length > 0;\n\n // Staff offered for the chosen slot. SlotStaffInfo carries id + name; richer\n // profiles (avatars/bios) would come from a staff roster fetch.\n const slotStaff = useMemo<Staff[]>(\n () =>\n (selectedSlot?.available_staff ?? []).map((member) => ({\n id: member.staff_id,\n name: member.name,\n })),\n [selectedSlot],\n );\n const selectedStaffName = selectedStaffId\n ? (slotStaff.find((member) => member.id === selectedStaffId)?.name ?? null)\n : null;\n\n const selectedAddOnOptions = useMemo(() => {\n if (!product.add_ons) return [];\n const options: AddOnOption[] = [];\n for (const addOn of product.add_ons) {\n for (const option of addOn.options) {\n if (selectedAddOnOptionIds.includes(option.id)) {\n options.push(option);\n }\n }\n }\n return options;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const normalizedAddOnOptionIds = useMemo(() => {\n if (selectedAddOnOptionIds.length === 0) return [];\n return Array.from(\n new Set(selectedAddOnOptionIds.map((id) => id.trim()).filter(Boolean)),\n ).sort();\n }, [selectedAddOnOptionIds]);\n\n const localTotalPrice = useMemo(() => {\n if (isComposite && compositePrice) {\n return parsePrice(compositePrice.final_price) * quantity;\n }\n\n if (isBundle && bundleTotalPrice != null) {\n return bundleTotalPrice * quantity;\n }\n\n let price = getVariantUnitPrice(product, selectedVariant);\n\n for (const option of selectedAddOnOptions) {\n if (option.default_price) {\n price += parsePrice(option.default_price);\n }\n }\n\n return price * quantity;\n }, [\n product.default_price,\n selectedVariant,\n selectedAddOnOptions,\n quantity,\n isComposite,\n compositePrice,\n isBundle,\n bundleTotalPrice,\n ]);\n\n const requiredAddOnsSatisfied = useMemo(() => {\n if (!product.add_ons) return true;\n\n for (const addOn of product.add_ons) {\n if (addOn.is_required) {\n const selectedInGroup = selectedAddOnOptionIds.filter((id) =>\n addOn.options.some((opt) => opt.id === id),\n ).length;\n\n const minRequired = addOn.min_selections || 1;\n if (selectedInGroup < minRequired) {\n return false;\n }\n }\n }\n return true;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const quoteInput = useMemo(\n () => ({\n productId: product.id,\n quantity,\n variantId: selectedVariantId,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n compositeSelections:\n isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n }),\n [\n product.id,\n quantity,\n selectedVariantId,\n normalizedAddOnOptionIds,\n isBundle,\n bundleSelections,\n isComposite,\n compositeSelections,\n ],\n );\n\n const requiredInputsSatisfied = useMemo(() => {\n if (!product.input_fields || product.input_fields.length === 0) return true;\n return product.input_fields.every((field) => {\n if (!field.is_required) return true;\n const val = customerInputValues[field.id];\n return val !== undefined && val !== \"\" && val !== null;\n });\n }, [product.input_fields, customerInputValues]);\n\n const quoteEnabled = isComposite\n ? compositeReady && requiredInputsSatisfied\n : isBundle\n ? bundleReady && requiredInputsSatisfied\n : requiredAddOnsSatisfied && requiredInputsSatisfied;\n\n const {\n quote,\n isLoading: isQuoteLoading,\n error: quoteError,\n isExpired: isQuoteExpired,\n } = useQuote(quoteInput, {\n enabled: quoteEnabled,\n });\n\n const quotedPriceInfo = quote?.quoted_total_price_info ?? quote?.final_price_info;\n const currency = quote?.currency ?? quotedPriceInfo?.currency ?? getProductCurrency(product);\n const quoteId = quote?.quote_id;\n const quotedUnitPrice = quotedPriceInfo?.final_price;\n const quotedTotalPrice = useMemo(() => {\n if (quotedUnitPrice === undefined || quotedUnitPrice === null) return undefined;\n return parsePrice(quotedUnitPrice) * quantity;\n }, [quotedUnitPrice, quantity]);\n\n const quoteIsReady =\n quoteEnabled && quote !== null && !isQuoteLoading && !quoteError && !isQuoteExpired;\n const validationMessage = !quoteEnabled\n ? REQUIRED_OPTIONS_MESSAGE\n : quoteError\n ? QUOTE_UNAVAILABLE_MESSAGE\n : !quoteIsReady\n ? QUOTE_LOADING_MESSAGE\n : null;\n\n // Services bill by basis × periods × units server-side, not by quantity —\n // mirror billing_multiplier so the preview matches the cart. Both totals\n // above already include × quantity, so divide it back out to the unit\n // price before applying the real multiplier.\n const displayTotalPrice = useMemo(() => {\n const total = quotedTotalPrice ?? localTotalPrice;\n const multiplier = serviceBillingMultiplier(product, {\n quantity,\n units: unitsCount,\n scheduledStart: selectedSlot?.start_time,\n scheduledEnd: selectedSlot?.end_time,\n });\n if (multiplier === null) return total;\n return (total / Math.max(1, quantity)) * multiplier;\n }, [quotedTotalPrice, localTotalPrice, product, quantity, unitsCount, selectedSlot]);\n\n const handleVariantChange = useCallback(\n (variantId: string | undefined, variant: VariantView | undefined) => {\n setVariantSelection({ id: variantId, variant });\n onVariantChange?.(variantId, variant);\n },\n [onVariantChange],\n );\n\n const addedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(addedTimerRef.current), []);\n\n const handleAddToCart = useCallback(async () => {\n if (\n isSubmitting ||\n !quoteIsReady ||\n !quote ||\n quotedUnitPrice === undefined ||\n quotedUnitPrice === null\n ) {\n return;\n }\n\n setIsSubmitting(true);\n\n const options: AddToCartOptions = {\n locationId: quote.location_id ?? undefined,\n variantId: selectedVariantId,\n variant: selectedVariant\n ? {\n id: selectedVariant.id,\n name: selectedVariant.name || \"\",\n price_adjustment: selectedVariant.price_adjustment,\n price_info: selectedVariant.price_info,\n }\n : undefined,\n quoteId,\n quotedUnitPrice,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n addOnOptions:\n selectedAddOnOptions.length > 0\n ? selectedAddOnOptions.map((opt) => ({\n id: opt.id,\n name: opt.name,\n add_on_id: opt.add_on_id,\n default_price: opt.default_price,\n }))\n : undefined,\n compositeSelections:\n isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n billingPlanId: selectedBillingPlan?.id,\n ...(isService && selectedSlot\n ? {\n scheduledStart: selectedSlot.start_time,\n scheduledEnd: selectedSlot.end_time,\n staffId: selectedStaffId ?? selectedSlot.available_staff?.[0]?.staff_id,\n resourceId: selectedSlot.available_resources?.[0]?.resource_id,\n units: unitsCount > 1 ? unitsCount : undefined,\n }\n : {}),\n customerInputs:\n Object.keys(customerInputValues).length > 0\n ? Object.entries(customerInputValues)\n .filter(([, v]) => v !== undefined && v !== \"\")\n .map(([fieldId, value]) => ({ field_id: fieldId, value }))\n : undefined,\n specialInstructions: specialInstructions.trim() || undefined,\n };\n\n try {\n if (onAddToCart) {\n await onAddToCart(product, quantity, options);\n } else {\n await cart.addItem(product, quantity, options);\n }\n setIsAdded(true);\n clearTimeout(addedTimerRef.current);\n addedTimerRef.current = setTimeout(() => {\n setIsAdded(false);\n setQuantity(product.min_order_quantity ?? 1);\n }, 2000);\n } catch {\n // Caller handles errors via onAddToCart or cart hook error state\n } finally {\n setIsSubmitting(false);\n }\n }, [\n product,\n quantity,\n selectedVariantId,\n selectedVariant,\n quoteId,\n quotedUnitPrice,\n normalizedAddOnOptionIds,\n selectedAddOnOptions,\n isComposite,\n compositeSelections,\n isBundle,\n bundleSelections,\n selectedBillingPlan,\n customerInputValues,\n specialInstructions,\n isService,\n selectedSlot,\n selectedStaffId,\n unitsCount,\n isSubmitting,\n onAddToCart,\n cart,\n quoteIsReady,\n quote,\n ]);\n\n return (\n <div data-cimplify-customizer className={cn(\"space-y-6\", className, classNames?.root)}>\n {isComposite && product.groups && product.composite_id && (\n <CompositeSelector\n compositeId={product.composite_id}\n groups={product.groups}\n currency={currency}\n onSelectionsChange={setCompositeSelections}\n onPriceChange={setCompositePrice}\n onReady={setCompositeReady}\n skipPriceFetch\n />\n )}\n\n {isBundle && product.components && (\n <BundleSelector\n components={product.components}\n bundlePrice={product.bundle_price}\n discountValue={product.discount_value}\n pricingType={product.pricing_type}\n currency={currency}\n onSelectionsChange={setBundleSelections}\n onPriceChange={setBundleTotalPrice}\n onReady={setBundleReady}\n />\n )}\n\n {isService && (\n <DateSlotPicker\n serviceId={product.id}\n selectedSlot={selectedSlot}\n onSlotSelect={(slot) => {\n setSelectedSlot(slot);\n setSelectedStaffId(null);\n }}\n participantCount={quantity}\n units={offersUnits ? unitsCount : undefined}\n currency={currency}\n // Without the resolved mode the picker renders intraday slots for\n // whole-stay services — stays need the stay-range treatment.\n schedulingMode={product.scheduling_mode ?? (offersUnits ? \"multi_day\" : \"intraday\")}\n durationUnit={product.duration_unit}\n durationValue={product.duration_value}\n />\n )}\n\n {offersUnits && (\n <div data-cimplify-customizer-units className={classNames?.units}>\n <label className=\"block text-xs font-medium uppercase tracking-wider text-muted-foreground mb-3\">\n Units\n </label>\n <QuantitySelector value={unitsCount} onChange={setUnitsCount} min={1} />\n </div>\n )}\n\n {isService && slotStaff.length > 0 && (\n <div data-cimplify-customizer-staff>\n <label className=\"block text-xs font-medium uppercase tracking-wider text-muted-foreground mb-3\">\n Choose your specialist\n </label>\n <StaffPicker\n staff={slotStaff}\n selectedStaffId={selectedStaffId}\n onStaffSelect={setSelectedStaffId}\n className=\"flex flex-wrap gap-2\"\n classNames={{\n option:\n \"inline-flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors hover:border-foreground/40 cursor-pointer data-[selected]:border-foreground data-[selected]:bg-foreground data-[selected]:text-background\",\n avatar: \"w-6 h-6 rounded-full object-cover\",\n }}\n />\n </div>\n )}\n\n {hasVariants && (\n <VariantSelector\n variants={product.variants!}\n variantAxes={product.variant_axes}\n basePrice={product.default_price}\n currency={currency}\n selectedVariantId={selectedVariantId}\n onVariantChange={handleVariantChange}\n productName={product.name}\n />\n )}\n\n {hasAddOns && (\n <AddOnSelector\n addOns={product.add_ons!}\n selectedOptions={selectedAddOnOptionIds}\n onOptionsChange={setSelectedAddOnOptionIds}\n currency={currency}\n />\n )}\n\n {/* Billing plans */}\n {product.billing_plans && product.billing_plans.length > 0 && (\n <BillingPlanSelector\n productId={product.id}\n plans={product.billing_plans}\n currency={currency}\n onPlanSelect={setSelectedBillingPlan}\n selectedPlanId={selectedBillingPlan?.id ?? null}\n showOneTimePurchase\n />\n )}\n\n {/* Volume pricing tiers */}\n {product.quantity_pricing && product.quantity_pricing.length > 1 && (\n <VolumePricing\n tiers={product.quantity_pricing}\n currentQuantity={quantity}\n currency={currency}\n />\n )}\n\n {/* Customer input fields */}\n {product.input_fields && product.input_fields.length > 0 && (\n <CustomerInputFields\n fields={product.input_fields}\n values={customerInputValues}\n onChange={setCustomerInputValues}\n currency={currency}\n />\n )}\n\n {/* Deposit info for service products */}\n {product.deposit_type && product.deposit_type !== \"none\" && product.deposit_amount && (\n <div\n data-cimplify-customizer-deposit\n className={cn(\"text-sm text-muted-foreground\", classNames?.depositInfo)}\n >\n {product.deposit_type === \"fixed\" ? (\n <span>\n Deposit required: <Price amount={product.deposit_amount} currency={currency} />\n </span>\n ) : (\n <span>Deposit required: {parsePrice(product.deposit_amount)}%</span>\n )}\n </div>\n )}\n\n {/* Allergens */}\n {product.allergies && product.allergies.length > 0 && (\n <div\n data-cimplify-customizer-allergens\n className={cn(\"flex flex-wrap gap-1.5\", classNames?.allergens)}\n >\n {product.allergies.map((allergen) => (\n <span\n key={allergen}\n data-cimplify-allergen-tag\n className=\"inline-block text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground\"\n >\n {allergen}\n </span>\n ))}\n </div>\n )}\n\n {/* Service duration */}\n {isService && product.duration_minutes != null && (\n <div\n data-cimplify-customizer-duration\n className={cn(\"text-sm text-muted-foreground\", classNames?.duration)}\n >\n Duration: {formatDuration(product.duration_minutes, product.duration_unit)}\n </div>\n )}\n\n {/* Special instructions */}\n {showSpecialInstructions && !isDigital && (\n <div data-cimplify-customizer-special-instructions>\n <textarea\n value={specialInstructions}\n onChange={(e) => setSpecialInstructions(e.target.value)}\n placeholder=\"Special instructions (e.g., no onions, extra sauce)\"\n rows={2}\n data-cimplify-customizer-textarea\n className={cn(\n \"w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring resize-none\",\n classNames?.specialInstructions,\n )}\n />\n </div>\n )}\n\n {isService && selectedSlot && (\n <BookingSummary\n slot={selectedSlot}\n total={displayTotalPrice}\n staffName={selectedStaffName}\n durationMinutes={product.duration_minutes}\n depositType={product.deposit_type}\n depositAmount={product.deposit_amount}\n currency={currency}\n />\n )}\n\n <div\n data-cimplify-customizer-actions\n className={cn(\"pt-4 border-t border-border\", classNames?.actions)}\n >\n {validationMessage && (\n <p\n id={CUSTOMIZER_VALIDATION_ID}\n data-cimplify-customizer-validation\n className={cn(\n \"text-sm mb-3\",\n quoteError ? \"text-destructive\" : \"text-muted-foreground\",\n classNames?.validation,\n )}\n >\n {validationMessage}\n </p>\n )}\n <div className=\"flex items-center gap-4\">\n <div className=\"flex flex-col gap-1\">\n <QuantitySelector\n value={quantity}\n onChange={setQuantity}\n min={product.min_order_quantity ?? 1}\n />\n {product.min_order_quantity && product.min_order_quantity > 1 && (\n <span\n data-cimplify-customizer-min-hint\n className=\"text-xs text-muted-foreground text-center\"\n >\n Min. {product.min_order_quantity}\n </span>\n )}\n </div>\n\n {product.quantity_pricing && product.quantity_pricing.length > 0 && (\n <div\n data-cimplify-customizer-unit-price\n className=\"text-sm text-muted-foreground text-right shrink-0\"\n >\n <Price\n amount={getUnitPriceAtQuantity(\n product.quantity_pricing,\n quantity,\n parsePrice(product.default_price),\n )}\n currency={currency}\n className=\"font-medium text-foreground\"\n />{\" \"}\n ea.\n </div>\n )}\n\n <Button\n onClick={handleAddToCart}\n disabled={isAdded || isSubmitting || !quoteIsReady}\n aria-describedby={validationMessage ? CUSTOMIZER_VALIDATION_ID : undefined}\n data-cimplify-customizer-submit\n className={cn(\n \"flex-1 h-14 text-base bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed rounded-full\",\n isAdded && classNames?.submitButtonAdded,\n classNames?.submitButton,\n )}\n >\n {isAdded ? (\n \"Added\"\n ) : !quoteEnabled ? (\n (submitLabel ?? \"Add to Cart\")\n ) : !quoteIsReady ? (\n quoteError ? (\n QUOTE_UNAVAILABLE_MESSAGE\n ) : (\n QUOTE_LOADING_MESSAGE\n )\n ) : (\n <>\n {submitLabel ?? \"Add to Cart\"} ·{\" \"}\n <Price amount={displayTotalPrice} currency={currency} />\n </>\n )}\n </Button>\n </div>\n </div>\n </div>\n );\n}\n"
|
|
18
18
|
}
|
|
19
19
|
]
|
|
20
20
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "search-input.tsx",
|
|
12
|
-
"content": "\"use client\";\n\nimport React, { useCallback, useMemo } from \"react\";\nimport { Field } from \"@base-ui/react/field\";\nimport { Input } from \"@base-ui/react/input\";\nimport type { Product } from \"@cimplify/sdk\";\nimport { useSearch } from \"@cimplify/sdk/react\";\nimport type { UseSearchOptions } from \"@cimplify/sdk/react\";\nimport { prefetchProduct } from \"./hooks/use-product\";\nimport { speculate } from \"./utils/prefetch-policy\";\nimport { useOptionalCimplifyClient } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\nimport {
|
|
12
|
+
"content": "\"use client\";\n\nimport React, { useCallback, useMemo } from \"react\";\nimport { Field } from \"@base-ui/react/field\";\nimport { Input } from \"@base-ui/react/input\";\nimport type { Product } from \"@cimplify/sdk\";\nimport { useSearch } from \"@cimplify/sdk/react\";\nimport type { UseSearchOptions } from \"@cimplify/sdk/react\";\nimport { prefetchProduct } from \"./hooks/use-product\";\nimport { speculate } from \"./utils/prefetch-policy\";\nimport { useOptionalCimplifyClient } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\nimport { getProductCurrency } from \"@cimplify/sdk\";\nimport { Price } from \"@cimplify/sdk/react\";\n\nexport interface SearchInputClassNames {\n root?: string;\n input?: string;\n clearButton?: string;\n results?: string;\n resultItem?: string;\n empty?: string;\n loading?: string;\n resultImage?: string;\n resultName?: string;\n resultPrice?: string;\n categoryFilter?: string;\n}\n\nexport interface SearchInputProps {\n /** Placeholder text for the input. */\n placeholder?: string;\n /** Search options forwarded to useSearch. */\n searchOptions?: UseSearchOptions;\n /** Called when a product result is clicked. */\n onResultClick?: (product: Product) => void;\n /** Custom result item renderer. */\n renderResult?: (product: Product) => React.ReactNode;\n /** Show inline results dropdown. Default: true. */\n showResults?: boolean;\n /** Optional category ID to scope search results. */\n categoryId?: string;\n /** Display name for the active category filter. */\n categoryName?: string;\n className?: string;\n classNames?: SearchInputClassNames;\n}\n\nconst SearchIcon = () => (\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n aria-hidden=\"true\"\n data-cimplify-search-icon\n >\n <circle cx=\"11\" cy=\"11\" r=\"8\" />\n <line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\" />\n </svg>\n);\n\n/**\n * SearchInput — search bar with debounced results dropdown.\n *\n * Wraps `useSearch` with a controlled input and optional inline results list.\n * Uses Base UI Field.Root + Input for accessible form semantics.\n */\nexport function SearchInput({\n placeholder = \"Search products...\",\n searchOptions,\n onResultClick,\n renderResult,\n showResults = true,\n categoryId,\n categoryName,\n className,\n classNames,\n}: SearchInputProps): React.ReactElement {\n const mergedOptions = useMemo<UseSearchOptions>(\n () => (categoryId ? { ...searchOptions, category: categoryId } : (searchOptions ?? {})),\n [searchOptions, categoryId],\n );\n const { results, isLoading, query, setQuery, clear } = useSearch(mergedOptions);\n\n const context = useOptionalCimplifyClient();\n const warmResult = useCallback(\n (product: Product) => {\n const client = mergedOptions.client ?? context?.client;\n if (!client || context?.prefetch.mode === \"off\") return;\n speculate(() => prefetchProduct(client, product.slug ?? product.id));\n },\n [mergedOptions.client, context],\n );\n\n const handleValueChange = useCallback(\n (value: string) => {\n setQuery(value);\n },\n [setQuery],\n );\n\n return (\n <Field.Root\n data-cimplify-search\n className={cn(className, classNames?.root)}\n style={{ position: \"relative\" }}\n >\n <Field.Label className=\"sr-only\">Search products</Field.Label>\n\n <div style={{ position: \"relative\", display: \"flex\", alignItems: \"center\" }}>\n <span\n data-cimplify-search-icon-wrapper\n style={{\n position: \"absolute\",\n left: \"0.5rem\",\n pointerEvents: \"none\",\n display: \"flex\",\n alignItems: \"center\",\n }}\n >\n <SearchIcon />\n </span>\n\n <Input\n type=\"search\"\n value={query}\n onValueChange={handleValueChange}\n placeholder={placeholder}\n data-cimplify-search-input\n className={classNames?.input}\n />\n\n {query.length > 0 && (\n <button\n type=\"button\"\n onClick={clear}\n data-cimplify-search-clear\n className={classNames?.clearButton}\n aria-label=\"Clear search\"\n >\n ×\n </button>\n )}\n </div>\n\n {categoryId && (\n <div data-cimplify-search-category-filter className={classNames?.categoryFilter}>\n Filtered by: {categoryName || categoryId}\n </div>\n )}\n\n {showResults && query.length > 0 && (\n <div data-cimplify-search-results className={classNames?.results}>\n {isLoading && (\n <div data-cimplify-search-loading className={classNames?.loading} aria-busy=\"true\">\n Searching...\n </div>\n )}\n\n {!isLoading && results.length === 0 && query.length >= 2 && (\n <div data-cimplify-search-empty className={classNames?.empty}>\n No results found\n </div>\n )}\n\n {!isLoading &&\n results.map((product) => (\n <button\n key={product.id}\n type=\"button\"\n onClick={() => onResultClick?.(product)}\n onPointerEnter={() => warmResult(product)}\n onFocus={() => warmResult(product)}\n data-cimplify-search-result\n className={classNames?.resultItem}\n style={\n !renderResult\n ? { display: \"flex\", alignItems: \"center\", gap: \"0.5rem\", width: \"100%\" }\n : undefined\n }\n >\n {renderResult ? (\n renderResult(product)\n ) : (\n <>\n {(product.images?.[0] || product.image_url) && (\n <img\n src={product.images?.[0] || product.image_url}\n alt=\"\"\n data-cimplify-search-result-image\n className={classNames?.resultImage}\n style={{\n width: \"2rem\",\n height: \"2rem\",\n objectFit: \"cover\",\n borderRadius: \"0.25rem\",\n flexShrink: 0,\n }}\n />\n )}\n <span\n data-cimplify-search-result-name\n className={classNames?.resultName}\n style={{ flex: 1, textAlign: \"left\" }}\n >\n {product.name}\n </span>\n {product.default_price !== undefined && (\n <span\n data-cimplify-search-result-price\n className={classNames?.resultPrice}\n style={{ flexShrink: 0 }}\n >\n <Price\n amount={product.default_price}\n currency={getProductCurrency(product)}\n />\n </span>\n )}\n </>\n )}\n </button>\n ))}\n </div>\n )}\n </Field.Root>\n );\n}\n"
|
|
13
13
|
}
|
|
14
14
|
]
|
|
15
15
|
}
|
package/dist/chunk-2LKAOU3L.mjs
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { GameError } from './chunk-XJIHN3BY.mjs';
|
|
2
|
-
|
|
3
|
-
// src/game/backends/engine/split.ts
|
|
4
|
-
var PLACEHOLDER_NAMESPACE = "placeholder.deferred", PRIMARY_NAMESPACE = "primary", MAX_REST_BYTES = 16 * 1024 * 1024, LANE_LINK_WAIT_MS = 2e3, LANE_STATE = 0, LANE_LENGTH = 1, LANE_HEADER_WORDS = 2, LANE_PUBLISHED = 1;
|
|
5
|
-
function placeholders(module) {
|
|
6
|
-
return WebAssembly.Module.imports(module).filter((i) => i.module === PLACEHOLDER_NAMESPACE).length;
|
|
7
|
-
}
|
|
8
|
-
var DeferredEngine = class _DeferredEngine {
|
|
9
|
-
constructor(slots, code) {
|
|
10
|
-
this.slots = slots;
|
|
11
|
-
this.code = code;
|
|
12
|
-
this.table = null;
|
|
13
|
-
this.linked = false;
|
|
14
|
-
this.arrived = null;
|
|
15
|
-
this.exports = null;
|
|
16
|
-
this.shared = null;
|
|
17
|
-
this.published = null;
|
|
18
|
-
/** Settles once the rest of the code is linked asynchronously. */
|
|
19
|
-
this.linking = Promise.resolve();
|
|
20
|
-
/** Placeholder calls: each one is a deferred function reached before the rest was linked. */
|
|
21
|
-
this.hits = 0;
|
|
22
|
-
/** Placeholder calls that had to fetch or compile the rest synchronously. */
|
|
23
|
-
this.blocked = 0;
|
|
24
|
-
}
|
|
25
|
-
/** A linker for `module`, or null when the module is not split. */
|
|
26
|
-
static of(module, code) {
|
|
27
|
-
let slots = placeholders(module);
|
|
28
|
-
if (slots === 0) return null;
|
|
29
|
-
if (code == null) throw new GameError("not_initialized", "a split engine core needs the rest of its code");
|
|
30
|
-
return new _DeferredEngine(slots, code);
|
|
31
|
-
}
|
|
32
|
-
get ready() {
|
|
33
|
-
return this.linked;
|
|
34
|
-
}
|
|
35
|
-
imports() {
|
|
36
|
-
let out = {};
|
|
37
|
-
for (let i = 0; i < this.slots; i++) out[String(i)] = (...args) => this.call(i, args);
|
|
38
|
-
return out;
|
|
39
|
-
}
|
|
40
|
-
/** The handoff for worker lanes, created on first use; the rest is published into it as soon as it is known. */
|
|
41
|
-
lanes() {
|
|
42
|
-
return this.shared === null && (this.shared = { header: new Int32Array(new SharedArrayBuffer((2) * Int32Array.BYTES_PER_ELEMENT)), bytes: new SharedArrayBuffer(MAX_REST_BYTES), slots: this.slots }, this.published !== null && this.publish(this.published)), this.shared;
|
|
43
|
-
}
|
|
44
|
-
publish(bytes) {
|
|
45
|
-
this.published = bytes;
|
|
46
|
-
let lanes = this.shared;
|
|
47
|
-
if (!(lanes === null || Atomics.load(lanes.header, (0)) === (1))) {
|
|
48
|
-
if (bytes.length > lanes.bytes.byteLength) throw new GameError("capacity_exceeded", `the rest of the engine is ${bytes.length} bytes, past the ${MAX_REST_BYTES} lanes can take`);
|
|
49
|
-
new Uint8Array(lanes.bytes).set(bytes), Atomics.store(lanes.header, (1), bytes.length), Atomics.store(lanes.header, (0), (1)), Atomics.notify(lanes.header, (0));
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
remember(source) {
|
|
53
|
-
source instanceof WebAssembly.Module || this.publish(source instanceof Uint8Array ? source : ArrayBuffer.isView(source) ? new Uint8Array(source.buffer, source.byteOffset, source.byteLength) : new Uint8Array(source));
|
|
54
|
-
}
|
|
55
|
-
/** Binds the instantiated core and starts linking the rest when it arrives. */
|
|
56
|
-
bind(instance) {
|
|
57
|
-
this.exports = instance.exports;
|
|
58
|
-
for (let value of Object.values(instance.exports))
|
|
59
|
-
value instanceof WebAssembly.Table && value.length === this.slots && (this.table = value);
|
|
60
|
-
if (this.table === null) throw new GameError("not_initialized", "the split engine core exports no placeholder table");
|
|
61
|
-
return this.linking = this.code.rest.then(async (arrival) => {
|
|
62
|
-
let compiling = arrival instanceof WebAssembly.Module || !("compiled" in arrival) ? null : arrival, source = compiling === null ? arrival : compiling.bytes;
|
|
63
|
-
if (this.arrived = source, this.remember(source), this.linked) return;
|
|
64
|
-
let module = source instanceof WebAssembly.Module ? source : await (compiling?.compiled.catch(() => WebAssembly.compile(source)) ?? WebAssembly.compile(source));
|
|
65
|
-
this.linked || (await WebAssembly.instantiate(module, this.restImports()), this.linked = true);
|
|
66
|
-
}), this.linking;
|
|
67
|
-
}
|
|
68
|
-
restImports() {
|
|
69
|
-
return { [PRIMARY_NAMESPACE]: this.exports };
|
|
70
|
-
}
|
|
71
|
-
/** Links synchronously, for a deferred call that cannot wait; browsers allow it on the main thread only below 8 MB. */
|
|
72
|
-
link(module) {
|
|
73
|
-
new WebAssembly.Instance(module, this.restImports()), this.linked = true;
|
|
74
|
-
}
|
|
75
|
-
linkNow(slot) {
|
|
76
|
-
this.blocked++;
|
|
77
|
-
let source = this.arrived ?? this.code.fetchSync?.() ?? null;
|
|
78
|
-
if (source === null) throw new GameError("not_initialized", `deferred engine code (placeholder ${slot}) ran before the rest of the engine arrived`);
|
|
79
|
-
this.remember(source), this.link(source instanceof WebAssembly.Module ? source : new WebAssembly.Module(source));
|
|
80
|
-
}
|
|
81
|
-
call(slot, args) {
|
|
82
|
-
return this.hits++, this.linked || this.linkNow(slot), this.table.get(slot)(...args);
|
|
83
|
-
}
|
|
84
|
-
};
|
|
85
|
-
function restArrival(fetchSync = null) {
|
|
86
|
-
let arrive = () => {
|
|
87
|
-
}, rest = new Promise((resolve) => {
|
|
88
|
-
arrive = resolve;
|
|
89
|
-
});
|
|
90
|
-
return {
|
|
91
|
-
code: fetchSync === null ? { rest } : { rest, fetchSync },
|
|
92
|
-
arrive: (bytes, compiled = null) => arrive(compiled === null ? bytes : { bytes, compiled })
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export { DeferredEngine, LANE_LENGTH, LANE_LINK_WAIT_MS, LANE_STATE, PLACEHOLDER_NAMESPACE, PRIMARY_NAMESPACE, restArrival };
|
package/dist/chunk-UWPQL2IQ.js
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
var chunk2V7IL4HC_js = require('./chunk-2V7IL4HC.js');
|
|
4
|
-
|
|
5
|
-
// src/game/backends/engine/split.ts
|
|
6
|
-
var PLACEHOLDER_NAMESPACE = "placeholder.deferred", PRIMARY_NAMESPACE = "primary", MAX_REST_BYTES = 16 * 1024 * 1024, LANE_LINK_WAIT_MS = 2e3, LANE_STATE = 0, LANE_LENGTH = 1, LANE_HEADER_WORDS = 2, LANE_PUBLISHED = 1;
|
|
7
|
-
function placeholders(module) {
|
|
8
|
-
return WebAssembly.Module.imports(module).filter((i) => i.module === PLACEHOLDER_NAMESPACE).length;
|
|
9
|
-
}
|
|
10
|
-
var DeferredEngine = class _DeferredEngine {
|
|
11
|
-
constructor(slots, code) {
|
|
12
|
-
this.slots = slots;
|
|
13
|
-
this.code = code;
|
|
14
|
-
this.table = null;
|
|
15
|
-
this.linked = false;
|
|
16
|
-
this.arrived = null;
|
|
17
|
-
this.exports = null;
|
|
18
|
-
this.shared = null;
|
|
19
|
-
this.published = null;
|
|
20
|
-
/** Settles once the rest of the code is linked asynchronously. */
|
|
21
|
-
this.linking = Promise.resolve();
|
|
22
|
-
/** Placeholder calls: each one is a deferred function reached before the rest was linked. */
|
|
23
|
-
this.hits = 0;
|
|
24
|
-
/** Placeholder calls that had to fetch or compile the rest synchronously. */
|
|
25
|
-
this.blocked = 0;
|
|
26
|
-
}
|
|
27
|
-
/** A linker for `module`, or null when the module is not split. */
|
|
28
|
-
static of(module, code) {
|
|
29
|
-
let slots = placeholders(module);
|
|
30
|
-
if (slots === 0) return null;
|
|
31
|
-
if (code == null) throw new chunk2V7IL4HC_js.GameError("not_initialized", "a split engine core needs the rest of its code");
|
|
32
|
-
return new _DeferredEngine(slots, code);
|
|
33
|
-
}
|
|
34
|
-
get ready() {
|
|
35
|
-
return this.linked;
|
|
36
|
-
}
|
|
37
|
-
imports() {
|
|
38
|
-
let out = {};
|
|
39
|
-
for (let i = 0; i < this.slots; i++) out[String(i)] = (...args) => this.call(i, args);
|
|
40
|
-
return out;
|
|
41
|
-
}
|
|
42
|
-
/** The handoff for worker lanes, created on first use; the rest is published into it as soon as it is known. */
|
|
43
|
-
lanes() {
|
|
44
|
-
return this.shared === null && (this.shared = { header: new Int32Array(new SharedArrayBuffer((2) * Int32Array.BYTES_PER_ELEMENT)), bytes: new SharedArrayBuffer(MAX_REST_BYTES), slots: this.slots }, this.published !== null && this.publish(this.published)), this.shared;
|
|
45
|
-
}
|
|
46
|
-
publish(bytes) {
|
|
47
|
-
this.published = bytes;
|
|
48
|
-
let lanes = this.shared;
|
|
49
|
-
if (!(lanes === null || Atomics.load(lanes.header, (0)) === (1))) {
|
|
50
|
-
if (bytes.length > lanes.bytes.byteLength) throw new chunk2V7IL4HC_js.GameError("capacity_exceeded", `the rest of the engine is ${bytes.length} bytes, past the ${MAX_REST_BYTES} lanes can take`);
|
|
51
|
-
new Uint8Array(lanes.bytes).set(bytes), Atomics.store(lanes.header, (1), bytes.length), Atomics.store(lanes.header, (0), (1)), Atomics.notify(lanes.header, (0));
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
remember(source) {
|
|
55
|
-
source instanceof WebAssembly.Module || this.publish(source instanceof Uint8Array ? source : ArrayBuffer.isView(source) ? new Uint8Array(source.buffer, source.byteOffset, source.byteLength) : new Uint8Array(source));
|
|
56
|
-
}
|
|
57
|
-
/** Binds the instantiated core and starts linking the rest when it arrives. */
|
|
58
|
-
bind(instance) {
|
|
59
|
-
this.exports = instance.exports;
|
|
60
|
-
for (let value of Object.values(instance.exports))
|
|
61
|
-
value instanceof WebAssembly.Table && value.length === this.slots && (this.table = value);
|
|
62
|
-
if (this.table === null) throw new chunk2V7IL4HC_js.GameError("not_initialized", "the split engine core exports no placeholder table");
|
|
63
|
-
return this.linking = this.code.rest.then(async (arrival) => {
|
|
64
|
-
let compiling = arrival instanceof WebAssembly.Module || !("compiled" in arrival) ? null : arrival, source = compiling === null ? arrival : compiling.bytes;
|
|
65
|
-
if (this.arrived = source, this.remember(source), this.linked) return;
|
|
66
|
-
let module = source instanceof WebAssembly.Module ? source : await (compiling?.compiled.catch(() => WebAssembly.compile(source)) ?? WebAssembly.compile(source));
|
|
67
|
-
this.linked || (await WebAssembly.instantiate(module, this.restImports()), this.linked = true);
|
|
68
|
-
}), this.linking;
|
|
69
|
-
}
|
|
70
|
-
restImports() {
|
|
71
|
-
return { [PRIMARY_NAMESPACE]: this.exports };
|
|
72
|
-
}
|
|
73
|
-
/** Links synchronously, for a deferred call that cannot wait; browsers allow it on the main thread only below 8 MB. */
|
|
74
|
-
link(module) {
|
|
75
|
-
new WebAssembly.Instance(module, this.restImports()), this.linked = true;
|
|
76
|
-
}
|
|
77
|
-
linkNow(slot) {
|
|
78
|
-
this.blocked++;
|
|
79
|
-
let source = this.arrived ?? this.code.fetchSync?.() ?? null;
|
|
80
|
-
if (source === null) throw new chunk2V7IL4HC_js.GameError("not_initialized", `deferred engine code (placeholder ${slot}) ran before the rest of the engine arrived`);
|
|
81
|
-
this.remember(source), this.link(source instanceof WebAssembly.Module ? source : new WebAssembly.Module(source));
|
|
82
|
-
}
|
|
83
|
-
call(slot, args) {
|
|
84
|
-
return this.hits++, this.linked || this.linkNow(slot), this.table.get(slot)(...args);
|
|
85
|
-
}
|
|
86
|
-
};
|
|
87
|
-
function restArrival(fetchSync = null) {
|
|
88
|
-
let arrive = () => {
|
|
89
|
-
}, rest = new Promise((resolve) => {
|
|
90
|
-
arrive = resolve;
|
|
91
|
-
});
|
|
92
|
-
return {
|
|
93
|
-
code: fetchSync === null ? { rest } : { rest, fetchSync },
|
|
94
|
-
arrive: (bytes, compiled = null) => arrive(compiled === null ? bytes : { bytes, compiled })
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
exports.DeferredEngine = DeferredEngine;
|
|
99
|
-
exports.LANE_LENGTH = (1);
|
|
100
|
-
exports.LANE_LINK_WAIT_MS = (2e3);
|
|
101
|
-
exports.LANE_STATE = (0);
|
|
102
|
-
exports.PLACEHOLDER_NAMESPACE = PLACEHOLDER_NAMESPACE;
|
|
103
|
-
exports.PRIMARY_NAMESPACE = PRIMARY_NAMESPACE;
|
|
104
|
-
exports.restArrival = restArrival;
|