diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4308b4b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +target +dist +.git +.tools +.coatcheck +node_modules +playwright-report +test-results +.env +.env.* +!.env.example diff --git a/.env.example b/.env.example index 74b0dcd..ba48aa9 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,23 @@ +APP_ENV=local +HOST=127.0.0.1 +PORT=3100 +DATABASE_URL= +COOKIE_KEY=replace-with-at-least-32-random-characters + CLERK_PUBLISHABLE_KEY=pk_test_replace_me CLERK_SECRET_KEY=sk_test_replace_me CLERK_ISSUER=https://replace-me.clerk.accounts.dev -CLERK_AUTHORIZED_PARTIES=http://127.0.0.1:3000 +CLERK_AUTHORIZED_PARTIES=http://127.0.0.1:3100 CLERK_AUDIENCE= CLERK_JWKS_URL= -APP_ENV=development + +REOWN_PROJECT_ID= + +# The first commerce release is test-only. Production mainnet, real postage, +# and provider-backed tax stay disabled until their acceptance gates pass. +COMMERCE_MAINNET_ENABLED=false +USPS_CLIENT_ID= +USPS_CLIENT_SECRET= +UPS_CLIENT_ID= +UPS_CLIENT_SECRET= +TAX_PROVIDER= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff7ebb3..e9f43da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: - run: cargo clippy --workspace --all-targets --all-features -- -D warnings - run: cargo test --workspace --all-features - run: node --check crates/coldfront-ui/src/auth.js + - run: node --check crates/coldfront-ui/src/wallet.js coatcheck: runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 2006ae4..1288ae1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -138,23 +147,58 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -207,6 +251,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -240,6 +290,20 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" @@ -308,9 +372,15 @@ dependencies = [ name = "coldfront" version = "0.1.0" dependencies = [ + "async-trait", + "chrono", "coldfront-ui", + "serde", + "serde_json", + "sqlx", "tokio", "topcoat", + "uuid", ] [[package]] @@ -337,6 +407,7 @@ name = "coldfront-ui" version = "0.1.0" dependencies = [ "topcoat", + "topcoat-blocks", ] [[package]] @@ -363,6 +434,12 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -405,6 +482,12 @@ dependencies = [ "url", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -423,6 +506,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -432,6 +530,21 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crypto-common" version = "0.1.7" @@ -467,6 +580,17 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -480,6 +604,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -491,7 +616,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", ] @@ -515,6 +640,21 @@ dependencies = [ "litrs", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +dependencies = [ + "serde", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -531,6 +671,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -547,12 +708,29 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -575,6 +753,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -583,6 +762,34 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + [[package]] name = "futures-macro" version = "0.3.33" @@ -613,9 +820,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", + "futures-io", "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -694,6 +903,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -702,7 +922,16 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", ] [[package]] @@ -711,6 +940,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hkdf" version = "0.12.4" @@ -729,6 +964,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.2" @@ -818,6 +1062,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -928,7 +1196,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -972,12 +1240,49 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + [[package]] name = "litemap" version = "0.8.2" @@ -990,6 +1295,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.33" @@ -1002,6 +1316,16 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + [[package]] name = "memchr" version = "2.8.3" @@ -1029,12 +1353,57 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1054,47 +1423,118 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "parking" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "parking_lot" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] [[package]] -name = "polyval" -version = "0.6.2" +name = "parking_lot_core" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", ] [[package]] -name = "potential_utf" -version = "0.1.5" +name = "pem-rfc7468" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" dependencies = [ - "zerovec", + "base64ct", ] [[package]] -name = "powerfmt" -version = "0.2.0" +name = "percent-encoding" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ @@ -1216,6 +1656,24 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags", +] + [[package]] name = "ref-cast" version = "1.0.26" @@ -1250,6 +1708,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustls" version = "0.23.42" @@ -1297,6 +1775,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.229" @@ -1421,6 +1905,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -1438,6 +1932,9 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -1449,12 +1946,240 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.7", + "rsa", + "serde", + "sha1", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.7", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1566,6 +2291,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.53.1" @@ -1593,6 +2333,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.29.0" @@ -1719,6 +2470,8 @@ dependencies = [ "topcoat-runtime", "topcoat-runtime-macro", "topcoat-session", + "topcoat-tailwind", + "topcoat-ui-registry", "topcoat-view", "topcoat-view-macro", ] @@ -1741,6 +2494,11 @@ dependencies = [ "topcoat-view", ] +[[package]] +name = "topcoat-blocks" +version = "0.2.0" +source = "git+https://github.com/superposition/topcoat-blocks.git?rev=b19fe63c7ad7c368ba1592a5120e82ed0f8881c0#b19fe63c7ad7c368ba1592a5120e82ed0f8881c0" + [[package]] name = "topcoat-cookie" version = "0.4.0" @@ -1764,7 +2522,7 @@ dependencies = [ "anyhow", "anymap3", "boxcar", - "hashbrown", + "hashbrown 0.17.1", "pin-project-lite", "tokio", ] @@ -1839,9 +2597,13 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "438d3a12ff218310785e0ef538ae2c03c0d95102a164159372185f334f1f024e" dependencies = [ + "serde", + "serde_json", + "thiserror", "topcoat-core", "topcoat-view", "topcoat-view-macro", + "ureq", ] [[package]] @@ -1985,6 +2747,24 @@ dependencies = [ "topcoat-router", ] +[[package]] +name = "topcoat-tailwind" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6776b0ed30e0f92192cc942c4a7ac8d0f1fa197a0ec0094eebf7ede887ee90" +dependencies = [ + "sha2 0.11.0", + "thiserror", + "topcoat-core", + "ureq", +] + +[[package]] +name = "topcoat-ui-registry" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4f2d4a5ec84adce65f380bb24c2840bff0998d95e3a2d8278e120aa3ce0fb6" + [[package]] name = "topcoat-view" version = "0.4.0" @@ -2075,10 +2855,23 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -2110,12 +2903,33 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "universal-hash" version = "0.5.1" @@ -2149,7 +2963,7 @@ dependencies = [ "serde_json", "ureq-proto", "utf8-zero", - "webpki-roots", + "webpki-roots 1.0.9", ] [[package]] @@ -2207,6 +3021,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -2228,6 +3048,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -2273,6 +3099,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -2282,19 +3117,91 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2306,34 +3213,67 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2346,24 +3286,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index a334eff..4ff6204 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,10 +14,25 @@ rust-version = "1.95" version = "0.1.0" [workspace.dependencies] +async-trait = "0.1" +chrono = { version = "0.4", features = ["serde"] } coatcheck = "0.2.0" coldfront-ui = { path = "crates/coldfront-ui" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sqlx = { version = "0.8", default-features = false, features = [ + "chrono", + "json", + "macros", + "migrate", + "postgres", + "runtime-tokio-rustls", + "uuid", +] } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } -topcoat = "0.4.0" +topcoat = { version = "0.4.0", features = ["icon-iconify", "tailwind", "ui"] } +topcoat-blocks = { git = "https://github.com/superposition/topcoat-blocks.git", rev = "b19fe63c7ad7c368ba1592a5120e82ed0f8881c0" } +uuid = { version = "1", features = ["serde", "v4"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5842dd0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM rust:1.97-bookworm AS builder + +WORKDIR /workspace +COPY . . +RUN cargo install topcoat-cli --version 0.4.0 --locked \ + && topcoat asset bundle --package coldfront --release + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /workspace/target/release/coldfront /usr/local/bin/coldfront +COPY --from=builder /workspace/target/assets /usr/local/bin/assets + +ENV HOST=0.0.0.0 +ENV APP_ENV=staging +EXPOSE 3000 + +CMD ["coldfront"] diff --git a/README.md b/README.md index 49bb230..fa24834 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Cold Front -Cold Front is a game website built in Rust with +Cold Front is a fictional espionage game and field-gear storefront built in Rust with [Topcoat](https://github.com/tokio-rs/topcoat) and Tokio. It presents a fictional Arctic espionage mission: intercept the courier, verify every transmission, and reach extraction before satellite cover returns. @@ -22,6 +22,13 @@ standalone [`coatcheck`](https://github.com/superposition/coatcheck) crate; Coatcheck stays product-neutral while Cold Front owns its components, stories, and visual language. +The commerce surfaces install provider-neutral source from the separate +[`topcoat-blocks`](https://github.com/superposition/topcoat-blocks) registry. +Cold Front owns all product data, pricing, cart state, payment policy, +fulfillment decisions, and gritty styling. See +[`docs/COMMERCE.md`](docs/COMMERCE.md) for the staging boundary and release +gates. + ## Start both servers ```sh @@ -36,6 +43,7 @@ and visual language. Then open: - Cold Front: +- Field gear: - Coatcheck: - Coatcheck manifest: - Direct story frame: `http://127.0.0.1:3101/__coatcheck/frame/{story_id}` @@ -88,8 +96,24 @@ workbench model is documented in [`docs/COATCHECK.md`](docs/COATCHECK.md). This checkout is linked to its own Clerk development application. Local keys live only in ignored `.env.local`; never add them to stories, snapshots, or -source. Login will use official ClerkJS in the browser and verified Clerk -session JWTs at the Rust boundary. +source. Official ClerkJS provides optional public-site sign-in. The current +non-production operator simulator is not authorized by that client-side +control; server-side Clerk JWT and role verification remains a release gate. + +## Commerce staging + +Local development uses fictional in-memory inventory unless `DATABASE_URL` is +set. Railway staging attaches Postgres, runs the embedded migration, and seeds +the same fictional catalog. Shipping, tax, direct-USDC confirmation, and USPS +labels are explicit fixtures: no real money or postage is requested. + +```sh +cp .env.example .env.local +./scripts/dev +``` + +Container and Railway configuration live in [`Dockerfile`](Dockerfile) and +[`railway.toml`](railway.toml). ## Framework note @@ -108,6 +132,7 @@ workspace toolchain is pinned separately for reproducible builds. - [Coatcheck browser checks](coatcheck/README.md) - [Documentation](docs/README.md) - [Component glossary](docs/COMPONENTS.md) + - [Commerce boundary](docs/COMMERCE.md) - [Coatcheck integration](docs/COATCHECK.md) - [Product plan](docs/PLAN.md) - [Workspace scripts](scripts/README.md) diff --git a/apps/coldfront/Cargo.toml b/apps/coldfront/Cargo.toml index aa56a68..8ef3f00 100644 --- a/apps/coldfront/Cargo.toml +++ b/apps/coldfront/Cargo.toml @@ -7,9 +7,15 @@ rust-version.workspace = true publish = false [dependencies] +async-trait.workspace = true +chrono.workspace = true coldfront-ui.workspace = true +serde.workspace = true +serde_json.workspace = true +sqlx.workspace = true tokio.workspace = true topcoat.workspace = true +uuid.workspace = true [lints] workspace = true diff --git a/apps/coldfront/src/commerce.rs b/apps/coldfront/src/commerce.rs new file mode 100644 index 0000000..ce935aa --- /dev/null +++ b/apps/coldfront/src/commerce.rs @@ -0,0 +1,849 @@ +use coldfront_ui::components::{ + cart_line_item::{CartLineItem, cart_line_item}, + catalog_filter_bar::{CatalogFilterOption, catalog_filter_bar}, + order_receipt::{OrderReceipt, ReceiptLine, order_receipt}, + order_totals::{TotalLine, TotalLineTone, order_totals}, + payment_route_selector::{PaymentRouteOption, payment_route_selector}, + payment_status::{ + PaymentState as BlockPaymentState, PaymentStatus as BlockPaymentStatus, payment_status, + }, + product_card::{ProductAvailability, ProductCard, product_card}, + shipping_address_form::{ShippingAddress as BlockShippingAddress, shipping_address_form}, +}; +use serde::Deserialize; +use topcoat::{ + Error, Result, + context::{Cx, app_context}, + cookie::{Cookies, SameSite, private_cookies}, + router::{ + Form, SeeOther, bad_request, internal_server_error, not_found, page, path_param, + query_params, route, see_other, + }, + view::{attributes, component, view}, +}; +use uuid::Uuid; + +use crate::{ + domain::{ + Cart, CheckoutQuote, OrderState, PaymentState, Product, ShippingAddress, format_usd, + format_usdc, + }, + store::{CommerceStore, StoreError}, +}; + +const CART_COOKIE: &str = "cf_cart"; +const CSRF_COOKIE: &str = "cf_csrf"; +const ADDRESS_COOKIE: &str = "cf_checkout_address"; + +#[query_params(error = bad_request)] +struct ShopQuery { + q: Option, + category: Option, + sort: Option, +} + +#[path_param] +struct Slug(str); + +#[path_param] +struct OrderId(str); + +#[derive(Debug, Deserialize)] +pub struct AddItemForm { + product_id: Uuid, + csrf_token: String, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateItemForm { + product_id: Uuid, + quantity: Option, + intent: String, + csrf_token: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AddressForm { + name: String, + email: String, + phone: String, + line1: String, + #[serde(default)] + line2: String, + city: String, + region: String, + postal_code: String, + country: String, + csrf_token: String, +} + +#[derive(Debug, Deserialize)] +pub struct PaymentRouteForm { + route_id: String, + csrf_token: String, +} + +#[derive(Debug, Deserialize)] +pub struct MutationForm { + csrf_token: String, +} + +fn store(cx: &Cx) -> &CommerceStore { + app_context(cx) +} + +fn store_error(error: StoreError) -> Error { + match error { + StoreError::NotFound(_) => not_found().into(), + StoreError::SoldOut + | StoreError::EmptyCart + | StoreError::CheckoutDisabled + | StoreError::DuplicateTransaction + | StoreError::InvalidState + | StoreError::InvalidRoute => bad_request(error.to_string()).into(), + _ => internal_server_error(error).into(), + } +} + +fn cookie_secure(cx: &Cx) -> bool { + !matches!(store(cx).environment(), "local" | "test") +} + +fn private_cookie(cx: &Cx, name: &str) -> Option { + private_cookies(cx) + .get(name) + .map(|cookie| cookie.value().to_owned()) +} + +fn set_private_cookie(cx: &Cx, name: &'static str, value: String) { + private_cookies(cx) + .default_http_only(true) + .default_same_site(SameSite::Lax) + .default_secure(cookie_secure(cx)) + .default_path("/") + .add((name, value)); +} + +fn cart_id(cx: &Cx) -> Uuid { + if let Some(id) = private_cookie(cx, CART_COOKIE).and_then(|value| Uuid::parse_str(&value).ok()) + { + return id; + } + let id = Uuid::new_v4(); + set_private_cookie(cx, CART_COOKIE, id.to_string()); + id +} + +fn csrf_token(cx: &Cx) -> String { + if let Some(token) = private_cookie(cx, CSRF_COOKIE) { + return token; + } + let token = Uuid::new_v4().simple().to_string(); + set_private_cookie(cx, CSRF_COOKIE, token.clone()); + token +} + +fn verify_csrf(cx: &Cx, submitted: &str) -> Result<()> { + let expected = private_cookie(cx, CSRF_COOKIE) + .ok_or_else(|| bad_request("the form expired; reload and try again"))?; + if expected != submitted { + return Err(bad_request("the form could not be verified; reload and try again").into()); + } + Ok(()) +} + +fn write_address(cx: &Cx, address: &ShippingAddress) -> Result<()> { + let encoded = serde_json::to_string(address).map_err(internal_server_error)?; + set_private_cookie(cx, ADDRESS_COOKIE, encoded); + Ok(()) +} + +fn read_address(cx: &Cx) -> Option { + private_cookie(cx, ADDRESS_COOKIE) + .and_then(|value| serde_json::from_str::(&value).ok()) +} + +fn availability(product: &Product) -> ProductAvailability { + match product.inventory { + 0 => ProductAvailability::SoldOut, + 1..=3 => ProductAvailability::LowStock, + _ => ProductAvailability::Available, + } +} + +fn totals_for_cart(cart: &Cart) -> Vec { + vec![ + TotalLine::new( + format!("{} field item(s)", cart.item_count()), + format_usd(cart.subtotal_cents()), + TotalLineTone::Standard, + ), + TotalLine::new( + "Shipping and tax", + "Calculated at checkout", + TotalLineTone::Muted, + ), + TotalLine::new( + "Subtotal", + format_usd(cart.subtotal_cents()), + TotalLineTone::Emphasis, + ), + ] +} + +fn totals_for_quote(quote: &CheckoutQuote) -> Vec { + vec![ + TotalLine::new( + "Field kit", + format_usd(quote.subtotal_cents), + TotalLineTone::Standard, + ), + TotalLine::new( + format!( + "{} · {}", + quote.shipping.carrier, quote.shipping.estimated_days + ), + format_usd(quote.shipping.amount_cents), + TotalLineTone::Standard, + ), + TotalLine::new( + "Estimated tax · staging fixture", + format_usd(quote.tax.amount_cents), + TotalLineTone::Muted, + ), + TotalLine::new( + "Total", + format_usd(quote.total_cents), + TotalLineTone::Emphasis, + ), + ] +} + +fn block_address(address: &ShippingAddress) -> BlockShippingAddress { + BlockShippingAddress { + name: address.name.clone(), + email: address.email.clone(), + phone: address.phone.clone(), + line1: address.line1.clone(), + line2: address.line2.clone(), + city: address.city.clone(), + region: address.region.clone(), + postal_code: address.postal_code.clone(), + country_code: "US".to_owned(), + country_label: "United States / staging only".to_owned(), + } +} + +fn payment_state(state: PaymentState) -> BlockPaymentState { + match state { + PaymentState::AwaitingWallet => BlockPaymentState::AwaitingWallet, + PaymentState::AwaitingTransfer => BlockPaymentState::AwaitingTransfer, + PaymentState::Confirming => BlockPaymentState::Confirming, + PaymentState::Paid => BlockPaymentState::Paid, + PaymentState::Expired => BlockPaymentState::Expired, + PaymentState::Canceled => BlockPaymentState::Canceled, + PaymentState::ManualReview => BlockPaymentState::ManualReview, + } +} + +#[component] +async fn store_header(cart_count: u32) -> Result { + view! { +
+ + "CF" + "COLD FRONT SUPPLY" + + +
+ } +} + +#[component] +async fn commerce_intro(kicker: String, title: String, copy: String) -> Result { + view! { +
+

