umpordez 1.2.1 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +3 -1
- package/README.md +28 -1
- package/cli.mjs +4 -2
- package/create.mjs +72 -6
- package/package.json +1 -1
- package/template/CLAUDE.md +1 -1
- package/template/misc/build-local.sh +82 -0
- package/template/mobile/{{PROJECT_SLUG}}/App.tsx +5 -2
- package/template/mobile/{{PROJECT_SLUG}}/CHANGELOG.md +28 -0
- package/template/mobile/{{PROJECT_SLUG}}/CLAUDE.md +10 -1
- package/template/mobile/{{PROJECT_SLUG}}/docs/releases.md +78 -0
- package/template/mobile/{{PROJECT_SLUG}}/docs/screenshots.md +84 -0
- package/template/mobile/{{PROJECT_SLUG}}/docs/store-copy.md +122 -0
- package/template/mobile/{{PROJECT_SLUG}}/docs/store-listing.md +107 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/config.ts +6 -0
- package/template/scripts/README.md +18 -1
- package/template/scripts/appstore-resize.sh +70 -0
- package/template/scripts/frame-shots.mjs +246 -0
- package/template/scripts/gen-mockups.mjs +120 -0
- package/template/scripts/screenshots-capture.sh +50 -0
- package/template/scripts/screenshots.flow.yaml +54 -0
- package/template/ui/site/_variants/app/en.ts +131 -0
- package/template/ui/site/_variants/app/footer.ejs +46 -0
- package/template/ui/site/_variants/app/header.ejs +102 -0
- package/template/ui/site/_variants/app/home.ejs +237 -0
- package/template/ui/site/_variants/app/not-found.ejs +22 -0
- package/template/ui/site/_variants/app/pt-BR.ts +203 -0
- package/template/ui/site/tailwind.config.js +9 -1
- package/template-variables.mjs +34 -0
- package/update.mjs +30 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# {{PROJECT_NAME}} — Store Copy (pt-BR · en)
|
|
2
|
+
|
|
3
|
+
> **This is the single canonical source for ship copy** — app name,
|
|
4
|
+
> subtitle, promotional text, keywords, short description and full
|
|
5
|
+
> descriptions (App Store + Google Play, per locale). Paste straight from
|
|
6
|
+
> here into App Store Connect / Google Play Console.
|
|
7
|
+
>
|
|
8
|
+
> Pricing, category, compliance reasoning and the publishing checklist
|
|
9
|
+
> live in [`store-listing.md`](./store-listing.md); release / "What's New"
|
|
10
|
+
> copy lives in [`releases.md`](./releases.md). This file is just the
|
|
11
|
+
> descriptive text.
|
|
12
|
+
|
|
13
|
+
**Positioning.** _One or two sentences on what {{PROJECT_NAME}} is and the
|
|
14
|
+
promise a user feels immediately. Lead with that promise in every field
|
|
15
|
+
below._
|
|
16
|
+
|
|
17
|
+
**House style**: never a hyphen as a separator; use `;` `,` `.` instead.
|
|
18
|
+
Write each store's description separately — never reuse one verbatim, since
|
|
19
|
+
"App Store" reads wrong on Play and vice-versa.
|
|
20
|
+
|
|
21
|
+
> ⚠️ **Confirm before submit**: support email and privacy-policy URL are
|
|
22
|
+
> live (e.g. `contato@{{DOMAIN}}`, `https://{{DOMAIN}}/privacidade` and
|
|
23
|
+
> `/privacy`).
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Format reference
|
|
28
|
+
|
|
29
|
+
### App Store Connect (per locale)
|
|
30
|
+
| Field | Limit | Editable post-submit? |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| App Name | 30 chars | needs new binary |
|
|
33
|
+
| Subtitle | 30 chars | needs new binary |
|
|
34
|
+
| Promotional Text | 170 chars | **yes** — change anytime |
|
|
35
|
+
| Description | 4000 chars | needs new binary |
|
|
36
|
+
| Keywords | 100 chars (CSV, no spaces) | needs new binary |
|
|
37
|
+
| What's New | 4000 chars | per release (see releases.md) |
|
|
38
|
+
|
|
39
|
+
### Google Play Console (per locale)
|
|
40
|
+
| Field | Limit |
|
|
41
|
+
|---|---|
|
|
42
|
+
| App name | 30 chars |
|
|
43
|
+
| Short description | 80 chars |
|
|
44
|
+
| Full description | 4000 chars |
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
# 🇧🇷 pt-BR — Português (Brasil)
|
|
49
|
+
|
|
50
|
+
**App name** (≤30)
|
|
51
|
+
```
|
|
52
|
+
{{PROJECT_NAME}}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Subtitle — App Store** (≤30)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Promotional Text — App Store** (≤170)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Keywords — App Store** (≤100, CSV no spaces)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Short description — Google Play** (≤80)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Full description — App Store (iOS)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Full description — Google Play (Android, keyword-rich)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
# 🇺🇸 en — English
|
|
88
|
+
|
|
89
|
+
**App name** (≤30)
|
|
90
|
+
```
|
|
91
|
+
{{PROJECT_NAME}}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Subtitle — App Store** (≤30)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Promotional Text — App Store** (≤170)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Keywords — App Store** (≤100, CSV no spaces)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**Short description — Google Play** (≤80)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Full description — App Store (iOS)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Full description — Google Play (Android, keyword-rich)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Store listing — {{PROJECT_NAME}}
|
|
2
|
+
|
|
3
|
+
Everything needed to publish {{PROJECT_NAME}} on both stores: the pricing
|
|
4
|
+
model, the category/compliance reasoning, the ASO strategy and the
|
|
5
|
+
publishing checklist.
|
|
6
|
+
|
|
7
|
+
> **The actual ship copy** (app name, subtitle, promotional text,
|
|
8
|
+
> keywords, short description and full descriptions per locale) lives in
|
|
9
|
+
> [`store-copy.md`](./store-copy.md). Release / "What's New" copy lives in
|
|
10
|
+
> [`releases.md`](./releases.md). Paste straight from there into App Store
|
|
11
|
+
> Connect / Google Play Console.
|
|
12
|
+
|
|
13
|
+
House style for the marketing copy: never a hyphen as a separator; use
|
|
14
|
+
`;` `,` `.` instead.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Pricing
|
|
19
|
+
|
|
20
|
+
Pick the model before you write the copy — it decides what trust signals
|
|
21
|
+
lead the listing.
|
|
22
|
+
|
|
23
|
+
| Model | What it unlocks | Notes |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| Free | Everything, no purchase | Lead with "free, no account, no ads". |
|
|
26
|
+
| Paid once | The whole app, one payment | Lowest paid bracket = impulse buy. Lead with "pay once, yours forever". |
|
|
27
|
+
| Freemium (IAP) | Free core + Premium unlock | Product IDs must match `src/services/iap/index.ts` exactly. |
|
|
28
|
+
|
|
29
|
+
If you use IAP, declare the product IDs here and keep them in lockstep
|
|
30
|
+
with the app:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
{{PROJECT_SLUG}}_premium_monthly
|
|
34
|
+
{{PROJECT_SLUG}}_premium_annual
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The app is anonymous-first: install creates a device account; a purchase
|
|
38
|
+
ties Premium to the user's Apple/Google account at buy time.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Category
|
|
43
|
+
|
|
44
|
+
- **Apple App Store:** primary category — pick where comparable apps rank.
|
|
45
|
+
- **Google Play:** primary category + up to 5 tags from Google's curated
|
|
46
|
+
list.
|
|
47
|
+
|
|
48
|
+
### Compliance note
|
|
49
|
+
|
|
50
|
+
Keep metadata at the **widest age rating you can**. Age gates and
|
|
51
|
+
sensitive-topic keywords (drugs, gambling, mature themes) push the rating
|
|
52
|
+
up on Apple and trip Google Play's IARC questionnaire toward Mature —
|
|
53
|
+
both trim country availability. If the app content is neutral, keep the
|
|
54
|
+
listing neutral and stay at 4+ / Everyone for the widest reach.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## ASO strategy
|
|
59
|
+
|
|
60
|
+
Field limits (hard caps):
|
|
61
|
+
|
|
62
|
+
| Field | Apple App Store | Google Play |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| Title | 30 | 30 |
|
|
65
|
+
| Subtitle | 30 | — |
|
|
66
|
+
| Short description | — | 80 |
|
|
67
|
+
| Promotional text | 170 | — |
|
|
68
|
+
| Keywords | 100 (commas, no spaces, single words) | — |
|
|
69
|
+
| Full description | 4000 (not indexed) | 4000 (indexed) |
|
|
70
|
+
|
|
71
|
+
Principles:
|
|
72
|
+
|
|
73
|
+
- **Front-load the title.** Apple weights the first word most; brand
|
|
74
|
+
first, then the strongest keyword phrase (e.g. `{{PROJECT_NAME}}: <keyword>`).
|
|
75
|
+
- **Apple keywords never repeat** title/subtitle words (repeats add no
|
|
76
|
+
ranking weight); single words, comma-separated, no spaces.
|
|
77
|
+
- **Play indexes the full description**; Apple does not. Weave keywords
|
|
78
|
+
naturally into the Play description; keep the Apple description pure
|
|
79
|
+
conversion.
|
|
80
|
+
- **Lead with trust**: no ads, no tracking, no account (whichever apply),
|
|
81
|
+
works offline. State the price model plainly.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Publishing checklist (store console clickwork)
|
|
86
|
+
|
|
87
|
+
Neither store exposes paid pricing or metadata via API, so these are
|
|
88
|
+
manual:
|
|
89
|
+
|
|
90
|
+
- ⬜ Create the App Store Connect record; accept Apple Developer terms
|
|
91
|
+
- ⬜ Create the Play Console app record
|
|
92
|
+
- ⬜ Set the price / free (both stores)
|
|
93
|
+
- ⬜ (If IAP) create the subscription/products with the exact IDs above
|
|
94
|
+
- ⬜ Paste the metadata for each language from [`store-copy.md`](./store-copy.md)
|
|
95
|
+
- ⬜ Set the category on both stores
|
|
96
|
+
- ⬜ Upload icon + screenshots (see [`screenshots.md`](./screenshots.md))
|
|
97
|
+
- ⬜ Privacy policy URL live (e.g. `https://{{DOMAIN}}/privacidade`)
|
|
98
|
+
- ⬜ Data safety / privacy nutrition label filled in honestly
|
|
99
|
+
- ⬜ First build submitted via `../../scripts/release-android.sh` (Play)
|
|
100
|
+
and EAS / Xcode (App Store)
|
|
101
|
+
|
|
102
|
+
Bundle IDs (must match `app.json`):
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
iOS {{IOS_BUNDLE_ID}}
|
|
106
|
+
Android {{ANDROID_PACKAGE}}
|
|
107
|
+
```
|
|
@@ -41,6 +41,12 @@ const APP_VERSION = '0.1.0';
|
|
|
41
41
|
const GOOGLE_CLIENT_ID = 'TODO_GOOGLE_WEB_CLIENT_ID';
|
|
42
42
|
const GOOGLE_IOS_CLIENT_ID = 'TODO_GOOGLE_IOS_CLIENT_ID';
|
|
43
43
|
|
|
44
|
+
// Splash behavior. `false` (instant show-up) hides the native splash the
|
|
45
|
+
// moment fonts load — the app feels immediate, no decorative delay.
|
|
46
|
+
// `true` plays the AnimatedSplash (glow + logo + pulse) before the app
|
|
47
|
+
// appears. Set at scaffold time via `umpordez create`.
|
|
48
|
+
export const SPLASH_ANIMATED = {{SPLASH_ANIMATED}};
|
|
49
|
+
|
|
44
50
|
if (__DEV__) {
|
|
45
51
|
console.log(
|
|
46
52
|
`[config] API: ${baseApiUrl} | prod=${isProdBuild} `
|
|
@@ -16,15 +16,32 @@ you find every command in one place.
|
|
|
16
16
|
| `generate-icons.sh` | (Re)generate the icon/splash asset set. From-scratch (uses primary color + first letter) or from a source PNG. |
|
|
17
17
|
| `extract-play-key.js` | Pull `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` out of `server/.env` and write it to `mobile/<slug>/google-play-key.json` for `eas submit`. Called automatically by `release-android.sh`. |
|
|
18
18
|
|
|
19
|
+
## Store screenshots
|
|
20
|
+
|
|
21
|
+
| Script | What it does |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `gen-mockups.mjs` | Render headless store-screen mockups (branded, no simulator) → `mobile/<slug>/screenshots/raw[/-tablet]`. Starting point — edit `SCREENS` + `LABELS` to match your app. |
|
|
24
|
+
| `screenshots-capture.sh` | Drive the Maestro flow on a booted sim/emulator for both locales → raw PNGs. `phone` or `tablet`. Tune labels to your nav. |
|
|
25
|
+
| `screenshots.flow.yaml` | The Maestro flow (reset → onboarding → language → walk tabs → capture). |
|
|
26
|
+
| `frame-shots.mjs` | Frame raw PNGs into branded, captioned, store-ready sizes (iPhone/Android/iPad/tablet). `--selftest`, `--preset=`, `--locale=`. |
|
|
27
|
+
| `appstore-resize.sh` | Re-encode iPhone PNG screenshots to the exact App Store Connect JPEG sizes (6.5/6.7/6.9in), stripping alpha. |
|
|
28
|
+
|
|
29
|
+
See [`../mobile/<slug>/docs/screenshots.md`](../mobile) for the full
|
|
30
|
+
capture → frame → upload workflow, and `docs/store-listing.md` /
|
|
31
|
+
`store-copy.md` / `releases.md` for the listing + release copy.
|
|
32
|
+
|
|
19
33
|
## Server / web stack
|
|
20
34
|
|
|
21
35
|
These live at the project root (one level up from this dir):
|
|
22
36
|
|
|
37
|
+
These live at the project root (one level up from this dir):
|
|
38
|
+
|
|
23
39
|
| Script | What it does |
|
|
24
40
|
|---|---|
|
|
25
41
|
| `install.sh` | Install npm deps for every sub-project. Pass `--native` to also run `expo prebuild`, `pod install`, and `install-native.sh` for mobile. |
|
|
26
42
|
| `seed.sh` | Create the database, run migrations, seed admin user. |
|
|
27
|
-
| `dev.sh` | Start every service that exists (server, admin UI, site UI).
|
|
43
|
+
| `dev.sh` | Start every service that exists (server, admin UI, site UI). Mobile Metro starts by default; pass `--no-mobile` to skip it. |
|
|
44
|
+
| `misc/build-local.sh` | Production build of server + admin SPA + site into `../builds/` with a build number. `--push` to commit + push the builds repo. |
|
|
28
45
|
|
|
29
46
|
## Conventions
|
|
30
47
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Resize iPhone screenshots to App Store Connect sizes.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# ./appstore-resize.sh <file1.png> [file2.png ...]
|
|
7
|
+
# ./appstore-resize.sh *.png
|
|
8
|
+
#
|
|
9
|
+
# Outputs each input as three new files in the same folder:
|
|
10
|
+
# <name>-6.5in.jpg 1242 x 2688 (iPhone XS Max / 11 Pro Max)
|
|
11
|
+
# <name>-6.7in.jpg 1290 x 2796 (iPhone 14 Pro Max class)
|
|
12
|
+
# <name>-6.9in.jpg 1320 x 2868 (iPhone 16 Pro Max)
|
|
13
|
+
#
|
|
14
|
+
# Why JPEG? macOS screenshots are PNG with an alpha channel.
|
|
15
|
+
# App Store Connect rejects PNGs that carry alpha — and reports
|
|
16
|
+
# the failure as "dimensions are wrong", which is misleading.
|
|
17
|
+
# JPEG has no alpha by definition, so re-encoding sidesteps the
|
|
18
|
+
# whole issue. Quality at formatOptions=best is visually lossless
|
|
19
|
+
# for typical UI screenshots.
|
|
20
|
+
|
|
21
|
+
set -euo pipefail
|
|
22
|
+
|
|
23
|
+
if [ $# -eq 0 ]; then
|
|
24
|
+
echo "usage: $0 <screenshot.png> [more.png ...]"
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
# size_label width height
|
|
29
|
+
SIZES=(
|
|
30
|
+
"6.5in 1242 2688"
|
|
31
|
+
"6.7in 1290 2796"
|
|
32
|
+
"6.9in 1320 2868"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
for src in "$@"; do
|
|
36
|
+
if [ ! -f "$src" ]; then
|
|
37
|
+
echo "skip: $src (not found)"
|
|
38
|
+
continue
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
dir=$(dirname "$src")
|
|
42
|
+
base=$(basename "$src")
|
|
43
|
+
name="${base%.*}"
|
|
44
|
+
|
|
45
|
+
w=$(sips -g pixelWidth "$src" | awk '/pixelWidth/ {print $2}')
|
|
46
|
+
h=$(sips -g pixelHeight "$src" | awk '/pixelHeight/ {print $2}')
|
|
47
|
+
if [ -z "$w" ] || [ -z "$h" ]; then
|
|
48
|
+
echo "skip: $src (couldn't read dimensions)"
|
|
49
|
+
continue
|
|
50
|
+
fi
|
|
51
|
+
ratio=$(awk -v a="$w" -v b="$h" 'BEGIN { printf "%.4f", a/b }')
|
|
52
|
+
echo "→ $base ${w}x${h} ratio=$ratio"
|
|
53
|
+
|
|
54
|
+
for entry in "${SIZES[@]}"; do
|
|
55
|
+
read -r label tw th <<< "$entry"
|
|
56
|
+
out="$dir/$name-$label.jpg"
|
|
57
|
+
# One-shot: convert to JPEG at max quality AND resize.
|
|
58
|
+
# `-s format jpeg` drops the alpha channel; --resampleHeightWidth
|
|
59
|
+
# is height-then-width per sips' argument order.
|
|
60
|
+
sips \
|
|
61
|
+
-s format jpeg \
|
|
62
|
+
-s formatOptions best \
|
|
63
|
+
--resampleHeightWidth "$th" "$tw" \
|
|
64
|
+
"$src" \
|
|
65
|
+
--out "$out" > /dev/null
|
|
66
|
+
echo " wrote $out (${tw}x${th})"
|
|
67
|
+
done
|
|
68
|
+
done
|
|
69
|
+
|
|
70
|
+
echo "done."
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* frame-shots — turn raw app screenshots into branded, captioned,
|
|
4
|
+
* store-ready images at the exact sizes Apple / Google require.
|
|
5
|
+
*
|
|
6
|
+
* No design tools: each raw capture is composited into an SVG (brand
|
|
7
|
+
* gradient background + caption + the screenshot with rounded corners
|
|
8
|
+
* and a soft shadow) and rendered to PNG with @resvg/resvg-js.
|
|
9
|
+
* Deterministic and re-runnable.
|
|
10
|
+
*
|
|
11
|
+
* Raw screens come from either:
|
|
12
|
+
* • scripts/gen-mockups.mjs (headless, no simulator), or
|
|
13
|
+
* • Maestro capture (scripts/screenshots-capture.sh)
|
|
14
|
+
* both write to mobile/{{PROJECT_SLUG}}/screenshots/raw[/-tablet]/<locale>/.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* node scripts/frame-shots.mjs # all presets, all locales
|
|
18
|
+
* node scripts/frame-shots.mjs --preset=iphone
|
|
19
|
+
* node scripts/frame-shots.mjs --locale=en
|
|
20
|
+
* node scripts/frame-shots.mjs --selftest # render a demo frame
|
|
21
|
+
*
|
|
22
|
+
* Numeric file prefixes (00-*, 01-*, …) drive order + caption lookup.
|
|
23
|
+
* Edit CAPTIONS below to mirror docs/store-copy.md.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync
|
|
28
|
+
} from 'node:fs';
|
|
29
|
+
import { dirname, join, basename } from 'node:path';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import { createRequire } from 'node:module';
|
|
32
|
+
|
|
33
|
+
const REPO = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
34
|
+
const APP = join(REPO, 'mobile', '{{PROJECT_SLUG}}');
|
|
35
|
+
const require = createRequire(join(APP, 'package.json'));
|
|
36
|
+
const { Resvg } = require('@resvg/resvg-js');
|
|
37
|
+
|
|
38
|
+
const SHOTS = join(APP, 'screenshots');
|
|
39
|
+
// Optional bundled brand fonts — drop .ttf files in assets/fonts to use
|
|
40
|
+
// them. Without any, resvg falls back to a system sans-serif.
|
|
41
|
+
const FONTS = ['Display.ttf', 'Body.ttf']
|
|
42
|
+
.map(f => join(APP, 'assets', 'fonts', f))
|
|
43
|
+
.filter(existsSync);
|
|
44
|
+
const FONT_DISPLAY = 'sans-serif';
|
|
45
|
+
const FONT_BODY = 'sans-serif';
|
|
46
|
+
|
|
47
|
+
// ── Brand gradient (from the project's primary color) ───────────────
|
|
48
|
+
const BRAND_TOP = '{{PRIMARY_COLOR_DARK}}'; // lifted primary (lighter)
|
|
49
|
+
const BRAND_BOT = '{{PRIMARY_COLOR}}'; // primary
|
|
50
|
+
const CAPTION_COLOR = '#FFFFFF';
|
|
51
|
+
|
|
52
|
+
// ── Store size presets ──────────────────────────────────────────────
|
|
53
|
+
const PRESETS = {
|
|
54
|
+
// Apple App Store — iPhone 6.9" (1290×2796) / 6.5" accepted.
|
|
55
|
+
iphone: { source: 'phone', w: 1290, h: 2796, radius: 62, capSize: 80, capTop: 150, sideMargin: 130, gap: 66 },
|
|
56
|
+
// Google Play phone (9:19.5).
|
|
57
|
+
android: { source: 'phone', w: 1080, h: 2340, radius: 52, capSize: 66, capTop: 120, sideMargin: 120, gap: 56 },
|
|
58
|
+
// Apple 13" iPad — required when supportsTablet=true.
|
|
59
|
+
ipad: { source: 'tablet', w: 2048, h: 2732, radius: 44, capSize: 92, capTop: 170, sideMargin: 260, gap: 90 },
|
|
60
|
+
// Google Play tablet (portrait 16:10).
|
|
61
|
+
androidtablet: { source: 'tablet', w: 1600, h: 2560, radius: 40, capSize: 74, capTop: 150, sideMargin: 200, gap: 74 },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ── Captions: headline + subtitle per shot ──────────────────────────
|
|
65
|
+
// Keyed by the numeric filename prefix. Mirror docs/store-copy.md; the
|
|
66
|
+
// first + last carry the strongest download pitch (most visitors only
|
|
67
|
+
// see the first 2–3 frames). Add keys as you add screens.
|
|
68
|
+
const CAPTIONS = {
|
|
69
|
+
'00': {
|
|
70
|
+
'pt-BR': { h: 'A tela inicial', s: 'Tudo o que importa, à mão' },
|
|
71
|
+
en: { h: 'Your home screen', s: 'Everything that matters, at hand' },
|
|
72
|
+
},
|
|
73
|
+
'01': {
|
|
74
|
+
'pt-BR': { h: 'Explore e encontre', s: 'Busque em um instante' },
|
|
75
|
+
en: { h: 'Explore and find', s: 'Search in an instant' },
|
|
76
|
+
},
|
|
77
|
+
'02': {
|
|
78
|
+
'pt-BR': { h: 'Sua biblioteca', s: 'Organizado do seu jeito' },
|
|
79
|
+
en: { h: 'Your library', s: 'Organized your way' },
|
|
80
|
+
},
|
|
81
|
+
'03': {
|
|
82
|
+
'pt-BR': { h: 'Feito para você', s: 'Local, privado, sem anúncios' },
|
|
83
|
+
en: { h: 'Made for you', s: 'Local, private, no ads' },
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
88
|
+
function pngSize(buf) {
|
|
89
|
+
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function wrap(text, maxChars, maxLines = 2) {
|
|
93
|
+
const words = text.split(' ');
|
|
94
|
+
const lines = [];
|
|
95
|
+
let cur = '';
|
|
96
|
+
for (const word of words) {
|
|
97
|
+
const next = cur ? `${cur} ${word}` : word;
|
|
98
|
+
if (next.length > maxChars && cur) {
|
|
99
|
+
lines.push(cur);
|
|
100
|
+
cur = word;
|
|
101
|
+
} else {
|
|
102
|
+
cur = next;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (cur) {
|
|
106
|
+
lines.push(cur);
|
|
107
|
+
}
|
|
108
|
+
return lines.slice(0, maxLines);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function esc(s) {
|
|
112
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildSvg(preset, caption, imgDataUri, imgW, imgH) {
|
|
116
|
+
const { w, h, radius, capSize, capTop, sideMargin, gap } = preset;
|
|
117
|
+
const head = caption?.h ?? '';
|
|
118
|
+
const sub = caption?.s ?? '';
|
|
119
|
+
|
|
120
|
+
const maxImgW = w - sideMargin * 2;
|
|
121
|
+
const maxTextW = w - Math.round(sideMargin * 0.7) * 2;
|
|
122
|
+
const headLines = wrap(head, Math.floor(maxTextW / (capSize * 0.54)));
|
|
123
|
+
const subSize = Math.round(capSize * 0.5);
|
|
124
|
+
const subLines = sub ? wrap(sub, Math.floor(maxTextW / (subSize * 0.52))) : [];
|
|
125
|
+
const headLineH = capSize * 1.16;
|
|
126
|
+
const subLineH = subSize * 1.4;
|
|
127
|
+
const headBottom = capTop + capSize + (headLines.length - 1) * headLineH;
|
|
128
|
+
const subTop = headBottom + (subLines.length ? subSize * 1.7 : 0);
|
|
129
|
+
const capBlock = subLines.length
|
|
130
|
+
? subTop + (subLines.length - 1) * subLineH + subSize * 0.4
|
|
131
|
+
: headBottom + capSize * 0.2;
|
|
132
|
+
const availH = h - capBlock - gap - Math.round(h * 0.03);
|
|
133
|
+
|
|
134
|
+
let drawW = maxImgW;
|
|
135
|
+
let drawH = (drawW * imgH) / imgW;
|
|
136
|
+
if (drawH > availH) {
|
|
137
|
+
drawH = availH;
|
|
138
|
+
drawW = (drawH * imgW) / imgH;
|
|
139
|
+
}
|
|
140
|
+
const drawX = (w - drawW) / 2;
|
|
141
|
+
const drawY = capBlock + gap;
|
|
142
|
+
|
|
143
|
+
const caps = headLines.map((line, i) =>
|
|
144
|
+
`<text x="${w / 2}" y="${capTop + capSize + i * headLineH}" text-anchor="middle" ` +
|
|
145
|
+
`font-family="${FONT_DISPLAY}" font-weight="700" font-size="${capSize}" fill="${CAPTION_COLOR}">${esc(line)}</text>`
|
|
146
|
+
).concat(subLines.map((line, i) =>
|
|
147
|
+
`<text x="${w / 2}" y="${subTop + i * subLineH}" text-anchor="middle" ` +
|
|
148
|
+
`font-family="${FONT_BODY}" font-weight="600" font-size="${subSize}" fill="${CAPTION_COLOR}" fill-opacity="0.82">${esc(line)}</text>`
|
|
149
|
+
)).join('\n');
|
|
150
|
+
|
|
151
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
|
|
152
|
+
<defs>
|
|
153
|
+
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
|
154
|
+
<stop offset="0" stop-color="${BRAND_TOP}"/>
|
|
155
|
+
<stop offset="1" stop-color="${BRAND_BOT}"/>
|
|
156
|
+
</linearGradient>
|
|
157
|
+
<clipPath id="round"><rect x="${drawX}" y="${drawY}" width="${drawW}" height="${drawH}" rx="${radius}" ry="${radius}"/></clipPath>
|
|
158
|
+
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
159
|
+
<feDropShadow dx="0" dy="24" stdDeviation="40" flood-color="#000000" flood-opacity="0.30"/>
|
|
160
|
+
</filter>
|
|
161
|
+
</defs>
|
|
162
|
+
<rect width="${w}" height="${h}" fill="url(#bg)"/>
|
|
163
|
+
<ellipse cx="${w / 2}" cy="${drawY + drawH * 0.28}" rx="${w * 0.64}" ry="${h * 0.26}" fill="#FFFFFF" opacity="0.08"/>
|
|
164
|
+
${caps}
|
|
165
|
+
<rect x="${drawX}" y="${drawY}" width="${drawW}" height="${drawH}" rx="${radius}" ry="${radius}" fill="#000" filter="url(#shadow)"/>
|
|
166
|
+
<image href="${imgDataUri}" x="${drawX}" y="${drawY}" width="${drawW}" height="${drawH}" preserveAspectRatio="xMidYMid slice" clip-path="url(#round)"/>
|
|
167
|
+
<rect x="${drawX}" y="${drawY}" width="${drawW}" height="${drawH}" rx="${radius}" ry="${radius}" fill="none" stroke="#FFFFFF" stroke-opacity="0.16" stroke-width="2"/>
|
|
168
|
+
</svg>`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function render(svg, w) {
|
|
172
|
+
return new Resvg(svg, {
|
|
173
|
+
fitTo: { mode: 'width', value: w },
|
|
174
|
+
font: {
|
|
175
|
+
fontFiles: FONTS,
|
|
176
|
+
loadSystemFonts: FONTS.length === 0,
|
|
177
|
+
defaultFontFamily: 'sans-serif'
|
|
178
|
+
},
|
|
179
|
+
}).render().asPng();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const RAW_DIR = { phone: 'raw', tablet: 'raw-tablet' };
|
|
183
|
+
|
|
184
|
+
function selftest() {
|
|
185
|
+
const demo = `<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="2340">
|
|
186
|
+
<rect width="1080" height="2340" fill="#0F1416"/>
|
|
187
|
+
<rect x="80" y="300" width="920" height="240" rx="34" fill="${BRAND_BOT}"/>
|
|
188
|
+
<text x="130" y="440" font-family="${FONT_DISPLAY}" font-weight="700" font-size="120" fill="#FFFFFF">1</text>
|
|
189
|
+
</svg>`;
|
|
190
|
+
const raw = render(demo, 1080);
|
|
191
|
+
const { w, h } = pngSize(raw);
|
|
192
|
+
const uri = `data:image/png;base64,${raw.toString('base64')}`;
|
|
193
|
+
const outDir = join(SHOTS, 'store', '_selftest');
|
|
194
|
+
mkdirSync(outDir, { recursive: true });
|
|
195
|
+
for (const [name, preset] of Object.entries(PRESETS)) {
|
|
196
|
+
writeFileSync(join(outDir, `${name}.png`),
|
|
197
|
+
render(buildSvg(preset, CAPTIONS['00']['pt-BR'], uri, w, h), preset.w));
|
|
198
|
+
console.log(` ✓ ${name}.png (${preset.w}×${preset.h})`);
|
|
199
|
+
}
|
|
200
|
+
console.log(`Self-test frames → ${outDir}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function frameAll(opts) {
|
|
204
|
+
const presets = Object.entries(PRESETS).filter(([n]) => !opts.preset || n === opts.preset);
|
|
205
|
+
const anyRaw = Object.values(RAW_DIR).some(d => existsSync(join(SHOTS, d)));
|
|
206
|
+
if (!anyRaw) {
|
|
207
|
+
console.error('No raw screenshots under mobile/{{PROJECT_SLUG}}/screenshots/raw[/-tablet].');
|
|
208
|
+
console.error('Generate them: node scripts/gen-mockups.mjs');
|
|
209
|
+
console.error('Or preview framing: node scripts/frame-shots.mjs --selftest');
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
let count = 0;
|
|
213
|
+
for (const [presetName, preset] of presets) {
|
|
214
|
+
const rawRoot = join(SHOTS, RAW_DIR[preset.source]);
|
|
215
|
+
if (!existsSync(rawRoot)) {
|
|
216
|
+
console.log(` – ${presetName}: skipped (no ${RAW_DIR[preset.source]}/ yet)`);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const locales = readdirSync(rawRoot).filter(d => !opts.locale || d === opts.locale);
|
|
220
|
+
for (const locale of locales) {
|
|
221
|
+
const files = readdirSync(join(rawRoot, locale)).filter(f => f.endsWith('.png')).sort();
|
|
222
|
+
const outDir = join(SHOTS, 'store', presetName, locale);
|
|
223
|
+
mkdirSync(outDir, { recursive: true });
|
|
224
|
+
for (const file of files) {
|
|
225
|
+
const key = basename(file).slice(0, 2);
|
|
226
|
+
const caption = CAPTIONS[key]?.[locale] ?? CAPTIONS[key]?.en ?? { h: '', s: '' };
|
|
227
|
+
const raw = readFileSync(join(rawRoot, locale, file));
|
|
228
|
+
const { w, h } = pngSize(raw);
|
|
229
|
+
const uri = `data:image/png;base64,${raw.toString('base64')}`;
|
|
230
|
+
writeFileSync(join(outDir, file), render(buildSvg(preset, caption, uri, w, h), preset.w));
|
|
231
|
+
count++;
|
|
232
|
+
}
|
|
233
|
+
console.log(` ✓ ${presetName}/${locale}: ${files.length} frames`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
console.log(`Done — ${count} store images under mobile/{{PROJECT_SLUG}}/screenshots/store/`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const opts = Object.fromEntries(
|
|
240
|
+
process.argv.slice(2).map(a => a.replace(/^--/, '').split('=')).map(([k, v]) => [k, v ?? true])
|
|
241
|
+
);
|
|
242
|
+
if (opts.selftest) {
|
|
243
|
+
selftest();
|
|
244
|
+
} else {
|
|
245
|
+
frameAll(opts);
|
|
246
|
+
}
|