This commit is contained in:
alexandrev-tibco
2026-02-01 22:47:26 +01:00
parent 3692e46cf9
commit 6b405419cf
8 changed files with 1057 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
APP_NAME=portfoliojournal-og
BUILD_DIR=bin
PORT?=8090
.PHONY: build run clean install-service
build:
@mkdir -p $(BUILD_DIR)
GO111MODULE=on go build -o $(BUILD_DIR)/$(APP_NAME) .
run:
PORT=$(PORT) GO111MODULE=on go run .
clean:
@rm -rf $(BUILD_DIR)
install-service: build
@echo "Install the binary and OpenRC service using the instructions in README.md"
+81
View File
@@ -0,0 +1,81 @@
# OG Share Service
This service powers dynamic Open Graph share pages and images for Portfolio Journal.
## Endpoints
- `/share?data=BASE64URL` renders the HTML page with OG tags.
- `/og?data=BASE64URL` renders the 1200x630 PNG image.
Payload (JSON, base64url-encoded):
```json
{
"n": "Goal name",
"r": 12500.5,
"g": 20000,
"c": "USD",
"td": "2026-12-31",
"ec": "2026-10-15",
"p": false
}
```
Fields:
- `n` goal name
- `r` current amount
- `g` goal amount
- `c` currency code (ISO 4217)
- `td` target date (YYYY-MM-DD, optional)
- `ec` estimated completion date (YYYY-MM-DD, optional)
- `p` privacy mode; when true, hides the current amount
## Run locally
```bash
cd og-service
GO111MODULE=on go run .
```
## OpenRC (Alpine) service
Example steps (requires root):
```bash
# Build and install the binary
make build
cp og-service/bin/portfoliojournal-og /usr/local/bin/portfoliojournal-og
chmod +x /usr/local/bin/portfoliojournal-og
# Install the OpenRC script
cp og-service/portfoliojournal-og.openrc /etc/init.d/portfoliojournal-og
chmod +x /etc/init.d/portfoliojournal-og
# Enable and start
rc-update add portfoliojournal-og default
rc-service portfoliojournal-og start
```
Or run the helper script (requires root):
```bash
sudo sh og-service/install-openrc.sh
```
## NGINX example
```nginx
location /share {
proxy_pass http://127.0.0.1:8090;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
location /og {
proxy_pass http://127.0.0.1:8090;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
```
+9
View File
@@ -0,0 +1,9 @@
module portfoliojournal.app/ogservice
go 1.22
require (
github.com/fogleman/gg v1.3.0
golang.org/x/image v0.21.0
golang.org/x/text v0.17.0
)
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
set -e
APP_NAME="portfoliojournal-og"
SERVICE_FILE="/etc/init.d/${APP_NAME}"
BIN_TARGET="/usr/local/bin/${APP_NAME}"
echo "Building ${APP_NAME}..."
make -C "$(dirname "$0")" build
echo "Installing binary to ${BIN_TARGET}..."
cp "$(dirname "$0")/bin/${APP_NAME}" "${BIN_TARGET}"
chmod +x "${BIN_TARGET}"
echo "Installing OpenRC service to ${SERVICE_FILE}..."
cp "$(dirname "$0")/${APP_NAME}.openrc" "${SERVICE_FILE}"
chmod +x "${SERVICE_FILE}"
echo "Enabling service..."
rc-update add "${APP_NAME}" default
echo "Starting service..."
rc-service "${APP_NAME}" start
echo "Done."
+626
View File
@@ -0,0 +1,626 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"image/color"
"log"
"math"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/fogleman/gg"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/gofont/goregular"
"golang.org/x/image/font/opentype"
"golang.org/x/text/currency"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
const (
imageWidth = 1200
imageHeight = 630
)
type shareData struct {
Name string `json:"n"`
RealAmount json.Number `json:"r"`
GoalAmount json.Number `json:"g"`
Currency string `json:"c"`
TargetDate string `json:"td"`
EstimateCompletion string `json:"ec"`
PrivateMode bool `json:"p"`
}
type shareView struct {
Title string
Description string
OGImageURL string
ShareURL string
GoalName string
CurrentAmount string
TargetAmount string
PercentText string
ProgressPercent float64
TargetDateLabel string
EstimateDateLabel string
PrivateMode bool
FormattedGoalAmount string
}
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8090"
}
http.HandleFunc("/share", shareHandler)
http.HandleFunc("/og", ogHandler)
log.Printf("og-service listening on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
func shareHandler(w http.ResponseWriter, r *http.Request) {
dataParam := r.URL.Query().Get("data")
if dataParam == "" {
http.Error(w, "missing data parameter", http.StatusBadRequest)
return
}
payload, err := decodeData(dataParam)
if err != nil {
http.Error(w, "invalid data parameter", http.StatusBadRequest)
return
}
view := buildShareView(payload, r, dataParam)
html := renderShareHTML(view)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(html))
}
func ogHandler(w http.ResponseWriter, r *http.Request) {
dataParam := r.URL.Query().Get("data")
if dataParam == "" {
http.Error(w, "missing data parameter", http.StatusBadRequest)
return
}
payload, err := decodeData(dataParam)
if err != nil {
http.Error(w, "invalid data parameter", http.StatusBadRequest)
return
}
pngBytes, err := renderOGImage(payload)
if err != nil {
http.Error(w, "failed to render image", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "public, max-age=3600")
_, _ = w.Write(pngBytes)
}
func decodeData(param string) (shareData, error) {
var data shareData
decoded, err := base64.RawURLEncoding.DecodeString(param)
if err != nil {
decoded, err = base64.URLEncoding.DecodeString(param)
if err != nil {
return data, err
}
}
dec := json.NewDecoder(bytes.NewReader(decoded))
dec.UseNumber()
if err := dec.Decode(&data); err != nil {
return data, err
}
data.Name = strings.TrimSpace(data.Name)
data.Currency = strings.ToUpper(strings.TrimSpace(data.Currency))
if data.Currency == "" {
data.Currency = "USD"
}
return data, nil
}
func buildShareView(data shareData, r *http.Request, rawParam string) shareView {
goalAmount, _ := numberToFloat(data.GoalAmount)
realAmount, _ := numberToFloat(data.RealAmount)
percent := 0.0
if goalAmount > 0 {
percent = (realAmount / goalAmount) * 100
}
percentText := "Progress unavailable"
if goalAmount > 0 {
percentText = fmt.Sprintf("%.0f%% complete", percent)
}
currencyTag := localeForCurrency(data.Currency)
formattedGoal := formatCurrency(goalAmount, data.Currency, currencyTag)
formattedReal := formatCurrency(realAmount, data.Currency, currencyTag)
currentAmount := formattedReal
if data.PrivateMode {
currentAmount = "Hidden"
}
goalName := data.Name
if goalName == "" {
goalName = "Investment Goal"
}
description := fmt.Sprintf("%s toward %s", percentText, formattedGoal)
if data.PrivateMode {
description = fmt.Sprintf("Goal progress: %s", percentText)
}
baseURL := buildBaseURL(r)
shareURL := fmt.Sprintf("%s/share?data=%s", baseURL, url.QueryEscape(rawParam))
ogImageURL := fmt.Sprintf("%s/og?data=%s", baseURL, url.QueryEscape(rawParam))
return shareView{
Title: fmt.Sprintf("Portfolio Journal — %s", goalName),
Description: description,
OGImageURL: ogImageURL,
ShareURL: shareURL,
GoalName: goalName,
CurrentAmount: currentAmount,
TargetAmount: formattedGoal,
PercentText: percentText,
ProgressPercent: percent,
TargetDateLabel: formatDateLabel("Target", data.TargetDate),
EstimateDateLabel: formatDateLabel("Est. completion", data.EstimateCompletion),
PrivateMode: data.PrivateMode,
FormattedGoalAmount: formattedGoal,
}
}
func renderShareHTML(view shareView) string {
htmlTemplate := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>` + escapeHTML(view.Title) + `</title>
<meta name="title" content="` + escapeHTML(view.Title) + `">
<meta name="description" content="` + escapeHTML(view.Description) + `">
<meta name="author" content="Portfolio Journal">
<meta name="robots" content="noindex, nofollow">
<meta property="og:type" content="website">
<meta property="og:url" content="` + escapeHTML(view.ShareURL) + `">
<meta property="og:title" content="` + escapeHTML(view.Title) + `">
<meta property="og:description" content="` + escapeHTML(view.Description) + `">
<meta property="og:image" content="` + escapeHTML(view.OGImageURL) + `">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:site_name" content="Portfolio Journal">
<meta property="og:locale" content="en_US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="` + escapeHTML(view.ShareURL) + `">
<meta name="twitter:title" content="` + escapeHTML(view.Title) + `">
<meta name="twitter:description" content="` + escapeHTML(view.Description) + `">
<meta name="twitter:image" content="` + escapeHTML(view.OGImageURL) + `">
<meta name="apple-itunes-app" content="app-id=6744983373">
<link rel="canonical" href="` + escapeHTML(view.ShareURL) + `">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
color: #fff;
}
.card {
max-width: 620px;
width: 100%;
background: rgba(255,255,255,0.12);
border-radius: 24px;
padding: 40px;
backdrop-filter: blur(18px);
box-shadow: 0 24px 60px rgba(0,0,0,0.25);
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.75rem;
opacity: 0.8;
margin-bottom: 12px;
}
h1 {
font-size: 2rem;
margin-bottom: 16px;
}
.amounts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.amounts span {
display: block;
font-size: 0.9rem;
opacity: 0.8;
}
.amounts strong {
display: block;
font-size: 1.5rem;
margin-top: 6px;
}
.progress {
height: 14px;
border-radius: 999px;
background: rgba(255,255,255,0.25);
overflow: hidden;
margin-top: 16px;
}
.progress .bar {
height: 100%;
background: #fff;
width: ` + fmt.Sprintf("%.2f", math.Max(0, math.Min(100, view.ProgressPercent))) + `%;
}
.meta {
margin-top: 18px;
font-size: 0.9rem;
opacity: 0.85;
}
.cta {
margin-top: 28px;
display: inline-flex;
align-items: center;
gap: 10px;
background: #fff;
color: #5c5bd4;
padding: 12px 20px;
border-radius: 14px;
font-weight: 600;
text-decoration: none;
}
</style>
</head>
<body>
<div class="card">
<div class="eyebrow">Portfolio Journal</div>
<h1>` + escapeHTML(view.GoalName) + `</h1>
<div class="amounts">
<div>
<span>Current amount</span>
<strong>` + escapeHTML(view.CurrentAmount) + `</strong>
</div>
<div>
<span>Goal amount</span>
<strong>` + escapeHTML(view.TargetAmount) + `</strong>
</div>
</div>
<div>` + escapeHTML(view.PercentText) + `</div>
<div class="progress"><div class="bar"></div></div>
<div class="meta">` + escapeHTML(joinLabels(view.TargetDateLabel, view.EstimateDateLabel)) + `</div>
<a class="cta" href="https://apps.apple.com/app/portfolio-journal/id6744983373">Download on the App Store</a>
</div>
</body>
</html>`
return htmlTemplate
}
func renderOGImage(data shareData) ([]byte, error) {
goalAmount, _ := numberToFloat(data.GoalAmount)
realAmount, _ := numberToFloat(data.RealAmount)
percent := 0.0
if goalAmount > 0 {
percent = (realAmount / goalAmount) * 100
}
currencyTag := localeForCurrency(data.Currency)
formattedGoal := formatCurrency(goalAmount, data.Currency, currencyTag)
formattedReal := formatCurrency(realAmount, data.Currency, currencyTag)
if data.PrivateMode {
formattedReal = "Hidden"
}
goalName := data.Name
if goalName == "" {
goalName = "Investment Goal"
}
dc := gg.NewContext(imageWidth, imageHeight)
grad := gg.NewLinearGradient(0, 0, imageWidth, imageHeight)
grad.AddColorStop(0, color.RGBA{0x66, 0x7e, 0xea, 0xff})
grad.AddColorStop(1, color.RGBA{0x76, 0x4b, 0xa2, 0xff})
dc.SetFillStyle(grad)
dc.DrawRectangle(0, 0, imageWidth, imageHeight)
dc.Fill()
marginX := 80.0
marginY := 80.0
// App icon placeholder
dc.SetRGBA(1, 1, 1, 0.2)
dc.DrawRoundedRectangle(marginX, marginY, 96, 96, 22)
dc.Fill()
iconFace, err := loadFontFace(38, true)
if err != nil {
return nil, err
}
dc.SetFontFace(iconFace)
dc.SetRGBA(1, 1, 1, 0.9)
dc.DrawStringAnchored("PJ", marginX+48, marginY+52, 0.5, 0.5)
labelFace, err := loadFontFace(20, false)
if err != nil {
return nil, err
}
dc.SetFontFace(labelFace)
dc.SetRGBA(1, 1, 1, 0.8)
dc.DrawString("Portfolio Journal", marginX+120, marginY+34)
titleFace, err := loadFontFace(54, true)
if err != nil {
return nil, err
}
dc.SetFontFace(titleFace)
dc.SetRGBA(1, 1, 1, 1)
goalName = fitText(dc, goalName, imageWidth-160)
dc.DrawStringWrapped(goalName, marginX, marginY+130, 0, 0, imageWidth-160, 1.3, gg.AlignLeft)
valueFace, err := loadFontFace(36, true)
if err != nil {
return nil, err
}
labelSmallFace, err := loadFontFace(18, false)
if err != nil {
return nil, err
}
dc.SetFontFace(labelSmallFace)
dc.SetRGBA(1, 1, 1, 0.75)
dc.DrawString("Current", marginX, marginY+260)
dc.DrawString("Goal", marginX+420, marginY+260)
dc.SetFontFace(valueFace)
dc.SetRGBA(1, 1, 1, 1)
dc.DrawString(formattedReal, marginX, marginY+305)
dc.DrawString(formattedGoal, marginX+420, marginY+305)
percentText := "Progress unavailable"
if goalAmount > 0 {
percentText = fmt.Sprintf("%.0f%% complete", percent)
}
if data.PrivateMode {
percentText = strings.ReplaceAll(percentText, "complete", "of goal")
}
dc.SetFontFace(labelSmallFace)
dc.SetRGBA(1, 1, 1, 0.85)
dc.DrawString(percentText, marginX, marginY+360)
barWidth := 720.0
barHeight := 18.0
barX := marginX
barY := marginY + 380
progress := math.Max(0, math.Min(100, percent)) / 100
dc.SetRGBA(1, 1, 1, 0.25)
dc.DrawRoundedRectangle(barX, barY, barWidth, barHeight, 9)
dc.Fill()
dc.SetRGBA(1, 1, 1, 0.9)
dc.DrawRoundedRectangle(barX, barY, barWidth*progress, barHeight, 9)
dc.Fill()
meta := joinLabels(formatDateLabel("Target", data.TargetDate), formatDateLabel("Est. completion", data.EstimateCompletion))
if meta != "" {
dc.SetFontFace(labelSmallFace)
dc.SetRGBA(1, 1, 1, 0.7)
dc.DrawString(meta, marginX, marginY+430)
}
ctaFace, err := loadFontFace(20, true)
if err != nil {
return nil, err
}
dc.SetFontFace(ctaFace)
dc.SetRGBA(1, 1, 1, 0.9)
dc.DrawString("Available on the App Store", marginX, imageHeight-70)
buf := bytes.NewBuffer(nil)
if err := dc.EncodePNG(buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func numberToFloat(n json.Number) (float64, bool) {
if n.String() == "" {
return 0, false
}
value, err := n.Float64()
if err != nil {
return 0, false
}
return value, true
}
func formatCurrency(amount float64, code string, tag language.Tag) string {
unit, err := currency.ParseISO(code)
if err != nil {
unit = currency.USD
}
printer := message.NewPrinter(tag)
return printer.Sprintf("%v", currency.Amount(amount, unit))
}
func localeForCurrency(code string) language.Tag {
switch code {
case "EUR":
return language.MustParse("es-ES")
case "GBP":
return language.MustParse("en-GB")
case "JPY":
return language.MustParse("ja-JP")
case "CNY":
return language.MustParse("zh-CN")
case "HKD":
return language.MustParse("zh-HK")
case "KRW":
return language.MustParse("ko-KR")
case "INR":
return language.MustParse("en-IN")
case "BRL":
return language.MustParse("pt-BR")
case "MXN":
return language.MustParse("es-MX")
case "ARS":
return language.MustParse("es-AR")
case "CLP":
return language.MustParse("es-CL")
case "COP":
return language.MustParse("es-CO")
case "CAD":
return language.MustParse("en-CA")
case "AUD":
return language.MustParse("en-AU")
case "NZD":
return language.MustParse("en-NZ")
case "CHF":
return language.MustParse("de-CH")
case "SEK":
return language.MustParse("sv-SE")
case "NOK":
return language.MustParse("nb-NO")
case "DKK":
return language.MustParse("da-DK")
case "PLN":
return language.MustParse("pl-PL")
case "CZK":
return language.MustParse("cs-CZ")
case "HUF":
return language.MustParse("hu-HU")
case "TRY":
return language.MustParse("tr-TR")
case "ZAR":
return language.MustParse("en-ZA")
case "AED":
return language.MustParse("ar-AE")
default:
return language.MustParse("en-US")
}
}
func formatDateLabel(prefix, dateString string) string {
if dateString == "" {
return ""
}
parsed, err := time.Parse("2006-01-02", dateString)
if err != nil {
return ""
}
return fmt.Sprintf("%s: %s", prefix, parsed.Format("Jan 2, 2006"))
}
func joinLabels(primary, secondary string) string {
if primary == "" && secondary == "" {
return ""
}
if primary == "" {
return secondary
}
if secondary == "" {
return primary
}
return primary + " • " + secondary
}
func buildBaseURL(r *http.Request) string {
proto := r.Header.Get("X-Forwarded-Proto")
if proto == "" {
proto = "https"
}
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
return fmt.Sprintf("%s://%s", proto, host)
}
func escapeHTML(input string) string {
replacer := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
"\"", "&quot;",
"'", "&#39;",
)
return replacer.Replace(input)
}
func loadFontFace(size float64, bold bool) (font.Face, error) {
var fontBytes []byte
if bold {
fontBytes = gobold.TTF
} else {
fontBytes = goregular.TTF
}
parsed, err := opentype.Parse(fontBytes)
if err != nil {
return nil, err
}
face, err := opentype.NewFace(parsed, &opentype.FaceOptions{
Size: size,
DPI: 72,
Hinting: font.HintingFull,
})
if err != nil {
return nil, err
}
return face, nil
}
func fitText(dc *gg.Context, text string, maxWidth float64) string {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return ""
}
if width, _ := dc.MeasureString(trimmed); width <= maxWidth {
return trimmed
}
runes := []rune(trimmed)
for len(runes) > 0 {
candidate := string(runes) + "…"
if width, _ := dc.MeasureString(candidate); width <= maxWidth {
return candidate
}
runes = runes[:len(runes)-1]
}
return trimmed
}
+21
View File
@@ -0,0 +1,21 @@
#!/sbin/openrc-run
name="portfoliojournal-og"
description="Portfolio Journal Open Graph share service"
command="/usr/local/bin/portfoliojournal-og"
command_args=""
command_background="yes"
output_log="/var/log/portfoliojournal-og.log"
error_log="/var/log/portfoliojournal-og.err"
pidfile="/run/portfoliojournal-og.pid"
start_pre() {
checkpath --file --mode 0644 --owner root:root "$output_log"
checkpath --file --mode 0644 --owner root:root "$error_log"
}
depend() {
need net
after firewall
}
+74
View File
@@ -0,0 +1,74 @@
# Open Graph Image Specification
## Required Image: `og-share-card.png`
Create this image and place it at: `https://portfoliojournal.app/images/og-share-card.png`
### Dimensions
- **Size:** 1200 x 630 pixels (Facebook/LinkedIn optimal)
- **Format:** PNG or JPG
- **File size:** Under 1MB recommended
### Design Recommendations
```
+----------------------------------------------------------+
| |
| [App Icon] |
| |
| Portfolio Journal |
| ───────────────── |
| |
| Track your investment goals |
| and celebrate your wins. |
| |
| [Progress bar illustration] |
| |
| ★ Goal Tracking ★ Smart Predictions |
| ★ Portfolio Analytics ★ Privacy Focused |
| |
| [App Store Badge] |
| |
+----------------------------------------------------------+
```
### Color Scheme
- **Background:** Gradient from #667eea to #764ba2 (match your app branding)
- **Text:** White
- **Accent:** Your app's accent color
### Elements to Include
1. App icon (prominent, top-left or center)
2. App name "Portfolio Journal"
3. Tagline or value proposition
4. Visual element (progress bar, chart icon, or screenshot)
5. Feature highlights
6. App Store badge (optional)
### Tools to Create
- Figma (free template available)
- Canva
- Adobe Illustrator/Photoshop
## Testing Your OG Tags
After deploying, test with these tools:
- **Facebook:** https://developers.facebook.com/tools/debug/
- **Twitter:** https://cards-dev.twitter.com/validator
- **LinkedIn:** https://www.linkedin.com/post-inspector/
- **General:** https://www.opengraph.xyz/
## Files to Deploy
1. `/share/index.html` (or configure your server to serve `share.html` at `/share`)
2. `/images/og-share-card.png`
3. `/images/app-icon.png` (your app icon, 120x120 or larger)
## Apple Smart App Banner
The HTML includes this meta tag:
```html
<meta name="apple-itunes-app" content="app-id=6744983373">
```
This shows a smart banner on iOS Safari prompting users to download your app.
+203
View File
@@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Portfolio Journal - Track Your Investment Goals</title>
<!-- Primary Meta Tags -->
<meta name="title" content="Portfolio Journal - Track Your Investment Goals">
<meta name="description" content="Set financial goals, track your progress, and celebrate your investment milestones. Available on the App Store.">
<meta name="keywords" content="investment tracker, portfolio tracker, financial goals, investment app, iOS app, wealth tracking">
<meta name="author" content="Portfolio Journal">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://portfoliojournal.app/share">
<meta property="og:title" content="Portfolio Journal - Track Your Investment Goals">
<meta property="og:description" content="Set financial goals, track your progress, and celebrate your investment milestones. Download now on the App Store.">
<meta property="og:image" content="https://portfoliojournal.app/images/og-share-card.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:site_name" content="Portfolio Journal">
<meta property="og:locale" content="en_US">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="https://portfoliojournal.app/share">
<meta name="twitter:title" content="Portfolio Journal - Track Your Investment Goals">
<meta name="twitter:description" content="Set financial goals, track your progress, and celebrate your investment milestones. Download now on the App Store.">
<meta name="twitter:image" content="https://portfoliojournal.app/images/og-share-card.png">
<!-- Apple Smart App Banner -->
<meta name="apple-itunes-app" content="app-id=6744983373">
<!-- Favicon -->
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<!-- Canonical URL -->
<link rel="canonical" href="https://portfoliojournal.app/share">
<!-- Schema.org structured data for SEO/AEO -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "MobileApplication",
"name": "Portfolio Journal",
"operatingSystem": "iOS",
"applicationCategory": "FinanceApplication",
"description": "Track your investment portfolio, set financial goals, and monitor your progress with beautiful charts and predictions.",
"url": "https://portfoliojournal.app",
"downloadUrl": "https://apps.apple.com/app/portfolio-journal/id6744983373",
"screenshot": "https://portfoliojournal.app/images/og-share-card.png",
"author": {
"@type": "Organization",
"name": "Portfolio Journal"
},
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "5",
"ratingCount": "1"
}
}
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
text-align: center;
color: white;
max-width: 480px;
}
.app-icon {
width: 120px;
height: 120px;
border-radius: 24px;
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
margin-bottom: 24px;
}
h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 12px;
}
.tagline {
font-size: 1.1rem;
opacity: 0.9;
margin-bottom: 32px;
line-height: 1.5;
}
.features {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
margin-bottom: 32px;
}
.feature {
background: rgba(255,255,255,0.15);
padding: 8px 16px;
border-radius: 20px;
font-size: 0.9rem;
backdrop-filter: blur(10px);
}
.download-btn {
display: inline-flex;
align-items: center;
gap: 12px;
background: white;
color: #667eea;
padding: 16px 32px;
border-radius: 14px;
text-decoration: none;
font-weight: 600;
font-size: 1.1rem;
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
transition: transform 0.2s, box-shadow 0.2s;
}
.download-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 30px rgba(0,0,0,0.3);
}
.download-btn svg {
width: 24px;
height: 24px;
}
.footer {
margin-top: 40px;
font-size: 0.85rem;
opacity: 0.7;
}
.footer a {
color: white;
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<!-- Replace with your actual app icon -->
<img src="/images/app-icon.png" alt="Portfolio Journal" class="app-icon">
<h1>Portfolio Journal</h1>
<p class="tagline">Track your investment goals and celebrate your financial milestones.</p>
<div class="features">
<span class="feature">Goal Tracking</span>
<span class="feature">Portfolio Analytics</span>
<span class="feature">Smart Predictions</span>
<span class="feature">Privacy Focused</span>
</div>
<a href="https://apps.apple.com/app/portfolio-journal/id6744983373" class="download-btn">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
</svg>
Download on App Store
</a>
<p class="footer">
<a href="https://portfoliojournal.app">portfoliojournal.app</a>
</p>
</div>
<!-- Auto-redirect to App Store after 3 seconds (optional) -->
<script>
// Uncomment to enable auto-redirect
// setTimeout(function() {
// window.location.href = 'https://apps.apple.com/app/portfolio-journal/id6744983373';
// }, 3000);
</script>
</body>
</html>