Compare commits

...

32 commits

Author SHA1 Message Date
SkateSpots
9ce57bedf9 docs: remove external submission drafts
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 23:18:58 +07:00
SkateSpots
bfc777c18b docs: make distribution Nostr-only, no external identities 2026-10-30 23:17:54 +07:00
SkateSpots
a2b732317e dist: point F-Droid recipe at splash-tracked source
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 23:11:15 +07:00
SkateSpots
5ebf741427 build: track Android splash source for clean checkouts 2026-10-30 23:09:23 +07:00
SkateSpots
4ae30ee8e6 dist: prepare unsigned F-Droid build output 2026-10-30 23:07:42 +07:00
SkateSpots
72e5f89b49 docs: add external store submission drafts
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 23:04:13 +07:00
SkateSpots
d003748bb1 dist: use valid F-Droid category
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 23:02:17 +07:00
SkateSpots
6048eafc0d docs: record future distribution and external submission limits
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:59:06 +07:00
SkateSpots
562a7de7c0 docs: record Forgejo release helper
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:53:00 +07:00
SkateSpots
8b12001fa6 docs: document self-hosted F-Droid republish
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:51:09 +07:00
SkateSpots
383ff8a5cf dist: add Forgejo release helper and F-Droid instructions
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:49:23 +07:00
SkateSpots
65914144d1 dist: document self-hosted Forgejo and F-Droid repo
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:43:47 +07:00
SkateSpots
a8780d5236 dist: self-hosted Forgejo release page and F-Droid metadata 2026-10-30 22:33:53 +07:00
SkateSpots
2dcf5a2aad docs: record 1.7.61 Cordn setup timeline
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:25:18 +07:00
SkateSpots
50c95d0ff7 release: 1.7.61 2026-10-30 22:21:24 +07:00
SkateSpots
4ff736734d ui(cordn): show setup progress through the join request 2026-10-30 22:18:11 +07:00
SkateSpots
fd1713f6b1 docs: record 1.7.60 Cordn and payment polish 2026-10-30 22:07:49 +07:00
SkateSpots
6c9b6fab02 release: 1.7.60 2026-10-30 22:03:46 +07:00
SkateSpots
0ae4c59630 feat(ux): cordn status line, payment readiness and image compression 2026-10-30 22:00:31 +07:00
SkateSpots
ab11b0d3ed docs: record 1.7.59 owner delete and mobile back 2026-10-30 21:28:21 +07:00
SkateSpots
0355c10dbc release: 1.7.59 2026-10-30 21:24:26 +07:00
SkateSpots
28d34e3fbe fix(ui): owner spot delete, mobile back and simplified account menu 2026-10-30 21:21:00 +07:00
SkateSpots
b0df599b52 docs: record 1.7.58 brand profile shortcut 2026-10-30 21:12:14 +07:00
SkateSpots
74bd140295 release: 1.7.58 2026-10-30 21:08:15 +07:00
SkateSpots
42f47304dd ui(header): brand lockup opens the user profile when signed in 2026-10-30 21:04:47 +07:00
SkateSpots
c00bffdf78 docs: record 1.7.57 avatar profile shortcut 2026-10-30 20:58:40 +07:00
SkateSpots
5026b63323 release: 1.7.57 2026-10-30 20:54:42 +07:00
SkateSpots
601393ece3 ui(header): make the user avatar open the profile 2026-10-30 20:51:21 +07:00
SkateSpots
ee6db357c2 docs: record 1.7.56 navigation simplification 2026-10-30 20:33:42 +07:00
SkateSpots
cdf4a63809 release: 1.7.56 2026-10-30 20:29:12 +07:00
SkateSpots
23929653d3 ui(nav): simplify mobile bar to map, three tabs and more 2026-10-30 20:25:52 +07:00
SkateSpots
344b41afc8 docs: record 1.7.55 operator dashboard and TLS hardening 2026-10-30 18:47:43 +07:00
25 changed files with 771 additions and 145 deletions

3
.gitignore vendored
View file