(kicker)

+

(title)

+

(copy)

+
+ } +} + +#[page("/shop")] +pub async fn shop(cx: &Cx) -> Result { + let query = query_params::(cx)?; + let q = query.q.as_deref().unwrap_or_default(); + let category = query.category.as_deref().unwrap_or_default(); + let sort = query.sort.as_deref().unwrap_or("field-order"); + let products = store(cx) + .products(q, category, sort) + .await + .map_err(store_error)?; + let cart = store(cx).cart(cart_id(cx)).await.map_err(store_error)?; + let csrf = csrf_token(cx); + let categories = ["", "Apparel", "Carry", "Desk"] + .into_iter() + .map(|value| { + CatalogFilterOption::new( + value, + if value.is_empty() { "All gear" } else { value }, + category == value, + ) + }) + .collect::>(); + let sorts = [ + ("field-order", "Field order"), + ("price-asc", "Price: low first"), + ("price-desc", "Price: high first"), + ("name", "Codename"), + ] + .into_iter() + .map(|(value, label)| CatalogFilterOption::new(value, label, sort == value)) + .collect::>(); + + view! { +
+ store_header(cart_count: cart.item_count()) + commerce_intro( + kicker: "QUARTERMASTER / UNCLASSIFIED STAGING".to_owned(), + title: "Gear for compromised conditions.".to_owned(), + copy: "Fictional field equipment for a pretend spy game. Nothing here is live inventory and no real-value payment is enabled.".to_owned() + ) +
+
+

"AVAILABLE ASSETS"

+

"Build the loadout"

+

+ (products.len()) + " result(s) · " + (store(cx).backend_label()) +

+
+ catalog_filter_bar( + action: "/shop", + query: q, + categories: categories, + sorts: sorts, + attrs: attributes! { class="cf-filter-bar" } + ) +
+ for product in products { + product_card( + product: ProductCard { + href: format!("/products/{}", product.slug), + image_src: product.image_url.clone(), + image_alt: format!("{} product staging placeholder", product.name), + eyebrow: Some(format!("{} · {}", product.codename, product.sku)), + title: product.name.clone(), + summary: product.summary.clone(), + price: product.price(), + compare_at_price: None, + availability: availability(&product), + }, + add_action: Some("/cart/items".to_owned()), + attrs: attributes! { class="cf-product-card" }, + + + ) + } +
+
+
+ } +} + +#[page("/products/{slug}")] +pub async fn product_page(cx: &Cx) -> Result { + let slug = path_param::(cx); + let product = store(cx).product(slug).await.map_err(store_error)?; + let cart = store(cx).cart(cart_id(cx)).await.map_err(store_error)?; + let csrf = csrf_token(cx); + + view! { +
+ store_header(cart_count: cart.item_count()) +
+ +
+

"EQUIPMENT DOSSIER"

+

(product.name.as_str())

+

(product.summary.as_str())

+

(product.description.as_str())

+
+
"Price"
(product.price())
+
"Mass"
(product.weight_grams)" g"
+
"Stock"
(product.inventory.max(0))" units"
+
"Status"
(availability(&product).label())
+
+
+ + + match availability(&product).can_purchase() { + true => , + false => , + } + "Return to quartermaster" +
+
+
+
+ } +} + +#[route(POST "/cart/items")] +pub async fn add_item(cx: &Cx, Form(input): Form) -> Result { + verify_csrf(cx, &input.csrf_token)?; + store(cx) + .add_item(cart_id(cx), input.product_id) + .await + .map_err(store_error)?; + Ok(see_other("/cart")) +} + +#[page("/cart")] +pub async fn cart_page(cx: &Cx) -> Result { + let cart = store(cx).cart(cart_id(cx)).await.map_err(store_error)?; + let csrf = csrf_token(cx); + + view! { +
+ store_header(cart_count: cart.item_count()) + commerce_intro( + kicker: "LOADOUT / GUEST CART".to_owned(), + title: "Check every asset before extraction.".to_owned(), + copy: "Your loadout is held in an encrypted browser cookie. Sign-in stays optional for public browsing and guest checkout.".to_owned() + ) + match cart.lines.is_empty() { + true =>
+

"NO ASSETS ASSIGNED"

+

"Your loadout is empty."

+

"Return to the quartermaster and select the equipment the mission requires."

+ "Open field gear" +
, + false =>
+
+ for line in cart.lines.iter() { + cart_line_item( + item: CartLineItem { + image_src: line.product.image_url.clone(), + image_alt: format!("{} product staging placeholder", line.product.name), + title: line.product.name.clone(), + variant: Some(format!("{} · {}", line.product.codename, line.product.sku)), + unit_price: line.product.price(), + line_total: format_usd(line.line_total_cents()), + quantity: line.quantity, + max_quantity: u32::try_from(line.product.inventory.max(1)).unwrap_or(1), + }, + action: "/cart/items/update", + id_prefix: format!("line-{}", line.product.id), + attrs: attributes! { class="cf-cart-line" }, + + + ) + } +
+ +
, + } +
+ } +} + +#[route(POST "/cart/items/update")] +pub async fn update_item(cx: &Cx, Form(input): Form) -> Result { + verify_csrf(cx, &input.csrf_token)?; + match input.intent.as_str() { + "update" => { + store(cx) + .update_item(cart_id(cx), input.product_id, input.quantity.unwrap_or(1)) + .await + .map_err(store_error)?; + } + "remove" => { + store(cx) + .remove_item(cart_id(cx), input.product_id) + .await + .map_err(store_error)?; + } + _ => return Err(bad_request("unknown cart action").into()), + } + Ok(see_other("/cart")) +} + +#[page("/checkout")] +pub async fn checkout(cx: &Cx) -> Result { + let cart = store(cx).cart(cart_id(cx)).await.map_err(store_error)?; + if cart.lines.is_empty() { + return Err(bad_request("add an item before checkout").into()); + } + let address = read_address(cx).unwrap_or_else(|| ShippingAddress { + country: "US".to_owned(), + ..ShippingAddress::default() + }); + let quote = CommerceStore::quote(&cart); + let csrf = csrf_token(cx); + + view! { +
+ store_header(cart_count: cart.item_count()) + commerce_intro( + kicker: "CHECKOUT / 01 ADDRESS".to_owned(), + title: "Name the extraction point.".to_owned(), + copy: "US shipping only in this staging exercise. Rates and tax are deterministic fixtures so the workflow can be reviewed without spending money.".to_owned() + ) +
+
+

"DELIVERY COORDINATES"

+

"Shipping address"

+ shipping_address_form( + action: "/checkout/address", + address: block_address(&address), + errors: Vec::new(), + id_prefix: "checkout", + submit_label: "Review payment routes", + attrs: attributes! { class="cf-address-form" }, + + ) +
+ +
+
+ } +} + +#[route(POST "/checkout/address")] +pub async fn save_address(cx: &Cx, Form(input): Form) -> Result { + verify_csrf(cx, &input.csrf_token)?; + let address = ShippingAddress { + name: input.name, + email: input.email, + phone: input.phone, + line1: input.line1, + line2: input.line2, + city: input.city, + region: input.region, + postal_code: input.postal_code, + country: input.country, + }; + let errors = address.validate_us(); + if let Some((_, message)) = errors.first() { + return Err(bad_request(message.clone()).into()); + } + write_address(cx, &address)?; + Ok(see_other("/checkout/payment")) +} + +#[page("/checkout/payment")] +pub async fn payment_routes(cx: &Cx) -> Result { + let cart = store(cx).cart(cart_id(cx)).await.map_err(store_error)?; + if cart.lines.is_empty() { + return Err(bad_request("add an item before checkout").into()); + } + let address = read_address(cx) + .ok_or_else(|| bad_request("enter a shipping address before choosing payment"))?; + let quote = CommerceStore::quote(&cart); + let routes = store(cx) + .payment_routes() + .into_iter() + .enumerate() + .map(|(index, route)| PaymentRouteOption { + id: route.id, + network: format!("{} · {}", route.network, route.environment), + asset: route.asset, + amount: format_usdc(quote.total_usdc_micros()), + fee: "Network fee shown by wallet".to_owned(), + timing: "Staging simulation".to_owned(), + available: route.available, + selected: index == 0, + unavailable_reason: (!route.available).then(|| "Disabled by host policy".to_owned()), + }) + .collect::>(); + let csrf = csrf_token(cx); + + view! { +
+ store_header(cart_count: cart.item_count()) + commerce_intro( + kicker: "CHECKOUT / 02 PAYMENT ROUTE".to_owned(), + title: "Choose the channel.".to_owned(), + copy: "Every route is a public test network. Reown wallet connection and direct USDC verification stay behind staging credentials; this review build never requests a real transfer.".to_owned() + ) +
+
+

"TESTNET CHANNELS"

+

"Direct USDC route"

