Improve goal sharing experience
This commit is contained in:
@@ -1,44 +1,190 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import CoreImage.CIFilterBuiltins
|
||||||
|
|
||||||
class GoalShareService {
|
class GoalShareService {
|
||||||
static let shared = GoalShareService()
|
static let shared = GoalShareService()
|
||||||
|
|
||||||
|
/// App Store URL - auto-redirects to user's country
|
||||||
|
static let appStoreURL = URL(string: "https://apps.apple.com/app/portfolio-journal/id6744983373")!
|
||||||
|
|
||||||
|
/// Website URL - has Open Graph tags for social media previews
|
||||||
|
static let websiteURL = URL(string: "https://portfoliojournal.app")!
|
||||||
|
|
||||||
|
/// Share page base URL - parameters will be appended
|
||||||
|
static let sharePageBaseURL = "https://portfoliojournal.app/share"
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
|
/// Build share URL with goal parameters for dynamic OG tags
|
||||||
|
static func buildShareURL(goalName: String, progressPercent: Int) -> URL {
|
||||||
|
var components = URLComponents(string: sharePageBaseURL)!
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "goal", value: goalName),
|
||||||
|
URLQueryItem(name: "progress", value: String(progressPercent))
|
||||||
|
]
|
||||||
|
return components.url ?? URL(string: sharePageBaseURL)!
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a QR code image for the App Store URL
|
||||||
|
static func generateQRCode(for url: URL, size: CGFloat = 60) -> UIImage? {
|
||||||
|
let context = CIContext()
|
||||||
|
let filter = CIFilter.qrCodeGenerator()
|
||||||
|
|
||||||
|
filter.message = Data(url.absoluteString.utf8)
|
||||||
|
filter.correctionLevel = "M"
|
||||||
|
|
||||||
|
guard let outputImage = filter.outputImage else { return nil }
|
||||||
|
|
||||||
|
// Scale up the QR code
|
||||||
|
let scale = size / outputImage.extent.size.width
|
||||||
|
let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
|
||||||
|
|
||||||
|
guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return UIImage(cgImage: cgImage)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func shareGoal(
|
func shareGoal(
|
||||||
name: String,
|
name: String,
|
||||||
progress: Double,
|
progress: Double,
|
||||||
currentValue: Decimal,
|
currentValue: Decimal,
|
||||||
targetValue: Decimal
|
targetValue: Decimal,
|
||||||
|
targetDate: Date? = nil,
|
||||||
|
estimatedCompletionDate: Date? = nil,
|
||||||
|
privacyMode: Bool = false
|
||||||
) {
|
) {
|
||||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
let viewController = windowScene.windows.first?.rootViewController else {
|
let viewController = windowScene.windows.first?.rootViewController else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate QR code for the card
|
||||||
|
let qrCodeImage = Self.generateQRCode(for: Self.appStoreURL, size: 200)
|
||||||
|
|
||||||
let card = GoalShareCardView(
|
let card = GoalShareCardView(
|
||||||
name: name,
|
name: name,
|
||||||
progress: progress,
|
progress: progress,
|
||||||
currentValue: currentValue,
|
currentValue: currentValue,
|
||||||
targetValue: targetValue
|
targetValue: targetValue,
|
||||||
|
targetDate: targetDate,
|
||||||
|
estimatedCompletionDate: estimatedCompletionDate,
|
||||||
|
privacyMode: privacyMode,
|
||||||
|
qrCodeImage: qrCodeImage
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let progressPercent = Int(progress * 100)
|
||||||
|
let shareURL = Self.buildShareURL(goalName: name, progressPercent: progressPercent)
|
||||||
|
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
let renderer = ImageRenderer(content: card)
|
let renderer = ImageRenderer(content: card)
|
||||||
let scale = viewController.view.window?.windowScene?.screen.scale
|
let scale = viewController.view.window?.windowScene?.screen.scale
|
||||||
?? viewController.traitCollection.displayScale
|
?? viewController.traitCollection.displayScale
|
||||||
renderer.scale = scale
|
renderer.scale = scale
|
||||||
|
|
||||||
if let image = renderer.uiImage {
|
if let image = renderer.uiImage {
|
||||||
let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil)
|
// Use combined item provider for text + image together
|
||||||
viewController.present(activityVC, animated: true)
|
let shareItem = GoalShareItem(
|
||||||
|
image: image,
|
||||||
|
goalName: name,
|
||||||
|
progressPercent: progressPercent,
|
||||||
|
shareURL: shareURL
|
||||||
|
)
|
||||||
|
presentShareSheet(items: [shareItem], from: viewController)
|
||||||
|
} else {
|
||||||
|
// Fallback to text only
|
||||||
|
let text = buildShareText(goalName: name, progressPercent: progressPercent, shareURL: shareURL)
|
||||||
|
presentShareSheet(items: [text], from: viewController)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let text = "I am \(Int(progress * 100))% towards \(name) on Portfolio Journal!"
|
// iOS 15 fallback - text only
|
||||||
let activityVC = UIActivityViewController(activityItems: [text], applicationActivities: nil)
|
let text = buildShareText(goalName: name, progressPercent: progressPercent, shareURL: shareURL)
|
||||||
viewController.present(activityVC, animated: true)
|
presentShareSheet(items: [text], from: viewController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func buildShareText(goalName: String, progressPercent: Int, shareURL: URL) -> String {
|
||||||
|
return """
|
||||||
|
I'm \(progressPercent)% towards my "\(goalName)" goal! 🎯
|
||||||
|
|
||||||
|
Track your investment goals with Portfolio Journal.
|
||||||
|
\(shareURL.absoluteString)
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentShareSheet(items: [Any], from viewController: UIViewController) {
|
||||||
|
let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||||
|
|
||||||
|
// Exclude some activities that don't make sense for goal sharing
|
||||||
|
activityVC.excludedActivityTypes = [
|
||||||
|
.addToReadingList,
|
||||||
|
.assignToContact,
|
||||||
|
.openInIBooks
|
||||||
|
]
|
||||||
|
|
||||||
|
// iPad support - prevent crash by setting popover source
|
||||||
|
if let popover = activityVC.popoverPresentationController {
|
||||||
|
popover.sourceView = viewController.view
|
||||||
|
popover.sourceRect = CGRect(
|
||||||
|
x: viewController.view.bounds.midX,
|
||||||
|
y: viewController.view.bounds.midY,
|
||||||
|
width: 0,
|
||||||
|
height: 0
|
||||||
|
)
|
||||||
|
popover.permittedArrowDirections = []
|
||||||
|
}
|
||||||
|
|
||||||
|
viewController.present(activityVC, animated: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Combined share item that provides both image and text together
|
||||||
|
private class GoalShareItem: NSObject, UIActivityItemSource {
|
||||||
|
let image: UIImage
|
||||||
|
let goalName: String
|
||||||
|
let progressPercent: Int
|
||||||
|
let shareURL: URL
|
||||||
|
|
||||||
|
init(image: UIImage, goalName: String, progressPercent: Int, shareURL: URL) {
|
||||||
|
self.image = image
|
||||||
|
self.goalName = goalName
|
||||||
|
self.progressPercent = progressPercent
|
||||||
|
self.shareURL = shareURL
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
|
||||||
|
// For most activities, return the image
|
||||||
|
// The text will be provided via LPLinkMetadata or as a separate item
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String {
|
||||||
|
return "My Investment Goal Progress - Portfolio Journal"
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
|
||||||
|
let metadata = LPLinkMetadata()
|
||||||
|
metadata.title = "I'm \(progressPercent)% towards my \"\(goalName)\" goal! 🎯"
|
||||||
|
metadata.originalURL = shareURL
|
||||||
|
metadata.url = shareURL
|
||||||
|
metadata.imageProvider = NSItemProvider(object: image)
|
||||||
|
|
||||||
|
// Set icon
|
||||||
|
if let appIcon = UIImage(named: "BrandMark") {
|
||||||
|
metadata.iconProvider = NSItemProvider(object: appIcon)
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import LinkPresentation
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ class GoalsViewModel: ObservableObject {
|
|||||||
goalRepository.deleteGoal(goal)
|
goalRepository.deleteGoal(goal)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func estimateCompletionDate(for goal: Goal) -> Date? {
|
func estimateCompletionDate(for goal: Goal) -> Date? {
|
||||||
// Performance: Use cached completion date if available
|
// Performance: Use cached completion date if available
|
||||||
if let cached = cachedCompletionDates[goal.id] {
|
if let cached = cachedCompletionDates[goal.id] {
|
||||||
return cached
|
return cached
|
||||||
|
|||||||
@@ -5,22 +5,39 @@ struct GoalShareCardView: View {
|
|||||||
let progress: Double
|
let progress: Double
|
||||||
let currentValue: Decimal
|
let currentValue: Decimal
|
||||||
let targetValue: Decimal
|
let targetValue: Decimal
|
||||||
|
var targetDate: Date? = nil
|
||||||
|
var estimatedCompletionDate: Date? = nil
|
||||||
|
var privacyMode: Bool = false
|
||||||
|
var qrCodeImage: UIImage? = nil
|
||||||
|
|
||||||
|
private var hasExtraContent: Bool {
|
||||||
|
targetDate != nil || estimatedCompletionDate != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cardHeight: CGFloat {
|
||||||
|
let base: CGFloat = hasExtraContent ? 340 : 290
|
||||||
|
return privacyMode ? base + 10 : base
|
||||||
|
}
|
||||||
|
|
||||||
|
private var displayCurrentValue: String {
|
||||||
|
privacyMode ? "***" : currentValue.currencyString
|
||||||
|
}
|
||||||
|
|
||||||
|
private var progressText: String {
|
||||||
|
let percent = Int((progress * 100).rounded())
|
||||||
|
return "\(percent)%"
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
HStack {
|
// Header with goal name
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Goal Progress")
|
Text("Goal Progress")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.foregroundColor(.white.opacity(0.8))
|
.foregroundColor(.white.opacity(0.8))
|
||||||
Text(name)
|
Text(name)
|
||||||
.font(.title2.weight(.bold))
|
.font(.title2.weight(.bold))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: "sparkles")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundColor(.white.opacity(0.9))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
GoalProgressBar(
|
GoalProgressBar(
|
||||||
@@ -32,25 +49,118 @@ struct GoalShareCardView: View {
|
|||||||
)
|
)
|
||||||
.frame(height: 10)
|
.frame(height: 10)
|
||||||
|
|
||||||
HStack {
|
Text("\(progressText) complete")
|
||||||
Text(currentValue.currencyString)
|
.font(.caption.weight(.semibold))
|
||||||
.font(.headline)
|
.foregroundColor(.white.opacity(0.85))
|
||||||
.foregroundColor(.white)
|
|
||||||
Spacer()
|
if privacyMode {
|
||||||
Text("of \(targetValue.currencyString)")
|
HStack {
|
||||||
.font(.subheadline.weight(.medium))
|
Text("Progress")
|
||||||
.foregroundColor(.white.opacity(0.8))
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundColor(.white.opacity(0.9))
|
||||||
|
Spacer()
|
||||||
|
Text(progressText)
|
||||||
|
.font(.headline.weight(.bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack {
|
||||||
|
Text(displayCurrentValue)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
Spacer()
|
||||||
|
Text("of \(targetValue.currencyString)")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
if let targetDate {
|
||||||
Image(systemName: "arrow.up.right")
|
HStack(spacing: 6) {
|
||||||
Text("Track yours in Portfolio Journal")
|
Image(systemName: "calendar")
|
||||||
|
.font(.caption)
|
||||||
|
Text("Target: \(targetDate.mediumDateString)")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
}
|
||||||
|
.foregroundColor(.white.opacity(0.85))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let estimatedCompletionDate {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "chart.line.uptrend.xyaxis")
|
||||||
|
.font(.caption)
|
||||||
|
Text("Est. completion: \(estimatedCompletionDate.mediumDateString)")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
}
|
||||||
|
.foregroundColor(.white.opacity(0.85))
|
||||||
|
}
|
||||||
|
|
||||||
|
if privacyMode {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "eye.slash")
|
||||||
|
Text("Privacy mode enabled")
|
||||||
|
}
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
// Branding footer with QR code
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
// Divider line
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.white.opacity(0.2))
|
||||||
|
.frame(height: 1)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// App icon and branding
|
||||||
|
Image("BrandMark")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Powered by")
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
Text("Portfolio Journal")
|
||||||
|
.font(.subheadline.weight(.bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// QR Code
|
||||||
|
if let qrCodeImage {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(uiImage: qrCodeImage)
|
||||||
|
.interpolation(.none)
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
.background(Color.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||||
|
|
||||||
|
Text("Scan to download")
|
||||||
|
.font(.system(size: 7, weight: .medium))
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback if QR code generation fails
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Image(systemName: "qrcode")
|
||||||
|
.font(.system(size: 28))
|
||||||
|
Text("App Store")
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
}
|
||||||
|
.foregroundColor(.white.opacity(0.9))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundColor(.white.opacity(0.85))
|
|
||||||
}
|
}
|
||||||
.padding(24)
|
.padding(20)
|
||||||
.frame(width: 320, height: 220)
|
.frame(width: 320, height: cardHeight)
|
||||||
.background(
|
.background(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [Color.appPrimary, Color.appSecondary],
|
colors: [Color.appPrimary, Color.appSecondary],
|
||||||
@@ -74,3 +184,24 @@ struct GoalShareCardView: View {
|
|||||||
targetValue: 1_000_000
|
targetValue: 1_000_000
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview("With Dates") {
|
||||||
|
GoalShareCardView(
|
||||||
|
name: "1M Goal",
|
||||||
|
progress: 0.42,
|
||||||
|
currentValue: 420_000,
|
||||||
|
targetValue: 1_000_000,
|
||||||
|
targetDate: Date().addingTimeInterval(365 * 24 * 60 * 60),
|
||||||
|
estimatedCompletionDate: Date().addingTimeInterval(300 * 24 * 60 * 60)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Privacy Mode") {
|
||||||
|
GoalShareCardView(
|
||||||
|
name: "1M Goal",
|
||||||
|
progress: 0.42,
|
||||||
|
currentValue: 420_000,
|
||||||
|
targetValue: 1_000_000,
|
||||||
|
privacyMode: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,12 +21,10 @@ struct GoalsView: View {
|
|||||||
goal: goal,
|
goal: goal,
|
||||||
progress: viewModel.progress(for: goal),
|
progress: viewModel.progress(for: goal),
|
||||||
totalValue: viewModel.totalValue(for: goal),
|
totalValue: viewModel.totalValue(for: goal),
|
||||||
paceStatus: viewModel.paceStatus(for: goal)
|
paceStatus: viewModel.paceStatus(for: goal),
|
||||||
|
estimatedCompletionDate: viewModel.estimateCompletionDate(for: goal),
|
||||||
|
onEdit: { editingGoal = goal }
|
||||||
)
|
)
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
editingGoal = goal
|
|
||||||
}
|
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
viewModel.deleteGoal(goal)
|
viewModel.deleteGoal(goal)
|
||||||
@@ -102,50 +100,78 @@ struct GoalRowView: View {
|
|||||||
let progress: Double
|
let progress: Double
|
||||||
let totalValue: Decimal
|
let totalValue: Decimal
|
||||||
let paceStatus: GoalPaceStatus?
|
let paceStatus: GoalPaceStatus?
|
||||||
|
let estimatedCompletionDate: Date?
|
||||||
|
let onEdit: () -> Void
|
||||||
|
|
||||||
|
@State private var showingShareOptions = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
ZStack(alignment: .topTrailing) {
|
||||||
HStack {
|
Button(action: onEdit) {
|
||||||
Text(goal.name)
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
.font(.headline)
|
Text(goal.name)
|
||||||
Spacer()
|
.font(.headline)
|
||||||
Button {
|
|
||||||
GoalShareService.shared.shareGoal(
|
GoalProgressBar(progress: progress, tint: .appSecondary, iconColor: .appSecondary)
|
||||||
name: goal.name,
|
|
||||||
progress: progress,
|
HStack {
|
||||||
currentValue: totalValue,
|
Text(totalValue.currencyString)
|
||||||
targetValue: goal.targetDecimal
|
.font(.subheadline.weight(.semibold))
|
||||||
)
|
Spacer()
|
||||||
} label: {
|
Text("of \(goal.targetDecimal.currencyString)")
|
||||||
Image(systemName: "square.and.arrow.up")
|
.font(.subheadline)
|
||||||
.foregroundColor(.appPrimary)
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let targetDate = goal.targetDate {
|
||||||
|
Text("Target date: \(targetDate.mediumDateString)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let paceStatus {
|
||||||
|
Text(paceStatus.statusText)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundColor(paceStatus.isBehind ? .negativeRed : .positiveGreen)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
GoalProgressBar(progress: progress, tint: .appSecondary, iconColor: .appSecondary)
|
Button {
|
||||||
|
showingShareOptions = true
|
||||||
HStack {
|
} label: {
|
||||||
Text(totalValue.currencyString)
|
Image(systemName: "square.and.arrow.up")
|
||||||
.font(.subheadline.weight(.semibold))
|
.foregroundColor(.appPrimary)
|
||||||
Spacer()
|
.padding(.top, 2)
|
||||||
Text("of \(goal.targetDecimal.currencyString)")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let targetDate = goal.targetDate {
|
|
||||||
Text("Target date: \(targetDate.mediumDateString)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let paceStatus {
|
|
||||||
Text(paceStatus.statusText)
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundColor(paceStatus.isBehind ? .negativeRed : .positiveGreen)
|
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
.confirmationDialog("Share Goal", isPresented: $showingShareOptions, titleVisibility: .visible) {
|
||||||
|
Button("Share with amounts") {
|
||||||
|
shareGoal(privacyMode: false)
|
||||||
|
}
|
||||||
|
Button("Share (privacy mode)") {
|
||||||
|
shareGoal(privacyMode: true)
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Choose how to share your goal progress")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shareGoal(privacyMode: Bool) {
|
||||||
|
GoalShareService.shared.shareGoal(
|
||||||
|
name: goal.name,
|
||||||
|
progress: progress,
|
||||||
|
currentValue: totalValue,
|
||||||
|
targetValue: goal.targetDecimal,
|
||||||
|
targetDate: goal.targetDate,
|
||||||
|
estimatedCompletionDate: estimatedCompletionDate,
|
||||||
|
privacyMode: privacyMode
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Cloudflare Worker for Dynamic Open Graph Tags
|
||||||
|
*
|
||||||
|
* Deploy this as a Cloudflare Worker to handle /share routes
|
||||||
|
* and inject dynamic OG tags based on URL parameters.
|
||||||
|
*
|
||||||
|
* URL format: https://portfoliojournal.app/share?goal=My%20Goal&progress=42
|
||||||
|
*
|
||||||
|
* Setup:
|
||||||
|
* 1. Go to Cloudflare Dashboard > Workers
|
||||||
|
* 2. Create a new Worker
|
||||||
|
* 3. Paste this code
|
||||||
|
* 4. Add a route: portfoliojournal.app/share*
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Only handle /share routes
|
||||||
|
if (!url.pathname.startsWith('/share')) {
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get parameters
|
||||||
|
const goalName = url.searchParams.get('goal') || 'Investment Goal';
|
||||||
|
const progress = parseInt(url.searchParams.get('progress')) || 0;
|
||||||
|
|
||||||
|
// Build dynamic OG content
|
||||||
|
const ogTitle = `I'm ${progress}% towards my "${goalName}" goal! 🎯`;
|
||||||
|
const ogDescription = 'Track your investment goals with Portfolio Journal. Download free on the App Store.';
|
||||||
|
const ogImage = 'https://portfoliojournal.app/images/og-share-card.png';
|
||||||
|
const appStoreUrl = 'https://apps.apple.com/app/portfolio-journal/id6744983373';
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${escapeHtml(ogTitle)} - Portfolio Journal</title>
|
||||||
|
|
||||||
|
<!-- Open Graph / Facebook -->
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="${escapeHtml(url.toString())}">
|
||||||
|
<meta property="og:title" content="${escapeHtml(ogTitle)}">
|
||||||
|
<meta property="og:description" content="${escapeHtml(ogDescription)}">
|
||||||
|
<meta property="og:image" content="${ogImage}">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta property="og:site_name" content="Portfolio Journal">
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:url" content="${escapeHtml(url.toString())}">
|
||||||
|
<meta name="twitter:title" content="${escapeHtml(ogTitle)}">
|
||||||
|
<meta name="twitter:description" content="${escapeHtml(ogDescription)}">
|
||||||
|
<meta name="twitter:image" content="${ogImage}">
|
||||||
|
|
||||||
|
<!-- Apple Smart App Banner -->
|
||||||
|
<meta name="apple-itunes-app" content="app-id=6744983373">
|
||||||
|
|
||||||
|
<!-- SEO -->
|
||||||
|
<meta name="description" content="${escapeHtml(ogDescription)}">
|
||||||
|
<link rel="canonical" href="${escapeHtml(url.toString())}">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 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: 100px; height: 100px;
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.goal-card {
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
.goal-label { font-size: 0.85rem; opacity: 0.8; margin-bottom: 4px; }
|
||||||
|
.goal-name { font-size: 1.5rem; font-weight: 700; margin-bottom: 16px; }
|
||||||
|
.progress-bar {
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
height: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
background: white;
|
||||||
|
height: 100%;
|
||||||
|
width: ${progress}%;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.progress-text { font-size: 1.1rem; font-weight: 600; }
|
||||||
|
h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 8px; }
|
||||||
|
.tagline { font-size: 1rem; opacity: 0.9; margin-bottom: 24px; line-height: 1.5; }
|
||||||
|
.download-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: white;
|
||||||
|
color: #667eea;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border-radius: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.download-btn:hover { transform: translateY(-2px); }
|
||||||
|
.footer { margin-top: 32px; font-size: 0.85rem; opacity: 0.7; }
|
||||||
|
.footer a { color: white; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<img src="/images/app-icon.png" alt="Portfolio Journal" class="app-icon">
|
||||||
|
|
||||||
|
<div class="goal-card">
|
||||||
|
<div class="goal-label">Goal Progress</div>
|
||||||
|
<div class="goal-name">${escapeHtml(goalName)}</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-text">${progress}% complete</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Portfolio Journal</h1>
|
||||||
|
<p class="tagline">Track your investment goals and celebrate your financial milestones.</p>
|
||||||
|
|
||||||
|
<a href="${appStoreUrl}" class="download-btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="22" height="22">
|
||||||
|
<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 Free
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<p class="footer">
|
||||||
|
<a href="https://portfoliojournal.app">portfoliojournal.app</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
return new Response(html, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/html;charset=UTF-8',
|
||||||
|
'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const map = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
};
|
||||||
|
return text.replace(/[&<>"']/g, m => map[m]);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Portfolio Journal - Goal Progress</title>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
IMPORTANT: For proper social media previews, Open Graph tags must be rendered server-side.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
1. Use a serverless function (Vercel, Netlify, Cloudflare Workers) to read query params and inject OG tags
|
||||||
|
2. Use a service like htmlrewriter or edge functions
|
||||||
|
3. Use a simple server (Node.js, PHP, etc.)
|
||||||
|
|
||||||
|
The URL format from the app is:
|
||||||
|
https://portfoliojournal.app/share?goal=My%20Goal&progress=42
|
||||||
|
|
||||||
|
Your server should read these params and set:
|
||||||
|
- og:title = "I'm 42% towards my 'My Goal' goal!"
|
||||||
|
- og:description = "Track your investment goals with Portfolio Journal"
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- Default/Fallback Meta Tags (shown when no params or for non-social contexts) -->
|
||||||
|
<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, goal sharing">
|
||||||
|
<meta name="author" content="Portfolio Journal">
|
||||||
|
|
||||||
|
<!-- Open Graph / Facebook - These should be dynamic on your server -->
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" id="og-url" content="https://portfoliojournal.app/share">
|
||||||
|
<meta property="og:title" id="og-title" content="I'm making progress on my investment goal! 🎯">
|
||||||
|
<meta property="og:description" content="Track your investment goals with Portfolio Journal. 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" id="twitter-url" content="https://portfoliojournal.app/share">
|
||||||
|
<meta name="twitter:title" id="twitter-title" content="I'm making progress on my investment goal! 🎯">
|
||||||
|
<meta name="twitter:description" content="Track your investment goals with Portfolio Journal. 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" id="canonical-url" href="https://portfoliojournal.app/share">
|
||||||
|
|
||||||
|
<!-- Schema.org structured data -->
|
||||||
|
<script type="application/ld+json" id="schema-data">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebPage",
|
||||||
|
"name": "Goal Progress - Portfolio Journal",
|
||||||
|
"description": "Track your investment goals with Portfolio Journal",
|
||||||
|
"url": "https://portfoliojournal.app/share",
|
||||||
|
"mainEntity": {
|
||||||
|
"@type": "MobileApplication",
|
||||||
|
"name": "Portfolio Journal",
|
||||||
|
"operatingSystem": "iOS",
|
||||||
|
"applicationCategory": "FinanceApplication",
|
||||||
|
"downloadUrl": "https://apps.apple.com/app/portfolio-journal/id6744983373"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-card {
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-name {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
height: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
background: white;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: white;
|
||||||
|
color: #667eea;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border-radius: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 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: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 32px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<!-- App Icon -->
|
||||||
|
<img src="/images/app-icon.png" alt="Portfolio Journal" class="app-icon">
|
||||||
|
|
||||||
|
<!-- Goal Progress Card (shown when params present) -->
|
||||||
|
<div class="goal-card" id="goal-card">
|
||||||
|
<div class="goal-label">Goal Progress</div>
|
||||||
|
<div class="goal-name" id="goal-name">Investment Goal</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" id="progress-fill" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-text"><span id="progress-text">0</span>% complete</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Portfolio Journal</h1>
|
||||||
|
<p class="tagline">Track your investment goals and celebrate your financial milestones.</p>
|
||||||
|
|
||||||
|
<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 Free
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<p class="footer">
|
||||||
|
<a href="https://portfoliojournal.app">portfoliojournal.app</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Read URL parameters and update the page
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const goalName = params.get('goal');
|
||||||
|
const progress = parseInt(params.get('progress')) || 0;
|
||||||
|
|
||||||
|
if (goalName) {
|
||||||
|
// Update visible content
|
||||||
|
document.getElementById('goal-name').textContent = goalName;
|
||||||
|
document.getElementById('progress-text').textContent = progress;
|
||||||
|
document.getElementById('progress-fill').style.width = progress + '%';
|
||||||
|
|
||||||
|
// Update page title
|
||||||
|
document.title = `${progress}% towards "${goalName}" - Portfolio Journal`;
|
||||||
|
|
||||||
|
// Note: These JavaScript updates won't affect social media crawlers
|
||||||
|
// as they don't execute JavaScript. You need server-side rendering for that.
|
||||||
|
} else {
|
||||||
|
// Hide goal card if no params
|
||||||
|
document.getElementById('goal-card').classList.add('hidden');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user