@ -52,7 +52,8 @@ stats.json
# Local/generated assets & incidental lockfiles (do not commit) # Local/generated assets & incidental lockfiles (do not commit)
/assets/ /assets/
/resources/ /resources/*
!/resources/splash.png
/bun.lock /bun.lock
cvm-server/bun.lock cvm-server/bun.lock
push-service/bun.lock push-service/bun.lock

109
AGENTS.md
View file

@ -2228,7 +2228,7 @@ session with **no other memory of this project**. So:
instead of showing the error boundary. instead of showing the error boundary.
- **Baselines** (verified 2026-10-29): `npm test` → **0 errors, 0 warnings**, - **Baselines** (verified 2026-10-29): `npm test` → **0 errors, 0 warnings**,
**156 tests**, build ok. The gate also type-checks the CVM server **156 tests**, build ok. The gate also type-checks the CVM server
(`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.7.54**. (`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.7.61**.
- ✅ Lint is **clean**: `no-explicit-any` = 0 · `react-hooks/exhaustive-deps` = 0 - ✅ Lint is **clean**: `no-explicit-any` = 0 · `react-hooks/exhaustive-deps` = 0
· `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were · `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were
**split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`, **split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`,
@ -2415,6 +2415,75 @@ Rules:
- Any time the deploy output isn't verified by the smoke test, run - Any time the deploy output isn't verified by the smoke test, run
`node scripts/smoke-test.mjs` manually before shipping. `node scripts/smoke-test.mjs` manually before shipping.
### Distribution without GitHub
> **Owner decision (2026-10-30): no external identities.** Distribution must
> stay self-hosted and Nostr-based. Do **not** create or ask for GitLab,
> Codeberg, GitHub, email or F-Droid-main accounts. The local Forgejo admin
> account and the self-hosted F-Droid repo key are infrastructure credentials,
> not third-party identities. F-Droid main and IzzyOnDroid are therefore
> **out of scope**.
- **Self-hosted Forgejo:** `https://git.skate-spots.org/skatespots/SkateSpots`
(public source, registration disabled, admin credentials only on the relay
host). It runs on `185.18.221.2` behind Caddy; do not commit its credentials.
- **Latest release (Obtanium HTML source):**
`https://git.skate-spots.org/skatespots/SkateSpots/releases/latest`.
The page exposes one signed `.apk`; Obtanium may need **Override Source →
HTML** because the host is self-hosted, not Codeberg.
- **Static download page:** `public/download/index.html` → `https://skate-spots.org/download/`.
- **Forgejo release helper:** `scripts/publish-forgejo.sh` (uses `FORGEJO_TOKEN`
or `FORGEJO_PASSWORD`; `scripts/release.sh` calls it automatically when either
is set). It uploads the APK and checksum, but does not push git history.
- **Self-hosted F-Droid repo:** `https://fdroid.skate-spots.org/repo`
(`org.skatespots.app`; currently 1.7.61). It is a **third-party repo**, not
F-Droid main. Repo signing fingerprint:
`88:B1:71:1C:24:02:89:FB:B2:A7:FC:C1:B3:1F:BB:9D:60:F5:7F:19:18:E0:3D:62:2D:35:F2:3D:E1:84:46:75`.
The developer APK signature is unchanged; the repo key only signs indexes.
A backup of the repo keystore/config is in
`~/.config/skatespots/fdroid-repo/` (mode 600).
- **F-Droid main/IzzyOnDroid:** **out of scope by owner decision.** Both require
external GitLab/Codeberg identities and review; do not submit, create accounts,
or present them as live. The self-hosted F-Droid repo above is the supported
F-Droid-family channel.
- **No GitHub required.** Keep Zapstore, Forgejo, the self-hosted F-Droid repo,
and the static page in sync for each release.
### Future release / distribution checklist
1. Bump `package.json`; update the top of `release-notes.txt`; run
`scripts/release.sh patch --yes --deploy`.
2. Set `FORGEJO_TOKEN` (or `FORGEJO_PASSWORD`) to upload the APK/checksum to the
latest Forgejo release; the helper is `scripts/publish-forgejo.sh`.
3. **Push source/tags to Forgejo.** The local machine's DNS for
`git.skate-spots.org` is intermittent, so the reliable path is: create a git
bundle locally, `scp` it to the relay, clone there, and push to
`http://127.0.0.1:3000/skatespots/SkateSpots.git` with the host admin
credentials. Do not put the password in a committed remote URL.
4. Add the APK to `/home/ubuntu/fdroid/repo/`, update the F-Droid metadata
changelog, run `fdroid update` in `/home/ubuntu/fdroid`, then rsync `repo/`
to `/var/www/fdroid/repo/`.
5. Update `public/download/index.html` with the new version/checksum and deploy
the web page.
6. Verify: latest Forgejo release exposes one `.apk`; static page 200;
`https://fdroid.skate-spots.org/repo/index-v2.json` lists the new
`versionCode`; Obtanium still resolves the latest release.
7. Keep Forgejo/F-Droid credentials and the F-Droid repo keystore out of git.
Local backups: `~/.config/skatespots/forgejo-admin.txt` and
`~/.config/skatespots/fdroid-repo/` (mode 600).
### Nostr-only distribution (no external identities)
- **Zapstore** is the Nostr-native app store/update channel; keep publishing
release events there.
- **Obtanium** is URL-based, not an account: users add the latest Forgejo
release URL (HTML source if needed) or the self-hosted F-Droid repo.
- **F-Droid main and IzzyOnDroid do not support Nostr identities.** They are
intentionally not pursued. Do not create or request GitLab/Codeberg/email
accounts, and do not claim those stores are live.
- The local Forgejo admin account, the relay, and the F-Droid repo signing key
are self-hosted infrastructure, not third-party identities.
- The F-Droid recipe remains in `distribution/fdroiddata/` only as an unused
technical reference. It was locally test-built at `5ebf741`, but must not be
submitted.
### Deploy ### Deploy
- **Web (auto)**: a systemd user timer (`skatespots-deploy.timer`) runs `scripts/auto-deploy.sh` every ~3 min. When `origin/main` changes it builds and runs `netlify deploy --prod --dir=dist`. It deploys **only committed code** (skips if tracked files are modified) and never mutates a dirty tree. **Doc/scripts-only commits are skipped** (matches `AGENTS.md`/`*.md`/`scripts/`/`cvm-server/`/`skills/`) — Netlify bills **15 credits per production deploy**, so don't spend them on non-site changes. One-time setup: `netlify login` + `netlify link`. Logs: `journalctl --user -u skatespots-deploy`. Manual run: `scripts/auto-deploy.sh`. - **Web (auto)**: a systemd user timer (`skatespots-deploy.timer`) runs `scripts/auto-deploy.sh` every ~3 min. When `origin/main` changes it builds and runs `netlify deploy --prod --dir=dist`. It deploys **only committed code** (skips if tracked files are modified) and never mutates a dirty tree. **Doc/scripts-only commits are skipped** (matches `AGENTS.md`/`*.md`/`scripts/`/`cvm-server/`/`skills/`) — Netlify bills **15 credits per production deploy**, so don't spend them on non-site changes. One-time setup: `netlify login` + `netlify link`. Logs: `journalctl --user -u skatespots-deploy`. Manual run: `scripts/auto-deploy.sh`.
- ⚠️ **Deploy throttle (2026-09-28):** production deploys are capped to one per **24h** by default (after a week of 68 deploys ≈ **1,020 credits** — a third of the monthly allowance). Batching a day's commits into one 15-credit deploy is the single biggest credit lever. Override with `SKATESPOTS_DEPLOY_MIN_INTERVAL=<seconds>` (`0` disables); deploy on demand with **`scripts/auto-deploy.sh --force`**. A throttled tick deliberately does **not** record the deployed SHA, so the pending change still deploys on a later tick; a **failed** deploy doesn't set the clock, so it retries immediately. - ⚠️ **Deploy throttle (2026-09-28):** production deploys are capped to one per **24h** by default (after a week of 68 deploys ≈ **1,020 credits** — a third of the monthly allowance). Batching a day's commits into one 15-credit deploy is the single biggest credit lever. Override with `SKATESPOTS_DEPLOY_MIN_INTERVAL=<seconds>` (`0` disables); deploy on demand with **`scripts/auto-deploy.sh --force`**. A throttled tick deliberately does **not** record the deployed SHA, so the pending change still deploys on a later tick; a **failed** deploy doesn't set the clock, so it retries immediately.
@ -2771,6 +2840,37 @@ Rules:
hourly backstop, 20 GB relay cap), app-side image/type/size guard and clear hourly backstop, 20 GB relay cap), app-side image/type/size guard and clear
errors; uploads use only our Blossom (mirrors are read-only fallbacks). errors; uploads use only our Blossom (mirrors are read-only fallbacks).
APK sha256 `f554d934…`, versionCode 10754. Web live at `465683c`. APK sha256 `f554d934…`, versionCode 10754. Web live at `465683c`.
- **1.7.55 (2026-10-30):** private read-only `/ops` status page for the two
relay admins, NIP-98-signed status endpoint fed by the host health timer;
admin link in Settings → Advanced. Added HSTS to relay/blossom and CAA
records limiting their certificates to Let’s Encrypt. APK sha256
`1142c425…`, versionCode 10755. Web live at `52a8447`.
- **1.7.56 (2026-10-30):** mobile nav simplified to five intents — Spots,
The Lab, Events, Crews, More. Profile/login stays in the header avatar;
Mints and all secondary sections moved to More. Customizer edits three
middle slots; desktop default primary tabs use Community instead of Mints.
APK sha256 `ec15b80d…`, versionCode 10756. Web live at `cdf4a63`.
- **1.7.57 (2026-10-30):** mobile header avatar opens the user’s public
profile; the adjacent chevron opens the account menu. Removed the
redundant “IN” label so the header fits at 390px. APK sha256 `846efb24…`,
versionCode 10757. Web live at `5026b63`.
- **1.7.58 (2026-10-30):** the top-left SkateSpots brand lockup opens the
signed-in user’s profile on mobile and desktop; signed out it still opens
the map. Bottom Spots / desktop Map remains the home shortcut. APK sha256
`0d7bdad7…`, versionCode 10758. Web live at `74bd140`.
- **1.7.59 (2026-10-30):** owner/admin Delete in spot/shop card menus; mobile
Back arrow on every non-map screen (history fallback to map); avatar account
menu trimmed to status/profile/settings/accounts/logout; brand wordmark
hidden on sub-pages for narrow-header fit. APK sha256 `0fd4a149…`,
versionCode 10759. Web live at `0355c10`.
- **1.7.60 (2026-10-30):** Cordn header shows last successful check; sellers
see payment-readiness in Create Listing + offers; uploads resize photos to
1600px WebP when smaller (GIF/undecodable formats untouched), cutting
quota/storage/upload time. APK sha256 `8f11c229…`, versionCode 10760.
Web live at `6c9b6fa`.
- **1.7.61 (2026-10-30):** Cordn setup timeline now checks prepared device
/sent request and spins on the approval step while waiting. APK sha256
`db0279f7…`, versionCode 10761. Web live at `50c95d0`.
### Relay archive (dataset backup) ### Relay archive (dataset backup)
- **Transfer content between keys (`scripts/transfer-content.mjs`, 2026-10-04):** - **Transfer content between keys (`scripts/transfer-content.mjs`, 2026-10-04):**
@ -3136,6 +3236,13 @@ The community relay moved off the hosted relay.tools instance
service, and >18 GiB media. App uploads now use **only** our Blossom; public service, and >18 GiB media. App uploads now use **only** our Blossom; public
mirrors are read-side fallbacks only, so a quota rejection cannot silently mirrors are read-side fallbacks only, so a quota rejection cannot silently
land on a third party. No video upload support was added. land on a third party. No video upload support was added.
**2026-10-30 — transport/ops hardening:** HSTS is set on `relay.` and
`blossom.` (`max-age=31536000; includeSubDomains`); CAA records restrict
issuance for those two hosts to `letsencrypt.org`. The health timer writes an
aggregate `/home/ubuntu/skatespots-quota/status.json`; the quota proxy serves
it at `https://relay.skate-spots.org/ops` only for a NIP-98-signed request
from one of the two admin pubkeys. `/ops` is a read-only app page linked from
Settings → Advanced (AdminReviewCard). No restart/config actions are exposed.
- **Cordn coordinator (2026-10-12):** the relay also runs Newlay's **embedded Cordn - **Cordn coordinator (2026-10-12):** the relay also runs Newlay's **embedded Cordn
coordinator** for crew MLS chat (`[cordn] enabled`, `mode = "embedded"`, coordinator** for crew MLS chat (`[cordn] enabled`, `mode = "embedded"`,
`data_dir = /var/lib/newlay/data/cordn` inside the volume, `relay_url = `data_dir = /var/lib/newlay/data/cordn` inside the volume, `relay_url =

97
distribution/README.md Normal file
View file

@ -0,0 +1,97 @@
# Distribution without GitHub
SkateSpots is published from a self-hosted **Forgejo** instance. No GitHub
account is required.
## Current release source
- Forge: <https://git.skate-spots.org/skatespots/SkateSpots>
- Latest release: <https://git.skate-spots.org/skatespots/SkateSpots/releases/latest>
- Signed APK (1.7.61):
<https://git.skate-spots.org/skatespots/SkateSpots/releases/download/v1.7.61/SkateSpots-1.7.61.apk>
- SHA-256:
`db0279f759efd9fa05d9d1bed57a3f5856777e6ccb208d3ed78b15398f266fba`
The APK is signed with the same key as the Zapstore build. Installing an
F-Droid-signed build over it would require uninstalling SkateSpots and losing
the local key/settings. Obtanium and direct APK updates keep the install.
## Obtanium
Obtanium cannot use a Zapstore URL as a source. Use the self-hosted Forgejo
release page instead:
1. Open Obtanium → **Add App**.
2. Paste:
`https://git.skate-spots.org/skatespots/SkateSpots/releases/latest`
3. If Obtanium does not recognise the self-hosted host, select **Override
Source → HTML**.
4. Set version extraction if desired:
`SkateSpots-([0-9.]+)\.apk`, match group `1`.
The release page contains exactly one `.apk`, so Obtanium can follow the latest
release without a custom store listing.
## F-Droid family
### Self-hosted F-Droid repo (live now)
A signed third-party F-Droid repository is published here:
- Repo: <https://fdroid.skate-spots.org/repo>
- App ID: `org.skatespots.app`
- Repo signing fingerprint:
`88:B1:71:1C:24:02:89:FB:B2:A7:FC:C1:B3:1F:BB:9D:60:F5:7F:19:18:E0:3D:62:2D:35:F2:3D:E1:84:46:75`
Users can add this URL in F-Droid, or add it in Obtanium as an **F-Droid Third
Party Repo**. This is not F-Droid main: it is a self-hosted, signed repository
of the developer-signed APK. It keeps the existing install and signing key.
The repository metadata and APK are served from the relay host. The repo index
is signed with a repository key stored on that host; the APK itself keeps the
SkateSpots developer signature.
### No external store identities
**Owner decision:** distribution stays self-hosted and Nostr-based. No GitLab,
Codeberg, GitHub, email or F-Droid-main accounts are created or requested.
F-Droid main and IzzyOnDroid both require external identities and review, so
they are intentionally out of scope. The self-hosted repo above is the supported
F-Droid-family channel.
`distribution/fdroiddata/` and `fastlane/` are kept as an unused technical
reference only. A fresh local clone at `5ebf741` built the unsigned APK through
the recipe's `npm ci → android:init → android:sync → assembleRelease` path, but
the recipe must not be submitted.
## Release checklist
1. `npm test`
2. `scripts/release.sh patch --yes --deploy` (Zapstore + web)
3. Push the release tag and `main` to Forgejo.
4. Upload the APK through the helper:
`FORGEJO_TOKEN=... scripts/publish-forgejo.sh <version>`
(or set `FORGEJO_TOKEN` before `release.sh`; the APK upload is then automatic)
5. Copy the signed APK into the F-Droid repo on the relay host, add a
`metadata/org.skatespots.app.yml` changelog entry, run `fdroid update`, and
sync `repo/` to `/var/www/fdroid/repo/`:
```bash
cd /home/ubuntu/fdroid
cp /path/to/SkateSpots-<version>.apk repo/
. /home/ubuntu/fdroid-venv/bin/activate
fdroid update
sudo rsync -a --delete repo/ /var/www/fdroid/repo/
sudo chown -R caddy:caddy /var/www/fdroid
```
6. Verify the public latest-release page contains the new `.apk`, and that
`https://fdroid.skate-spots.org/repo/index-v2.json` lists the new
`versionCode`.
7. Update the static download page if the displayed version/checksum changes:
`public/download/index.html`.
The Forgejo instance runs on the same host as the relay. It exposes public
read access and HTTPS through Caddy; registration is disabled and administrative
credentials are kept on the host, not in this repository.

View file

@ -0,0 +1,51 @@
Categories:
- Sports & Health
License: AGPL-3.0-only
AuthorName: SkateSpots
WebSite: https://skate-spots.org
SourceCode: https://git.skate-spots.org/skatespots/SkateSpots
IssueTracker: https://git.skate-spots.org/skatespots/SkateSpots/issues
Changelog: https://git.skate-spots.org/skatespots/SkateSpots/releases
AutoName: SkateSpots
Summary: Local-first skate map, crews, events and marketplace
Description: |-
SkateSpots is a local-first skating map for finding spots, crews, events and
gear. Your keys stay on your device, your messages stay in encrypted crew
chats and the app does not use analytics or sell your data.
Find skate spots on the map, add new ones, organise sessions with your crew,
follow events and browse the community marketplace. The Android app bundles
the same web app for fast shell startup and native camera, location, push and
NFC support where available.
SkateSpots is self-hosted and built on open protocols. You can run your own
relay and Blossom storage instead of relying on a central service.
RepoType: git
Repo: https://git.skate-spots.org/skatespots/SkateSpots.git
Builds:
- versionName: 1.7.61
versionCode: 10761
commit: 5ebf741427303be0b0feeff5ce1d8420e2c0ddea
subdir: .
prebuild:
- npm ci
- npm run android:init
- npm run android:sync
# F-Droid signs the APK itself. Remove the developer signing config so
# Gradle emits an unsigned release APK instead of requiring the keystore.
- sed -i '/signingConfig signingConfigs.release/d' android/app/build.gradle
build:
- cd android && ./gradlew assembleRelease --no-daemon
output: android/app/build/outputs/apk/release/app-release-unsigned.apk
# The generated Android project is intentionally not committed. This
# recipe recreates it with Capacitor before Gradle runs. F-Droid will sign
# the resulting APK with its own key. The F-Droid buildserver supplies the
# JDK/Android SDK; do not install packages with sudo in a recipe.
AutoUpdateMode: Version
UpdateCheckMode: Tags
CurrentVersion: 1.7.61
CurrentVersionCode: 10761

View file

@ -0,0 +1 @@
Cordn setup now shows visible progress: the device-key step is checked once prepared, and the approval step shows a spinner while waiting. The APK uses the same signing key as the Zapstore release.

View file

@ -0,0 +1,15 @@
SkateSpots is a local-first skating map for finding spots, crews, events and
gear. Your keys stay on your device, your messages stay in encrypted crew chats
and the app does not use analytics or sell your data.
Find skate spots on the map, add new ones, organise sessions with your crew,
follow events and browse the community marketplace. The Android app bundles the
same web app for fast offline shell startup and native camera, location, push
and NFC support where available.
SkateSpots is self-hosted and built on open protocols. You can run your own
relay and Blossom storage instead of relying on a central service.
Lightning payments are optional and self-custodial. Transport and driver
profiles are self-attested; SkateSpots does not verify a licence, insurance or
vehicle roadworthiness.

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -0,0 +1 @@
Local-first skate map, crews, events and marketplace.

View file

@ -0,0 +1 @@
SkateSpots

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "skate-spots", "name": "skate-spots",
"version": "1.7.55", "version": "1.7.61",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "skate-spots", "name": "skate-spots",
"version": "1.7.55", "version": "1.7.61",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@bitcoinerlab/secp256k1": "^1.2.0", "@bitcoinerlab/secp256k1": "^1.2.0",

View file

@ -1,7 +1,7 @@
{ {
"name": "skate-spots", "name": "skate-spots",
"private": true, "private": true,
"version": "1.7.55", "version": "1.7.61",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"type": "module", "type": "module",
"homepage": "https://skate-spots.org", "homepage": "https://skate-spots.org",

127
public/download/index.html Normal file
View file

@ -0,0 +1,127 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<title>SkateSpots APK</title>
<meta
name="description"
content="Download the signed SkateSpots Android APK. No account, no store, no tracking."
/>
<style>
:root {
color-scheme: dark;
--bg: #090b0c;
--panel: #121719;
--line: #273033;
--text: #f2f5f4;
--muted: #9aa7a5;
--teal: #56e0c0;
--amber: #f5b83d;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at 20% 0%, rgba(86, 224, 192, 0.12), transparent 42%),
var(--bg);
color: var(--text);
font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
main {
width: min(100%, 560px);
padding: 28px;
border: 1px solid var(--line);
border-radius: 18px;
background: color-mix(in srgb, var(--panel) 92%, transparent);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.35);
}
.eyebrow {
margin: 0 0 10px;
color: var(--teal);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 { margin: 0; font-size: clamp(28px, 7vw, 38px); line-height: 1.02; }
p { margin: 14px 0 0; color: var(--muted); }
a.download {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 24px;
padding: 16px 18px;
border-radius: 12px;
background: var(--teal);
color: #06201b;
font-weight: 800;
text-decoration: none;
}
a.download:focus-visible { outline: 3px solid var(--amber); outline-offset: 3px; }
a.download span:last-child { font-size: 20px; }
.meta {
display: grid;
gap: 8px;
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 13px;
}
code {
overflow-wrap: anywhere;
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.links { display: flex; flex-wrap: wrap; gap: 10px 18px; margin-top: 16px; }
.links a { color: var(--amber); font-weight: 700; text-decoration: none; }
.links a:hover { text-decoration: underline; }
</style>
</head>
<body>
<main>
<p class="eyebrow">SkateSpots · Android</p>
<h1>Get the signed APK.</h1>
<p>
Local-first skating map, crews, events and marketplace. No store account
required. This is the same signing key as the Zapstore and Obtanium
releases, so it updates over an existing install.
</p>
<a
class="download"
href="https://git.skate-spots.org/skatespots/SkateSpots/releases/download/v1.7.61/SkateSpots-1.7.61.apk"
rel="noopener"
>
<span>Download SkateSpots-1.7.61.apk · 56 MB</span>
<span aria-hidden="true">↓</span>
</a>
<div class="meta">
<span>Requires Android 7.0 or newer.</span>
<span>SHA-256: <code>db0279f759efd9fa05d9d1bed57a3f5856777e6ccb208d3ed78b15398f266fba</code></span>
<span>Only install APKs from a source you trust. Android will ask you to allow installs from your browser.</span>
</div>
<div class="links">
<a href="https://git.skate-spots.org/skatespots/SkateSpots/releases">All releases</a>
<a href="https://git.skate-spots.org/skatespots/SkateSpots">Source code</a>
<a href="https://skate-spots.org">Open the web app</a>
</div>
<div class="meta">
<span><strong>Obtanium:</strong> add the latest release page above as the app source, or select <em>Override Source → HTML</em> if it is not recognised.</span>
<span><strong>F-Droid repo:</strong> <a href="https://fdroid.skate-spots.org/repo">https://fdroid.skate-spots.org/repo</a> (third-party self-hosted repo; app ID <code>org.skatespots.app</code>).</span>
<span>Repo signing fingerprint: <code>88:B1:71:1C:24:02:89:FB:B2:A7:FC:C1:B3:1F:BB:9D:60:F5:7F:19:18:E0:3D:62:2D:35:F2:3D:E1:84:46:75</code></span>
</div>
</main>
</body>
</html>

View file

@ -1,3 +1,66 @@
🧩 Cordn setup is easier to follow
- The setup steps now show progress: preparing this device is checked once the
key package is ready, and the approval step shows a spinner while waiting.
- The private-chat header already shows the last successful check, so “live” and
“still waiting” are distinguishable at a glance.
---
⚡ Cordn status, payment readiness, lighter photos
- Cordn panels now show the last successful check next to the connection status,
so “waiting” and “live” are easier to tell apart.
- Sellers see a payment-readiness card in listing creation and on incoming
offers, with a direct link to add a Lightning address before accepting a deal.
- Photos are resized to max 1600px and converted to WebP before upload when that
makes them smaller. GIFs and formats the WebView cannot decode are left alone.
This cuts upload time, quota use and Blossom storage.
---
🧹 Spot owners can delete from the card menu
- Owner/admin members now get a **Delete** action in the three-dot menu on
spot and shop cards, opening the existing confirmation dialog.
- Added a mobile **Back** arrow on every non-map screen, with history fallback
to the map. The bottom Spots tab remains the home shortcut.
- Trimmed the avatar account menu to purpose-built actions: status, profile,
settings, account switching and logout. The larger navigation stays in the
burger menu.
- Brand wordmark hides on sub-pages so the Back arrow and header controls fit
on narrow phones without truncation.
---
🧭 Brand logo opens your profile
- The top-left SkateSpots lockup now opens your public profile when signed in
(both mobile and desktop). Signed out, it still goes to the map.
- The bottom Spots tab / desktop Map tab remains the home shortcut, so map
access is unchanged.
- The green avatar still opens your profile too; its chevron opens the account
menu.
---
👤 Avatar opens your profile
- The green user avatar in the mobile header now opens your public profile.
- The small chevron beside it opens the account menu (status, add account,
wallet, settings, logout) — the avatar itself is no longer a menu trigger.
- Removed the redundant “IN” label so the brand and header controls fit on
narrow phones.
- The SkateSpots brand mark still goes to the map.
---
🧭 Simpler mobile navigation
- The bottom bar is now exactly five intents: Spots, The Lab, Events, Crews
and More. Profile/login moved to the header avatar, where it already lived.
- Mints and every other secondary section stay in the More menu — nothing was
removed, only moved out of the way.
- The nav customiser now edits three middle slots and previews the fixed More
button. Existing four-slot layouts are normalised to three on read.
- Desktop defaults now show Community instead of Mints as a primary tab.
---
🔐 Private operator status + transport hardening 🔐 Private operator status + transport hardening
- New admin-only `/ops` page shows relay/coordinator health, Cordn 429s, - New admin-only `/ops` page shows relay/coordinator health, Cordn 429s,
services, disk/memory, media usage and TLS expiry. services, disk/memory, media usage and TLS expiry.

BIN
resources/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

108
scripts/publish-forgejo.sh Executable file
View file

@ -0,0 +1,108 @@
#!/usr/bin/env bash
#
# Publish a built APK to the self-hosted Forgejo release page.
#
# Usage:
# FORGEJO_TOKEN=... scripts/publish-forgejo.sh 1.7.62 [path/to.apk]
# FORGEJO_PASSWORD=... FORGEJO_USER=skatespots scripts/publish-forgejo.sh 1.7.62
#
# This uploads the APK and its .sha256 file. It does not push the git tag or
# source; do that separately (the Forgejo repo is a mirror, not an auto-push
# remote). Keep credentials in your shell/environment, never in this repo.
set -euo pipefail
VERSION="${1:?usage: publish-forgejo.sh <version> [apk]}"
APK="${2:-android/app/build/outputs/apk/release/app-release.apk}"
TAG="${FORGEJO_TAG:-v$VERSION}"
BASE="${FORGEJO_URL:-https://git.skate-spots.org}"
OWNER="${FORGEJO_OWNER:-skatespots}"
REPO="${FORGEJO_REPO:-SkateSpots}"
API="$BASE/api/v1/repos/$OWNER/$REPO"
command -v curl >/dev/null || { echo "curl is required" >&2; exit 1; }
command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 1; }
[ -f "$APK" ] || { echo "APK not found: $APK" >&2; exit 1; }
AUTH=()
if [ -n "${FORGEJO_TOKEN:-}" ]; then
AUTH=(-H "Authorization: token $FORGEJO_TOKEN")
elif [ -n "${FORGEJO_PASSWORD:-}" ]; then
AUTH=(-u "${FORGEJO_USER:-skatespots}:$FORGEJO_PASSWORD")
else
echo "set FORGEJO_TOKEN (preferred) or FORGEJO_PASSWORD" >&2
exit 1
fi
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
sha="$(sha256sum "$APK" | awk '{print $1}')"
body="$tmpdir/release.md"
cat >"$body" <<EOF
## SkateSpots $VERSION
Signed Android release. SHA-256: \`$sha\`
- Source: $BASE/$OWNER/$REPO
- APK signing key: same as the Zapstore release.
EOF
python3 - "$TAG" "$VERSION" "$body" >"$tmpdir/payload.json" <<'PY'
import json
import sys
from pathlib import Path
tag, version, body_path = sys.argv[1:]
print(json.dumps({
"tag_name": tag,
"name": version,
"body": Path(body_path).read_text(),
"draft": False,
"prerelease": False,
}))
PY
create_code="$(curl -sS -o "$tmpdir/release.json" -w '%{http_code}' \
"${AUTH[@]}" -H 'Content-Type: application/json' \
-X POST "$API/releases" --data-binary @"$tmpdir/payload.json" || true)"
if [ "$create_code" = "201" ]; then
release_id="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmpdir/release.json")"
elif [ "$create_code" = "409" ] || [ "$create_code" = "422" ]; then
curl -fsS "${AUTH[@]}" "$API/releases/tags/$TAG" -o "$tmpdir/release.json"
release_id="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmpdir/release.json")"
else
echo "Forgejo release creation failed (HTTP $create_code)" >&2
cat "$tmpdir/release.json" >&2
exit 1
fi
sha_file="$tmpdir/SkateSpots-$VERSION.apk.sha256"
printf '%s SkateSpots-%s.apk\n' "$sha" "$VERSION" >"$sha_file"
existing_assets="$(python3 - "$tmpdir/release.json" <<'PY'
import json
import sys
data = json.load(open(sys.argv[1]))
print("\n".join(asset["name"] for asset in data.get("assets", [])))
PY
)"
for file in "$APK" "$sha_file"; do
name="$(basename "$file")"
if printf '%s\n' "$existing_assets" | grep -Fqx "$name"; then
echo "already present: $name"
continue
fi
code="$(curl -sS -o "$tmpdir/asset.json" -w '%{http_code}' \
"${AUTH[@]}" -X POST "$API/releases/$release_id/assets?name=$name" \
-F "attachment=@$file" || true)"
case "$code" in
201|202) echo "uploaded $name" ;;
*) echo "warning: upload of $name returned HTTP $code" >&2 ;;
esac
done
echo "release: $BASE/$OWNER/$REPO/releases/tag/$TAG"
echo "latest: $BASE/$OWNER/$REPO/releases/latest"

View file

@ -126,6 +126,14 @@ for _ in 1 2 3 4 5 6; do
done done
[ "$FOUND" -eq 1 ] || log "warning: the release event did not appear on the relay yet (it usually propagates shortly)" [ "$FOUND" -eq 1 ] || log "warning: the release event did not appear on the relay yet (it usually propagates shortly)"
if [ -n "${FORGEJO_TOKEN:-}" ] || [ -n "${FORGEJO_PASSWORD:-}" ]; then
log "publishing the APK to the self-hosted Forgejo release page…"
scripts/publish-forgejo.sh "$VERSION" "$APK" || \
log "warning: Forgejo upload failed — the Zapstore release is still valid"
else
log "Forgejo: skipped (set FORGEJO_TOKEN or FORGEJO_PASSWORD to publish there)"
fi
log "committing the release…" log "committing the release…"
git add package.json package-lock.json release-notes.txt git add package.json package-lock.json release-notes.txt
git commit -m "release: $VERSION" >/dev/null git commit -m "release: $VERSION" >/dev/null

View file

@ -7,10 +7,10 @@ import {
DEFAULT_TAB_IDS, DEFAULT_TAB_IDS,
type NavTab, type NavTab,
} from '@/hooks/useNavPreferences'; } from '@/hooks/useNavPreferences';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { MapPin, User, Lock, ArrowUp, ArrowDown, RotateCcw, Check, Sparkles } from 'lucide-react'; import { MapPin, MoreHorizontal, Lock, ArrowUp, ArrowDown, RotateCcw, Check, Sparkles } from 'lucide-react';
interface CustomizeNavSheetProps { interface CustomizeNavSheetProps {
open: boolean; open: boolean;
@ -20,14 +20,14 @@ interface CustomizeNavSheetProps {
/** /**
* Bottom-sheet editor for the mobile nav bar. * Bottom-sheet editor for the mobile nav bar.
* *
* Layout model: Map (fixed left) · 3 user-picked · Profile (fixed right). * Layout model: Map (fixed left) · 3 user-picked · More (fixed right).
* The user toggles up to 3 destinations from the catalog and can reorder them. * The user toggles up to 3 destinations from the catalog and can reorder them.
* Draft state is local until "Done", so changes feel deliberate and cancelable. * Draft state is local until "Done", so changes feel deliberate and cancelable.
*/ */
export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps) { export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps) {
const { middleIds, setMiddleIds, reset, catalog } = useNavPreferences(); const { middleIds, setMiddleIds, reset, catalog } = useNavPreferences();
const { user } = useCurrentUser();
const { t } = useTranslation(); const { t } = useTranslation();
const { user } = useCurrentUser();
// Local draft so we can cancel without persisting. // Local draft so we can cancel without persisting.
const [draft, setDraft] = useState<string[]>(middleIds); const [draft, setDraft] = useState<string[]>(middleIds);
@ -115,8 +115,8 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
} }
return <PreviewSlot key={tab.id} icon={tab.icon} label={label(tab)} accent="#ff0040" />; return <PreviewSlot key={tab.id} icon={tab.icon} label={label(tab)} accent="#ff0040" />;
})} })}
{/* Fixed Profile */} {/* Fixed More */}
<PreviewSlot icon={User} label={isLoggedIn ? t('navSheet.profile') : t('navSheet.login')} locked accent="#ff0040" /> <PreviewSlot icon={MoreHorizontal} label={t('nav.more')} locked accent="#ff0040" />
</div> </div>
</div> </div>