+ payment_route_selector( + action: "/payments/intents", + routes: routes, + attrs: attributes! { class="cf-payment-routes" }, + + ) +
+ +
+
+ } +} + +#[route(POST "/payments/intents")] +pub async fn create_payment(cx: &Cx, Form(input): Form) -> Result { + verify_csrf(cx, &input.csrf_token)?; + let address = read_address(cx) + .ok_or_else(|| bad_request("enter a shipping address before choosing payment"))?; + let order = store(cx) + .create_order(cart_id(cx), address, &input.route_id) + .await + .map_err(store_error)?; + Ok(see_other(&format!("/orders/{}/payment", order.public_id))) +} + +#[page("/orders/{order_id}/payment")] +pub async fn order_payment(cx: &Cx) -> Result { + let order_id = path_param::(cx); + let order = store(cx).order(order_id).await.map_err(store_error)?; + let csrf = csrf_token(cx); + let status = BlockPaymentStatus { + state: payment_state(order.payment.state), + title: "Awaiting testnet transfer".to_owned(), + message: "This is a review-safe payment intent. Connect-wallet wiring is credential-gated; use the staging simulator below to exercise verification, replay protection, and fulfillment handoff without real value.".to_owned(), + amount: format_usdc(order.payment.amount_usdc_micros), + network: format!( + "{} · {}", + order.payment.route.network, order.payment.route.environment + ), + recipient: Some(order.payment.recipient.clone()), + transaction_href: None, + transaction_label: None, + recovery_href: Some(format!("/orders/{}", order.public_id)), + }; + + view! { +
+ store_header(cart_count: 0) + commerce_intro( + kicker: format!("PAYMENT INTENT / {}", order.payment.public_id), + title: "Transmit on the test channel.".to_owned(), + copy: "Mainnet routes are disabled. The server is the authority for amount, recipient, transaction uniqueness, and final order state.".to_owned() + ) +
+ payment_status( + status: status, + attrs: attributes! { class="cf-payment-status" } + ) + match order.state { + OrderState::AwaitingPayment =>
+
+

"REOWN APPKIT / TESTNET ONLY"

+

"Connect a wallet"

+

+ "Opens Solana Devnet, Ethereum Sepolia, and Optimism Sepolia. Connection never confirms an order; the server still verifies the route and transaction." +

+ +
+
+ +

"STAGING CONTROL"

+

"Simulate verified USDC"

+

+ "Creates a unique test transaction marker, commits the inventory reservation, and moves the order to fulfillment review." +

+ +
+
, + _ => + "Open order receipt" + , + } +
+
+ } +} + +#[route(POST "/orders/{order_id}/simulate-payment")] +pub async fn simulate_payment(cx: &Cx, Form(input): Form) -> Result { + verify_csrf(cx, &input.csrf_token)?; + if store(cx).environment() == "production" { + return Err(bad_request("payment simulation is disabled in production").into()); + } + let order_id = path_param::(cx); + let transaction_hash = format!("test_{}", Uuid::new_v4().simple()); + store(cx) + .simulate_payment(order_id, &transaction_hash) + .await + .map_err(store_error)?; + Ok(see_other(&format!("/orders/{order_id}"))) +} + +#[page("/orders/{order_id}")] +pub async fn order_page(cx: &Cx) -> Result { + let order_id = path_param::(cx); + let order = store(cx).order(order_id).await.map_err(store_error)?; + let receipt = OrderReceipt { + order_number: order.public_id.clone(), + status: order.state.label().to_owned(), + placed_at: order + .created_at + .format("%B %-d, %Y · %H:%M UTC") + .to_string(), + lines: order + .cart + .lines + .iter() + .map(|line| ReceiptLine { + title: line.product.name.clone(), + quantity: line.quantity, + total: format_usd(line.line_total_cents()), + }) + .collect(), + total: format_usd(order.quote.total_cents), + shipping_address: order.address.lines(), + tracking_label: order + .tracking_number + .as_ref() + .map(|number| format!("Track fixture {number}")), + tracking_href: order.tracking_href.clone(), + continue_href: "/shop".to_owned(), + }; + + view! { +
+ store_header(cart_count: 0) + commerce_intro( + kicker: "ORDER SIGNAL".to_owned(), + title: "The transmission was received.".to_owned(), + copy: "The receipt is immutable host data rendered through the shared Topcoat block. Fulfillment remains operator-approved.".to_owned() + ) +
+ order_receipt( + receipt: receipt, + attrs: attributes! { class="cf-order-receipt" } + ) + match order.state { + OrderState::AwaitingPayment => + "Resume test payment" + , + _ => {}, + } +
+
+ } +} + +#[page("/ops/orders")] +pub async fn operations(cx: &Cx) -> Result { + if store(cx).environment() == "production" { + return Err(not_found().into()); + } + let orders = store(cx).orders().await.map_err(store_error)?; + let csrf = csrf_token(cx); + + view! { +
+ store_header(cart_count: 0) + commerce_intro( + kicker: "OPS / STAGING CONSOLE".to_owned(), + title: "Approve the handoff.".to_owned(), + copy: "This simulator is non-production by construction. Every label is a USPS test-environment fixture and requires an explicit operator action.".to_owned() + ) +
+
+

"FULFILLMENT QUEUE"

+

"Orders awaiting action"