View file

@ -1,5 +1,5 @@
import { useState, useRef, useEffect, lazy, Suspense } from 'react'; import { useState, useRef, useEffect, lazy, Suspense } from 'react';
import { Link, Outlet, useLocation } from 'react-router-dom'; import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { isNativePlatform } from '@/lib/platform'; import { isNativePlatform } from '@/lib/platform';
@ -39,7 +39,6 @@ import {
User, User,
Plus, Plus,
CheckCircle2, CheckCircle2,
Database,
Package, Package,
Heart, Heart,
CreditCard, CreditCard,
@ -52,6 +51,8 @@ import {
MessagesSquare, MessagesSquare,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
ChevronDown,
ArrowLeft,
Search, Search,
} from 'lucide-react'; } from 'lucide-react';
import { OfflineIndicator } from '@/components/OfflineIndicator'; import { OfflineIndicator } from '@/components/OfflineIndicator';
@ -220,8 +221,12 @@ function MobileHeaderUserMenu({
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <div className="flex items-center gap-0.5">
<button className="touch-manipulation relative flex items-center gap-2 px-2 py-1.5 rounded-full bg-green-500/10 border border-green-500/30 active:scale-95 transition-transform"> <Link
to={`/${nip19.npubEncode(pubkey)}`}
aria-label={t('chromeUi.viewMyProfile')}
className="touch-manipulation relative flex items-center gap-1.5 rounded-l-full rounded-r-md border border-green-500/30 bg-green-500/10 px-1.5 py-1.5 transition-transform active:scale-95"
>
<Avatar className="h-7 w-7 border-2 border-green-500/50"> <Avatar className="h-7 w-7 border-2 border-green-500/50">
<AvatarImage src={avatar} alt={displayName} /> <AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-green-500/20 text-green-400 text-[10px] font-bold"> <AvatarFallback className="bg-green-500/20 text-green-400 text-[10px] font-bold">
@ -230,14 +235,19 @@ function MobileHeaderUserMenu({
</Avatar> </Avatar>
{/* Status indicator */} {/* Status indicator */}
<UserStatusIndicator pubkey={pubkey} size="sm" /> <UserStatusIndicator pubkey={pubkey} size="sm" />
<span className="text-green-400 text-xs font-bold uppercase tracking-wide hidden xs:inline">
<CheckCircle2 className="h-3 w-3 inline mr-1" />
{t('chromeUi.inBadge')}
</span>
{/* Green online indicator dot */} {/* Green online indicator dot */}
<span className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-green-500 border-2 border-black rounded-full xs:hidden" /> <span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-black bg-green-500 xs:hidden" />
</button> </Link>
</DropdownMenuTrigger> <DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t('chromeUi.menu')}
className="flex h-9 w-5 items-center justify-center rounded-r-full text-green-400/80 transition-colors hover:text-green-300 active:scale-95"
>
<ChevronDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent align="end" className="w-56 p-2 [&_[role=menuitem]]:min-h-11"> <DropdownMenuContent align="end" className="w-56 p-2 [&_[role=menuitem]]:min-h-11">
{/* User info header */} {/* User info header */}
<div className="px-2 py-3 mb-2 rounded-lg bg-green-500/10 border border-green-500/20"> <div className="px-2 py-3 mb-2 rounded-lg bg-green-500/10 border border-green-500/20">
@ -273,12 +283,6 @@ function MobileHeaderUserMenu({
<span className="text-green-400 font-bold">{t('chromeUi.setStatus')}</span> <span className="text-green-400 font-bold">{t('chromeUi.setStatus')}</span>
<span className="ml-auto text-xs text-green-500">🛹</span> <span className="ml-auto text-xs text-green-500">🛹</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/create-spot" className="flex items-center gap-2 cursor-pointer">
<Plus className="h-4 w-4 text-[#ccff00]" />
<span className="text-[#ccff00] font-medium">{t('chromeUi.addSpot')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-2 cursor-pointer"> <Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-2 cursor-pointer">
<User className="h-4 w-4" /> <User className="h-4 w-4" />
@ -291,36 +295,6 @@ function MobileHeaderUserMenu({
{t('nav.settings')} {t('nav.settings')}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings?section=mydata" className="flex items-center gap-2 cursor-pointer">
<Database className="h-4 w-4 text-[#ccff00]" />
<span className="text-[#ccff00]">{t('chromeUi.myData')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/stash" className="flex items-center gap-2 cursor-pointer">
<Package className="h-4 w-4 text-purple-400" />
<span className="text-purple-400">{t('chromeUi.myStash')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/wallet" className="flex items-center gap-2 cursor-pointer">
<Wallet className="h-4 w-4 text-orange-400" />
<span className="text-orange-400">{t('nav.wallet')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/leaderboards" className="flex items-center gap-2 cursor-pointer">
<BarChart3 className="h-4 w-4 text-[#ccff00]" />
<span className="text-[#ccff00]">{t('nav.leaderboards')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/marketplace" className="flex items-center gap-2 cursor-pointer">
<Package className="h-4 w-4 text-orange-400" />
<span className="text-orange-400">{t('nav.marketplace')}</span>
</Link>
</DropdownMenuItem>
{/* ── Accounts: switch between logged-in npubs + add another ── */} {/* ── Accounts: switch between logged-in npubs + add another ── */}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@ -367,21 +341,17 @@ function MobileHeaderUserMenu({
function MobileNav({ function MobileNav({
isActive, isActive,
isLoggedIn, isLoggedIn,
userPubkey,
userAvatar,
userName,
t, t,
onCustomize, onCustomize,
onMore,
fixedMapTab, fixedMapTab,
middleTabs, middleTabs,
}: { }: {
isActive: (path: string) => boolean; isActive: (path: string) => boolean;
isLoggedIn: boolean; isLoggedIn: boolean;
userPubkey?: string;
userAvatar?: string;
userName?: string;
t: (key: string) => string; t: (key: string) => string;
onCustomize: () => void; onCustomize: () => void;
onMore: () => void;
fixedMapTab: NavTab; fixedMapTab: NavTab;
middleTabs: NavTab[]; middleTabs: NavTab[];
}) { }) {
@ -483,60 +453,21 @@ function MobileNav({
{/* CUSTOM: 3 middle tabs */} {/* CUSTOM: 3 middle tabs */}
{resolvedMiddle.map((tab, i) => renderTab(tab, `mid-${tab.id}-${i}`))} {resolvedMiddle.map((tab, i) => renderTab(tab, `mid-${tab.id}-${i}`))}
{/* FIXED: Profile / Login tab. {/* FIXED: More — the burger menu owns profile, login, wallet,
Logged in → your public profile (View Profile). settings and every secondary section. */}
Logged out → Settings (login lives there). <button
Long-press / right-click still opens nav customization. */} type="button"
{(() => { onClick={onMore}
const profileHref = isLoggedIn && userPubkey ? `/${nip19.npubEncode(userPubkey)}` : '/settings'; className="group relative flex flex-1 touch-manipulation flex-col items-center justify-center gap-1 px-1 py-1.5 active:scale-90 transition-transform duration-150 text-gray-500"
const tabActive = isActive(profileHref); aria-label={t('nav.more')}
return ( >
<Link <span className="relative flex h-9 w-12 items-center justify-center rounded-2xl bg-transparent transition-all duration-300">
to={profileHref} <MoreHorizontal className="h-[22px] w-[22px] transition-transform duration-300 group-active:scale-95" />
onTouchStart={startPress} </span>
onTouchEnd={cancelPress} <span className="block w-full truncate text-center text-[9px] xs:text-[10px] font-semibold uppercase leading-tight tracking-wide">
onTouchMove={cancelPress} {t('nav.more')}
onContextMenu={(e) => { </span>
e.preventDefault(); </button>
onCustomize();
}}
className={cn(
'group relative flex flex-col items-center justify-center gap-1 py-1.5 px-1 flex-1 touch-manipulation active:scale-90 transition-transform duration-150',
tabActive ? 'text-[#ff0040]' : 'text-gray-500'
)}
>
<span
className={cn(
'relative flex items-center justify-center h-9 w-12 rounded-2xl transition-all duration-300',
tabActive ? 'bg-[#ff0040]/15 shadow-[0_0_16px_rgba(255,0,64,0.35)]' : 'bg-transparent',
)}
>
{isLoggedIn && userPubkey ? (
<div className="relative">
<Avatar className={cn(
'h-[24px] w-[24px] border-2 transition-transform duration-300',
tabActive ? 'border-[#ff0040] scale-110' : 'border-gray-600 group-active:scale-95'
)}>
<AvatarImage src={userAvatar} alt={userName} />
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] text-[8px] font-bold">
{(userName || '??').slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="absolute -bottom-0.5 -right-0.5 w-2 h-2 bg-green-500 border border-black rounded-full" />
</div>
) : (
<User className={cn('h-[22px] w-[22px] transition-transform duration-300', tabActive ? 'scale-110' : 'group-active:scale-95')} />
)}
</span>
<span className={cn(
'block w-full truncate text-center text-[9px] xs:text-[10px] leading-tight tracking-wide uppercase transition-all',
tabActive ? 'font-extrabold' : 'font-semibold',
)}>
{isLoggedIn ? t('chromeUi.profile') : t('chromeUi.logIn')}
</span>
</Link>
);
})()}
</div> </div>
</div> </div>
</nav> </nav>
@ -545,6 +476,8 @@ function MobileNav({
export function Layout({ children, noPadding }: LayoutProps) { export function Layout({ children, noPadding }: LayoutProps) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const showMobileBack = location.pathname !== '/' && location.pathname !== '/map';
// The map is full-bleed (it manages its own height), so it gets no bottom // The map is full-bleed (it manages its own height), so it gets no bottom
// padding even though it shares this one persistent layout with every route. // padding even though it shares this one persistent layout with every route.
const isFullBleed = noPadding || location.pathname === '/' || location.pathname === '/map'; const isFullBleed = noPadding || location.pathname === '/' || location.pathname === '/map';
@ -618,7 +551,6 @@ export function Layout({ children, noPadding }: LayoutProps) {
const { currentUser: currentAccount, removeLogin } = useLoggedInAccounts(); const { currentUser: currentAccount, removeLogin } = useLoggedInAccounts();
const author = useAuthor(user?.pubkey || ''); const author = useAuthor(user?.pubkey || '');
const displayName = author.data?.metadata?.name ?? genUserName(user?.pubkey); const displayName = author.data?.metadata?.name ?? genUserName(user?.pubkey);
const userAvatar = author.data?.metadata?.picture;
const { fixedMapTab, middleTabs, catalog: navCatalog } = useNavPreferences(); const { fixedMapTab, middleTabs, catalog: navCatalog } = useNavPreferences();
const { const {
primaryTabs: desktopPrimaryTabs, primaryTabs: desktopPrimaryTabs,
@ -653,7 +585,11 @@ export function Layout({ children, noPadding }: LayoutProps) {
<div className="mx-auto flex w-full max-w-[1700px] items-center justify-between h-16 px-4 gap-4 xl:gap-8"> <div className="mx-auto flex w-full max-w-[1700px] items-center justify-between h-16 px-4 gap-4 xl:gap-8">
{/* Brand lockup — `shrink-0` + `whitespace-nowrap` guarantee the {/* Brand lockup — `shrink-0` + `whitespace-nowrap` guarantee the
logo never gets squeezed into (or glowed over by) the nav. */} logo never gets squeezed into (or glowed over by) the nav. */}
<Link to="/" className="flex items-center gap-3 group shrink-0"> <Link
to={user ? `/${nip19.npubEncode(user.pubkey)}` : '/'}
className="flex items-center gap-3 group shrink-0"
title={user ? t('chromeUi.viewMyProfile') : t('nav.map')}
>
<img <img
src="/images/icon-1024.png" src="/images/icon-1024.png"
alt="SkateSpots" alt="SkateSpots"
@ -872,13 +808,34 @@ export function Layout({ children, noPadding }: LayoutProps) {
{/* Mobile Header */} {/* Mobile Header */}
<header className="md:hidden sticky top-0 z-50 border-b border-red-500/30 bg-black/95 backdrop-blur safe-area-top"> <header className="md:hidden sticky top-0 z-50 border-b border-red-500/30 bg-black/95 backdrop-blur safe-area-top">
<div className="flex items-center justify-between h-14 px-3 gap-3"> <div className="flex items-center justify-between h-14 px-3 gap-3">
<Link to="/" className="flex items-center gap-2 touch-manipulation active:opacity-80 flex-1 min-w-0"> {showMobileBack && (
<Button
type="button"
variant="ghost"
size="icon"
className="-ml-1 h-9 w-9 shrink-0 text-gray-300 hover:bg-white/5 hover:text-white"
aria-label={t('common.back')}
title={t('common.back')}
onClick={() => {
if (location.key !== 'default') navigate(-1);
else navigate('/');
}}
>
<ArrowLeft className="h-5 w-5" />
</Button>
)}
<Link
to={user ? `/${nip19.npubEncode(user.pubkey)}` : '/'}
className="flex items-center gap-2 touch-manipulation active:opacity-80 flex-1 min-w-0"
title={user ? t('chromeUi.viewMyProfile') : t('nav.map')}
aria-label={user ? t('chromeUi.viewMyProfile') : t('nav.map')}
>
<img <img
src="/images/icon-1024.png" src="/images/icon-1024.png"
alt="SkateSpots" alt="SkateSpots"
className="w-9 h-9 rounded-lg flex-shrink-0 shadow-lg shadow-red-500/30" className="w-9 h-9 rounded-lg flex-shrink-0 shadow-lg shadow-red-500/30"
/> />
<span className="min-w-0 truncate font-display text-lg neon-red tracking-wide" style={{ <span className={cn('min-w-0 truncate font-display text-lg neon-red tracking-wide', showMobileBack && 'hidden')} style={{
textShadow: '0 0 8px #ff0040' textShadow: '0 0 8px #ff0040'
}}>SKATESPOTS</span> }}>SKATESPOTS</span>
</Link> </Link>
@ -1283,10 +1240,8 @@ export function Layout({ children, noPadding }: LayoutProps) {
{/* Mobile Bottom Navigation */} {/* Mobile Bottom Navigation */}
<MobileNav <MobileNav
isActive={isActive} isActive={isActive}
onMore={() => setMobileMenuOpen(true)}
isLoggedIn={!!user} isLoggedIn={!!user}
userPubkey={user?.pubkey}
userAvatar={userAvatar}
userName={displayName}
t={t} t={t}
onCustomize={() => setShowCustomizeNav(true)} onCustomize={() => setShowCustomizeNav(true)}
fixedMapTab={fixedMapTab} fixedMapTab={fixedMapTab}

View file

@ -20,6 +20,7 @@ import { genUserName } from '@/lib/genUserName';
import type { MarketplaceItem } from '@/lib/types'; import type { MarketplaceItem } from '@/lib/types';
import { PayWithSats } from '@/components/PayWithSats'; import { PayWithSats } from '@/components/PayWithSats';
import { MarkSoldDialog } from '@/components/MarkSoldDialog'; import { MarkSoldDialog } from '@/components/MarkSoldDialog';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2, Zap } from 'lucide-react'; import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2, Zap } from 'lucide-react';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
@ -183,6 +184,8 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
<Handshake className="h-4 w-4" /> {isOwner ? t('marketUi.offers.titleOwner', { count: mine.length }) : t('marketUi.offers.titleBuyer')} <Handshake className="h-4 w-4" /> {isOwner ? t('marketUi.offers.titleOwner', { count: mine.length }) : t('marketUi.offers.titleBuyer')}
</h3> </h3>
{isOwner && <PaymentReadinessNote />}
{mine.map((thread) => { {mine.map((thread) => {
const latest = thread.latest; const latest = thread.latest;
const other = latest.author === user.pubkey const other = latest.author === user.pubkey

View file

@ -58,7 +58,7 @@ export function PaymentReadinessNote() {
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-yellow-400" /> <AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-yellow-400" />
<span> <span>
{t('marketUi.paymentReadiness.notReadyBefore')}{' '} {t('marketUi.paymentReadiness.notReadyBefore')}{' '}
<Link to="/settings" className="text-yellow-400 underline hover:text-yellow-300"> <Link to="/settings?section=profile" className="text-yellow-400 underline hover:text-yellow-300">
{t('marketUi.paymentReadiness.nostrProfile')} {t('marketUi.paymentReadiness.nostrProfile')}
</Link>{' '} </Link>{' '}
{t('marketUi.paymentReadiness.notReadyAfter')} {t('marketUi.paymentReadiness.notReadyAfter')}

View file

@ -11,7 +11,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { MapPin, Store, Bookmark, MoreVertical, Flag, ShieldCheck } from 'lucide-react'; import { MapPin, Store, Bookmark, MoreVertical, Flag, ShieldCheck, Trash2 } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useVenuesWithPasses } from '@/hooks/useRiderCard'; import { useVenuesWithPasses } from '@/hooks/useRiderCard';
@ -23,6 +23,7 @@ import { OfficialBadge } from '@/components/OfficialBadge';
import { AddToCollectionDialog } from '@/components/AddToCollectionDialog'; import { AddToCollectionDialog } from '@/components/AddToCollectionDialog';
import { ReportDialog } from '@/components/ReportDialog'; import { ReportDialog } from '@/components/ReportDialog';
import { MuteMenuItem } from '@/components/MuteMenuItem'; import { MuteMenuItem } from '@/components/MuteMenuItem';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { SpotStatusBadge } from '@/components/SpotStatusBadge'; import { SpotStatusBadge } from '@/components/SpotStatusBadge';
import type { SkateSpot } from '@/lib/types'; import type { SkateSpot } from '@/lib/types';
import { ACCESS_TYPE_INFO, SHELTER_INFO, SKATE_SHOP_KIND } from '@/lib/types'; import { ACCESS_TYPE_INFO, SHELTER_INFO, SKATE_SHOP_KIND } from '@/lib/types';
@ -30,6 +31,7 @@ import { spotTypeLabel } from '@/lib/spotTypeLabel';
import { ACCESS_TYPE_KEYS, SHELTER_SHORT_KEYS } from '@/lib/labelKeys'; import { ACCESS_TYPE_KEYS, SHELTER_SHORT_KEYS } from '@/lib/labelKeys';
import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus'; import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { isAdminPubkey } from '@/lib/admins';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface SpotCardProps { interface SpotCardProps {
@ -48,10 +50,13 @@ export function SpotCard({ spot, status, community }: SpotCardProps) {
const displayName = metadata?.name ?? genUserName(spot.pubkey); const displayName = metadata?.name ?? genUserName(spot.pubkey);
const [showCollectionDialog, setShowCollectionDialog] = useState(false); const [showCollectionDialog, setShowCollectionDialog] = useState(false);
const [showReportDialog, setShowReportDialog] = useState(false); const [showReportDialog, setShowReportDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Determine if this is a shop or a spot // Determine if this is a shop or a spot
const isShop = spot.types.includes('shop'); const isShop = spot.types.includes('shop');
const isOwner = user?.pubkey === spot.pubkey; const isOwner = user?.pubkey === spot.pubkey;
const isAdmin = isAdminPubkey(user?.pubkey);
const canDelete = !!user && (isOwner || isAdmin);
const route = isShop ? `/shop/${spot.pubkey}/${spot.identifier}` : `/spot/${spot.pubkey}/${spot.identifier}`; const route = isShop ? `/shop/${spot.pubkey}/${spot.identifier}` : `/spot/${spot.pubkey}/${spot.identifier}`;
// Shops that actually issue access passes get a badge (one shared, cached query). // Shops that actually issue access passes get a badge (one shared, cached query).
@ -245,7 +250,19 @@ export function SpotCard({ spot, status, community }: SpotCardProps) {
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40"> <DropdownMenuContent align="end" className="w-44">
{canDelete && (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setShowDeleteDialog(true);
}}
className="text-red-500 focus:text-red-500"
>
<Trash2 className="mr-2 h-4 w-4" />
{isOwner ? t('common.delete') : t('spotUi.removeAdmin')}
</DropdownMenuItem>
)}
{!isOwner && ( {!isOwner && (
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
@ -275,6 +292,17 @@ export function SpotCard({ spot, status, community }: SpotCardProps) {
/> />
)} )}
{/* Owner/admin delete confirmation */}
{canDelete && (
<DeleteEntityDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
event={spot.event}
entityLabel={isShop ? t('mapUi.skateShop') : t('spotUi.spot')}
entityName={spot.title}
/>
)}
{/* Report Dialog */} {/* Report Dialog */}
<ReportDialog <ReportDialog
open={showReportDialog} open={showReportDialog}

View file

@ -814,6 +814,11 @@ export function CordnNativePanel({
</div> </div>
</div> </div>
<p className="pt-1 text-xs text-gray-500">{copy.subtitle}</p> <p className="pt-1 text-xs text-gray-500">{copy.subtitle}</p>
{native.group && native.lastCheckedAt && (
<p className="pt-0.5 text-[10px] text-gray-600">
{t('cordnUi.lastChecked')} {formatDistanceToNow(new Date(native.lastCheckedAt), { addSuffix: true })}
</p>
)}
{!isCommunity && ( {!isCommunity && (
<details className="group mt-1 rounded-md border border-white/10 bg-black/20 px-2.5 py-1.5"> <details className="group mt-1 rounded-md border border-white/10 bg-black/20 px-2.5 py-1.5">
<summary className="flex cursor-pointer list-none items-center gap-1 text-[11px] text-gray-400"> <summary className="flex cursor-pointer list-none items-center gap-1 text-[11px] text-gray-400">
@ -870,14 +875,29 @@ export function CordnNativePanel({
</p> </p>
<ol className="mx-auto max-w-sm space-y-2 text-left"> <ol className="mx-auto max-w-sm space-y-2 text-left">
{steps.map((step, index) => ( {steps.map((step, index) => {
<li key={step} className="flex items-start gap-2 text-xs text-gray-400"> // Members see the setup move visibly: device key prepared →
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-teal-500/15 text-[10px] font-bold text-teal-300"> // request sent → waiting for an admin. Founders start at step 1.
{index + 1} const stepDone = !canCreate && index === 0 && (awaiting || native.keyPackageReady);
</span> const stepWaiting = !canCreate && index === 1 && awaiting;
<span>{step}</span> return (
</li> <li
))} key={step}
className={cn(
'flex items-start gap-2 text-xs',
stepDone || stepWaiting ? 'text-teal-200' : 'text-gray-400',
)}
>
<span className={cn(
'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] font-bold',
stepDone ? 'bg-teal-500/20 text-teal-300' : 'bg-teal-500/15 text-teal-300',
)}>
{stepDone ? <Check className="h-2.5 w-2.5" /> : stepWaiting ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : index + 1}
</span>
<span>{step}</span>
</li>
);
})}
</ol> </ol>
<p className="mx-auto max-w-sm text-[11px] text-gray-500"> <p className="mx-auto max-w-sm text-[11px] text-gray-500">

View file

@ -23,8 +23,8 @@ import { usePreferences } from '@/hooks/usePreferences';
* Bottom-nav customization (mobile). * Bottom-nav customization (mobile).
* *
* Model: the bar always shows a FIXED Map (Shops & Spots) on the left and a * Model: the bar always shows a FIXED Map (Shops & Spots) on the left and a
* FIXED Profile/Login slot on the right. The user customizes the **3 middle * FIXED More button on the right. The user customizes the **3 middle slots**
* slots** from a curated catalog. Anything they don't pin stays reachable in the * from a curated catalog. Profile/login lives in the header avatar menu. Anything they don't pin stays reachable in the
* burger menu, so nothing is ever lost — just re-prioritized. * burger menu, so nothing is ever lost — just re-prioritized.
* *
* Stored per-device in localStorage (v1). Could later sync across devices via a * Stored per-device in localStorage (v1). Could later sync across devices via a
@ -55,7 +55,7 @@ export const FIXED_MAP_TAB: NavTab = {
/** /**
* Everything the user can pin to the 3 middle slots. * Everything the user can pin to the 3 middle slots.
* (Map is excluded — it's the fixed anchor. Profile is the fixed right slot.) * (Map is excluded — it's the fixed anchor. More is the fixed right slot.)
*/ */
export const NAV_CATALOG: NavTab[] = [ export const NAV_CATALOG: NavTab[] = [
{ id: 'lab', href: '/lab', labelKey: 'nav.lab', label: 'The Lab', icon: GraduationCap }, { id: 'lab', href: '/lab', labelKey: 'nav.lab', label: 'The Lab', icon: GraduationCap },
@ -75,11 +75,12 @@ export const NAV_CATALOG: NavTab[] = [
]; ];
/** Number of user-customizable middle slots. */ /** Number of user-customizable middle slots. */
export const MIDDLE_SLOTS = 4; export const MIDDLE_SLOTS = 3;
/** Default middle tabs. Includes Mints (Cashu mint directory) so it's reachable /** Default middle tabs. Keep the mobile bar to five intents total:
* from the phone nav out of the box; users can swap any of them via long-press. */ * fixed Map + these three + a fixed More button. Everything else lives in the
export const DEFAULT_TAB_IDS = ['lab', 'events', 'crews', 'mints']; * burger menu, and users can still swap any middle tab via long-press. */
export const DEFAULT_TAB_IDS = ['lab', 'events', 'crews'];
const STORAGE_KEY = 'skatespots:nav-tabs:v1'; const STORAGE_KEY = 'skatespots:nav-tabs:v1';
@ -90,7 +91,7 @@ const STORAGE_KEY = 'skatespots:nav-tabs:v1';
// ever lost. Map is always the first primary tab and cannot be hidden/moved. // ever lost. Map is always the first primary tab and cannot be hidden/moved.
/** Default desktop primary tabs. */ /** Default desktop primary tabs. */
export const DEFAULT_DESKTOP_IDS = ['lab', 'events', 'crews', 'mints']; export const DEFAULT_DESKTOP_IDS = ['lab', 'events', 'crews', 'community'];
const DESKTOP_STORAGE_KEY = 'skatespots:desktop-nav:v1'; const DESKTOP_STORAGE_KEY = 'skatespots:desktop-nav:v1';

View file

@ -47,6 +47,41 @@ function validateImage(file: File): void {
} }
} }
/**
* Resize/compress camera photos before upload. Most phone photos are far larger
* than the app ever displays; 1600px WebP cuts upload time and Blossom storage
* without touching GIFs or formats the WebView cannot decode.
*/
async function prepareImage(file: File): Promise<File> {
const type = (file.type || '').toLowerCase();
if (type === 'image/gif' || type === 'image/heic' || type === 'image/heif') return file;
if (typeof createImageBitmap !== 'function' || typeof document === 'undefined') return file;
try {
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height));
// Already small enough: keep the original bytes/format.
if (scale === 1 && file.size <= 1_200_000) {
bitmap.close();
return file;
}
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const ctx = canvas.getContext('2d');
if (!ctx) { bitmap.close(); return file; }
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/webp', 0.84);
});
if (!blob || blob.size >= file.size) return file;
const base = file.name.replace(/\.[^.]+$/, '') || 'image';
return new File([blob], `${base}.webp`, { type: 'image/webp', lastModified: file.lastModified });
} catch {
return file;
}
}
function hostOf(url: string): string { function hostOf(url: string): string {
try { try {
return new URL(url).hostname; return new URL(url).hostname;
@ -65,6 +100,7 @@ export function useUploadFile() {
} }
validateImage(file); validateImage(file);
const prepared = await prepareImage(file);
const uploader = new BlossomUploader({ const uploader = new BlossomUploader({
servers: UPLOAD_SERVERS, servers: UPLOAD_SERVERS,
@ -73,7 +109,7 @@ export function useUploadFile() {
let tags; let tags;
try { try {
tags = await uploader.upload(file); tags = await uploader.upload(prepared);
} catch (error) { } catch (error) {
throw new Error(uploadErrorMessage(error)); throw new Error(uploadErrorMessage(error));
} }

View file

@ -21,6 +21,7 @@ import { useListingRestrictions, recordListingCreation } from '@/hooks/useListin
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
import { Nip05Required } from '@/components/Nip05Required'; import { Nip05Required } from '@/components/Nip05Required';
import { SpamFeePayment } from '@/components/SpamFeePayment'; import { SpamFeePayment } from '@/components/SpamFeePayment';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { MARKETPLACE_ITEM_KIND, GEAR_CATEGORIES, GEAR_CONDITIONS, PRICE_TYPES, SIZE_OPTIONS, TRUCK_HEIGHTS, WHEEL_HARDNESS, EXPIRATION_OPTIONS, CURRENCY_OPTIONS, CATEGORY_REQUIRED_FIELDS } from '@/lib/types'; import { MARKETPLACE_ITEM_KIND, GEAR_CATEGORIES, GEAR_CONDITIONS, PRICE_TYPES, SIZE_OPTIONS, TRUCK_HEIGHTS, WHEEL_HARDNESS, EXPIRATION_OPTIONS, CURRENCY_OPTIONS, CATEGORY_REQUIRED_FIELDS } from '@/lib/types';
import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS, GEAR_CONDITION_DESC_KEYS, PRICE_TYPE_KEYS, PRICE_TYPE_DESC_KEYS } from '@/lib/labelKeys'; import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS, GEAR_CONDITION_DESC_KEYS, PRICE_TYPE_KEYS, PRICE_TYPE_DESC_KEYS } from '@/lib/labelKeys';
import { ShoppingBag, Upload, X, Loader2, ArrowLeft, MapPin, AlertTriangle, CheckCircle, Search, Clock, Shield, Zap } from 'lucide-react'; import { ShoppingBag, Upload, X, Loader2, ArrowLeft, MapPin, AlertTriangle, CheckCircle, Search, Clock, Shield, Zap } from 'lucide-react';
@ -857,6 +858,8 @@ export default function CreateListingPage() {
))} ))}
</div> </div>
{listingType === 'selling' && <PaymentReadinessNote />}
{priceType === 'fixed' && ( {priceType === 'fixed' && (
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-2"> <div className="col-span-2 space-y-2">