+
+ match orders.is_empty() { + true =>

"No orders have entered the queue."

, + false =>
+ for order in orders { +
+
+ (order.public_id.as_str()) + (order.state.label()) +
+
+
"Value"
(format_usd(order.quote.total_cents))
+
"Route"
(order.payment.route.network.as_str())
+
"Recipient"
(order.address.name.as_str())
+
+
+ "Inspect" + match order.state { + OrderState::FulfillmentReview =>
+ + +
, + _ => {}, + } +
+
+ } +
, + } +
+
+ } +} + +#[route(POST "/ops/orders/{order_id}/approve-label")] +pub async fn approve_label(cx: &Cx, Form(input): Form) -> Result { + verify_csrf(cx, &input.csrf_token)?; + if store(cx).environment() == "production" { + return Err(not_found().into()); + } + let order_id = path_param::(cx); + store(cx) + .approve_label(order_id) + .await + .map_err(store_error)?; + Ok(see_other("/ops/orders")) +} diff --git a/apps/coldfront/src/domain.rs b/apps/coldfront/src/domain.rs new file mode 100644 index 0000000..4f64007 --- /dev/null +++ b/apps/coldfront/src/domain.rs @@ -0,0 +1,404 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Product { + pub id: Uuid, + pub slug: String, + pub sku: String, + pub category: String, + pub name: String, + pub codename: String, + pub summary: String, + pub description: String, + pub image_url: Option, + pub price_cents: i64, + pub inventory: i32, + pub weight_grams: i32, + pub length_mm: i32, + pub width_mm: i32, + pub height_mm: i32, + pub active: bool, +} + +impl Product { + #[must_use] + pub fn staging_fixtures() -> Vec { + vec![ + Self { + id: Uuid::from_u128(0xcf00_0000_0000_0000_0000_0000_0000_0001), + slug: "cipher-field-jacket".to_owned(), + sku: "CF-JKT-01".to_owned(), + category: "Apparel".to_owned(), + name: "Cipher Field Jacket".to_owned(), + codename: "BLACK ICE / 01".to_owned(), + summary: "Low-profile shell for wet streets, cold platforms, and fast exits." + .to_owned(), + description: "A fictional staging product: weather shell, concealed document pocket, and a cut that stays quiet under movement. No real inventory is offered from this environment.".to_owned(), + image_url: None, + price_cents: 18_400, + inventory: 7, + weight_grams: 980, + length_mm: 420, + width_mm: 310, + height_mm: 90, + active: true, + }, + Self { + id: Uuid::from_u128(0xcf00_0000_0000_0000_0000_0000_0000_0002), + slug: "dead-drop-sling".to_owned(), + sku: "CF-BAG-04".to_owned(), + category: "Carry".to_owned(), + name: "Dead Drop Sling".to_owned(), + codename: "COURIER / 04".to_owned(), + summary: "Compact cross-body carry for documents, radio, and one clean passport." + .to_owned(), + description: "A fictional staging product with an ambidextrous strap, internal dividers, and a deliberately anonymous profile.".to_owned(), + image_url: None, + price_cents: 12_800, + inventory: 3, + weight_grams: 620, + length_mm: 330, + width_mm: 220, + height_mm: 120, + active: true, + }, + Self { + id: Uuid::from_u128(0xcf00_0000_0000_0000_0000_0000_0000_0003), + slug: "one-time-pad-notebook".to_owned(), + sku: "CF-NBK-07".to_owned(), + category: "Desk".to_owned(), + name: "One-Time Pad Notebook".to_owned(), + codename: "BURN NOTE / 07".to_owned(), + summary: "Numbered field pages, hard cover, and no cloud account." + .to_owned(), + description: "A fictional staging notebook for briefing fragments, route codes, and anything that should never become a notification.".to_owned(), + image_url: None, + price_cents: 2_400, + inventory: 18, + weight_grams: 280, + length_mm: 220, + width_mm: 150, + height_mm: 24, + active: true, + }, + Self { + id: Uuid::from_u128(0xcf00_0000_0000_0000_0000_0000_0000_0004), + slug: "signal-watch-cap".to_owned(), + sku: "CF-CAP-09".to_owned(), + category: "Apparel".to_owned(), + name: "Signal Watch Cap".to_owned(), + codename: "NIGHT SHIFT / 09".to_owned(), + summary: "Dense merino watch cap for long surveillance and bad coffee." + .to_owned(), + description: "A fictional staging product with a close fit and no visible branding." + .to_owned(), + image_url: None, + price_cents: 4_800, + inventory: 0, + weight_grams: 140, + length_mm: 240, + width_mm: 190, + height_mm: 40, + active: true, + }, + ] + } + + #[must_use] + pub fn price(&self) -> String { + format_usd(self.price_cents) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct CartLine { + pub product: Product, + pub quantity: u32, +} + +impl CartLine { + #[must_use] + pub fn line_total_cents(&self) -> i64 { + self.product.price_cents * i64::from(self.quantity) + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Cart { + pub id: Uuid, + pub lines: Vec, +} + +impl Cart { + #[must_use] + pub fn subtotal_cents(&self) -> i64 { + self.lines.iter().map(CartLine::line_total_cents).sum() + } + + #[must_use] + pub fn item_count(&self) -> u32 { + self.lines.iter().map(|line| line.quantity).sum() + } + + #[must_use] + pub fn total_weight_grams(&self) -> i64 { + self.lines + .iter() + .map(|line| i64::from(line.product.weight_grams) * i64::from(line.quantity)) + .sum() + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ShippingAddress { + pub name: String, + pub email: String, + pub phone: String, + pub line1: String, + pub line2: String, + pub city: String, + pub region: String, + pub postal_code: String, + pub country: String, +} + +impl ShippingAddress { + #[must_use] + pub fn validate_us(&self) -> Vec<(String, String)> { + let mut errors = Vec::new(); + for (field, value, message) in [ + ("name", self.name.trim(), "Enter the recipient name."), + ("email", self.email.trim(), "Enter an email address."), + ("phone", self.phone.trim(), "Enter a phone number."), + ("line1", self.line1.trim(), "Enter a street address."), + ("city", self.city.trim(), "Enter a city."), + ("region", self.region.trim(), "Enter a state."), + ( + "postal_code", + self.postal_code.trim(), + "Enter a US postal code.", + ), + ] { + if value.is_empty() { + errors.push((field.to_owned(), message.to_owned())); + } + } + if self.country != "US" { + errors.push(( + "country".to_owned(), + "Cold Front staging ships to US addresses only.".to_owned(), + )); + } + let postal_digits = self + .postal_code + .chars() + .filter(char::is_ascii_digit) + .count(); + if !self.postal_code.is_empty() && !matches!(postal_digits, 5 | 9) { + errors.push(( + "postal_code".to_owned(), + "Enter a five- or nine-digit US postal code.".to_owned(), + )); + } + errors + } + + #[must_use] + pub fn lines(&self) -> Vec { + let mut lines = vec![self.name.clone(), self.line1.clone()]; + if !self.line2.trim().is_empty() { + lines.push(self.line2.clone()); + } + lines.push(format!( + "{}, {} {}", + self.city, self.region, self.postal_code + )); + lines.push("United States".to_owned()); + lines + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ShippingQuote { + pub carrier: String, + pub service: String, + pub amount_cents: i64, + pub estimated_days: String, + pub fixture: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct TaxQuote { + pub amount_cents: i64, + pub source: String, + pub fixture: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct CheckoutQuote { + pub subtotal_cents: i64, + pub shipping: ShippingQuote, + pub tax: TaxQuote, + pub total_cents: i64, +} + +impl CheckoutQuote { + #[must_use] + pub fn total_usdc_micros(&self) -> i64 { + self.total_cents * 10_000 + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct PaymentRoute { + pub id: String, + pub network: String, + pub asset: String, + pub environment: String, + pub available: bool, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PaymentState { + AwaitingWallet, + AwaitingTransfer, + Confirming, + Paid, + Expired, + Canceled, + ManualReview, +} + +impl PaymentState { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::AwaitingWallet => "awaiting_wallet", + Self::AwaitingTransfer => "awaiting_transfer", + Self::Confirming => "confirming", + Self::Paid => "paid", + Self::Expired => "expired", + Self::Canceled => "canceled", + Self::ManualReview => "manual_review", + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum OrderState { + AwaitingPayment, + Paid, + FulfillmentReview, + LabelPurchased, + Shipped, + Canceled, + ManualReview, +} + +impl OrderState { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::AwaitingPayment => "awaiting_payment", + Self::Paid => "paid", + Self::FulfillmentReview => "fulfillment_review", + Self::LabelPurchased => "label_purchased", + Self::Shipped => "shipped", + Self::Canceled => "canceled", + Self::ManualReview => "manual_review", + } + } + + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::AwaitingPayment => "Awaiting payment", + Self::Paid => "Paid", + Self::FulfillmentReview => "Fulfillment review", + Self::LabelPurchased => "Label purchased", + Self::Shipped => "Shipped", + Self::Canceled => "Canceled", + Self::ManualReview => "Manual review", + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct PaymentIntent { + pub public_id: String, + pub route: PaymentRoute, + pub amount_usdc_micros: i64, + pub recipient: String, + pub state: PaymentState, + pub transaction_hash: Option, + pub expires_at: DateTime, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Order { + pub public_id: String, + pub cart: Cart, + pub address: ShippingAddress, + pub quote: CheckoutQuote, + pub payment: PaymentIntent, + pub state: OrderState, + pub tracking_number: Option, + pub tracking_href: Option, + pub created_at: DateTime, +} + +#[must_use] +pub fn format_usd(cents: i64) -> String { + format!("${}.{:02}", cents / 100, cents.unsigned_abs() % 100) +} + +#[must_use] +pub fn format_usdc(micros: i64) -> String { + format!( + "{}.{:02} USDC", + micros / 1_000_000, + (micros.unsigned_abs() % 1_000_000) / 10_000 + ) +} + +#[cfg(test)] +mod tests { + use super::{Cart, CartLine, Product, ShippingAddress, format_usd, format_usdc}; + use uuid::Uuid; + + #[test] + fn money_formatting_is_integer_only() { + assert_eq!(format_usd(12_345), "$123.45"); + assert_eq!(format_usdc(123_450_000), "123.45 USDC"); + } + + #[test] + fn cart_totals_fixture_products() { + let product = Product::staging_fixtures().remove(0); + let cart = Cart { + id: Uuid::nil(), + lines: vec![CartLine { + product, + quantity: 2, + }], + }; + assert_eq!(cart.item_count(), 2); + assert_eq!(cart.subtotal_cents(), 36_800); + assert_eq!(cart.total_weight_grams(), 1_960); + } + + #[test] + fn us_address_validation_fails_closed() { + let address = ShippingAddress { + country: "CA".to_owned(), + ..ShippingAddress::default() + }; + let errors = address.validate_us(); + assert!(errors.iter().any(|(field, _)| field == "country")); + assert!(errors.iter().any(|(field, _)| field == "line1")); + } +} diff --git a/apps/coldfront/src/main.rs b/apps/coldfront/src/main.rs index 4364329..c2a1a68 100644 --- a/apps/coldfront/src/main.rs +++ b/apps/coldfront/src/main.rs @@ -1,30 +1,60 @@ -use coldfront_ui::{AUTH_SCRIPT, STYLES, organisms::landing_shell}; +mod commerce; +mod domain; +mod store; + +use coldfront_ui::{ + AUTH_SCRIPT, COMPONENT_STYLES, STYLES, WALLET_SCRIPT, organisms::landing_shell, +}; use topcoat::{ Result, asset::{AssetBundle, RouterBuilderAssetExt}, - router::{Router, Slot, layout, page, route}, + context::{Cx, app_context}, + cookie::{Key, RouterBuilderCookieExt}, + router::{Router, RouterBuilderDiscoverExt, Slot, layout, page, route}, view::view, }; +use store::CommerceStore; + #[tokio::main] async fn main() { + let store = CommerceStore::connect() + .await + .expect("commerce store should initialize"); + let cookie_key = cookie_key(store.environment()); + let router = Router::builder() - .layout(root_layout) - .page(home) - .route(health) + .discover() .assets(AssetBundle::load().unwrap()) + .app_context(store) + .app_context(cookie_key) + .cookies() .build(); topcoat::start(router).await.unwrap(); } +fn cookie_key(environment: &str) -> Key { + match std::env::var("COOKIE_KEY") + .ok() + .filter(|value| value.len() >= 32) + { + Some(secret) => Key::derive_from(secret.as_bytes()), + None if environment == "production" => { + panic!("COOKIE_KEY with at least 32 characters is required in production") + } + None => Key::derive_from(b"coldfront-local-cookie-key-change-before-deploy"), + } +} + #[layout("/")] async fn root_layout(slot: Slot<'_>) -> Result { let clerk_publishable_key = std::env::var("CLERK_PUBLISHABLE_KEY").unwrap_or_default(); + let reown_project_id = std::env::var("REOWN_PROJECT_ID").unwrap_or_default(); view! { - + @@ -37,7 +67,12 @@ async fn root_layout(slot: Slot<'_>) -> Result { name="clerk-publishable-key" content=(clerk_publishable_key) > + topcoat::dev::script() + @@ -51,6 +86,7 @@ async fn root_layout(slot: Slot<'_>) -> Result { (slot.await?) + } @@ -62,6 +98,10 @@ async fn home() -> Result { } #[route(GET "/health")] -async fn health() -> Result<&'static str> { +async fn health(cx: &Cx) -> Result<&'static str> { + app_context::(cx) + .health() + .await + .map_err(topcoat::router::internal_server_error)?; Ok("coldfront:ok") } diff --git a/apps/coldfront/src/store.rs b/apps/coldfront/src/store.rs new file mode 100644 index 0000000..ce852ca --- /dev/null +++ b/apps/coldfront/src/store.rs @@ -0,0 +1,1049 @@ +use std::{ + collections::{HashMap, HashSet}, + env, + sync::Arc, +}; + +use chrono::{Duration, Utc}; +use serde_json::json; +use sqlx::{ + PgPool, Row, + migrate::MigrateError, + postgres::{PgPoolOptions, PgRow}, + types::Json, +}; +use tokio::sync::RwLock; +use uuid::Uuid; + +use crate::domain::{ + Cart, CartLine, CheckoutQuote, Order, OrderState, PaymentIntent, PaymentRoute, PaymentState, + Product, ShippingAddress, ShippingQuote, TaxQuote, +}; + +#[derive(Debug)] +pub enum StoreError { + Database(sqlx::Error), + Migration(MigrateError), + MissingDatabase, + NotFound(&'static str), + SoldOut, + EmptyCart, + CheckoutDisabled, + DuplicateTransaction, + InvalidState, + InvalidRoute, +} + +impl std::fmt::Display for StoreError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Database(error) => write!(formatter, "database error: {error}"), + Self::Migration(error) => write!(formatter, "database migration error: {error}"), + Self::MissingDatabase => { + formatter.write_str("DATABASE_URL is required outside local development") + } + Self::NotFound(kind) => write!(formatter, "{kind} not found"), + Self::SoldOut => formatter.write_str("requested inventory is unavailable"), + Self::EmptyCart => formatter.write_str("the cart is empty"), + Self::CheckoutDisabled => { + formatter.write_str("live checkout is disabled until tax and risk providers exist") + } + Self::DuplicateTransaction => { + formatter.write_str("the transaction hash is already attached to a payment") + } + Self::InvalidState => formatter.write_str("the requested transition is not allowed"), + Self::InvalidRoute => formatter.write_str("the selected payment route is unavailable"), + } + } +} + +impl std::error::Error for StoreError {} + +impl From for StoreError { + fn from(value: sqlx::Error) -> Self { + Self::Database(value) + } +} + +impl From for StoreError { + fn from(value: MigrateError) -> Self { + Self::Migration(value) + } +} + +#[derive(Clone)] +pub struct CommerceStore { + environment: String, + backend: Backend, +} + +#[derive(Clone)] +enum Backend { + Memory(Arc>), + Postgres(PgPool), +} + +#[derive(Default)] +struct MemoryState { + products: Vec, + carts: HashMap>, + orders: HashMap, + transaction_hashes: HashSet, +} + +impl CommerceStore { + pub async fn connect() -> Result { + let environment = env::var("APP_ENV").unwrap_or_else(|_| "local".to_owned()); + let database_url = env::var("DATABASE_URL").ok().filter(|url| !url.is_empty()); + + match database_url { + Some(database_url) => { + let pool = PgPoolOptions::new() + .max_connections(8) + .connect(&database_url) + .await?; + sqlx::migrate!("../../migrations").run(&pool).await?; + let store = Self { + environment, + backend: Backend::Postgres(pool), + }; + store.seed_staging_if_empty().await?; + Ok(store) + } + None if environment == "production" => Err(StoreError::MissingDatabase), + None => Ok(Self { + environment, + backend: Backend::Memory(Arc::new(RwLock::new(MemoryState { + products: Product::staging_fixtures(), + ..MemoryState::default() + }))), + }), + } + } + + #[cfg(test)] + #[must_use] + pub fn memory() -> Self { + Self { + environment: "test".to_owned(), + backend: Backend::Memory(Arc::new(RwLock::new(MemoryState { + products: Product::staging_fixtures(), + ..MemoryState::default() + }))), + } + } + + #[must_use] + pub fn environment(&self) -> &str { + &self.environment + } + + #[must_use] + pub fn checkout_enabled(&self) -> bool { + self.environment != "production" + } + + #[must_use] + pub fn backend_label(&self) -> &'static str { + match self.backend { + Backend::Memory(_) => "memory-fixture", + Backend::Postgres(_) => "postgres", + } + } + + async fn seed_staging_if_empty(&self) -> Result<(), StoreError> { + if self.environment != "staging" { + return Ok(()); + } + let Backend::Postgres(pool) = &self.backend else { + return Ok(()); + }; + + for product in Product::staging_fixtures() { + sqlx::query( + r" + INSERT INTO products ( + id, slug, sku, category, name, codename, summary, description, + image_url, price_cents, weight_grams, length_mm, width_mm, + height_mm, active + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 + ) + ON CONFLICT (id) DO NOTHING + ", + ) + .bind(product.id) + .bind(&product.slug) + .bind(&product.sku) + .bind(&product.category) + .bind(&product.name) + .bind(&product.codename) + .bind(&product.summary) + .bind(&product.description) + .bind(&product.image_url) + .bind(product.price_cents) + .bind(product.weight_grams) + .bind(product.length_mm) + .bind(product.width_mm) + .bind(product.height_mm) + .bind(product.active) + .execute(pool) + .await?; + + sqlx::query( + r" + INSERT INTO inventory (product_id, available, reserved) + VALUES ($1, $2, 0) + ON CONFLICT (product_id) DO NOTHING + ", + ) + .bind(product.id) + .bind(product.inventory) + .execute(pool) + .await?; + } + + Ok(()) + } + + pub async fn products( + &self, + query: &str, + category: &str, + sort: &str, + ) -> Result, StoreError> { + if self.environment == "production" { + return Ok(Vec::new()); + } + let mut products = match &self.backend { + Backend::Memory(state) => state.read().await.products.clone(), + Backend::Postgres(pool) => { + let rows = sqlx::query( + r" + SELECT p.*, COALESCE(i.available, 0) AS inventory + FROM products p + LEFT JOIN inventory i ON i.product_id = p.id + WHERE p.active = TRUE + ", + ) + .fetch_all(pool) + .await?; + rows.iter() + .map(product_from_row) + .collect::, _>>()? + } + }; + + let normalized_query = query.trim().to_lowercase(); + let normalized_category = category.trim().to_lowercase(); + products.retain(|product| { + let matches_query = normalized_query.is_empty() + || [ + product.name.as_str(), + product.codename.as_str(), + product.summary.as_str(), + product.sku.as_str(), + ] + .iter() + .any(|value| value.to_lowercase().contains(&normalized_query)); + let matches_category = normalized_category.is_empty() + || product.category.to_lowercase() == normalized_category; + matches_query && matches_category && product.active + }); + + match sort { + "price-asc" => products.sort_by_key(|product| product.price_cents), + "price-desc" => { + products.sort_by_key(|product| std::cmp::Reverse(product.price_cents)); + } + "name" => products.sort_by(|left, right| left.name.cmp(&right.name)), + _ => products.sort_by(|left, right| left.sku.cmp(&right.sku)), + } + Ok(products) + } + + pub async fn product(&self, slug: &str) -> Result { + self.products("", "", "") + .await? + .into_iter() + .find(|product| product.slug == slug) + .ok_or(StoreError::NotFound("product")) + } + + pub async fn cart(&self, cart_id: Uuid) -> Result { + let lines = match &self.backend { + Backend::Memory(state) => { + let state = state.read().await; + let quantities = state.carts.get(&cart_id).cloned().unwrap_or_default(); + state + .products + .iter() + .filter_map(|product| { + quantities.get(&product.id).map(|quantity| CartLine { + product: product.clone(), + quantity: *quantity, + }) + }) + .collect() + } + Backend::Postgres(pool) => { + let rows = sqlx::query( + r" + SELECT p.*, i.available AS inventory, ci.quantity + FROM cart_items ci + JOIN products p ON p.id = ci.product_id + JOIN inventory i ON i.product_id = p.id + WHERE ci.cart_id = $1 + ORDER BY p.sku + ", + ) + .bind(cart_id) + .fetch_all(pool) + .await?; + rows.iter() + .map(|row| { + Ok(CartLine { + product: product_from_row(row)?, + quantity: u32::try_from(row.try_get::("quantity")?) + .map_err(|_| StoreError::InvalidState)?, + }) + }) + .collect::, StoreError>>()? + } + }; + Ok(Cart { id: cart_id, lines }) + } + + pub async fn add_item(&self, cart_id: Uuid, product_id: Uuid) -> Result<(), StoreError> { + if !self.checkout_enabled() { + return Err(StoreError::CheckoutDisabled); + } + match &self.backend { + Backend::Memory(state) => { + let mut state = state.write().await; + let available = state + .products + .iter() + .find(|product| product.id == product_id && product.active) + .map(|product| product.inventory) + .ok_or(StoreError::NotFound("product"))?; + if available <= 0 { + return Err(StoreError::SoldOut); + } + let quantity = state + .carts + .entry(cart_id) + .or_default() + .entry(product_id) + .or_insert(0); + *quantity = (*quantity + 1).min(u32::try_from(available).unwrap_or(0)); + } + Backend::Postgres(pool) => { + let mut transaction = pool.begin().await?; + sqlx::query("INSERT INTO carts (id) VALUES ($1) ON CONFLICT (id) DO NOTHING") + .bind(cart_id) + .execute(&mut *transaction) + .await?; + let available = sqlx::query_scalar::<_, i32>( + r" + SELECT i.available + FROM inventory i + JOIN products p ON p.id = i.product_id + WHERE i.product_id = $1 AND p.active = TRUE + FOR UPDATE + ", + ) + .bind(product_id) + .fetch_optional(&mut *transaction) + .await? + .ok_or(StoreError::NotFound("product"))?; + if available <= 0 { + return Err(StoreError::SoldOut); + } + sqlx::query( + r" + INSERT INTO cart_items (cart_id, product_id, quantity) + VALUES ($1, $2, 1) + ON CONFLICT (cart_id, product_id) + DO UPDATE SET quantity = LEAST(cart_items.quantity + 1, $3) + ", + ) + .bind(cart_id) + .bind(product_id) + .bind(available) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + } + } + Ok(()) + } + + pub async fn update_item( + &self, + cart_id: Uuid, + product_id: Uuid, + quantity: u32, + ) -> Result<(), StoreError> { + if quantity == 0 { + return self.remove_item(cart_id, product_id).await; + } + match &self.backend { + Backend::Memory(state) => { + let mut state = state.write().await; + let available = state + .products + .iter() + .find(|product| product.id == product_id) + .map(|product| product.inventory) + .ok_or(StoreError::NotFound("product"))?; + if quantity > u32::try_from(available).unwrap_or(0) { + return Err(StoreError::SoldOut); + } + state + .carts + .entry(cart_id) + .or_default() + .insert(product_id, quantity); + } + Backend::Postgres(pool) => { + let available = sqlx::query_scalar::<_, i32>( + "SELECT available FROM inventory WHERE product_id = $1", + ) + .bind(product_id) + .fetch_optional(pool) + .await? + .ok_or(StoreError::NotFound("product"))?; + if quantity > u32::try_from(available).unwrap_or(0) { + return Err(StoreError::SoldOut); + } + sqlx::query( + "UPDATE cart_items SET quantity = $3 WHERE cart_id = $1 AND product_id = $2", + ) + .bind(cart_id) + .bind(product_id) + .bind(i32::try_from(quantity).map_err(|_| StoreError::SoldOut)?) + .execute(pool) + .await?; + } + } + Ok(()) + } + + pub async fn remove_item(&self, cart_id: Uuid, product_id: Uuid) -> Result<(), StoreError> { + match &self.backend { + Backend::Memory(state) => { + if let Some(cart) = state.write().await.carts.get_mut(&cart_id) { + cart.remove(&product_id); + } + } + Backend::Postgres(pool) => { + sqlx::query("DELETE FROM cart_items WHERE cart_id = $1 AND product_id = $2") + .bind(cart_id) + .bind(product_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + + #[must_use] + pub fn payment_routes(&self) -> Vec { + [ + ("solana-devnet", "Solana", "Devnet"), + ("ethereum-sepolia", "Ethereum", "Sepolia"), + ("optimism-sepolia", "Optimism", "OP Sepolia"), + ] + .into_iter() + .map(|(id, network, environment)| PaymentRoute { + id: id.to_owned(), + network: network.to_owned(), + asset: "USDC".to_owned(), + environment: environment.to_owned(), + available: self.checkout_enabled(), + }) + .collect() + } + + #[must_use] + pub fn quote(cart: &Cart) -> CheckoutQuote { + let subtotal_cents = cart.subtotal_cents(); + let shipping_cents = if subtotal_cents >= 30_000 { + 0 + } else { + 700 + cart.total_weight_grams().div_euclid(100).clamp(0, 1_500) + }; + let taxable_cents = subtotal_cents + shipping_cents; + let tax_cents = taxable_cents * 725 / 10_000; + CheckoutQuote { + subtotal_cents, + shipping: ShippingQuote { + carrier: "USPS".to_owned(), + service: "Ground Advantage / staging fixture".to_owned(), + amount_cents: shipping_cents, + estimated_days: "3–5 business days".to_owned(), + fixture: true, + }, + tax: TaxQuote { + amount_cents: tax_cents, + source: "Provider-neutral staging fixture / 7.25%".to_owned(), + fixture: true, + }, + total_cents: taxable_cents + tax_cents, + } + } + + // Keeping the reservation, quote, intent, and audit writes together makes + // the atomic checkout boundary easier to review than splitting the + // transaction across helpers. + #[allow(clippy::too_many_lines)] + pub async fn create_order( + &self, + cart_id: Uuid, + address: ShippingAddress, + route_id: &str, + ) -> Result { + if !self.checkout_enabled() { + return Err(StoreError::CheckoutDisabled); + } + let cart = self.cart(cart_id).await?; + if cart.lines.is_empty() { + return Err(StoreError::EmptyCart); + } + let route = self + .payment_routes() + .into_iter() + .find(|route| route.id == route_id && route.available) + .ok_or(StoreError::InvalidRoute)?; + let quote = Self::quote(&cart); + let order_uuid = Uuid::new_v4(); + let payment_uuid = Uuid::new_v4(); + let order_public_id = public_id("CF"); + let payment_public_id = public_id("PAY"); + let recipient = format!( + "STAGING_{}_TREASURY_NOT_FOR_VALUE", + route.network.to_uppercase() + ); + let expires_at = Utc::now() + Duration::minutes(30); + let order = Order { + public_id: order_public_id.clone(), + cart: cart.clone(), + address, + quote, + payment: PaymentIntent { + public_id: payment_public_id.clone(), + route: route.clone(), + amount_usdc_micros: Self::quote(&cart).total_usdc_micros(), + recipient: recipient.clone(), + state: PaymentState::AwaitingWallet, + transaction_hash: None, + expires_at, + }, + state: OrderState::AwaitingPayment, + tracking_number: None, + tracking_href: None, + created_at: Utc::now(), + }; + + match &self.backend { + Backend::Memory(state) => { + let mut state = state.write().await; + for line in &cart.lines { + let product = state + .products + .iter_mut() + .find(|product| product.id == line.product.id) + .ok_or(StoreError::NotFound("product"))?; + let requested = + i32::try_from(line.quantity).map_err(|_| StoreError::SoldOut)?; + if product.inventory < requested { + return Err(StoreError::SoldOut); + } + product.inventory -= requested; + } + state.carts.remove(&cart_id); + state.orders.insert(order_public_id, order.clone()); + } + Backend::Postgres(pool) => { + let mut transaction = pool.begin().await?; + sqlx::query( + r" + INSERT INTO orders (id, public_id, cart_id, state, payload) + VALUES ($1, $2, $3, $4, $5) + ", + ) + .bind(order_uuid) + .bind(&order.public_id) + .bind(cart_id) + .bind(order.state.as_str()) + .bind(Json(order.clone())) + .execute(&mut *transaction) + .await?; + + for line in &cart.lines { + let quantity = i32::try_from(line.quantity).map_err(|_| StoreError::SoldOut)?; + let result = sqlx::query( + r" + UPDATE inventory + SET available = available - $2, + reserved = reserved + $2, + updated_at = NOW() + WHERE product_id = $1 AND available >= $2 + ", + ) + .bind(line.product.id) + .bind(quantity) + .execute(&mut *transaction) + .await?; + if result.rows_affected() != 1 { + return Err(StoreError::SoldOut); + } + sqlx::query( + r" + INSERT INTO inventory_reservations ( + id, order_id, product_id, quantity, state, expires_at + ) + VALUES ($1, $2, $3, $4, 'active', $5) + ", + ) + .bind(Uuid::new_v4()) + .bind(order_uuid) + .bind(line.product.id) + .bind(quantity) + .bind(expires_at) + .execute(&mut *transaction) + .await?; + } + + sqlx::query( + r" + INSERT INTO checkout_quotes ( + id, cart_id, subtotal_cents, shipping_cents, tax_cents, + total_cents, payload, expires_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ", + ) + .bind(Uuid::new_v4()) + .bind(cart_id) + .bind(order.quote.subtotal_cents) + .bind(order.quote.shipping.amount_cents) + .bind(order.quote.tax.amount_cents) + .bind(order.quote.total_cents) + .bind(Json(order.quote.clone())) + .bind(expires_at) + .execute(&mut *transaction) + .await?; + + sqlx::query( + r" + INSERT INTO payment_intents ( + id, public_id, order_id, route_id, network, asset, + amount_atomic, recipient, state, expires_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ", + ) + .bind(payment_uuid) + .bind(&payment_public_id) + .bind(order_uuid) + .bind(&route.id) + .bind(&route.network) + .bind(&route.asset) + .bind(order.payment.amount_usdc_micros) + .bind(&recipient) + .bind(order.payment.state.as_str()) + .bind(expires_at) + .execute(&mut *transaction) + .await?; + + sqlx::query("DELETE FROM cart_items WHERE cart_id = $1") + .bind(cart_id) + .execute(&mut *transaction) + .await?; + insert_audit( + &mut transaction, + "order", + &order.public_id, + "created", + json!({ "route": route.id, "fixture": true }), + ) + .await?; + transaction.commit().await?; + } + } + + Ok(order) + } + + pub async fn order(&self, public_id: &str) -> Result { + match &self.backend { + Backend::Memory(state) => state + .read() + .await + .orders + .get(public_id) + .cloned() + .ok_or(StoreError::NotFound("order")), + Backend::Postgres(pool) => { + let row = sqlx::query("SELECT payload FROM orders WHERE public_id = $1") + .bind(public_id) + .fetch_optional(pool) + .await? + .ok_or(StoreError::NotFound("order"))?; + let Json(order): Json = row.try_get("payload")?; + Ok(order) + } + } + } + + pub async fn orders(&self) -> Result, StoreError> { + let mut orders = match &self.backend { + Backend::Memory(state) => state.read().await.orders.values().cloned().collect(), + Backend::Postgres(pool) => { + let rows = + sqlx::query("SELECT payload FROM orders ORDER BY created_at DESC LIMIT 100") + .fetch_all(pool) + .await?; + rows.into_iter() + .map(|row| { + let Json(order): Json = row.try_get("payload")?; + Ok(order) + }) + .collect::, StoreError>>()? + } + }; + orders.sort_by_key(|order| std::cmp::Reverse(order.created_at)); + Ok(orders) + } + + pub async fn simulate_payment( + &self, + public_id: &str, + transaction_hash: &str, + ) -> Result { + if !transaction_hash.starts_with("test_") { + return Err(StoreError::InvalidState); + } + match &self.backend { + Backend::Memory(state) => { + let mut state = state.write().await; + if state.transaction_hashes.contains(transaction_hash) { + return Err(StoreError::DuplicateTransaction); + } + state.transaction_hashes.insert(transaction_hash.to_owned()); + let order = state + .orders + .get_mut(public_id) + .ok_or(StoreError::NotFound("order"))?; + if order.state != OrderState::AwaitingPayment { + return Err(StoreError::InvalidState); + } + order.payment.state = PaymentState::Paid; + order.payment.transaction_hash = Some(transaction_hash.to_owned()); + order.state = OrderState::FulfillmentReview; + Ok(order.clone()) + } + Backend::Postgres(pool) => { + let mut transaction = pool.begin().await?; + let row = + sqlx::query("SELECT id, payload FROM orders WHERE public_id = $1 FOR UPDATE") + .bind(public_id) + .fetch_optional(&mut *transaction) + .await? + .ok_or(StoreError::NotFound("order"))?; + let order_id: Uuid = row.try_get("id")?; + let Json(mut order): Json = row.try_get("payload")?; + if order.state != OrderState::AwaitingPayment { + return Err(StoreError::InvalidState); + } + let duplicate = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM payment_intents WHERE transaction_hash = $1", + ) + .bind(transaction_hash) + .fetch_one(&mut *transaction) + .await?; + if duplicate > 0 { + return Err(StoreError::DuplicateTransaction); + } + order.payment.state = PaymentState::Paid; + order.payment.transaction_hash = Some(transaction_hash.to_owned()); + order.state = OrderState::FulfillmentReview; + sqlx::query( + r" + UPDATE payment_intents + SET state = 'paid', transaction_hash = $2, updated_at = NOW() + WHERE order_id = $1 + ", + ) + .bind(order_id) + .bind(transaction_hash) + .execute(&mut *transaction) + .await?; + sqlx::query( + "UPDATE orders SET state = $2, payload = $3, updated_at = NOW() WHERE id = $1", + ) + .bind(order_id) + .bind(order.state.as_str()) + .bind(Json(order.clone())) + .execute(&mut *transaction) + .await?; + sqlx::query( + r" + UPDATE inventory_reservations + SET state = 'committed' + WHERE order_id = $1 AND state = 'active' + ", + ) + .bind(order_id) + .execute(&mut *transaction) + .await?; + insert_audit( + &mut transaction, + "payment", + &order.payment.public_id, + "staging_confirmed", + json!({ "transaction_hash": transaction_hash }), + ) + .await?; + transaction.commit().await?; + Ok(order) + } + } + } + + pub async fn approve_label(&self, public_id: &str) -> Result { + match &self.backend { + Backend::Memory(state) => { + let mut state = state.write().await; + let order = state + .orders + .get_mut(public_id) + .ok_or(StoreError::NotFound("order"))?; + approve_fixture_label(order)?; + Ok(order.clone()) + } + Backend::Postgres(pool) => { + let mut transaction = pool.begin().await?; + let row = + sqlx::query("SELECT id, payload FROM orders WHERE public_id = $1 FOR UPDATE") + .bind(public_id) + .fetch_optional(&mut *transaction) + .await? + .ok_or(StoreError::NotFound("order"))?; + let order_id: Uuid = row.try_get("id")?; + let Json(mut order): Json = row.try_get("payload")?; + approve_fixture_label(&mut order)?; + sqlx::query( + "UPDATE orders SET state = $2, payload = $3, updated_at = NOW() WHERE id = $1", + ) + .bind(order_id) + .bind(order.state.as_str()) + .bind(Json(order.clone())) + .execute(&mut *transaction) + .await?; + sqlx::query( + r" + INSERT INTO fulfillments ( + id, order_id, carrier, service, state, tracking_number, + label_reference, payload + ) + VALUES ($1, $2, 'USPS', 'Ground Advantage', 'label_purchased', $3, $4, $5) + ON CONFLICT (order_id) DO NOTHING + ", + ) + .bind(Uuid::new_v4()) + .bind(order_id) + .bind(&order.tracking_number) + .bind(format!("TEM-LABEL-{}", order.public_id)) + .bind(Json(json!({ "fixture": true, "environment": "TEM" }))) + .execute(&mut *transaction) + .await?; + insert_audit( + &mut transaction, + "fulfillment", + &order.public_id, + "fixture_label_approved", + json!({ "carrier": "USPS", "environment": "TEM" }), + ) + .await?; + transaction.commit().await?; + Ok(order) + } + } + } + + pub async fn health(&self) -> Result<(), StoreError> { + if let Backend::Postgres(pool) = &self.backend { + sqlx::query("SELECT 1").execute(pool).await?; + } + Ok(()) + } +} + +fn product_from_row(row: &PgRow) -> Result { + Ok(Product { + id: row.try_get("id")?, + slug: row.try_get("slug")?, + sku: row.try_get("sku")?, + category: row.try_get("category")?, + name: row.try_get("name")?, + codename: row.try_get("codename")?, + summary: row.try_get("summary")?, + description: row.try_get("description")?, + image_url: row.try_get("image_url")?, + price_cents: row.try_get("price_cents")?, + inventory: row.try_get("inventory")?, + weight_grams: row.try_get("weight_grams")?, + length_mm: row.try_get("length_mm")?, + width_mm: row.try_get("width_mm")?, + height_mm: row.try_get("height_mm")?, + active: row.try_get("active")?, + }) +} + +fn public_id(prefix: &str) -> String { + let value = Uuid::new_v4().simple().to_string().to_uppercase(); + format!("{prefix}-{}", &value[..10]) +} + +fn approve_fixture_label(order: &mut Order) -> Result<(), StoreError> { + if order.state != OrderState::FulfillmentReview { + return Err(StoreError::InvalidState); + } + let suffix = order.public_id.replace('-', ""); + let tracking = format!("TEM{}", &suffix[..suffix.len().min(12)]); + order.state = OrderState::LabelPurchased; + order.tracking_number = Some(tracking.clone()); + order.tracking_href = Some(format!( + "https://tools.usps.com/go/TrackConfirmAction?tLabels={tracking}" + )); + Ok(()) +} + +async fn insert_audit( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + aggregate_type: &str, + aggregate_id: &str, + action: &str, + payload: serde_json::Value, +) -> Result<(), StoreError> { + sqlx::query( + r" + INSERT INTO audit_events (id, aggregate_type, aggregate_id, action, payload) + VALUES ($1, $2, $3, $4, $5) + ", + ) + .bind(Uuid::new_v4()) + .bind(aggregate_type) + .bind(aggregate_id) + .bind(action) + .bind(Json(payload)) + .execute(&mut **transaction) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::{Backend, CommerceStore, MemoryState, StoreError}; + use crate::domain::{OrderState, Product, ShippingAddress}; + use tokio::sync::RwLock; + use uuid::Uuid; + + fn address() -> ShippingAddress { + ShippingAddress { + name: "Morgan Field".to_owned(), + email: "morgan@example.com".to_owned(), + phone: "2125550142".to_owned(), + line1: "120 Lafayette Street".to_owned(), + line2: String::new(), + city: "New York".to_owned(), + region: "NY".to_owned(), + postal_code: "10013".to_owned(), + country: "US".to_owned(), + } + } + + #[tokio::test] + async fn staging_checkout_reserves_inventory_and_rejects_replay() { + let store = CommerceStore::memory(); + let cart_id = Uuid::new_v4(); + let product = store.product("dead-drop-sling").await.unwrap(); + store.add_item(cart_id, product.id).await.unwrap(); + let order = store + .create_order(cart_id, address(), "solana-devnet") + .await + .unwrap(); + assert_eq!(order.state, OrderState::AwaitingPayment); + assert!(store.cart(cart_id).await.unwrap().lines.is_empty()); + + let paid = store + .simulate_payment(&order.public_id, "test_signature_001") + .await + .unwrap(); + assert_eq!(paid.state, OrderState::FulfillmentReview); + + let replay = store + .simulate_payment(&order.public_id, "test_signature_001") + .await; + assert!(matches!( + replay, + Err(StoreError::DuplicateTransaction | StoreError::InvalidState) + )); + } + + #[tokio::test] + async fn label_purchase_needs_payment_and_is_single_use() { + let store = CommerceStore::memory(); + let cart_id = Uuid::new_v4(); + let product = store.product("one-time-pad-notebook").await.unwrap(); + store.add_item(cart_id, product.id).await.unwrap(); + let order = store + .create_order(cart_id, address(), "ethereum-sepolia") + .await + .unwrap(); + assert!(matches!( + store.approve_label(&order.public_id).await, + Err(StoreError::InvalidState) + )); + store + .simulate_payment(&order.public_id, "test_hash_label") + .await + .unwrap(); + let labeled = store.approve_label(&order.public_id).await.unwrap(); + assert_eq!(labeled.state, OrderState::LabelPurchased); + assert!(matches!( + store.approve_label(&order.public_id).await, + Err(StoreError::InvalidState) + )); + } + + #[tokio::test] + async fn production_hides_seeded_rows_and_rejects_cart_writes() { + let store = CommerceStore { + environment: "production".to_owned(), + backend: Backend::Memory(Arc::new(RwLock::new(MemoryState { + products: Product::staging_fixtures(), + ..MemoryState::default() + }))), + }; + let product_id = Product::staging_fixtures()[0].id; + + assert!( + store + .products("", "", "field-order") + .await + .expect("catalog lookup") + .is_empty() + ); + assert!(matches!( + store.add_item(Uuid::new_v4(), product_id).await, + Err(StoreError::CheckoutDisabled) + )); + } +} diff --git a/crates/coldfront-ui/Cargo.toml b/crates/coldfront-ui/Cargo.toml index 0a9b585..f234e2a 100644 --- a/crates/coldfront-ui/Cargo.toml +++ b/crates/coldfront-ui/Cargo.toml @@ -7,6 +7,13 @@ rust-version.workspace = true [dependencies] topcoat.workspace = true +topcoat-blocks.workspace = true + +[build-dependencies] +topcoat = { version = "0.4.0", default-features = false, features = [ + "icon-iconify", + "tailwind", +] } [lints] workspace = true diff --git a/crates/coldfront-ui/build.rs b/crates/coldfront-ui/build.rs new file mode 100644 index 0000000..3667b51 --- /dev/null +++ b/crates/coldfront-ui/build.rs @@ -0,0 +1,14 @@ +fn main() { + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=styles.css"); + + topcoat::tailwind::BuildConfig::new() + .input("styles.css") + .render() + .expect("Cold Front component stylesheet builds"); + + topcoat::icon::iconify::BuildConfig::new() + .icon_set("feather") + .stage() + .expect("Cold Front component icons stage"); +} diff --git a/crates/coldfront-ui/components.toml b/crates/coldfront-ui/components.toml new file mode 100644 index 0000000..d908c66 --- /dev/null +++ b/crates/coldfront-ui/components.toml @@ -0,0 +1,69 @@ +# Topcoat UI install state. Managed by `topcoat ui`. +version = 1 +components_dir = "src/components" + +[theme] +name = "neutral" +registry = "topcoat" +hash = "sha256:c7bdcb5ea4ff757611ba616e92178169b262be98155c06dfa2407584d51e7459" +file = "styles.css" + +[registries.topcoat.components.badge] +hash = "sha256:ee86a6d59d019450aad3274da752d9ad490885d58edf434c56a95f4afdaaa95e" +file = "src/components/badge.rs" + +[registries.topcoat.components.button] +hash = "sha256:8dcc9609183d0a51138c7a15de06d5525388f25a3958071fab25b96efaa2b160" +file = "src/components/button.rs" + +[registries.topcoat.components.card] +hash = "sha256:f7baecc0a71b59126d311ac504c36a14fa98d438d18230b807f10f7e976a30b7" +file = "src/components/card.rs" + +[registries.topcoat.components.input] +hash = "sha256:7769330ae511769c1c50b39c75b7475b676af9072d2156398762cf505a3bf306" +file = "src/components/input.rs" + +[registries.topcoat.components.label] +hash = "sha256:cd8e7569717c82abad745f0586f03fbc759e6000988a82c8dce15182a409d0d6" +file = "src/components/label.rs" + +[registries.topcoat.components.progress] +hash = "sha256:4234a867cf267c07e0f31431ff67c530dcfdebe66a4aa2b923be690a8d630262" +file = "src/components/progress.rs" + +[registries.topcoat.components.select] +hash = "sha256:5d6bc5c890c12fad1b004569212b6bb83fda3a1e2bd5da261225845419dfbc85" +file = "src/components/select.rs" + +[registries.topcoat-blocks.components.cart_line_item] +hash = "sha256:81a3de7ea8706a0cfb8f35c08e6dd62379109d521f696725c68f4e82ee2bd065" +file = "src/components/cart_line_item.rs" + +[registries.topcoat-blocks.components.catalog_filter_bar] +hash = "sha256:990de7bf7006f3411888a5990dbca10b85783f166daf371f5ce75b848fb71010" +file = "src/components/catalog_filter_bar.rs" + +[registries.topcoat-blocks.components.order_receipt] +hash = "sha256:a6c1a5d5f66b7ba104f8bc0b4d39b61614d066849ed1f6f406a865aaca2c6f4e" +file = "src/components/order_receipt.rs" + +[registries.topcoat-blocks.components.order_totals] +hash = "sha256:668eaa092724bfcb92d521a21c9ced5fcfb8fca2aeac28d27fb15ac8309181f2" +file = "src/components/order_totals.rs" + +[registries.topcoat-blocks.components.payment_route_selector] +hash = "sha256:5a1071f608986339df78ed4d0d96cba76a4967e6d6ae8cc318aa422ab2c02589" +file = "src/components/payment_route_selector.rs" + +[registries.topcoat-blocks.components.payment_status] +hash = "sha256:a4b99f945c4db9e4669d9c5b6ae495c00db93cdc95257db64dc78c097d67f3d4" +file = "src/components/payment_status.rs" + +[registries.topcoat-blocks.components.product_card] +hash = "sha256:27c3ead6008504a769103aef2657d89626d3b60085eb9c9accac110ac320fc90" +file = "src/components/product_card.rs" + +[registries.topcoat-blocks.components.shipping_address_form] +hash = "sha256:4705242c0f96865efa318349cf2aa8e2070a2f264b26e67c57b223ebc3eff67e" +file = "src/components/shipping_address_form.rs" diff --git a/crates/coldfront-ui/src/coldfront.css b/crates/coldfront-ui/src/coldfront.css index 3e9975f..5d00051 100644 --- a/crates/coldfront-ui/src/coldfront.css +++ b/crates/coldfront-ui/src/coldfront.css @@ -1262,6 +1262,687 @@ select { } } +/* Cold Front owns this commerce skin. The Topcoat blocks above it remain + portable, provider-neutral source installed through the registry. */ +.cf-commerce { + --background: #0b1512; + --foreground: var(--cf-ice); + --muted-foreground: var(--cf-muted); + --primary: var(--cf-signal); + --primary-foreground: var(--cf-ink); + --destructive: var(--cf-warning); + --destructive-foreground: var(--cf-ink); + --border: var(--cf-line); + --ring: var(--cf-signal); + position: relative; + width: min(100%, 1600px); + min-height: 100svh; + margin: 0 auto; + padding: 0 clamp(18px, 3vw, 48px) 96px; +} + +.cf-store-header { + display: flex; + min-height: 88px; + align-items: center; + justify-content: space-between; + gap: 30px; + border-bottom: 1px solid var(--cf-line); +} + +.cf-store-header .cf-wordmark { + display: inline-flex; + align-items: center; + gap: 12px; +} + +.cf-store-header .cf-wordmark > span { + display: grid; + width: 31px; + height: 31px; + place-items: center; + border: 1px solid var(--cf-signal); + color: var(--cf-signal); + font: 800 10px/1 var(--cf-mono); +} + +.cf-store-header nav { + display: flex; + align-items: center; + gap: clamp(16px, 3vw, 42px); + padding-right: 280px; + color: var(--cf-muted); + font: 650 10px/1 var(--cf-mono); + letter-spacing: .08em; + text-transform: uppercase; +} + +.cf-store-header nav a { + transition: color var(--cf-motion-quick) var(--cf-ease-out); +} + +.cf-store-header nav a:hover, +.cf-store-header nav a:focus-visible { + color: var(--cf-signal); +} + +.cf-cart-count { + display: inline-grid; + min-width: 21px; + height: 21px; + margin-left: 5px; + place-items: center; + border: 1px solid var(--cf-line-strong); + color: var(--cf-ice); +} + +.cf-store-hero { + display: grid; + min-height: clamp(360px, 48vw, 660px); + align-content: end; + padding: clamp(70px, 12vw, 170px) 0 clamp(48px, 7vw, 96px); + border-bottom: 1px solid var(--cf-line); + background: + linear-gradient(90deg, transparent 0 68%, rgba(187, 255, 69, .04) 68% 68.15%, transparent 68.15%), + radial-gradient(circle at 79% 42%, rgba(187, 255, 69, .08), transparent 21rem); +} + +.cf-store-hero h1, +.cf-product-copy h1 { + max-width: 13ch; + margin: 14px 0 22px; + color: var(--cf-ice-bright); + font-family: var(--cf-display); + font-size: clamp(54px, 8.2vw, 132px); + font-stretch: condensed; + font-weight: 900; + letter-spacing: -.065em; + line-height: .82; + text-transform: uppercase; +} + +.cf-store-hero > p:last-child, +.cf-product-copy > p, +.cf-section-heading > p:last-child { + max-width: 650px; + margin: 0; + color: var(--cf-muted); + font-size: clamp(15px, 1.3vw, 19px); + line-height: 1.6; +} + +.cf-kicker { + margin: 0; + color: var(--cf-signal); + font: 650 10px/1.4 var(--cf-mono); + letter-spacing: .13em; + text-transform: uppercase; +} + +.cf-catalog, +.cf-ops-table { + padding-top: clamp(48px, 7vw, 96px); +} + +.cf-section-heading { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px 40px; + align-items: end; + margin-bottom: 32px; +} + +.cf-section-heading .cf-kicker { + grid-column: 1 / -1; +} + +.cf-section-heading h2, +.cf-commerce-panel h2, +.cf-empty-state h2, +.cf-simulator h2 { + margin: 0; + color: var(--cf-ice-bright); + font-family: var(--cf-display); + font-size: clamp(34px, 4vw, 64px); + font-weight: 850; + letter-spacing: -.045em; + line-height: .95; + text-transform: uppercase; +} + +.cf-section-heading > p:last-child { + font: 600 9px/1.4 var(--cf-mono); + letter-spacing: .05em; + text-transform: uppercase; +} + +.cf-filter-bar[data-topcoat-block] { + margin-bottom: 26px; + border-color: var(--cf-line); + border-radius: 0; + background: rgba(220, 232, 223, .025); +} + +.cf-commerce [data-topcoat-block] input, +.cf-commerce [data-topcoat-block] select { + min-height: 44px; + border-color: var(--cf-line-strong); + border-radius: 0; + color: var(--cf-ice); + background: var(--cf-ink); +} + +.cf-commerce [data-topcoat-block] input:focus, +.cf-commerce [data-topcoat-block] select:focus { + border-color: var(--cf-signal); + outline: 1px solid var(--cf-signal); + outline-offset: 2px; +} + +.cf-commerce [data-topcoat-block] button, +.cf-inline-action button, +.cf-simulator button, +.cf-ops-actions button { + min-height: 44px; + border: 1px solid var(--cf-signal); + border-radius: 0; + color: var(--cf-ink); + background: var(--cf-signal); + font: 750 10px/1 var(--cf-mono); + letter-spacing: .05em; + text-transform: uppercase; + box-shadow: none; +} + +.cf-commerce [data-topcoat-block] button:hover, +.cf-inline-action button:hover, +.cf-simulator button:hover, +.cf-ops-actions button:hover { + border-color: var(--cf-ice-bright); + background: var(--cf-ice-bright); +} + +.cf-commerce [data-topcoat-block] button:disabled, +.cf-inline-action button:disabled { + border-color: var(--cf-line); + color: var(--cf-muted); + background: transparent; +} + +.cf-product-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1px; + border: 1px solid var(--cf-line); + background: var(--cf-line); +} + +.cf-product-card[data-topcoat-block] { + min-width: 0; + background: var(--cf-ink); +} + +.cf-product-card[data-topcoat-block] > div, +.cf-order-totals[data-topcoat-block] > div, +.cf-payment-status[data-topcoat-block] > div, +.cf-order-receipt[data-topcoat-block] > div, +.cf-payment-routes[data-topcoat-block] > fieldset > div { + border-color: var(--cf-line); + border-radius: 0; + background: var(--cf-ink-soft); + box-shadow: none; +} + +.cf-product-card[data-topcoat-block] > div > a { + position: relative; + background: + repeating-linear-gradient(135deg, transparent 0 22px, rgba(220,232,223,.035) 22px 23px), + radial-gradient(circle at 50% 50%, rgba(187, 255, 69, .08), transparent 9rem), + var(--cf-ink); +} + +.cf-product-card[data-topcoat-block] > div > a::after { + position: absolute; + right: 18px; + bottom: 16px; + content: "IMAGE SIGNAL WITHHELD"; + color: var(--cf-muted); + font: 600 8px/1 var(--cf-mono); + letter-spacing: .08em; +} + +.cf-product-card[data-topcoat-block] h3, +.cf-commerce [data-topcoat-block] h2, +.cf-commerce [data-topcoat-block] h3 { + color: var(--cf-ice-bright); + font-family: var(--cf-display); + font-weight: 800; + letter-spacing: -.025em; + text-transform: uppercase; +} + +.cf-product-card[data-topcoat-block] [data-availability] { + border-radius: 0; + font: 650 8px/1 var(--cf-mono); + letter-spacing: .05em; + text-transform: uppercase; +} + +.cf-product-card[data-state="available"] [data-availability], +.cf-product-card[data-state="low-stock"] [data-availability] { + border-color: rgba(187,255,69,.4); + color: var(--cf-signal); + background: rgba(187,255,69,.06); +} + +.cf-product-card[data-state="sold-out"] { + opacity: .62; +} + +.cf-product-detail { + display: grid; + grid-template-columns: minmax(300px, .8fr) minmax(0, 1.2fr); + min-height: calc(100svh - 88px); + border-bottom: 1px solid var(--cf-line); +} + +.cf-product-mark { + display: grid; + align-content: space-between; + padding: clamp(40px, 5vw, 78px); + border-right: 1px solid var(--cf-line); + background: + linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px), + radial-gradient(circle at center, rgba(187,255,69,.08), transparent 19rem); + background-size: 26px 26px, 26px 26px, auto; +} + +.cf-product-mark span, +.cf-product-mark strong { + font-family: var(--cf-mono); + letter-spacing: .09em; +} + +.cf-product-mark span { + color: var(--cf-muted); + font-size: 10px; +} + +.cf-product-mark strong { + color: var(--cf-signal); + font-size: clamp(24px, 3vw, 48px); + line-height: .95; +} + +.cf-product-copy { + display: flex; + flex-direction: column; + justify-content: center; + padding: clamp(52px, 8vw, 128px); +} + +.cf-product-copy .cf-lede { + color: var(--cf-ice); + font-size: clamp(18px, 2vw, 28px); + line-height: 1.35; +} + +.cf-specs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 40px 0; + border-top: 1px solid var(--cf-line); + border-left: 1px solid var(--cf-line); +} + +.cf-specs > div { + padding: 18px; + border-right: 1px solid var(--cf-line); + border-bottom: 1px solid var(--cf-line); +} + +.cf-specs dt { + color: var(--cf-muted); + font: 600 8px/1 var(--cf-mono); + letter-spacing: .08em; + text-transform: uppercase; +} + +.cf-specs dd { + margin: 9px 0 0; + color: var(--cf-ice-bright); + font-weight: 700; +} + +.cf-inline-action { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.cf-inline-action button, +.cf-inline-action a, +.cf-primary-link { + display: inline-flex; + min-height: 48px; + align-items: center; + padding: 0 18px; +} + +.cf-inline-action a, +.cf-primary-link { + border: 1px solid var(--cf-line-strong); + color: var(--cf-ice); + font: 650 10px/1 var(--cf-mono); + letter-spacing: .05em; + text-transform: uppercase; +} + +.cf-cart-layout, +.cf-checkout-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(310px, 420px); + gap: clamp(34px, 5vw, 80px); + padding-top: clamp(50px, 7vw, 90px); +} + +.cf-cart-lines { + border-top: 1px solid var(--cf-line); +} + +.cf-cart-line[data-topcoat-block] { + border-color: var(--cf-line); +} + +.cf-cart-line[data-topcoat-block] > div { + border-radius: 0; + background: + repeating-linear-gradient(135deg, transparent 0 10px, rgba(220,232,223,.035) 10px 11px), + var(--cf-ink-soft); +} + +.cf-order-totals { + position: sticky; + top: 30px; +} + +.cf-order-totals [class*="rounded"], +.cf-payment-status [class*="rounded"], +.cf-order-receipt [class*="rounded"] { + border-radius: 0; +} + +.cf-order-totals a[class], +.cf-order-receipt a[class] { + border-radius: 0; + background: var(--cf-signal); + color: var(--cf-ink); + font: 750 10px/1 var(--cf-mono); + letter-spacing: .04em; + text-transform: uppercase; +} + +.cf-fixture-note, +.cf-address-summary { + display: grid; + gap: 6px; + margin: 0; + color: var(--cf-muted); + font: 600 9px/1.5 var(--cf-mono); + letter-spacing: .035em; + text-transform: uppercase; +} + +.cf-address-summary a { + margin-top: 9px; + color: var(--cf-signal); +} + +.cf-commerce-panel, +.cf-simulator, +.cf-wallet-connect { + padding: clamp(26px, 4vw, 54px); + border: 1px solid var(--cf-line); + background: rgba(220, 232, 223, .025); +} + +.cf-commerce-panel > h2, +.cf-simulator h2, +.cf-wallet-connect h2 { + margin: 10px 0 34px; + font-size: clamp(30px, 4vw, 56px); +} + +.cf-address-form[data-topcoat-block] { + border: 0; +} + +.cf-payment-layout, +.cf-receipt-layout { + display: grid; + max-width: 860px; + gap: 30px; + margin: 0 auto; + padding-top: clamp(50px, 8vw, 110px); +} + +.cf-payment-controls { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + background: var(--cf-line); +} + +.cf-wallet-connect { + background: var(--cf-ink-soft); +} + +.cf-wallet-connect p:not(.cf-kicker) { + color: var(--cf-muted); + line-height: 1.65; +} + +.cf-wallet-connect button { + min-height: 44px; + padding: 0 16px; + border: 1px solid var(--cf-signal); + color: var(--cf-ink); + background: var(--cf-signal); + font: 750 10px/1 var(--cf-mono); + letter-spacing: .05em; + text-transform: uppercase; +} + +.cf-wallet-connect button:disabled { + border-color: var(--cf-line-strong); + color: var(--cf-muted); + background: transparent; +} + +.cf-simulator { + position: relative; +} + +.cf-simulator::before { + position: absolute; + top: -1px; + right: -1px; + padding: 8px 10px; + content: "NO REAL VALUE"; + border: 1px solid var(--cf-warning); + color: var(--cf-warning); + font: 700 8px/1 var(--cf-mono); + letter-spacing: .08em; +} + +.cf-simulator p:not(.cf-kicker) { + max-width: 60ch; + color: var(--cf-muted); + line-height: 1.65; +} + +.cf-empty-state { + display: grid; + max-width: 780px; + min-height: 520px; + align-content: center; + gap: 22px; +} + +.cf-empty-state p:not(.cf-kicker) { + color: var(--cf-muted); + line-height: 1.6; +} + +.cf-order-list { + border-top: 1px solid var(--cf-line); +} + +.cf-order-row { + display: grid; + grid-template-columns: minmax(180px, .7fr) minmax(0, 1fr) auto; + gap: 30px; + align-items: center; + padding: 24px 0; + border-bottom: 1px solid var(--cf-line); +} + +.cf-order-row > div:first-child { + display: grid; + gap: 8px; +} + +.cf-order-row > div:first-child span { + color: var(--cf-muted); + font: 600 9px/1 var(--cf-mono); +} + +.cf-order-row > div:first-child strong { + color: var(--cf-signal); + font-family: var(--cf-display); + font-size: 23px; + text-transform: uppercase; +} + +.cf-order-row dl { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 20px; + margin: 0; +} + +.cf-order-row dt { + color: var(--cf-muted); + font: 600 8px/1 var(--cf-mono); + text-transform: uppercase; +} + +.cf-order-row dd { + margin: 7px 0 0; + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; +} + +.cf-ops-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.cf-ops-actions a { + padding: 12px; + border: 1px solid var(--cf-line-strong); + font: 650 9px/1 var(--cf-mono); + text-transform: uppercase; +} + +.cf-empty-row { + padding: 50px 0; + border-top: 1px solid var(--cf-line); + border-bottom: 1px solid var(--cf-line); + color: var(--cf-muted); +} + +@media (max-width: 1100px) { + .cf-product-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .cf-store-header nav { + padding-right: 220px; + } +} + +@media (max-width: 760px) { + .cf-auth-dock { + top: 75px; + right: 18px; + } + + .cf-store-header { + align-items: flex-start; + flex-direction: column; + justify-content: center; + padding: 20px 0; + } + + .cf-store-header nav { + width: 100%; + gap: 20px; + padding-right: 0; + } + + .cf-product-grid, + .cf-cart-layout, + .cf-checkout-grid, + .cf-product-detail, + .cf-order-row, + .cf-payment-controls { + grid-template-columns: 1fr; + } + + .cf-store-hero { + min-height: 470px; + } + + .cf-product-mark { + min-height: 340px; + border-right: 0; + border-bottom: 1px solid var(--cf-line); + } + + .cf-product-copy { + padding: 54px 0; + } + + .cf-section-heading { + grid-template-columns: 1fr; + } + + .cf-order-totals { + position: static; + } + + .cf-order-row dl { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 480px) { + .cf-product-grid, + .cf-specs, + .cf-order-row dl { + grid-template-columns: 1fr; + } + + .cf-commerce { + padding-inline: 16px; + } +} + /* Coatcheck workbench: manager, canvas, globals, and bottom inspector. */ [hidden] { diff --git a/crates/coldfront-ui/src/components.rs b/crates/coldfront-ui/src/components.rs new file mode 100644 index 0000000..a601f46 --- /dev/null +++ b/crates/coldfront-ui/src/components.rs @@ -0,0 +1,15 @@ +pub mod badge; +pub mod button; +pub mod card; +pub mod cart_line_item; +pub mod catalog_filter_bar; +pub mod input; +pub mod label; +pub mod order_receipt; +pub mod order_totals; +pub mod payment_route_selector; +pub mod payment_status; +pub mod product_card; +pub mod progress; +pub mod select; +pub mod shipping_address_form; diff --git a/crates/coldfront-ui/src/components/badge.rs b/crates/coldfront-ui/src/components/badge.rs new file mode 100644 index 0000000..fd80266 --- /dev/null +++ b/crates/coldfront-ui/src/components/badge.rs @@ -0,0 +1,86 @@ +use topcoat::{ + Result, + view::{Attributes, View, class, component, view}, +}; + +/// The visual style of a [`badge`]. +/// +/// [`Default`] is `BadgeVariant::Primary`, used when no variant is given. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[allow(dead_code)] +pub enum BadgeVariant { + /// The primary-filled badge for highlighted statuses. + #[default] + Primary, + /// A muted, tinted fill for neutral statuses. + Secondary, + /// A hairline-bordered badge on the page background. + Outline, + /// A destructive-filled badge for errors and warnings. + Destructive, +} + +impl BadgeVariant { + /// The Tailwind classes for this variant. + /// + /// Each variant sets its own border color rather than inheriting a + /// transparent one from [`BASE`]: with two border-color classes on the + /// same element, stylesheet order (not class order) would decide the + /// winner. + fn classes(self) -> &'static str { + match self { + Self::Primary => "border-transparent bg-primary text-primary-foreground", + Self::Secondary => "border-transparent bg-foreground/5 text-foreground", + Self::Outline => "border-border text-foreground", + Self::Destructive => "border-transparent bg-destructive text-destructive-foreground", + } + } +} + +/// The classes shared by every badge, regardless of variant. +/// +/// Every badge carries a border (colored per variant) so that the `Outline` +/// variant, which only recolors it, does not change the badge's dimensions. +const BASE: &str = "inline-flex w-fit shrink-0 items-center justify-center gap-1 rounded-md \ + border px-2 py-0.5 text-xs font-medium whitespace-nowrap"; + +/// Builds the full class string for a badge of the given `variant`. +/// +/// Use it to give badge styling to another element, such as a link: +/// +/// ```ignore +/// view! { +/// "v2.0" +/// } +/// ``` +#[must_use] +pub fn badge_variants(variant: BadgeVariant) -> String { + format!("{BASE} {}", variant.classes()) +} + +/// A badge component: a small inline pill for statuses, counts, and tags. +/// +/// The `variant` parameter selects the styling, defaulting to `Primary`. The +/// `attrs` (such as `class` or `title`) are forwarded to the underlying +/// ``; a `class` among them is appended to the computed classes. Child +/// nodes become the badge's content. +/// +/// ```ignore +/// view! { +/// badge(variant: BadgeVariant::Destructive, "Failed") +/// } +/// ``` +/// +/// To style another element like a badge, use [`badge_variants`] directly. +#[component] +pub async fn badge( + #[default] variant: BadgeVariant, + #[default] mut attrs: Attributes, + #[default] child: View, +) -> Result { + view! { + + (child) + + } +} diff --git a/crates/coldfront-ui/src/components/button.rs b/crates/coldfront-ui/src/components/button.rs new file mode 100644 index 0000000..73a2134 --- /dev/null +++ b/crates/coldfront-ui/src/components/button.rs @@ -0,0 +1,161 @@ +use topcoat::{ + Result, + view::{Attributes, View, class, component, view}, +}; + +/// The visual style of a [`button`]. +/// +/// [`Default`] is `ButtonVariant::Primary`, used when no variant is given. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[allow(dead_code)] +pub enum ButtonVariant { + /// The primary-filled button for the main action. + #[default] + Primary, + /// A muted, tinted fill for secondary actions. + Secondary, + /// A hairline-bordered button on the page background. + Outline, + /// No fill until hovered, for toolbars and inline actions. + Ghost, + /// A destructive-filled button for actions such as deleting data. + Destructive, +} + +impl ButtonVariant { + /// The Tailwind classes for this variant. + /// + /// Hover and press states apply the fill or foreground color at reduced + /// opacity, so they hold up in both color schemes without `dark:` + /// overrides. Every variant with a resting fill or border casts the + /// theme's control shadow; `Ghost` is flat until hovered, so it casts + /// none. + /// + /// Each variant sets its own border color rather than inheriting a + /// transparent one from [`BASE`]: with two border-color classes on the + /// same element, stylesheet order (not class order) would decide the + /// winner. + fn classes(self) -> &'static str { + match self { + Self::Primary => { + "border-transparent bg-primary text-primary-foreground shadow-xs \ + hover:bg-primary/90 active:bg-primary/80" + } + Self::Secondary => { + "border-transparent bg-foreground/5 text-foreground shadow-xs \ + hover:bg-foreground/10 active:bg-foreground/15" + } + Self::Outline => { + "border-border text-foreground shadow-xs hover:bg-foreground/5 \ + active:bg-foreground/10" + } + Self::Ghost => { + "border-transparent text-foreground hover:bg-foreground/5 active:bg-foreground/10" + } + Self::Destructive => { + "border-transparent bg-destructive text-destructive-foreground shadow-xs \ + hover:bg-destructive/90 active:bg-destructive/80" + } + } + } +} + +/// The size of a [`button`]. +/// +/// [`Default`] is `ButtonSize::Md`, used when no size is given. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[allow(dead_code)] +pub enum ButtonSize { + /// A compact button. + Sm, + /// The standard button size. + #[default] + Md, + /// A prominent button. + Lg, + /// A square button sized for a single icon. + Icon, +} + +impl ButtonSize { + /// The Tailwind classes for this size. + /// + /// Each size sets a text size, which also scales any icons inside: the + /// `icon` component is `1em` square by default. + fn classes(self) -> &'static str { + match self { + Self::Sm => "h-8 gap-1.5 rounded-md px-3 text-xs", + Self::Md => "h-9 gap-2 rounded-lg px-4 text-sm", + Self::Lg => "h-10 gap-2 rounded-lg px-5 text-base", + Self::Icon => "size-9 rounded-lg text-base", + } + } +} + +/// The classes shared by every button, regardless of variant or size. +/// +/// Every button carries a border (colored per variant) so that the `Outline` +/// variant, which only recolors it, does not change the button's dimensions. +const BASE: &str = "inline-flex shrink-0 items-center justify-center border \ + font-medium whitespace-nowrap transition-colors outline-none select-none \ + focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 \ + focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50"; + +/// Builds the full class string for a button of the given `variant` and `size`. +/// +/// Use it to give button styling to an element that is not a ` + } +} diff --git a/crates/coldfront-ui/src/components/card.rs b/crates/coldfront-ui/src/components/card.rs new file mode 100644 index 0000000..e13dede --- /dev/null +++ b/crates/coldfront-ui/src/components/card.rs @@ -0,0 +1,98 @@ +use topcoat::{ + Result, + view::{Attributes, View, class, component, view}, +}; + +/// The classes for the [`card`] container. +/// +/// The card is a column of sections separated by a uniform gap. It carries +/// vertical padding only; each section brings its own horizontal padding, so +/// full-bleed content such as an image can span the card's width. The card +/// casts the theme's raised-surface shadow and sets its own background and +/// text color, so it reads as a card on any ancestor. +const CARD: &str = "flex flex-col gap-5 rounded-xl border border-border bg-background py-6 \ + text-foreground shadow-sm"; + +/// A card component: a bordered, raised surface grouping related content. +/// +/// A card stacks sections vertically: typically a [`card_header`], then a +/// [`card_content`], closed by a [`card_footer`]. Any section can be omitted. +/// The `attrs` (such as `class` or event handlers) are forwarded to the +/// underlying `
`; a `class` among them is appended to the computed +/// classes. Child nodes become the card's sections. +/// +/// ```ignore +/// view! { +/// card( +/// attrs: attributes! { class="max-w-sm" }, +/// card_header( +/// card_title("Delete workspace") +/// card_description("This cannot be undone.") +/// ) +/// card_footer( +/// attrs: attributes! { class="justify-end" }, +/// button(variant: ButtonVariant::Destructive, "Delete") +/// ) +/// ) +/// } +/// ``` +#[component] +pub async fn card(#[default] mut attrs: Attributes, #[default] child: View) -> Result { + view! {
(child)
} +} + +/// The opening section of a [`card`], stacking a [`card_title`] and an +/// optional [`card_description`]. +#[component] +pub async fn card_header(#[default] mut attrs: Attributes, #[default] child: View) -> Result { + view! { +
+ (child) +
+ } +} + +/// The heading of a [`card`], rendered as an `

`. +#[component] +pub async fn card_title(#[default] mut attrs: Attributes, #[default] child: View) -> Result { + view! { +

+ (child) +

+ } +} + +/// The supporting text under a [`card_title`]. +#[component] +pub async fn card_description(#[default] mut attrs: Attributes, #[default] child: View) -> Result { + view! { +

+ (child) +

+ } +} + +/// The main body of a [`card`]. +#[component] +pub async fn card_content(#[default] mut attrs: Attributes, #[default] child: View) -> Result { + view! {
(child)
} +} + +/// The closing section of a [`card`], a horizontal row for actions. +#[component] +pub async fn card_footer(#[default] mut attrs: Attributes, #[default] child: View) -> Result { + view! { +
+ (child) +
+ } +} diff --git a/crates/coldfront-ui/src/components/cart_line_item.rs b/crates/coldfront-ui/src/components/cart_line_item.rs new file mode 100644 index 0000000..b9fb94b --- /dev/null +++ b/crates/coldfront-ui/src/components/cart_line_item.rs @@ -0,0 +1,122 @@ +use super::{button::button, input::input, label::label}; +use topcoat::{ + Result, + view::{Attributes, View, attributes, class, component, view}, +}; + +/// Host-owned line content rendered by [`cart_line_item`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CartLineItem { + pub image_src: Option, + pub image_alt: String, + pub title: String, + pub variant: Option, + pub unit_price: String, + pub line_total: String, + pub quantity: u32, + pub max_quantity: u32, +} + +/// Quantity and removal form for one cart line. +/// +/// Both buttons submit to `action`. The host distinguishes them through +/// `intent=update` and `intent=remove`. Hidden line identifiers and CSRF input +/// belong in `child`. +#[component] +pub async fn cart_line_item( + item: CartLineItem, + #[into] action: String, + #[default] + #[into] + id_prefix: String, + #[default] mut attrs: Attributes, + #[default] child: View, +) -> Result { + let id_prefix = if id_prefix.is_empty() { + "cart-line".to_owned() + } else { + id_prefix + }; + let quantity_id = format!("{id_prefix}-quantity"); + let max_quantity = item.max_quantity.max(1); + + view! { +
+ (child) +
+ match item.image_src.as_deref() { + Some(src) => (item.image_alt.as_str()), + None => , + } +
+
+

(item.title.as_str())

+ match item.variant.as_deref() { + Some(variant) =>

(variant)

, + None => {}, + } +

+ (item.unit_price.as_str()) + " each" +

+
+
+ label( + attrs: attributes! { + for=(quantity_id.as_str()) + class="text-xs" + }, + "Quantity" + ) + input(attrs: attributes! { + id=(quantity_id) + name="quantity" + type="number" + min="1" + max=(max_quantity) + value=(item.quantity) + inputmode="numeric" + class="w-24" + }) +
+ button( + variant: super::button::ButtonVariant::Outline, + attrs: attributes! { + type="submit" + name="intent" + value="update" + class="min-h-10" + }, + "Update" + ) + button( + variant: super::button::ButtonVariant::Ghost, + attrs: attributes! { + type="submit" + name="intent" + value="remove" + class="min-h-10" + }, + "Remove" + ) +
+
+

(item.line_total.as_str())

+
+ } +} diff --git a/crates/coldfront-ui/src/components/catalog_filter_bar.rs b/crates/coldfront-ui/src/components/catalog_filter_bar.rs new file mode 100644 index 0000000..afb175c --- /dev/null +++ b/crates/coldfront-ui/src/components/catalog_filter_bar.rs @@ -0,0 +1,92 @@ +use super::{button::button, input::input, label::label, select::select}; +use topcoat::{ + Result, + view::{Attributes, attributes, class, component, view}, +}; + +/// One option in a catalog category or sort control. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CatalogFilterOption { + pub value: String, + pub label: String, + pub selected: bool, +} + +impl CatalogFilterOption { + #[must_use] + pub fn new(value: impl Into, option_label: impl Into, selected: bool) -> Self { + Self { + value: value.into(), + label: option_label.into(), + selected, + } + } +} + +/// Native GET form for catalog search, category, and sorting. +/// +/// The host owns the query contract and result set. This block only preserves +/// labels, names, and browser submission semantics. +#[component] +pub async fn catalog_filter_bar( + #[into] action: String, + #[default] + #[into] + query: String, + categories: Vec, + sorts: Vec, + #[default] mut attrs: Attributes, +) -> Result { + view! { +
+
+ label(attrs: attributes! { for="catalog-search" }, "Search") + input(attrs: attributes! { + id="catalog-search" + name="q" + type="search" + value=(query) + autocomplete="off" + placeholder="Search products" + }) +
+
+ label(attrs: attributes! { for="catalog-category" }, "Category") + select( + attrs: attributes! { id="catalog-category" name="category" }, + for option in categories { + match option.selected { + true => , + false => , + } + } + ) +
+
+ label(attrs: attributes! { for="catalog-sort" }, "Sort") + select( + attrs: attributes! { id="catalog-sort" name="sort" }, + for option in sorts { + match option.selected { + true => , + false => , + } + } + ) +
+ button( + attrs: attributes! { + type="submit" + class="min-h-11" + }, + "Apply" + ) +
+ } +} diff --git a/crates/coldfront-ui/src/components/input.rs b/crates/coldfront-ui/src/components/input.rs new file mode 100644 index 0000000..c874d85 --- /dev/null +++ b/crates/coldfront-ui/src/components/input.rs @@ -0,0 +1,33 @@ +use topcoat::{ + Result, + view::{Attributes, class, component, view}, +}; + +/// The classes for the [`input`] control. +/// +/// The height, text size, radius, shadow, and focus ring match the `Md` +/// button, so an input and a button sit flush in a row. File inputs restyle +/// the browser's upload button into quiet, borderless text. +const INPUT: &str = "h-9 w-full min-w-0 rounded-lg border border-border bg-background px-3 \ + text-sm shadow-xs transition-colors outline-none \ + placeholder:text-muted-foreground \ + file:mr-3 file:h-full file:border-0 file:bg-transparent file:text-sm file:font-medium \ + focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 \ + focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50"; + +/// A text input component. +/// +/// The `attrs` (such as `type`, `name`, `placeholder`, `disabled`, or event +/// handlers) are forwarded to the underlying ``; a `class` among them +/// is appended to the computed classes. The input fills its container, so +/// size it through the container or with a width class. +/// +/// ```ignore +/// view! { +/// input(attrs: attributes! { type="email" placeholder="you@example.com" }) +/// } +/// ``` +#[component] +pub async fn input(#[default] mut attrs: Attributes) -> Result { + view! { } +} diff --git a/crates/coldfront-ui/src/components/label.rs b/crates/coldfront-ui/src/components/label.rs new file mode 100644 index 0000000..0901496 --- /dev/null +++ b/crates/coldfront-ui/src/components/label.rs @@ -0,0 +1,35 @@ +use topcoat::{ + Result, + view::{Attributes, View, class, component, view}, +}; + +/// The classes for the [`label`] element. +/// +/// The label lays out its content in a centered row, so an inline icon or a +/// wrapped control lines up with the text. It dims and stops receiving +/// pointer events when its control is disabled: a wrapped control is matched +/// with `has-[:disabled]`, a preceding sibling control marked `peer` with +/// `peer-disabled`. +const LABEL: &str = "flex items-center gap-2 text-sm leading-none font-medium select-none \ + peer-disabled:pointer-events-none peer-disabled:opacity-50 \ + has-[:disabled]:pointer-events-none has-[:disabled]:opacity-50"; + +/// A caption for a form control, rendered as a `