v4.0.6 - Simplicity focus
This commit is contained in:
@@ -207,12 +207,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"27"
|
||||
],
|
||||
@@ -432,12 +432,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"45"
|
||||
],
|
||||
@@ -657,12 +657,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"95"
|
||||
],
|
||||
@@ -882,12 +882,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"103"
|
||||
],
|
||||
@@ -1107,12 +1107,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"39"
|
||||
],
|
||||
@@ -1332,12 +1332,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"41"
|
||||
],
|
||||
@@ -1557,12 +1557,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"36"
|
||||
],
|
||||
@@ -1782,12 +1782,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"83"
|
||||
],
|
||||
@@ -2007,12 +2007,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"76",
|
||||
"49"
|
||||
@@ -2233,12 +2233,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"99"
|
||||
],
|
||||
@@ -2458,12 +2458,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"84"
|
||||
],
|
||||
@@ -2683,12 +2683,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"51"
|
||||
],
|
||||
@@ -2908,12 +2908,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"87"
|
||||
],
|
||||
@@ -3134,12 +3134,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"88"
|
||||
],
|
||||
@@ -3359,12 +3359,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"34"
|
||||
],
|
||||
@@ -3584,12 +3584,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"61",
|
||||
"47"
|
||||
@@ -3811,12 +3811,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"57"
|
||||
],
|
||||
@@ -4036,12 +4036,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"32"
|
||||
],
|
||||
@@ -4261,12 +4261,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"22"
|
||||
],
|
||||
@@ -4486,12 +4486,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"64"
|
||||
],
|
||||
@@ -4711,12 +4711,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"74"
|
||||
],
|
||||
@@ -4936,12 +4936,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"56"
|
||||
],
|
||||
@@ -5161,12 +5161,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"116",
|
||||
"93"
|
||||
@@ -5387,12 +5387,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"59"
|
||||
],
|
||||
@@ -5612,12 +5612,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"30"
|
||||
],
|
||||
@@ -5837,12 +5837,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"53"
|
||||
],
|
||||
@@ -6062,12 +6062,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"107"
|
||||
],
|
||||
@@ -6287,12 +6287,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"24"
|
||||
],
|
||||
@@ -6512,12 +6512,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"43"
|
||||
],
|
||||
@@ -6737,12 +6737,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"97"
|
||||
],
|
||||
@@ -6962,12 +6962,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"90"
|
||||
],
|
||||
@@ -7188,12 +7188,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"105"
|
||||
],
|
||||
@@ -7413,12 +7413,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"101"
|
||||
],
|
||||
@@ -7638,12 +7638,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"78"
|
||||
],
|
||||
@@ -7664,12 +7664,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [],
|
||||
"contractEntryIds": [
|
||||
"29"
|
||||
@@ -7887,12 +7887,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"80"
|
||||
],
|
||||
|
||||
@@ -83,12 +83,14 @@ class ScheduledEmailRescheduleRequest(BaseModel):
|
||||
|
||||
class BirthdayEmailRequest(BaseModel):
|
||||
email: EmailStr
|
||||
dogId: str | None = None
|
||||
preview: bool = False
|
||||
previewRecipients: list[EmailStr] = []
|
||||
|
||||
|
||||
class BirthdayAutoSendRequest(BaseModel):
|
||||
email: EmailStr
|
||||
dogId: str | None = None
|
||||
enabled: bool
|
||||
|
||||
|
||||
@@ -110,6 +112,8 @@ class ClientProfileUpdate(BaseModel):
|
||||
phone: str = ""
|
||||
address: str = ""
|
||||
dogName: str
|
||||
dogBreed: str = ""
|
||||
dogAge: str = ""
|
||||
|
||||
|
||||
class ResetOnboardingRequest(BaseModel):
|
||||
@@ -131,6 +135,7 @@ class NewClientRequest(BaseModel):
|
||||
address: str = ""
|
||||
dogName: str = ""
|
||||
dogBreed: str = ""
|
||||
dogAge: str = ""
|
||||
# Where the client came from, e.g. "Instagram", "Facebook", "Referral".
|
||||
source: str = ""
|
||||
# When true (and the MYOB integration is configured), also create the client
|
||||
@@ -139,6 +144,15 @@ class NewClientRequest(BaseModel):
|
||||
createInMyob: bool = False
|
||||
|
||||
|
||||
class ClientDogUpsertRequest(BaseModel):
|
||||
email: EmailStr
|
||||
dogId: str | None = None
|
||||
dogName: str
|
||||
dogBreed: str = ""
|
||||
dogAge: str = ""
|
||||
birthdayAutoSend: bool = False
|
||||
|
||||
|
||||
class ContractSubmission(BaseSubmission):
|
||||
address: str
|
||||
dogName: str
|
||||
|
||||
+408
-56
@@ -66,6 +66,7 @@ from mail_api.models import (
|
||||
BaseSubmission,
|
||||
BirthdayAutoSendRequest,
|
||||
BirthdayEmailRequest,
|
||||
ClientDogUpsertRequest,
|
||||
ClientProfileUpdate,
|
||||
BookingSubmission,
|
||||
ClientStatusUpdate,
|
||||
@@ -425,17 +426,133 @@ def _client_is_reachable(profile: dict) -> bool:
|
||||
return lifecycle.get("status") not in {"cancelled", "archived"}
|
||||
|
||||
|
||||
def _dog_slug(value: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", (value or "").strip().lower()).strip("-")
|
||||
return slug or f"dog-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _normalize_profile_dog_entry(raw: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
name = str(raw.get("name") or raw.get("dogName") or "").strip()
|
||||
breed = str(raw.get("breed") or raw.get("dogBreed") or "").strip()
|
||||
birth_date = str(raw.get("birthDate") or raw.get("dogAge") or "").strip()
|
||||
if not name and not breed and not birth_date:
|
||||
return None
|
||||
dog_id = str(raw.get("id") or raw.get("dogId") or "").strip() or _dog_slug(name or breed or birth_date)
|
||||
return {
|
||||
"id": dog_id,
|
||||
"name": name,
|
||||
"breed": breed,
|
||||
"birthDate": birth_date,
|
||||
"birthdayAutoSend": bool(raw.get("birthdayAutoSend")),
|
||||
"birthdayEmailLastSentAt": str(raw.get("birthdayEmailLastSentAt") or "").strip(),
|
||||
"birthdayEmailLastSentYear": str(raw.get("birthdayEmailLastSentYear") or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def _get_profile_dogs(profile: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
raw_dogs = profile.get("dogs")
|
||||
dogs: list[dict[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
if isinstance(raw_dogs, list):
|
||||
for raw in raw_dogs:
|
||||
dog = _normalize_profile_dog_entry(raw)
|
||||
if not dog or dog["id"] in seen_ids:
|
||||
continue
|
||||
seen_ids.add(dog["id"])
|
||||
dogs.append(dog)
|
||||
if dogs:
|
||||
return dogs
|
||||
|
||||
legacy = _normalize_profile_dog_entry(
|
||||
{
|
||||
"id": "primary",
|
||||
"name": profile.get("dogName", ""),
|
||||
"breed": profile.get("dogBreed", ""),
|
||||
"birthDate": profile.get("dogAge", ""),
|
||||
"birthdayAutoSend": profile.get("birthdayAutoSend", False),
|
||||
"birthdayEmailLastSentAt": profile.get("birthdayEmailLastSentAt", ""),
|
||||
"birthdayEmailLastSentYear": profile.get("birthdayEmailLastSentYear", ""),
|
||||
}
|
||||
)
|
||||
return [legacy] if legacy else []
|
||||
|
||||
|
||||
def _apply_profile_dogs(profile: dict[str, Any], dogs: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
next_profile = dict(profile)
|
||||
if dogs:
|
||||
normalized_dogs = []
|
||||
seen_ids: set[str] = set()
|
||||
for raw in dogs:
|
||||
dog = _normalize_profile_dog_entry(raw)
|
||||
if not dog or dog["id"] in seen_ids:
|
||||
continue
|
||||
seen_ids.add(dog["id"])
|
||||
normalized_dogs.append(dog)
|
||||
dogs = normalized_dogs
|
||||
|
||||
if dogs:
|
||||
primary = dogs[0]
|
||||
next_profile["dogs"] = dogs
|
||||
next_profile["dogName"] = primary.get("name", "")
|
||||
next_profile["dogBreed"] = primary.get("breed", "")
|
||||
next_profile["dogAge"] = primary.get("birthDate", "")
|
||||
next_profile["birthdayAutoSend"] = bool(primary.get("birthdayAutoSend"))
|
||||
next_profile["birthdayEmailLastSentAt"] = primary.get("birthdayEmailLastSentAt", "")
|
||||
next_profile["birthdayEmailLastSentYear"] = primary.get("birthdayEmailLastSentYear", "")
|
||||
else:
|
||||
for key in ("dogs", "dogName", "dogBreed", "dogAge", "birthdayAutoSend", "birthdayEmailLastSentAt", "birthdayEmailLastSentYear"):
|
||||
next_profile.pop(key, None)
|
||||
|
||||
return next_profile
|
||||
|
||||
|
||||
def _merge_client_profile(existing: dict[str, Any], updates: dict[str, Any]) -> dict[str, Any]:
|
||||
merged = {
|
||||
k: v
|
||||
for k, v in {**existing, **updates}.items()
|
||||
if v is not None and not (isinstance(v, str) and v == "")
|
||||
}
|
||||
|
||||
if "dogs" in updates:
|
||||
return _apply_profile_dogs(merged, updates.get("dogs") if isinstance(updates.get("dogs"), list) else [])
|
||||
|
||||
dog_related_keys = {"dogName", "dogBreed", "dogAge", "birthdayAutoSend", "birthdayEmailLastSentAt", "birthdayEmailLastSentYear"}
|
||||
if dog_related_keys.intersection(updates.keys()):
|
||||
dogs = _get_profile_dogs(existing)
|
||||
primary = dict(dogs[0]) if dogs else {"id": "primary"}
|
||||
primary["name"] = str(merged.get("dogName", "")).strip()
|
||||
primary["breed"] = str(merged.get("dogBreed", "")).strip()
|
||||
primary["birthDate"] = str(merged.get("dogAge", "")).strip()
|
||||
primary["birthdayAutoSend"] = bool(merged.get("birthdayAutoSend"))
|
||||
primary["birthdayEmailLastSentAt"] = str(merged.get("birthdayEmailLastSentAt", "")).strip()
|
||||
primary["birthdayEmailLastSentYear"] = str(merged.get("birthdayEmailLastSentYear", "")).strip()
|
||||
next_dogs = [primary] + dogs[1:] if dogs else [primary]
|
||||
return _apply_profile_dogs(merged, next_dogs)
|
||||
|
||||
return _apply_profile_dogs(merged, _get_profile_dogs(merged)) if _get_profile_dogs(merged) else merged
|
||||
|
||||
|
||||
def _get_selected_dog(profile: dict[str, Any], dog_id: str | None) -> dict[str, Any] | None:
|
||||
dogs = _get_profile_dogs(profile)
|
||||
if not dogs:
|
||||
return None
|
||||
if not dog_id:
|
||||
return dogs[0]
|
||||
for dog in dogs:
|
||||
if dog.get("id") == dog_id:
|
||||
return dog
|
||||
return None
|
||||
|
||||
|
||||
async def _store_client_profile(email: str, profile: dict) -> None:
|
||||
normalized = email.strip().lower()
|
||||
if not normalized:
|
||||
return
|
||||
async with _auth_lock:
|
||||
existing = _client_profiles.get(normalized, {})
|
||||
merged = {
|
||||
k: v
|
||||
for k, v in {**existing, **profile}.items()
|
||||
if v is not None and not (isinstance(v, str) and v == "")
|
||||
}
|
||||
merged = _merge_client_profile(existing, profile)
|
||||
if merged != existing:
|
||||
_client_profiles[normalized] = merged
|
||||
snapshot = dict(_client_profiles)
|
||||
@@ -488,11 +605,7 @@ async def _update_client_profile(
|
||||
if next_normalized != normalized and isinstance(target_existing, dict):
|
||||
raise HTTPException(status_code=409, detail="Another client already uses that email address.")
|
||||
|
||||
merged = {
|
||||
k: v
|
||||
for k, v in {**existing, **profile_updates}.items()
|
||||
if v is not None and not (isinstance(v, str) and v == "")
|
||||
}
|
||||
merged = _merge_client_profile(existing, profile_updates)
|
||||
|
||||
if next_normalized != normalized:
|
||||
_client_profiles.pop(normalized, None)
|
||||
@@ -2518,12 +2631,16 @@ async def _send_birthday_email_for_profile(
|
||||
email: str,
|
||||
profile: dict,
|
||||
request_id: str,
|
||||
dog_id: str | None = None,
|
||||
mark_auto_year: int | None = None,
|
||||
preview: bool = False,
|
||||
preview_recipients: list[str] | None = None,
|
||||
) -> None:
|
||||
client_name = str(profile.get("fullName", "")).strip()
|
||||
dog_name = str(profile.get("dogName", "")).strip()
|
||||
dog = _get_selected_dog(profile, dog_id)
|
||||
if not dog:
|
||||
raise HTTPException(status_code=400, detail="This client does not have a dog on file.")
|
||||
dog_name = str(dog.get("name", "")).strip()
|
||||
recipients = _resolve_preview_recipients(preview_recipients) if preview else [email]
|
||||
subject = f"Happy birthday {dog_name or 'from Goodwalk'}"
|
||||
if preview:
|
||||
@@ -2544,11 +2661,15 @@ async def _send_birthday_email_for_profile(
|
||||
if preview:
|
||||
return
|
||||
|
||||
profile_update = {
|
||||
"birthdayEmailLastSentAt": datetime.now().isoformat(timespec="seconds"),
|
||||
}
|
||||
if mark_auto_year is not None:
|
||||
profile_update["birthdayEmailLastSentYear"] = str(mark_auto_year)
|
||||
sent_at = datetime.now().isoformat(timespec="seconds")
|
||||
dogs = _get_profile_dogs(profile)
|
||||
for item in dogs:
|
||||
if item.get("id") == dog.get("id"):
|
||||
item["birthdayEmailLastSentAt"] = sent_at
|
||||
if mark_auto_year is not None:
|
||||
item["birthdayEmailLastSentYear"] = str(mark_auto_year)
|
||||
break
|
||||
profile_update = {"dogs": dogs}
|
||||
await _store_client_profile(email, profile_update)
|
||||
|
||||
|
||||
@@ -2559,23 +2680,25 @@ async def _run_birthday_auto_sender_once() -> None:
|
||||
for email, profile in list(_client_profiles.items()):
|
||||
if not profile.get("onboardingCompleted"):
|
||||
continue
|
||||
if not profile.get("birthdayAutoSend"):
|
||||
continue
|
||||
|
||||
upcoming = _upcoming_birthday_date(str(profile.get("dogAge", "")))
|
||||
if not upcoming or (upcoming.month, upcoming.day) != today_month_day:
|
||||
continue
|
||||
for dog in _get_profile_dogs(profile):
|
||||
if not dog.get("birthdayAutoSend"):
|
||||
continue
|
||||
|
||||
last_sent_year = str(profile.get("birthdayEmailLastSentYear", "")).strip()
|
||||
if last_sent_year == str(today.year):
|
||||
continue
|
||||
upcoming = _upcoming_birthday_date(str(dog.get("birthDate", "")))
|
||||
if not upcoming or (upcoming.month, upcoming.day) != today_month_day:
|
||||
continue
|
||||
|
||||
request_id = f"birthday-auto-{uuid.uuid4().hex[:6]}"
|
||||
try:
|
||||
await _send_birthday_email_for_profile(email, profile, request_id, mark_auto_year=today.year)
|
||||
logger.info("[%s] auto birthday email sent: email=%s", request_id, email)
|
||||
except Exception as exc:
|
||||
logger.error("[%s] auto birthday email failed: %s", request_id, exc, exc_info=True)
|
||||
last_sent_year = str(dog.get("birthdayEmailLastSentYear", "")).strip()
|
||||
if last_sent_year == str(today.year):
|
||||
continue
|
||||
|
||||
request_id = f"birthday-auto-{uuid.uuid4().hex[:6]}"
|
||||
try:
|
||||
await _send_birthday_email_for_profile(email, profile, request_id, dog_id=str(dog.get("id", "")), mark_auto_year=today.year)
|
||||
logger.info("[%s] auto birthday email sent: email=%s dog=%s", request_id, email, dog.get("name", ""))
|
||||
except Exception as exc:
|
||||
logger.error("[%s] auto birthday email failed: %s", request_id, exc, exc_info=True)
|
||||
|
||||
|
||||
async def _birthday_auto_sender_loop() -> None:
|
||||
@@ -3313,7 +3436,10 @@ async def owner_render_birthday_email(data: BirthdayEmailRequest, request: Reque
|
||||
raise HTTPException(status_code=404, detail="Client profile not found.")
|
||||
|
||||
owner_name = str(profile.get("fullName", "")).strip()
|
||||
dog_name = str(profile.get("dogName", "")).strip()
|
||||
dog = _get_selected_dog(profile, data.dogId)
|
||||
if not dog:
|
||||
raise HTTPException(status_code=404, detail="Dog not found.")
|
||||
dog_name = str(dog.get("name", "")).strip()
|
||||
html = _birthday_email_html(owner_name, dog_name)
|
||||
return {"ok": True, "html": html}
|
||||
|
||||
@@ -3543,6 +3669,69 @@ async def owner_activity(request: Request):
|
||||
return {"ok": True, "events": events}
|
||||
|
||||
|
||||
@app.get("/owner/resend-overview")
|
||||
async def owner_resend_overview(request: Request):
|
||||
await _require_owner_email(request)
|
||||
qp = request.query_params
|
||||
try:
|
||||
limit = int(qp.get("limit", "50"))
|
||||
except ValueError:
|
||||
limit = 50
|
||||
limit = max(1, min(100, limit))
|
||||
|
||||
try:
|
||||
emails_result = await asyncio.to_thread(resend.Emails.list, {"limit": limit})
|
||||
logs_result = await asyncio.to_thread(resend.Logs.list, {"limit": limit})
|
||||
except Exception as exc:
|
||||
logger.warning("owner_resend_overview failed: %s", exc, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"message": "Could not load Resend activity right now.",
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
email_rows = emails_result.get("data", []) if isinstance(emails_result, dict) else []
|
||||
log_rows = logs_result.get("data", []) if isinstance(logs_result, dict) else []
|
||||
|
||||
emails: list[dict[str, Any]] = []
|
||||
for item in email_rows:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
emails.append({
|
||||
"id": item.get("id"),
|
||||
"to": item.get("to") if isinstance(item.get("to"), list) else [],
|
||||
"from": item.get("from"),
|
||||
"createdAt": item.get("created_at"),
|
||||
"subject": item.get("subject"),
|
||||
"lastEvent": item.get("last_event"),
|
||||
"scheduledAt": item.get("scheduled_at"),
|
||||
})
|
||||
|
||||
logs: list[dict[str, Any]] = []
|
||||
for item in log_rows:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
endpoint = str(item.get("endpoint") or "")
|
||||
if "/emails" not in endpoint:
|
||||
continue
|
||||
logs.append({
|
||||
"id": item.get("id"),
|
||||
"createdAt": item.get("created_at"),
|
||||
"endpoint": endpoint,
|
||||
"method": item.get("method"),
|
||||
"responseStatus": item.get("response_status"),
|
||||
"userAgent": item.get("user_agent"),
|
||||
})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"emails": emails,
|
||||
"logs": logs[:limit],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/owner/submissions")
|
||||
async def owner_submissions(request: Request):
|
||||
await _require_owner_email(request)
|
||||
@@ -3595,6 +3784,8 @@ async def owner_pending_onboarding(request: Request):
|
||||
"address": profile.get("address", ""),
|
||||
"dogName": profile.get("dogName", ""),
|
||||
"dogBreed": profile.get("dogBreed", ""),
|
||||
"dogAge": profile.get("dogAge", ""),
|
||||
"dogs": _get_profile_dogs(profile),
|
||||
"services": profile.get("services", []) if isinstance(profile.get("services"), list) else [],
|
||||
"lastEnquiryAt": profile.get("lastEnquiryAt", ""),
|
||||
"welcomePackSentAt": profile.get("welcomePackSentAt", ""),
|
||||
@@ -3650,8 +3841,9 @@ async def owner_completed_onboarding(request: Request):
|
||||
"dogName": profile.get("dogName", ""),
|
||||
"dogBreed": profile.get("dogBreed", ""),
|
||||
"dogAge": profile.get("dogAge", ""),
|
||||
"dogs": _get_profile_dogs(profile),
|
||||
"onboardingSubmittedAt": profile.get("onboardingSubmittedAt", ""),
|
||||
"hasBirthdayInvite": bool(_trimmed(str(profile.get("dogAge", "")))),
|
||||
"hasBirthdayInvite": any(_trimmed(str(dog.get("birthDate", ""))) for dog in _get_profile_dogs(profile)),
|
||||
})
|
||||
|
||||
completed_clients.sort(
|
||||
@@ -3713,6 +3905,8 @@ async def owner_all_clients(request: Request):
|
||||
"address": profile.get("address", ""),
|
||||
"dogName": profile.get("dogName", ""),
|
||||
"dogBreed": profile.get("dogBreed", ""),
|
||||
"dogAge": profile.get("dogAge", ""),
|
||||
"dogs": _get_profile_dogs(profile),
|
||||
"status": "completed" if profile.get("onboardingCompleted") else "pending",
|
||||
"lifecycle": lifecycle or {"status": "active", "reason": "", "changedAt": "", "changedBy": ""},
|
||||
"lastActivityAt": profile.get("onboardingSubmittedAt", "") or profile.get("lastEnquiryAt", "") or profile.get("welcomePackSentAt", ""),
|
||||
@@ -3745,6 +3939,32 @@ async def owner_all_clients(request: Request):
|
||||
}
|
||||
|
||||
|
||||
@app.get("/owner/client-directory")
|
||||
async def owner_client_directory(request: Request):
|
||||
await _require_owner_email(request)
|
||||
|
||||
clients: list[dict[str, Any]] = []
|
||||
for email, profile in _client_profiles.items():
|
||||
if email == OWNER_EMAIL.strip().lower():
|
||||
continue
|
||||
lifecycle = profile.get("lifecycle") if isinstance(profile.get("lifecycle"), dict) else None
|
||||
clients.append({
|
||||
"email": email,
|
||||
"fullName": profile.get("fullName", ""),
|
||||
"phone": profile.get("phone", ""),
|
||||
"address": profile.get("address", ""),
|
||||
"dogName": profile.get("dogName", ""),
|
||||
"dogBreed": profile.get("dogBreed", ""),
|
||||
"dogAge": profile.get("dogAge", ""),
|
||||
"dogs": _get_profile_dogs(profile),
|
||||
"status": "completed" if profile.get("onboardingCompleted") else "pending",
|
||||
"lifecycle": lifecycle or {"status": "active", "reason": "", "changedAt": "", "changedBy": ""},
|
||||
})
|
||||
|
||||
clients.sort(key=lambda item: (item.get("fullName", "").lower(), item.get("email", "").lower()))
|
||||
return {"ok": True, "clients": clients}
|
||||
|
||||
|
||||
@app.post("/owner/client-profile")
|
||||
async def owner_client_profile(data: ClientProfileUpdate, request: Request):
|
||||
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
|
||||
@@ -3752,6 +3972,8 @@ async def owner_client_profile(data: ClientProfileUpdate, request: Request):
|
||||
|
||||
full_name = str(data.fullName).strip()
|
||||
dog_name = str(data.dogName).strip()
|
||||
dog_breed = str(data.dogBreed).strip()
|
||||
dog_age = str(data.dogAge).strip()
|
||||
phone = str(data.phone).strip()
|
||||
address = str(data.address).strip()
|
||||
current_email = str(data.email).strip().lower()
|
||||
@@ -3770,6 +3992,8 @@ async def owner_client_profile(data: ClientProfileUpdate, request: Request):
|
||||
"phone": phone,
|
||||
"address": address,
|
||||
"dogName": dog_name,
|
||||
"dogBreed": dog_breed,
|
||||
"dogAge": dog_age,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -3784,6 +4008,8 @@ async def owner_client_profile(data: ClientProfileUpdate, request: Request):
|
||||
"email": next_email,
|
||||
"fullName": full_name,
|
||||
"dogName": dog_name,
|
||||
"dogBreed": dog_breed,
|
||||
"dogAge": dog_age,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -3796,6 +4022,8 @@ async def owner_client_profile(data: ClientProfileUpdate, request: Request):
|
||||
"address": profile.get("address", ""),
|
||||
"dogName": profile.get("dogName", ""),
|
||||
"dogBreed": profile.get("dogBreed", ""),
|
||||
"dogAge": profile.get("dogAge", ""),
|
||||
"dogs": _get_profile_dogs(profile),
|
||||
"welcomePackSentAt": profile.get("welcomePackSentAt", ""),
|
||||
"welcomePackOffer": profile.get("welcomePackOffer", {}) if isinstance(profile.get("welcomePackOffer"), dict) else {},
|
||||
},
|
||||
@@ -3816,6 +4044,7 @@ async def owner_add_client(data: NewClientRequest, request: Request):
|
||||
address = str(data.address).strip()
|
||||
dog_name = str(data.dogName).strip()
|
||||
dog_breed = str(data.dogBreed).strip()
|
||||
dog_age = str(data.dogAge).strip()
|
||||
source = str(data.source).strip()
|
||||
|
||||
if not full_name:
|
||||
@@ -3836,6 +4065,7 @@ async def owner_add_client(data: NewClientRequest, request: Request):
|
||||
"address": address,
|
||||
"dogName": dog_name,
|
||||
"dogBreed": dog_breed,
|
||||
"dogAge": dog_age,
|
||||
"source": source,
|
||||
# Treated like an enquiry timestamp so the client surfaces in the
|
||||
# pending-onboarding and recent-clients views straight away.
|
||||
@@ -3901,6 +4131,8 @@ async def owner_add_client(data: NewClientRequest, request: Request):
|
||||
"address": profile.get("address", ""),
|
||||
"dogName": profile.get("dogName", ""),
|
||||
"dogBreed": profile.get("dogBreed", ""),
|
||||
"dogAge": profile.get("dogAge", ""),
|
||||
"dogs": _get_profile_dogs(profile),
|
||||
"status": "completed" if profile.get("onboardingCompleted") else "pending",
|
||||
"lifecycle": lifecycle or {"status": "active", "reason": "", "changedAt": "", "changedBy": ""},
|
||||
"lastActivityAt": profile.get("lastEnquiryAt", ""),
|
||||
@@ -3909,6 +4141,103 @@ async def owner_add_client(data: NewClientRequest, request: Request):
|
||||
}
|
||||
|
||||
|
||||
@app.post("/owner/client-dog")
|
||||
async def owner_client_dog(data: ClientDogUpsertRequest, request: Request):
|
||||
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
|
||||
owner_email = await _require_owner_email(request)
|
||||
|
||||
email = str(data.email).strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Client email is required.")
|
||||
|
||||
dog_name = str(data.dogName).strip()
|
||||
dog_breed = str(data.dogBreed).strip()
|
||||
dog_age = str(data.dogAge).strip()
|
||||
auto_send = bool(data.birthdayAutoSend)
|
||||
if not dog_name:
|
||||
raise HTTPException(status_code=400, detail="Please enter the dog name.")
|
||||
if auto_send and not _upcoming_birthday_date(dog_age):
|
||||
raise HTTPException(status_code=400, detail="Add a valid birthday before turning on automatic birthday emails.")
|
||||
|
||||
profile = _client_profiles.get(email, {})
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Client not found.")
|
||||
|
||||
dogs = _get_profile_dogs(profile)
|
||||
requested_dog_id = str(data.dogId or "").strip()
|
||||
existing = next((dog for dog in dogs if dog.get("id") == requested_dog_id), None) if requested_dog_id else None
|
||||
dog_id = requested_dog_id or _dog_slug(dog_name)
|
||||
|
||||
duplicate_name = next(
|
||||
(
|
||||
dog for dog in dogs
|
||||
if dog.get("id") != requested_dog_id
|
||||
and str(dog.get("name", "")).strip().lower() == dog_name.lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if duplicate_name and not existing:
|
||||
dog_id = str(duplicate_name.get("id") or dog_id)
|
||||
|
||||
next_dog = {
|
||||
"id": dog_id,
|
||||
"name": dog_name,
|
||||
"breed": dog_breed,
|
||||
"birthDate": dog_age,
|
||||
"birthdayAutoSend": auto_send,
|
||||
"birthdayEmailLastSentAt": str(existing.get("birthdayEmailLastSentAt", "")) if existing and dog_age else "",
|
||||
"birthdayEmailLastSentYear": str(existing.get("birthdayEmailLastSentYear", "")) if existing and dog_age else "",
|
||||
}
|
||||
|
||||
updated_existing = False
|
||||
next_dogs: list[dict[str, Any]] = []
|
||||
for dog in dogs:
|
||||
if dog.get("id") == dog_id:
|
||||
next_dogs.append(next_dog)
|
||||
updated_existing = True
|
||||
else:
|
||||
next_dogs.append(dog)
|
||||
if not updated_existing:
|
||||
next_dogs.append(next_dog)
|
||||
|
||||
await _store_client_profile(email, {"dogs": next_dogs})
|
||||
updated_profile = _client_profiles.get(email, {})
|
||||
lifecycle = updated_profile.get("lifecycle") if isinstance(updated_profile.get("lifecycle"), dict) else None
|
||||
|
||||
await admin_db.record_event(
|
||||
event_type="owner_client_dog_updated" if updated_existing else "owner_client_dog_added",
|
||||
request_id=request_id,
|
||||
actor_email=owner_email,
|
||||
ip=_get_ip(request),
|
||||
status="ok",
|
||||
detail={
|
||||
"email": email,
|
||||
"dogId": dog_id,
|
||||
"dogName": dog_name,
|
||||
"dogBreed": dog_breed,
|
||||
"dogAge": dog_age,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"client": {
|
||||
"email": email,
|
||||
"fullName": updated_profile.get("fullName", ""),
|
||||
"phone": updated_profile.get("phone", ""),
|
||||
"address": updated_profile.get("address", ""),
|
||||
"dogName": updated_profile.get("dogName", ""),
|
||||
"dogBreed": updated_profile.get("dogBreed", ""),
|
||||
"dogAge": updated_profile.get("dogAge", ""),
|
||||
"dogs": _get_profile_dogs(updated_profile),
|
||||
"status": "completed" if updated_profile.get("onboardingCompleted") else "pending",
|
||||
"lifecycle": lifecycle or {"status": "active", "reason": "", "changedAt": "", "changedBy": ""},
|
||||
"lastActivityAt": updated_profile.get("lastEnquiryAt", ""),
|
||||
"welcomePackSentAt": updated_profile.get("welcomePackSentAt", ""),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/owner/birthdays")
|
||||
async def owner_birthdays(request: Request):
|
||||
await _require_owner_email(request)
|
||||
@@ -3932,21 +4261,28 @@ async def owner_birthdays(request: Request):
|
||||
if not _client_is_reachable(profile):
|
||||
continue
|
||||
|
||||
upcoming = _upcoming_birthday_date(str(profile.get("dogAge", "")), today)
|
||||
if not upcoming:
|
||||
dogs = _get_profile_dogs(profile)
|
||||
if not dogs:
|
||||
continue
|
||||
|
||||
birthdays.append({
|
||||
"email": email,
|
||||
"fullName": profile.get("fullName", ""),
|
||||
"dogName": profile.get("dogName", ""),
|
||||
"dogBreed": profile.get("dogBreed", ""),
|
||||
"dogAge": profile.get("dogAge", ""),
|
||||
"birthdayLabel": upcoming.isoformat(),
|
||||
"daysUntil": (upcoming - today.date()).days,
|
||||
"birthdayAutoSend": bool(profile.get("birthdayAutoSend")),
|
||||
"birthdayEmailLastSentAt": profile.get("birthdayEmailLastSentAt", ""),
|
||||
})
|
||||
for dog in dogs:
|
||||
upcoming = _upcoming_birthday_date(str(dog.get("birthDate", "")), today)
|
||||
if not upcoming:
|
||||
continue
|
||||
|
||||
birthdays.append({
|
||||
"email": email,
|
||||
"fullName": profile.get("fullName", ""),
|
||||
"dogId": dog.get("id", ""),
|
||||
"dogName": dog.get("name", ""),
|
||||
"dogBreed": dog.get("breed", ""),
|
||||
"dogAge": dog.get("birthDate", ""),
|
||||
"birthdayLabel": upcoming.isoformat(),
|
||||
"daysUntil": (upcoming - today.date()).days,
|
||||
"birthdayAutoSend": bool(dog.get("birthdayAutoSend")),
|
||||
"birthdayEmailLastSentAt": dog.get("birthdayEmailLastSentAt", ""),
|
||||
"dogs": dogs,
|
||||
})
|
||||
|
||||
birthdays.sort(
|
||||
key=lambda item: (
|
||||
@@ -3987,9 +4323,13 @@ async def owner_birthday_ics(request: Request):
|
||||
if not profile or not profile.get("onboardingCompleted"):
|
||||
raise HTTPException(status_code=404, detail="Completed client not found.")
|
||||
|
||||
dog = _get_selected_dog(profile, _trimmed(request.query_params.get("dogId", "")) or None)
|
||||
if not dog:
|
||||
raise HTTPException(status_code=404, detail="Dog not found.")
|
||||
|
||||
attachment = _birthday_ics_attachment(
|
||||
str(profile.get("dogName", "")),
|
||||
str(profile.get("dogAge", "")),
|
||||
str(dog.get("name", "")),
|
||||
str(dog.get("birthDate", "")),
|
||||
str(profile.get("fullName", "")),
|
||||
request_id,
|
||||
)
|
||||
@@ -4373,14 +4713,18 @@ async def owner_send_birthday_email(data: BirthdayEmailRequest, request: Request
|
||||
if not profile or not profile.get("onboardingCompleted"):
|
||||
raise HTTPException(status_code=404, detail="Completed client not found.")
|
||||
|
||||
if not _upcoming_birthday_date(str(profile.get("dogAge", ""))):
|
||||
raise HTTPException(status_code=400, detail="This client does not have a valid dog birthday on file.")
|
||||
dog = _get_selected_dog(profile, data.dogId)
|
||||
if not dog:
|
||||
raise HTTPException(status_code=404, detail="Dog not found.")
|
||||
if not _upcoming_birthday_date(str(dog.get("birthDate", ""))):
|
||||
raise HTTPException(status_code=400, detail="This dog does not have a valid birthday on file.")
|
||||
|
||||
try:
|
||||
await _send_birthday_email_for_profile(
|
||||
email,
|
||||
profile,
|
||||
request_id,
|
||||
dog_id=data.dogId,
|
||||
preview=bool(data.preview),
|
||||
preview_recipients=[str(e) for e in (data.previewRecipients or [])],
|
||||
)
|
||||
@@ -4398,7 +4742,7 @@ async def owner_send_birthday_email(data: BirthdayEmailRequest, request: Request
|
||||
await admin_db.record_event(
|
||||
event_type="owner_birthday_email_sent",
|
||||
request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok",
|
||||
detail={"recipient": email, "preview": bool(data.preview)},
|
||||
detail={"recipient": email, "dogId": data.dogId or str(dog.get("id", "")), "dogName": str(dog.get("name", "")), "preview": bool(data.preview)},
|
||||
)
|
||||
return {"ok": True, "sentAt": datetime.now().isoformat(timespec="seconds"), "preview": bool(data.preview)}
|
||||
|
||||
@@ -4487,6 +4831,8 @@ async def owner_reset_onboarding(data: ResetOnboardingRequest, request: Request)
|
||||
"address": updated.get("address", ""),
|
||||
"dogName": updated.get("dogName", ""),
|
||||
"dogBreed": updated.get("dogBreed", ""),
|
||||
"dogAge": updated.get("dogAge", ""),
|
||||
"dogs": _get_profile_dogs(updated),
|
||||
"status": "pending",
|
||||
"lifecycle": lifecycle or {"status": "active", "reason": "", "changedAt": "", "changedBy": ""},
|
||||
"lastActivityAt": updated.get("lastEnquiryAt", "") or updated.get("welcomePackSentAt", ""),
|
||||
@@ -4504,17 +4850,23 @@ async def owner_birthday_auto_send(data: BirthdayAutoSendRequest, request: Reque
|
||||
if not profile or not profile.get("onboardingCompleted"):
|
||||
raise HTTPException(status_code=404, detail="Completed client not found.")
|
||||
|
||||
if not _upcoming_birthday_date(str(profile.get("dogAge", ""))):
|
||||
raise HTTPException(status_code=400, detail="This client does not have a valid dog birthday on file.")
|
||||
dog = _get_selected_dog(profile, data.dogId)
|
||||
if not dog:
|
||||
raise HTTPException(status_code=404, detail="Dog not found.")
|
||||
if not _upcoming_birthday_date(str(dog.get("birthDate", ""))):
|
||||
raise HTTPException(status_code=400, detail="This dog does not have a valid birthday on file.")
|
||||
|
||||
await _store_client_profile(email, {
|
||||
"birthdayAutoSend": data.enabled,
|
||||
})
|
||||
dogs = _get_profile_dogs(profile)
|
||||
for item in dogs:
|
||||
if item.get("id") == dog.get("id"):
|
||||
item["birthdayAutoSend"] = data.enabled
|
||||
break
|
||||
await _store_client_profile(email, {"dogs": dogs})
|
||||
|
||||
await admin_db.record_event(
|
||||
event_type="owner_birthday_auto_toggled",
|
||||
actor_email=owner_email, ip=_get_ip(request), status="ok",
|
||||
detail={"clientEmail": email, "enabled": bool(data.enabled)},
|
||||
detail={"clientEmail": email, "dogId": data.dogId or str(dog.get("id", "")), "dogName": str(dog.get("name", "")), "enabled": bool(data.enabled)},
|
||||
)
|
||||
return {"ok": True, "enabled": data.enabled}
|
||||
|
||||
|
||||
@@ -9,6 +9,14 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
const { surface } = resolveSurface(event.url, event.cookies);
|
||||
const path = event.url.pathname;
|
||||
|
||||
// Design variant previews are not for production. The whole /variants
|
||||
// namespace returns 500 off-localhost so unfinished work never leaks to the
|
||||
// public site. Localhost (the dev server, or a local prod build) is allowed.
|
||||
const onLocalhost = ['localhost', '127.0.0.1', '[::1]'].includes(event.url.hostname);
|
||||
if (!dev && !onLocalhost && (path === '/variants' || path.startsWith('/variants/'))) {
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
|
||||
// Sticky A/B assignment, marketing surface only — no point polluting the
|
||||
// owner/clients hosts with marketing-experiment cookies, and it skews
|
||||
// exposure counts when staff hit the public site from the dashboard.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,10 +27,40 @@
|
||||
export let content: HomePageContent;
|
||||
export let ab: AbContext | undefined = undefined;
|
||||
export let pagePath = '/';
|
||||
// The booking form is swappable so variants can preview an alternate Meet &
|
||||
// Greet form on the same homepage design. Defaults to the live wizard, so the
|
||||
// homepage route renders unchanged. See /variants/meet-greet.
|
||||
export let BookingComponent: typeof BookingWizard = BookingWizard;
|
||||
|
||||
const meetGreetVariantPath = '/variants/meet-greet';
|
||||
$: isMeetGreetVariant = pagePath === meetGreetVariantPath;
|
||||
const reviewCta = content.intro.reviewCta;
|
||||
const heroImage = '/images/smiling-happy-dog-maya.webp';
|
||||
const stars = Array.from({ length: 5 });
|
||||
$: heroLead = isMeetGreetVariant
|
||||
? 'Small group walks, solo support, and puppy visits for Auckland dogs who do better with a calm, consistent routine.'
|
||||
: 'Real adventures with the Tiny Gang, in small, matched groups across Auckland. Same walker every time, so your dog comes home happy, tired, and ready to settle.';
|
||||
$: founderQuote = isMeetGreetVariant
|
||||
? "Small groups, careful matching, and the same walker each time. That's how a walk feels safe, social, and worth looking forward to."
|
||||
: "Every dog gets a walk built around them, not a one-size pack: the right small group, a walker who knows their name and their quirks, and adventures that grow with their confidence. That's how a walk becomes the best part of a dog's day.";
|
||||
$: servicesHeading = isMeetGreetVariant
|
||||
? 'Choose the setup that fits your dog.'
|
||||
: undefined;
|
||||
$: servicesIntro = isMeetGreetVariant
|
||||
? 'Pack walks for social dogs, solo walks for dogs who need more space, and puppy visits for the early routine.'
|
||||
: undefined;
|
||||
$: howItWorksContent = isMeetGreetVariant
|
||||
? {
|
||||
...content.howItWorks,
|
||||
intro: 'Start with a free Meet & Greet. We assess the fit, then build the right routine for your dog.'
|
||||
}
|
||||
: content.howItWorks;
|
||||
$: bookingContent = isMeetGreetVariant
|
||||
? {
|
||||
...content.booking,
|
||||
subtitle: 'Tell us a little about your dog. We reply within 24 hours to arrange your free Meet & Greet.'
|
||||
}
|
||||
: content.booking;
|
||||
</script>
|
||||
|
||||
<Header navigation={content.navigation} />
|
||||
@@ -40,14 +70,10 @@
|
||||
<div class="chero-copy">
|
||||
<p class="chero-eyebrow">Goodwalk · Auckland dog walking</p>
|
||||
<h1 class="chero-title">
|
||||
<span class="chero-title-lead">All we do is</span>
|
||||
<span class="chero-title-main">small & medium dogs</span>
|
||||
<span class="chero-title-lead">Specialist walks for small & medium dogs</span>
|
||||
<span class="chero-title-main">Meet the Tiny Gang.</span>
|
||||
</h1>
|
||||
<p class="chero-lead">
|
||||
Real adventures with the Tiny Gang, in small, matched groups across
|
||||
Auckland. Same walker every time, so your dog comes home happy, tired,
|
||||
and ready to settle.
|
||||
</p>
|
||||
<p class="chero-lead">{heroLead}</p>
|
||||
</div>
|
||||
|
||||
<div class="chero-media">
|
||||
@@ -107,10 +133,7 @@
|
||||
<figure class="cfounder-quote">
|
||||
<blockquote class="cfounder-blockquote">
|
||||
<p class="cfounder-text">
|
||||
Every dog gets a walk built around them, not a one-size pack: the right
|
||||
small group, a walker who knows their name and their quirks, and
|
||||
adventures that grow with their confidence. That's how a walk becomes
|
||||
the best part of a dog's day.
|
||||
{founderQuote}
|
||||
</p>
|
||||
</blockquote>
|
||||
<figcaption class="cfounder-attr">
|
||||
@@ -133,19 +156,24 @@
|
||||
</section>
|
||||
|
||||
<!-- Offer first: what can I actually book, and roughly what does it cost? -->
|
||||
<ServicesSection services={content.services} />
|
||||
<ServicesSection services={content.services} heading={servicesHeading} intro={servicesIntro} />
|
||||
|
||||
<!-- Then the process: what happens after I enquire? Lowers the unknown. -->
|
||||
<HowItWorksSection content={content.howItWorks} />
|
||||
<HowItWorksSection content={howItWorksContent} />
|
||||
|
||||
<!-- Only now the reasons to choose us. -->
|
||||
<ValuesSection values={content.values} />
|
||||
|
||||
<TestimonialsSection testimonials={content.testimonials} seedKey={pagePath} />
|
||||
<FounderStorySection founderStory={content.founderStory} />
|
||||
<InfoSection info={content.info} />
|
||||
<BookingWizard booking={content.booking} {pagePath} {ab} />
|
||||
<InstagramSection instagram={content.instagram} />
|
||||
{#if isMeetGreetVariant}
|
||||
<TestimonialsSection testimonials={content.testimonials} seedKey={pagePath} />
|
||||
<InfoSection info={content.info} />
|
||||
<svelte:component this={BookingComponent} booking={bookingContent} {pagePath} {ab} />
|
||||
{:else}
|
||||
<!-- Only now the reasons to choose us. -->
|
||||
<ValuesSection values={content.values} />
|
||||
<TestimonialsSection testimonials={content.testimonials} seedKey={pagePath} />
|
||||
<FounderStorySection founderStory={content.founderStory} />
|
||||
<InfoSection info={content.info} />
|
||||
<svelte:component this={BookingComponent} booking={bookingContent} {pagePath} {ab} />
|
||||
<InstagramSection instagram={content.instagram} />
|
||||
{/if}
|
||||
<Footer footer={content.footer} />
|
||||
|
||||
<style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,613 +0,0 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* Tiny Gang pack walks page — clarity redesign.
|
||||
*
|
||||
* One design system, one message. Replaces the multi-card / multi-gradient
|
||||
* ServiceLandingPage treatment (for this page) with a single green hero +
|
||||
* light body that shares the homepage's visual language. The message is said
|
||||
* once, in order: what it is -> what's different -> how joining works -> fit
|
||||
* -> proof -> price -> FAQ -> book.
|
||||
*
|
||||
* View component only: the route owns Header/Footer, SEO, and data.
|
||||
* Preview at /variants/tiny-gang. To go live, render this instead of
|
||||
* ServiceLandingPage for pack-walks in src/routes/[slug]/+page.svelte.
|
||||
*/
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ServiceHero from '$lib/components/sections/ServiceHero.svelte';
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import BookingWizard from '$lib/components/sections/BookingWizard.svelte';
|
||||
import PricingPlanCard from '$lib/components/ui/PricingPlanCard.svelte';
|
||||
import TestimonialsSection from '$lib/components/sections/TestimonialsSection.svelte';
|
||||
import FaqSection from '$lib/components/sections/FaqSection.svelte';
|
||||
import { decoratePlans } from '$lib/utils/pricing';
|
||||
import type { ServicePageContent, SiteSharedContent } from '$lib/types';
|
||||
|
||||
export let content: SiteSharedContent;
|
||||
export let pageContent: ServicePageContent;
|
||||
export let currentPath = '/pack-walks';
|
||||
|
||||
// What makes Tiny Gang different. Said once here, not spread across a
|
||||
// 3-point highlight and a 6-card benefit grid that repeated each other.
|
||||
const traits = [
|
||||
{
|
||||
icon: 'fas fa-users',
|
||||
title: 'Carefully matched groups',
|
||||
body: '4 to 8 dogs, grouped by temperament, energy, and play style. Never a random pack.'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-shield-dog',
|
||||
title: 'Small & medium dogs only',
|
||||
body: 'Pace, group size, and play style are all built for little dogs, so yours is never the odd one out.'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-user-check',
|
||||
title: 'The same walker, every week',
|
||||
body: 'One familiar face at the door and on the walk. Your dog is known, not handed around.'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-eye',
|
||||
title: 'Eyes on every dog',
|
||||
body: 'Small groups mean we notice the little things early, from recall to a quiet day.'
|
||||
}
|
||||
];
|
||||
|
||||
// How joining works. The process, made explicit instead of buried in a
|
||||
// paragraph and an FAQ.
|
||||
const steps = [
|
||||
{
|
||||
title: 'Free Meet & Greet',
|
||||
body: 'We come to your home, meet your dog, and talk honestly about whether the pack suits them.'
|
||||
},
|
||||
{
|
||||
title: 'Two assessment walks',
|
||||
body: 'Your dog settles in gently while we confirm the right group, pace, and handling.'
|
||||
},
|
||||
{
|
||||
title: 'Your weekly slot',
|
||||
body: 'A regular spot with the same little crew and the same walker, week in, week out.'
|
||||
}
|
||||
];
|
||||
|
||||
// Distilled pricing message. The title already says the routine and the
|
||||
// price says the cost, so each card keeps only the one or two lines that
|
||||
// actually differ. The universal "free pickup" promise is stated once under
|
||||
// the grid instead of repeated on every card.
|
||||
const planLines: Record<string, string[]> = {
|
||||
'1 Walk Per Week': [
|
||||
'A calm weekly outing',
|
||||
'Time to sniff, play, and unwind',
|
||||
'Great for dogs just starting out'
|
||||
],
|
||||
'2-3 Walks Per Week': [
|
||||
'Steady exercise and real social time',
|
||||
'A settled mid-week rhythm',
|
||||
'Our balanced routine'
|
||||
],
|
||||
'4-5 Walks Per Week': [
|
||||
'Best value per walk',
|
||||
'Maximum routine and structure',
|
||||
'For high-energy, social dogs'
|
||||
],
|
||||
'Casual Pack Walk': [
|
||||
'No weekly commitment',
|
||||
'Book only the weeks you need',
|
||||
'For dogs already in the pack'
|
||||
]
|
||||
};
|
||||
|
||||
$: pricingPlans = decoratePlans(
|
||||
pageContent.pricing.plans.map((plan) => ({
|
||||
...plan,
|
||||
features: planLines[plan.title] ?? plan.features
|
||||
}))
|
||||
);
|
||||
$: faqItems = pageContent.faq?.items?.slice(0, 6) ?? [];
|
||||
</script>
|
||||
|
||||
<main class="tg">
|
||||
<!-- Hero: the shared service-page hero (green copy left, photo bleeds
|
||||
right), identical to /dog-walking and /puppy-visits. -->
|
||||
<ServiceHero
|
||||
eyebrow={pageContent.hero.eyebrow}
|
||||
title={pageContent.hero.title}
|
||||
subtitle={pageContent.hero.subtitle}
|
||||
imageUrl={pageContent.hero.imageUrl}
|
||||
imageAlt={pageContent.hero.imageAlt}
|
||||
chips={pageContent.hero.chips ?? []}
|
||||
cta={pageContent.hero.cta}
|
||||
/>
|
||||
|
||||
<!-- What makes it different (said once) -->
|
||||
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-diff-heading">
|
||||
<div class="tg-inner">
|
||||
<div class="tg-head">
|
||||
<p class="tg-eyebrow">What makes Tiny Gang different</p>
|
||||
<h2 id="tg-diff-heading" class="tg-h2">Not a big anonymous pack. A small, matched crew.</h2>
|
||||
</div>
|
||||
<ul class="tg-traits">
|
||||
{#each traits as trait}
|
||||
<li class="tg-trait">
|
||||
<span class="tg-trait-icon" aria-hidden="true"><Icon name={trait.icon} /></span>
|
||||
<div>
|
||||
<h3>{trait.title}</h3>
|
||||
<p>{trait.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How joining works (the process, explicit) -->
|
||||
<section class="tg-section tg-section-steps" use:reveal={{ distance: 0 }} aria-labelledby="tg-steps-heading">
|
||||
<div class="tg-inner">
|
||||
<div class="tg-head">
|
||||
<p class="tg-eyebrow">How joining works</p>
|
||||
<h2 id="tg-steps-heading" class="tg-h2">Three simple steps, no pressure.</h2>
|
||||
</div>
|
||||
<ol class="tg-steps">
|
||||
{#each steps as step, i}
|
||||
<li class="tg-step">
|
||||
<span class="tg-step-num" aria-hidden="true">{i + 1}</span>
|
||||
<h3>{step.title}</h3>
|
||||
<p>{step.body}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Is it right for your dog? -->
|
||||
{#if pageContent.decision}
|
||||
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-fit-heading">
|
||||
<div class="tg-inner">
|
||||
<div class="tg-head">
|
||||
<p class="tg-eyebrow">Honest fit check</p>
|
||||
<h2 id="tg-fit-heading" class="tg-h2">{pageContent.decision.title ?? 'Is Tiny Gang right for your dog?'}</h2>
|
||||
</div>
|
||||
<div class="tg-fit">
|
||||
<div class="tg-fit-col">
|
||||
<h3>{pageContent.decision.fitTitle ?? 'A great fit if your dog:'}</h3>
|
||||
<ul>
|
||||
{#each pageContent.decision.fitItems as item}
|
||||
<li>
|
||||
<span class="tg-fit-mark tg-fit-mark-yes" aria-hidden="true"><Icon name="fas fa-check" /></span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tg-fit-col">
|
||||
<h3>{pageContent.decision.notFitTitle ?? 'Better on a solo walk if your dog:'}</h3>
|
||||
<ul>
|
||||
{#each pageContent.decision.notFitItems as item}
|
||||
<li>
|
||||
<span class="tg-fit-mark tg-fit-mark-no" aria-hidden="true"><Icon name="fas fa-xmark" /></span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{#if pageContent.decision.footnote}
|
||||
<p class="tg-fit-note">{pageContent.decision.footnote}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Proof: owners love it -->
|
||||
<TestimonialsSection
|
||||
heading="Owners love the Tiny Gang"
|
||||
testimonials={content.testimonials}
|
||||
seedKey={currentPath}
|
||||
/>
|
||||
|
||||
<!-- Price -->
|
||||
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-price-heading">
|
||||
<div class="tg-inner tg-inner-wide">
|
||||
<div class="tg-head">
|
||||
<p class="tg-eyebrow">Simple weekly pricing</p>
|
||||
<h2 id="tg-price-heading" class="tg-h2">{pageContent.pricing.title}</h2>
|
||||
<p class="tg-price-tagline">The best investment you'll ever make is in your dog's happiness.</p>
|
||||
{#if pageContent.pricing.intro}
|
||||
<p class="tg-head-sub">{pageContent.pricing.intro}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="tg-plans">
|
||||
{#each pricingPlans as plan}
|
||||
<PricingPlanCard {plan} variant="service" hideCtaOnMobile={true} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="tg-price-notes">
|
||||
{#if pageContent.pricing.scarcityNote}
|
||||
<p class="tg-note tg-note-warm"><Icon name="fas fa-clock" />{pageContent.pricing.scarcityNote}</p>
|
||||
{/if}
|
||||
<p class="tg-note"><Icon name="fas fa-stopwatch" />60+ minute adventures.</p>
|
||||
<p class="tg-note"><Icon name="fas fa-car" />Free pickup & drop-off, every walk.</p>
|
||||
<p class="tg-note"><Icon name="fas fa-shield-heart" />Starts with a free Meet & Greet.</p>
|
||||
</div>
|
||||
|
||||
<a class="btn btn-yellow btn-mobile-center tg-plans-cta" href="#newlead">Book a Meet & Greet</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQ -->
|
||||
{#if faqItems.length}
|
||||
<section class="tg-section" use:reveal={{ distance: 0 }}>
|
||||
<div class="tg-inner tg-inner-narrow">
|
||||
<FaqSection
|
||||
title={pageContent.faq?.title ?? 'Tiny Gang FAQs'}
|
||||
intro={pageContent.faq?.intro}
|
||||
faqs={faqItems}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<BookingWizard booking={pageContent.booking} pagePath={currentPath} />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
/* ── One system: tokens for this page ──────────────────────────────── */
|
||||
.tg {
|
||||
--tg-max: 1120px;
|
||||
--tg-narrow: 820px;
|
||||
background: var(--off-white);
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.tg-inner {
|
||||
max-width: var(--tg-max);
|
||||
margin: 0 auto;
|
||||
padding-inline: var(--space-container-x);
|
||||
}
|
||||
|
||||
.tg-inner-narrow {
|
||||
max-width: var(--tg-narrow);
|
||||
}
|
||||
|
||||
/* Pricing breaks out wider than the body so four cards have real width
|
||||
(and the full CTA never clips). */
|
||||
.tg-inner-wide {
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
.tg-section {
|
||||
padding-block: clamp(48px, 6vw, 84px);
|
||||
}
|
||||
|
||||
/* Alternating surface for rhythm, not a new "system": just a tint. */
|
||||
.tg-section-steps {
|
||||
background: var(--surface-panel-cream);
|
||||
border-block: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
/* One eyebrow, one heading, everywhere. */
|
||||
.tg-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 0 14px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gw-green);
|
||||
}
|
||||
|
||||
.tg-eyebrow::before {
|
||||
content: '';
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--yellow);
|
||||
}
|
||||
|
||||
.tg-head {
|
||||
max-width: 40rem;
|
||||
margin: 0 auto clamp(28px, 4vw, 44px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tg-h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(28px, 3.4vw, 42px);
|
||||
font-weight: 700;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-heading);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.tg-head-sub {
|
||||
margin: 14px auto 0;
|
||||
max-width: 36rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 17px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.tg-price-tagline {
|
||||
margin: 14px auto 0;
|
||||
max-width: 32rem;
|
||||
color: var(--gw-green);
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(17px, 1.7vw, 20px);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.01em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* ── Traits: a clean feature list, not boxed cards ─────────────────── */
|
||||
.tg-traits {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: clamp(24px, 3vw, 40px) clamp(32px, 5vw, 64px);
|
||||
}
|
||||
|
||||
.tg-trait {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.tg-trait-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
background: var(--surface-brand);
|
||||
color: var(--yellow);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.tg-trait h3 {
|
||||
margin: 2px 0 6px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 19px;
|
||||
line-height: 1.2;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.tg-trait p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
max-width: 38ch;
|
||||
}
|
||||
|
||||
/* ── Steps: numerals, not cards ────────────────────────────────────── */
|
||||
.tg-steps {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: clamp(24px, 4vw, 48px);
|
||||
counter-reset: tg-step;
|
||||
}
|
||||
|
||||
.tg-step {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tg-step-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--yellow);
|
||||
color: var(--gw-green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tg-step h3 {
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 19px;
|
||||
line-height: 1.2;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.tg-step p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Fit check: one card, two columns ──────────────────────────────── */
|
||||
.tg-fit {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: clamp(20px, 3vw, 32px);
|
||||
padding: clamp(24px, 3vw, 40px);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--surface-panel);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--border-soft-strong),
|
||||
var(--shadow-card);
|
||||
}
|
||||
|
||||
.tg-fit-col h3 {
|
||||
margin: 0 0 16px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 17px;
|
||||
line-height: 1.3;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.tg-fit-col ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tg-fit-col li {
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
color: var(--text-heading-soft);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tg-fit-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-top: 1px;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.tg-fit-mark-yes {
|
||||
background: rgba(var(--brand-rgb), 0.1);
|
||||
color: var(--gw-green);
|
||||
}
|
||||
|
||||
.tg-fit-mark-no {
|
||||
background: rgba(var(--ink-rgb), 0.06);
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.tg-fit-note {
|
||||
margin: 22px auto 0;
|
||||
max-width: 48rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Pricing ───────────────────────────────────────────────────────── */
|
||||
.tg-plans {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Roomier, consistent cards on this page, scoped via .tg-plans so the shared
|
||||
PricingPlanCard and other pages keep their own sizing. */
|
||||
.tg-plans :global(.plan-card) {
|
||||
padding: 44px 30px 38px;
|
||||
}
|
||||
|
||||
/* The featured card carries the "Goodwalk favourite" ribbon (absolute,
|
||||
top: 16px). Give it enough head room that the ribbon sits clearly above
|
||||
the title instead of over it. */
|
||||
.tg-plans :global(.plan-card--featured) {
|
||||
padding-top: 62px;
|
||||
}
|
||||
|
||||
/* Full-width CTA: never squished, and consistent across all four cards.
|
||||
display:flex (block-level) overrides the component's inline-flex +
|
||||
align-self:center, so the button always spans the full card width. */
|
||||
.tg-plans :global(.plan-card__cta) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
padding-inline: 15px;
|
||||
}
|
||||
|
||||
.tg-price-notes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.tg-note {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-brand-soft);
|
||||
color: var(--gw-green);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tg-note-warm {
|
||||
background: var(--surface-accent-muted);
|
||||
color: #5a4500;
|
||||
}
|
||||
|
||||
:global(.tg-note .icon) {
|
||||
color: var(--yellow);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:global(.tg-note-warm .icon) {
|
||||
color: #b88800;
|
||||
}
|
||||
|
||||
.tg-plans-cta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────────────────────────────────── */
|
||||
@media (max-width: 980px) {
|
||||
.tg-plans {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tg-traits {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tg-steps {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.tg-fit {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tg-plans {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tg-plans :global(.plan-card) {
|
||||
padding: 30px 24px 26px;
|
||||
}
|
||||
|
||||
.tg-plans :global(.plan-card--featured) {
|
||||
padding-top: 46px;
|
||||
}
|
||||
|
||||
.tg-plans-cta {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,9 @@
|
||||
export let faqs: FaqItem[];
|
||||
export let emitSchema = true;
|
||||
export let variant: 'panel' | 'plain' = 'panel';
|
||||
// When the host page supplies its own heading (e.g. to match a page-wide
|
||||
// section system), suppress the built-in one but keep the schema + list.
|
||||
export let showHeading = true;
|
||||
|
||||
$: schemaJson = JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
@@ -30,12 +33,14 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class:faq-section-plain={variant === 'plain'} class="faq-section">
|
||||
<h2 class="faq-section-heading">
|
||||
<span class="faq-section-icon"><Icon name="fas fa-circle-question" /></span>
|
||||
{title}
|
||||
</h2>
|
||||
{#if intro}
|
||||
<p class="faq-section-intro">{intro}</p>
|
||||
{#if showHeading}
|
||||
<h2 class="faq-section-heading">
|
||||
<span class="faq-section-icon"><Icon name="fas fa-circle-question" /></span>
|
||||
{title}
|
||||
</h2>
|
||||
{#if intro}
|
||||
<p class="faq-section-intro">{intro}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
<div use:accordion class="faq">
|
||||
{#each faqs as faq}
|
||||
|
||||
@@ -1,929 +0,0 @@
|
||||
<script lang="ts">
|
||||
type LabelAnchor = 'top' | 'bottom' | 'left' | 'right';
|
||||
type LabelTone = 'brand' | 'accent';
|
||||
type Breakpoint = 'desktop' | 'tablet' | 'mobile';
|
||||
|
||||
type Placement = {
|
||||
x: number;
|
||||
y: number;
|
||||
anchor: LabelAnchor;
|
||||
dx?: number;
|
||||
dy?: number;
|
||||
visible?: boolean;
|
||||
z?: number;
|
||||
};
|
||||
|
||||
type Pin = {
|
||||
slug: string;
|
||||
suburb: string;
|
||||
tone?: LabelTone;
|
||||
desktop: Placement;
|
||||
tablet?: Partial<Placement>;
|
||||
mobile?: Partial<Placement>;
|
||||
};
|
||||
|
||||
const mapWidth = 640;
|
||||
const mapViewY = 100;
|
||||
const mapHeight = 350;
|
||||
const centre = { x: 320, y: 238 };
|
||||
const city = { x: 344, y: 176 };
|
||||
|
||||
const pins: Pin[] = [
|
||||
{
|
||||
slug: 'pt-chevalier',
|
||||
suburb: 'Pt Chevalier',
|
||||
tone: 'accent',
|
||||
desktop: { x: 124, y: 228, anchor: 'left', dy: -6, z: 4 },
|
||||
tablet: { x: 120, y: 230, anchor: 'left', dy: -4 },
|
||||
mobile: { x: 114, y: 230, anchor: 'right', dx: 8, dy: -2, z: 6 }
|
||||
},
|
||||
{
|
||||
slug: 'herne-bay',
|
||||
suburb: 'Herne Bay',
|
||||
tone: 'accent',
|
||||
desktop: { x: 226, y: 118, anchor: 'top', dx: -10, z: 5 },
|
||||
tablet: { x: 224, y: 122, anchor: 'top', dx: -14, dy: -2 },
|
||||
mobile: { x: 214, y: 116, anchor: 'bottom', dx: -14, dy: 6, z: 6 }
|
||||
},
|
||||
{
|
||||
slug: 'freemans-bay',
|
||||
suburb: 'Freemans Bay',
|
||||
desktop: { x: 330, y: 124, anchor: 'top', dx: 14, dy: -4, z: 5 },
|
||||
tablet: { x: 326, y: 130, anchor: 'top', dx: 10, dy: -2 },
|
||||
mobile: { x: 322, y: 132, anchor: 'top', dx: 8, dy: -4, visible: false }
|
||||
},
|
||||
{
|
||||
slug: 'ponsonby',
|
||||
suburb: 'Ponsonby',
|
||||
desktop: { x: 250, y: 178, anchor: 'left', dx: -4, dy: -10, z: 5 },
|
||||
tablet: { x: 244, y: 184, anchor: 'left', dx: -6, dy: -10 },
|
||||
mobile: { x: 238, y: 172, anchor: 'top', dx: -6, dy: -10, z: 6 }
|
||||
},
|
||||
{
|
||||
slug: 'grey-lynn',
|
||||
suburb: 'Grey Lynn',
|
||||
desktop: { x: 214, y: 220, anchor: 'left', dx: -10, dy: -2, z: 5 },
|
||||
tablet: { x: 208, y: 222, anchor: 'left', dx: -10, dy: 2 },
|
||||
mobile: { x: 196, y: 222, anchor: 'left', dx: -6, dy: 0, z: 6 }
|
||||
},
|
||||
{
|
||||
slug: 'kingsland',
|
||||
suburb: 'Kingsland',
|
||||
desktop: { x: 248, y: 258, anchor: 'left', dx: -6, dy: -2 },
|
||||
tablet: { x: 236, y: 260, anchor: 'left', dx: -8, dy: -2 },
|
||||
mobile: { x: 232, y: 260, anchor: 'left', dx: -2, visible: false }
|
||||
},
|
||||
{
|
||||
slug: 'morningside',
|
||||
suburb: 'Morningside',
|
||||
desktop: { x: 196, y: 290, anchor: 'left', dx: -10, dy: 2 },
|
||||
tablet: { x: 186, y: 290, anchor: 'left', dx: -8, dy: 4 },
|
||||
mobile: { x: 184, y: 288, anchor: 'left', dx: -2, dy: 2, visible: false }
|
||||
},
|
||||
{
|
||||
slug: 'mt-albert',
|
||||
suburb: 'Mt Albert',
|
||||
tone: 'accent',
|
||||
desktop: { x: 148, y: 314, anchor: 'left', dx: -6, dy: 2, z: 4 },
|
||||
tablet: { x: 148, y: 314, anchor: 'left', dx: -8, dy: 4 },
|
||||
mobile: { x: 144, y: 312, anchor: 'left', dx: -2, dy: 0, z: 5 }
|
||||
},
|
||||
{
|
||||
slug: 'sandringham',
|
||||
suburb: 'Sandringham',
|
||||
desktop: { x: 244, y: 344, anchor: 'bottom', dx: -10, dy: 0, z: 4 },
|
||||
tablet: { x: 238, y: 340, anchor: 'bottom', dx: -14, dy: 2 },
|
||||
mobile: { x: 238, y: 340, anchor: 'bottom', dx: -14, dy: 2, visible: false }
|
||||
},
|
||||
{
|
||||
slug: 'mt-eden',
|
||||
suburb: 'Mt Eden',
|
||||
desktop: { x: 344, y: 280, anchor: 'right', dx: 6, dy: -8, z: 5 },
|
||||
tablet: { x: 334, y: 282, anchor: 'right', dx: 6, dy: -6, z: 5 },
|
||||
mobile: { x: 332, y: 280, anchor: 'right', dx: 4, dy: -8, z: 6 }
|
||||
},
|
||||
{
|
||||
slug: 'balmoral',
|
||||
suburb: 'Balmoral',
|
||||
desktop: { x: 314, y: 334, anchor: 'right', dx: 8, dy: 4, z: 4 },
|
||||
tablet: { x: 306, y: 338, anchor: 'bottom', dx: 0, dy: 6, z: 4 },
|
||||
mobile: { x: 304, y: 338, anchor: 'bottom', dx: 0, dy: 6, visible: false }
|
||||
},
|
||||
{
|
||||
slug: 'remuera',
|
||||
suburb: 'Remuera',
|
||||
tone: 'accent',
|
||||
desktop: { x: 470, y: 272, anchor: 'right', dx: 10, dy: -10, z: 4 },
|
||||
tablet: { x: 452, y: 280, anchor: 'right', dx: 8, dy: -8, z: 4 },
|
||||
mobile: { x: 438, y: 276, anchor: 'right', dx: 6, dy: -6, z: 5 }
|
||||
},
|
||||
{
|
||||
slug: 'greenlane',
|
||||
suburb: 'Greenlane',
|
||||
desktop: { x: 426, y: 348, anchor: 'right', dx: 10, dy: 6, z: 4 },
|
||||
tablet: { x: 410, y: 346, anchor: 'right', dx: 8, dy: 6, z: 4 },
|
||||
mobile: { x: 404, y: 340, anchor: 'right', dx: 4, dy: 6, z: 5 }
|
||||
},
|
||||
{
|
||||
slug: 'mt-roskill',
|
||||
suburb: 'Mt Roskill',
|
||||
desktop: { x: 182, y: 384, anchor: 'left', dx: -2, dy: 4, z: 3 },
|
||||
tablet: { x: 180, y: 384, anchor: 'left', dx: -2, dy: 4, z: 3 },
|
||||
mobile: { x: 178, y: 382, anchor: 'left', dx: 4, dy: 2, z: 5 }
|
||||
},
|
||||
{
|
||||
slug: 'three-kings',
|
||||
suburb: 'Three Kings',
|
||||
desktop: { x: 296, y: 390, anchor: 'bottom', dx: 8, dy: 0, z: 4 },
|
||||
tablet: { x: 292, y: 388, anchor: 'bottom', dx: 8, dy: 0, z: 4 },
|
||||
mobile: { x: 292, y: 390, anchor: 'bottom', dx: 6, dy: 0, z: 6 }
|
||||
},
|
||||
{
|
||||
slug: 'hillsborough',
|
||||
suburb: 'Hillsborough',
|
||||
tone: 'accent',
|
||||
desktop: { x: 216, y: 426, anchor: 'bottom', dx: -6, dy: 0, z: 4 },
|
||||
tablet: { x: 214, y: 422, anchor: 'bottom', dx: -8, dy: 0, z: 4 },
|
||||
mobile: { x: 218, y: 418, anchor: 'top', dx: -2, dy: -8, z: 6 }
|
||||
},
|
||||
{
|
||||
slug: 'onehunga',
|
||||
suburb: 'Onehunga',
|
||||
desktop: { x: 364, y: 426, anchor: 'bottom', dx: 8, dy: 0, z: 3 },
|
||||
tablet: { x: 354, y: 424, anchor: 'bottom', dx: 8, dy: 0, z: 3 },
|
||||
mobile: { x: 352, y: 420, anchor: 'top', dx: 10, dy: -8, visible: false }
|
||||
}
|
||||
];
|
||||
|
||||
function percentX(x: number) {
|
||||
return `${(x / mapWidth) * 100}%`;
|
||||
}
|
||||
|
||||
function percentY(y: number) {
|
||||
return `${((y - mapViewY) / mapHeight) * 100}%`;
|
||||
}
|
||||
|
||||
function resolvePlacement(pin: Pin, breakpoint: Breakpoint): Placement {
|
||||
const desktop = pin.desktop;
|
||||
const override =
|
||||
breakpoint === 'desktop' ? {} : breakpoint === 'tablet' ? pin.tablet ?? {} : pin.mobile ?? {};
|
||||
|
||||
return {
|
||||
...desktop,
|
||||
...override
|
||||
};
|
||||
}
|
||||
|
||||
function placementTokens(prefix: string, placement: Placement, gap: number, stem: number) {
|
||||
const dx = placement.dx ?? 0;
|
||||
const dy = placement.dy ?? 0;
|
||||
|
||||
if (placement.anchor === 'left') {
|
||||
return [
|
||||
`--${prefix}-direction: row-reverse`,
|
||||
`--${prefix}-transform: translate(calc(-100% - ${gap}px + ${dx}px), calc(-50% + ${dy}px))`,
|
||||
`--${prefix}-stem-w: ${stem}px`,
|
||||
`--${prefix}-stem-h: 1.5px`
|
||||
];
|
||||
}
|
||||
|
||||
if (placement.anchor === 'right') {
|
||||
return [
|
||||
`--${prefix}-direction: row`,
|
||||
`--${prefix}-transform: translate(calc(${gap}px + ${dx}px), calc(-50% + ${dy}px))`,
|
||||
`--${prefix}-stem-w: ${stem}px`,
|
||||
`--${prefix}-stem-h: 1.5px`
|
||||
];
|
||||
}
|
||||
|
||||
if (placement.anchor === 'top') {
|
||||
return [
|
||||
`--${prefix}-direction: column-reverse`,
|
||||
`--${prefix}-transform: translate(calc(-50% + ${dx}px), calc(-100% - ${gap}px + ${dy}px))`,
|
||||
`--${prefix}-stem-w: 1.5px`,
|
||||
`--${prefix}-stem-h: ${stem}px`
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
`--${prefix}-direction: column`,
|
||||
`--${prefix}-transform: translate(calc(-50% + ${dx}px), calc(${gap}px + ${dy}px))`,
|
||||
`--${prefix}-stem-w: 1.5px`,
|
||||
`--${prefix}-stem-h: ${stem}px`
|
||||
];
|
||||
}
|
||||
|
||||
function displayToken(placement: Placement | undefined) {
|
||||
return placement?.visible === false ? 'none' : 'inline-flex';
|
||||
}
|
||||
|
||||
function pinStyle(pin: Pin, index: number) {
|
||||
const desktop = resolvePlacement(pin, 'desktop');
|
||||
const tablet = resolvePlacement(pin, 'tablet');
|
||||
const mobile = resolvePlacement(pin, 'mobile');
|
||||
|
||||
return [
|
||||
`--desktop-left: ${percentX(desktop.x)}`,
|
||||
`--desktop-top: ${percentY(desktop.y)}`,
|
||||
`--desktop-z: ${desktop.z ?? 3}`,
|
||||
`--desktop-display: ${displayToken(desktop)}`,
|
||||
...placementTokens('desktop', desktop, 14, 16),
|
||||
`--tablet-left: ${percentX(tablet.x)}`,
|
||||
`--tablet-top: ${percentY(tablet.y)}`,
|
||||
`--tablet-z: ${tablet.z ?? desktop.z ?? 3}`,
|
||||
`--tablet-display: ${displayToken(tablet)}`,
|
||||
...placementTokens('tablet', tablet, 12, 14),
|
||||
`--mobile-left: ${percentX(mobile.x)}`,
|
||||
`--mobile-top: ${percentY(mobile.y)}`,
|
||||
`--mobile-z: ${mobile.z ?? tablet.z ?? desktop.z ?? 3}`,
|
||||
`--mobile-display: ${displayToken(mobile)}`,
|
||||
...placementTokens('mobile', mobile, 10, 12),
|
||||
`--pin-delay: ${((index * 0.11) % 1.6).toFixed(2)}s`
|
||||
].join('; ');
|
||||
}
|
||||
|
||||
function routePath(pin: Pin) {
|
||||
const target = pin.desktop;
|
||||
const controlX = (centre.x + target.x) / 2;
|
||||
const controlY = (centre.y + target.y) / 2 + (target.y >= centre.y ? 14 : -14);
|
||||
|
||||
return `M ${centre.x} ${centre.y} Q ${controlX} ${controlY} ${target.x} ${target.y}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<figure class="area-map" aria-labelledby="area-map-caption">
|
||||
<div class="area-map-shell">
|
||||
<div class="area-map-stage">
|
||||
<svg
|
||||
class="area-map-svg"
|
||||
viewBox={`0 ${mapViewY} ${mapWidth} ${mapHeight}`}
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="area-map-bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="rgba(var(--white-rgb), 0.98)" />
|
||||
<stop offset="58%" stop-color="rgba(var(--white-rgb), 0.94)" />
|
||||
<stop offset="100%" stop-color="rgba(var(--accent-rgb), 0.1)" />
|
||||
</linearGradient>
|
||||
<radialGradient id="area-map-glow" cx="50%" cy="46%" r="54%">
|
||||
<stop offset="0%" stop-color="rgba(var(--accent-rgb), 0.14)" />
|
||||
<stop offset="48%" stop-color="rgba(var(--accent-rgb), 0.04)" />
|
||||
<stop offset="100%" stop-color="rgba(var(--accent-rgb), 0)" />
|
||||
</radialGradient>
|
||||
<radialGradient id="area-map-core-glow" cx="50%" cy="50%" r="58%">
|
||||
<stop offset="0%" stop-color="rgba(var(--brand-rgb), 0.12)" />
|
||||
<stop offset="70%" stop-color="rgba(var(--brand-rgb), 0.015)" />
|
||||
<stop offset="100%" stop-color="rgba(var(--brand-rgb), 0)" />
|
||||
</radialGradient>
|
||||
<linearGradient id="area-map-district-main" x1="18%" y1="12%" x2="82%" y2="88%">
|
||||
<stop offset="0%" stop-color="rgba(var(--brand-rgb), 0.16)" />
|
||||
<stop offset="100%" stop-color="rgba(var(--brand-rgb), 0.05)" />
|
||||
</linearGradient>
|
||||
<linearGradient id="area-map-district-soft" x1="10%" y1="20%" x2="95%" y2="85%">
|
||||
<stop offset="0%" stop-color="rgba(var(--accent-rgb), 0.1)" />
|
||||
<stop offset="100%" stop-color="rgba(var(--accent-rgb), 0.02)" />
|
||||
</linearGradient>
|
||||
<linearGradient id="area-map-route-flow" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="rgba(var(--brand-rgb), 0)" />
|
||||
<stop offset="40%" stop-color="rgba(var(--brand-rgb), 0.04)" />
|
||||
<stop offset="56%" stop-color="rgba(var(--accent-rgb), 0.44)" />
|
||||
<stop offset="72%" stop-color="rgba(var(--brand-rgb), 0.08)" />
|
||||
<stop offset="100%" stop-color="rgba(var(--brand-rgb), 0)" />
|
||||
</linearGradient>
|
||||
<pattern id="area-map-grid" width="52" height="52" patternUnits="userSpaceOnUse">
|
||||
<path d="M 52 0 L 0 0 0 52" fill="none" stroke="rgba(var(--brand-rgb), 0.045)" stroke-width="1" />
|
||||
</pattern>
|
||||
<filter id="area-map-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="rgba(var(--brand-rgb), 0.1)" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect x="0" y="0" width={mapWidth} height="500" rx="32" fill="url(#area-map-bg)" />
|
||||
<rect x="0" y="0" width={mapWidth} height="500" rx="32" fill="url(#area-map-grid)" />
|
||||
<ellipse cx="324" cy="234" rx="206" ry="154" fill="url(#area-map-glow)" />
|
||||
|
||||
<g class="area-map-waterways">
|
||||
<path
|
||||
d="M 26 108 C 96 74, 176 66, 244 84 C 302 100, 336 124, 396 122 C 470 120, 548 76, 620 90"
|
||||
class="area-map-waterway"
|
||||
/>
|
||||
<path
|
||||
d="M 78 454 C 140 430, 186 416, 240 420 C 292 424, 330 460, 392 462 C 472 466, 560 428, 616 384"
|
||||
class="area-map-waterway area-map-waterway-soft"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g class="area-map-districts" filter="url(#area-map-shadow)">
|
||||
<path
|
||||
d="M 94 206 C 132 162, 198 132, 274 126 C 334 122, 396 134, 446 160 C 500 188, 532 240, 530 300 C 528 362, 492 412, 430 434 C 342 466, 224 458, 150 412 C 92 376, 70 282, 94 206 Z"
|
||||
fill="url(#area-map-district-main)"
|
||||
/>
|
||||
<path
|
||||
d="M 126 214 C 168 180, 222 164, 284 164 C 348 164, 402 180, 442 214 C 474 242, 490 280, 486 320 C 482 366, 448 402, 394 420 C 316 446, 214 438, 154 394 C 112 360, 98 274, 126 214 Z"
|
||||
fill="rgba(var(--white-rgb), 0.32)"
|
||||
stroke="rgba(var(--brand-rgb), 0.08)"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<path
|
||||
d="M 300 158 C 360 158, 420 176, 462 210 C 500 240, 522 278, 522 322 C 522 370, 500 402, 462 414 C 420 426, 362 412, 332 378 C 304 346, 302 312, 338 282 C 366 256, 384 230, 378 202 C 372 178, 346 162, 300 158 Z"
|
||||
fill="url(#area-map-district-soft)"
|
||||
/>
|
||||
<path
|
||||
d="M 156 248 C 188 236, 224 244, 248 268 C 268 288, 278 320, 266 348 C 252 382, 218 402, 180 402 C 148 400, 120 384, 104 356 C 88 324, 92 286, 116 262 C 128 250, 142 244, 156 248 Z"
|
||||
fill="rgba(var(--white-rgb), 0.16)"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g class="area-map-roads" aria-hidden="true">
|
||||
<path d="M 148 248 C 204 250, 244 266, 286 292 C 322 316, 360 354, 406 390" class="area-map-road" />
|
||||
<path d="M 240 126 C 270 160, 292 194, 316 238 C 336 278, 356 340, 364 430" class="area-map-road area-map-road-strong" />
|
||||
<path d="M 122 300 C 182 304, 244 300, 306 290 C 356 282, 418 264, 468 238" class="area-map-road area-map-road-soft" />
|
||||
</g>
|
||||
|
||||
<g class="area-map-routes">
|
||||
{#each pins as pin}
|
||||
<path d={routePath(pin)} class="area-map-route-base" pathLength="1" />
|
||||
<path d={routePath(pin)} class="area-map-route-flow" pathLength="1" />
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<g class="area-map-core">
|
||||
<circle cx={centre.x} cy={centre.y} r="64" class="area-map-core-aura" />
|
||||
<circle cx={centre.x} cy={centre.y} r="48" class="area-map-core-halo" />
|
||||
<circle cx={centre.x} cy={centre.y} r="24" class="area-map-core-pulse" />
|
||||
<circle cx={centre.x} cy={centre.y} r="15" class="area-map-core-ring" />
|
||||
<circle cx={centre.x} cy={centre.y} r="10" class="area-map-core-dot" />
|
||||
</g>
|
||||
|
||||
<g class="area-map-skyline" transform={`translate(${city.x - 46} ${city.y - 54}) scale(0.94)`}>
|
||||
<ellipse cx="42" cy="74" rx="34" ry="8" class="area-map-tower-shadow" />
|
||||
<path d="M 16 72 C 24 58, 29 41, 32 21 C 34 10, 36 6, 38.5 2.5 C 39.4 1.2, 40.2 0.5, 40.8 0 C 41.5 0.5, 42.3 1.2, 43.2 2.5 C 45.8 6, 47.8 10, 49.8 21 C 52.8 41, 57.8 58, 65.5 72 L 59 72 C 54.8 63.5, 51.8 49.5, 48.4 31 L 47.2 31 L 47.2 20.5 C 47.2 17.6, 46.3 15.7, 44.8 13.8 L 43.9 12.4 L 45 12.4 L 44 8.6 L 42.5 8.6 L 42.8 4.8 L 41.4 4.8 L 40.2 4.8 L 38.8 4.8 L 39.1 8.6 L 37.6 8.6 L 36.6 12.4 L 37.7 12.4 L 36.8 13.8 C 35.3 15.7, 34.4 17.6, 34.4 20.5 L 34.4 31 L 33.2 31 C 29.8 49.5, 26.8 63.5, 22.6 72 Z" class="area-map-tower-body" />
|
||||
<path d="M 34 21.5 C 34 16.5, 36.8 13.6, 40.8 13.6 C 44.8 13.6, 47.6 16.5, 47.6 21.5 C 47.6 25.3, 45.8 28.2, 43 29.5 L 43 34.2 C 45.8 34.9, 48 36.5, 49.6 39.3 L 31.8 39.3 C 33.5 36.5, 35.6 34.9, 38.6 34.2 L 38.6 29.5 C 35.8 28.2, 34 25.3, 34 21.5 Z" class="area-map-tower-observation" />
|
||||
<path d="M 30.8 39.3 L 50.6 39.3 L 52 42.9 L 29.4 42.9 Z" class="area-map-tower-band" />
|
||||
<path d="M 29.4 42.9 C 32.8 46.4, 36.1 47.8, 40.8 47.8 C 45.5 47.8, 48.8 46.4, 52.2 42.9 L 51.1 49.4 C 47.8 51.5, 45 52.2, 40.8 52.2 C 36.6 52.2, 33.8 51.5, 30.5 49.4 Z" class="area-map-tower-ring" />
|
||||
<path d="M 33.2 52.2 L 48.4 52.2 L 50 72 L 31.6 72 Z" class="area-map-tower-stem" />
|
||||
<path d="M 37.4 55.6 L 39.4 55.6 L 39.4 68.8 L 37.4 68.8 Z M 42.2 55.6 L 44.2 55.6 L 44.2 68.8 L 42.2 68.8 Z" class="area-map-tower-slit" />
|
||||
<circle cx="40.8" cy="10.4" r="1.9" class="area-map-tower-beacon" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div class="area-map-overlay">
|
||||
<span
|
||||
class="area-map-label area-map-label-static area-map-label-city"
|
||||
style={`left:${percentX(city.x)}; top:${percentY(city.y)};`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="area-map-label-stem"></span>
|
||||
<span class="area-map-label-dot-wrap">
|
||||
<span class="area-map-label-dot area-map-label-dot-city"></span>
|
||||
</span>
|
||||
<span class="area-map-label-pill area-map-label-pill-city">City</span>
|
||||
</span>
|
||||
|
||||
{#each pins as pin, index}
|
||||
<a
|
||||
href={`/locations/${pin.slug}`}
|
||||
class={`area-map-label ${pin.tone === 'accent' ? 'area-map-label-accent' : ''}`}
|
||||
style={pinStyle(pin, index)}
|
||||
aria-label={`View ${pin.suburb} location page`}
|
||||
>
|
||||
<span class="area-map-label-stem" aria-hidden="true"></span>
|
||||
<span class="area-map-label-dot-wrap" aria-hidden="true">
|
||||
<span class="area-map-label-pulse"></span>
|
||||
<span class="area-map-label-dot"></span>
|
||||
</span>
|
||||
<span class="area-map-label-pill">
|
||||
<span class="area-map-label-text-full">{pin.suburb}</span>
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<figcaption id="area-map-caption" class="area-map-caption">
|
||||
Tap a suburb to open its local page with parks, routes, and service details.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<style>
|
||||
.area-map {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.area-map {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.area-map-shell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0 clamp(12px, 1.8vw, 18px) clamp(14px, 2vw, 20px);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.area-map-stage {
|
||||
position: relative;
|
||||
width: min(100%, 54rem);
|
||||
overflow: hidden;
|
||||
border-radius: clamp(26px, 2.8vw, 34px);
|
||||
background:
|
||||
radial-gradient(circle at 16% 12%, rgba(var(--accent-rgb), 0.1), transparent 30%),
|
||||
linear-gradient(180deg, rgba(var(--white-rgb), 0.24), rgba(var(--white-rgb), 0));
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(var(--brand-rgb), 0.08),
|
||||
0 18px 42px rgba(var(--ink-rgb), 0.08);
|
||||
}
|
||||
|
||||
.area-map-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 640 / 340;
|
||||
}
|
||||
|
||||
.area-map-overlay {
|
||||
position: absolute;
|
||||
inset: clamp(14px, 2vw, 20px) clamp(28px, 4vw, 48px);
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.area-map-waterway {
|
||||
fill: none;
|
||||
stroke: rgba(var(--white-rgb), 0.56);
|
||||
stroke-width: 10;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
.area-map-waterway-soft {
|
||||
stroke-width: 7;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.area-map-road {
|
||||
fill: none;
|
||||
stroke: rgba(var(--brand-rgb), 0.1);
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 1 9;
|
||||
opacity: 0.66;
|
||||
}
|
||||
|
||||
.area-map-road-soft {
|
||||
opacity: 0.38;
|
||||
stroke-dasharray: 1 11;
|
||||
}
|
||||
|
||||
.area-map-road-strong {
|
||||
stroke: rgba(var(--brand-rgb), 0.15);
|
||||
stroke-width: 2.2;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.area-map-route-base {
|
||||
fill: none;
|
||||
stroke: rgba(var(--brand-rgb), 0.08);
|
||||
stroke-width: 1.2;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.area-map-route-flow {
|
||||
fill: none;
|
||||
stroke: url(#area-map-route-flow);
|
||||
stroke-width: 2.4;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 0.18 0.82;
|
||||
animation: areaRouteFlow 7.2s linear infinite;
|
||||
opacity: 0.76;
|
||||
}
|
||||
|
||||
.area-map-core-aura {
|
||||
fill: url(#area-map-core-glow);
|
||||
}
|
||||
|
||||
.area-map-core-halo {
|
||||
fill: rgba(var(--accent-rgb), 0.12);
|
||||
}
|
||||
|
||||
.area-map-core-pulse {
|
||||
fill: rgba(var(--brand-rgb), 0.1);
|
||||
transform-origin: 320px 238px;
|
||||
animation: areaCorePulse 4.8s ease-out infinite;
|
||||
}
|
||||
|
||||
.area-map-core-ring {
|
||||
fill: rgba(var(--white-rgb), 0.86);
|
||||
stroke: rgba(var(--brand-rgb), 0.16);
|
||||
stroke-width: 1.25;
|
||||
}
|
||||
|
||||
.area-map-core-dot {
|
||||
fill: var(--gw-green);
|
||||
stroke: rgba(var(--accent-rgb), 0.9);
|
||||
stroke-width: 2.2;
|
||||
}
|
||||
|
||||
.area-map-skyline {
|
||||
pointer-events: none;
|
||||
opacity: 0.62;
|
||||
filter: drop-shadow(0 6px 12px rgba(var(--ink-rgb), 0.12));
|
||||
}
|
||||
|
||||
.area-map-tower-shadow {
|
||||
fill: rgba(var(--ink-rgb), 0.08);
|
||||
}
|
||||
|
||||
.area-map-tower-body,
|
||||
.area-map-tower-observation,
|
||||
.area-map-tower-band,
|
||||
.area-map-tower-ring,
|
||||
.area-map-tower-stem,
|
||||
.area-map-tower-slit {
|
||||
animation: areaTowerFloat 8s ease-in-out infinite;
|
||||
transform-origin: 40.8px 74px;
|
||||
}
|
||||
|
||||
.area-map-tower-body {
|
||||
fill: rgba(var(--white-rgb), 0.92);
|
||||
stroke: rgba(var(--ink-rgb), 0.24);
|
||||
stroke-width: 1.05;
|
||||
}
|
||||
|
||||
.area-map-tower-observation {
|
||||
fill: rgba(var(--white-rgb), 0.96);
|
||||
stroke: rgba(var(--ink-rgb), 0.3);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.area-map-tower-band {
|
||||
fill: rgba(var(--ink-rgb), 0.72);
|
||||
}
|
||||
|
||||
.area-map-tower-ring {
|
||||
fill: rgba(var(--white-rgb), 0.98);
|
||||
stroke: rgba(var(--ink-rgb), 0.26);
|
||||
stroke-width: 0.9;
|
||||
}
|
||||
|
||||
.area-map-tower-stem {
|
||||
fill: rgba(var(--white-rgb), 0.84);
|
||||
stroke: rgba(var(--ink-rgb), 0.2);
|
||||
stroke-width: 0.85;
|
||||
}
|
||||
|
||||
.area-map-tower-slit {
|
||||
fill: rgba(var(--ink-rgb), 0.76);
|
||||
}
|
||||
|
||||
.area-map-tower-beacon {
|
||||
fill: var(--yellow);
|
||||
opacity: 0.7;
|
||||
animation: areaBeaconBlink 4.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.area-map-label {
|
||||
position: absolute;
|
||||
left: var(--desktop-left);
|
||||
top: var(--desktop-top);
|
||||
z-index: var(--desktop-z);
|
||||
display: var(--desktop-display);
|
||||
flex-direction: var(--desktop-direction);
|
||||
align-items: center;
|
||||
gap: clamp(6px, 0.85vw, 9px);
|
||||
transform: var(--desktop-transform);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
pointer-events: auto;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.area-map-label-static {
|
||||
left: 53.75%;
|
||||
top: 32.38%;
|
||||
z-index: 2;
|
||||
transform: translate(-50%, calc(-100% - 12px));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.area-map-label-stem {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
width: var(--desktop-stem-w);
|
||||
height: var(--desktop-stem-h);
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--brand-rgb), 0.18);
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
|
||||
.area-map-label-dot-wrap {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: clamp(11px, 1.4vw, 14px);
|
||||
height: clamp(11px, 1.4vw, 14px);
|
||||
}
|
||||
|
||||
.area-map-label-dot {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: clamp(7px, 1vw, 9px);
|
||||
height: clamp(7px, 1vw, 9px);
|
||||
border-radius: 50%;
|
||||
background: var(--gw-green);
|
||||
border: 2px solid rgba(var(--white-rgb), 0.96);
|
||||
box-shadow: 0 0 0 5px rgba(var(--accent-rgb), 0.11);
|
||||
transition:
|
||||
transform var(--motion-fast),
|
||||
background var(--motion-fast),
|
||||
box-shadow var(--motion-fast);
|
||||
}
|
||||
|
||||
.area-map-label-pulse {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--brand-rgb), 0.14);
|
||||
transform: scale(0.5);
|
||||
opacity: 0;
|
||||
animation: areaPinPulse 4.6s ease-out infinite;
|
||||
animation-delay: var(--pin-delay);
|
||||
}
|
||||
|
||||
.area-map-label-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: clamp(30px, 3vw, 34px);
|
||||
padding: clamp(6px, 0.95vw, 8px) clamp(10px, 1.35vw, 14px);
|
||||
border: 1px solid rgba(var(--brand-rgb), 0.12);
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--white-rgb), 0.95);
|
||||
color: var(--text-brand);
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(0.61rem, 0.52rem + 0.24vw, 0.78rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(var(--white-rgb), 0.82),
|
||||
0 8px 18px rgba(var(--ink-rgb), 0.06);
|
||||
transition:
|
||||
transform var(--motion-fast),
|
||||
background var(--motion-fast),
|
||||
border-color var(--motion-fast),
|
||||
box-shadow var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.area-map-label-accent .area-map-label-pill {
|
||||
background: rgba(var(--accent-rgb), 0.12);
|
||||
border-color: rgba(var(--accent-rgb), 0.2);
|
||||
}
|
||||
|
||||
.area-map-label-pill-city {
|
||||
min-height: 28px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(var(--brand-rgb), 0.1);
|
||||
border-color: rgba(var(--brand-rgb), 0.16);
|
||||
font-size: 0.66rem;
|
||||
box-shadow: 0 6px 14px rgba(var(--ink-rgb), 0.05);
|
||||
}
|
||||
|
||||
.area-map-label-dot-city {
|
||||
background: var(--yellow);
|
||||
box-shadow: 0 0 0 5px rgba(var(--brand-rgb), 0.1);
|
||||
}
|
||||
|
||||
.area-map-label:hover .area-map-label-pill,
|
||||
.area-map-label:focus-visible .area-map-label-pill {
|
||||
background: var(--gw-green);
|
||||
border-color: rgba(var(--brand-rgb), 0.18);
|
||||
color: var(--text-inverse);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 22px rgba(var(--brand-rgb), 0.14);
|
||||
}
|
||||
|
||||
.area-map-label:hover .area-map-label-stem,
|
||||
.area-map-label:focus-visible .area-map-label-stem {
|
||||
background: rgba(var(--brand-rgb), 0.32);
|
||||
}
|
||||
|
||||
.area-map-label:hover .area-map-label-dot,
|
||||
.area-map-label:focus-visible .area-map-label-dot {
|
||||
background: var(--yellow);
|
||||
transform: scale(1.12);
|
||||
box-shadow: 0 0 0 6px rgba(var(--accent-rgb), 0.16);
|
||||
}
|
||||
|
||||
.area-map-caption {
|
||||
margin: 12px auto 0;
|
||||
max-width: 54rem;
|
||||
color: var(--text-subtle);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes areaCorePulse {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0.42;
|
||||
}
|
||||
72% {
|
||||
transform: scale(1.2);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes areaPinPulse {
|
||||
0% {
|
||||
transform: scale(0.52);
|
||||
opacity: 0.32;
|
||||
}
|
||||
78% {
|
||||
transform: scale(2.25);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.25);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes areaRouteFlow {
|
||||
from {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: -1.3;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes areaTowerFloat {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-1.5px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes areaBeaconBlink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.74;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.area-map-shell {
|
||||
padding-block: clamp(14px, 2vw, 20px);
|
||||
}
|
||||
|
||||
.area-map-stage {
|
||||
width: min(100%, 48rem);
|
||||
}
|
||||
|
||||
.area-map-overlay {
|
||||
inset: 16px 18px 18px;
|
||||
}
|
||||
|
||||
.area-map-label {
|
||||
left: var(--tablet-left);
|
||||
top: var(--tablet-top);
|
||||
z-index: var(--tablet-z);
|
||||
display: var(--tablet-display);
|
||||
flex-direction: var(--tablet-direction);
|
||||
transform: var(--tablet-transform);
|
||||
}
|
||||
|
||||
.area-map-label-stem {
|
||||
width: var(--tablet-stem-w);
|
||||
height: var(--tablet-stem-h);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.area-map-stage {
|
||||
width: 100%;
|
||||
border-radius: 26px;
|
||||
}
|
||||
|
||||
.area-map-overlay {
|
||||
inset: 14px 16px 18px;
|
||||
}
|
||||
|
||||
.area-map-label-pill {
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(var(--white-rgb), 0.84),
|
||||
0 6px 14px rgba(var(--ink-rgb), 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.area-map-shell {
|
||||
padding-inline: 4px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.area-map-stage {
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.area-map-overlay {
|
||||
inset: 16px 14px 22px;
|
||||
}
|
||||
|
||||
.area-map-label {
|
||||
left: var(--mobile-left);
|
||||
top: var(--mobile-top);
|
||||
z-index: var(--mobile-z);
|
||||
display: var(--mobile-display);
|
||||
flex-direction: var(--mobile-direction);
|
||||
gap: 5px;
|
||||
transform: var(--mobile-transform);
|
||||
}
|
||||
|
||||
.area-map-label-stem {
|
||||
width: var(--mobile-stem-w);
|
||||
height: var(--mobile-stem-h);
|
||||
}
|
||||
|
||||
.area-map-label-pill {
|
||||
min-height: 28px;
|
||||
padding: 6px 9px;
|
||||
font-size: clamp(0.56rem, 0.52rem + 0.18vw, 0.64rem);
|
||||
}
|
||||
|
||||
.area-map-label-pill-city {
|
||||
min-height: 26px;
|
||||
padding-inline: 8px;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.area-map-label-dot-wrap {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
.area-map-label-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.area-map-shell {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.area-map-overlay {
|
||||
inset: 18px 12px 24px;
|
||||
}
|
||||
|
||||
.area-map-label-pill {
|
||||
min-height: 27px;
|
||||
padding: 5px 8px;
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: -0.012em;
|
||||
}
|
||||
|
||||
.area-map-caption {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.area-map-route-flow,
|
||||
.area-map-core-pulse,
|
||||
.area-map-label-pulse,
|
||||
.area-map-tower-body,
|
||||
.area-map-tower-observation,
|
||||
.area-map-tower-band,
|
||||
.area-map-tower-ring,
|
||||
.area-map-tower-stem,
|
||||
.area-map-tower-slit,
|
||||
.area-map-tower-beacon {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,8 @@
|
||||
import { locationPages } from '$lib/content/locations';
|
||||
|
||||
export let footer: FooterContent;
|
||||
// 'minimal' is a typography test running on the /variants/* pages.
|
||||
export let variant: 'default' | 'minimal' = 'default';
|
||||
|
||||
|
||||
const socialLinks: LinkItem[] = [
|
||||
@@ -35,7 +37,7 @@
|
||||
$: navigationLinks = withAboutLink(footer.navigationLinks);
|
||||
</script>
|
||||
|
||||
<footer data-track-location="footer">
|
||||
<footer data-track-location="footer" class:footer--min={variant === 'minimal'}>
|
||||
<div class="footer-inner">
|
||||
<div class="footer-brand">
|
||||
<enhanced:img
|
||||
@@ -125,3 +127,61 @@
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* Minimal footer variant (typography test on /variants/* pages). One calm
|
||||
* type scale, lighter weights, more air. Structure and colour unchanged.
|
||||
* Two sizes do all the work: 14px for links/text, 12px for meta. Labels drop
|
||||
* the display font for tracked body caps, which reads cleaner at small sizes.
|
||||
*/
|
||||
.footer--min :global(.footer-logo) {
|
||||
height: 26px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.footer--min :global(.footer-brand p) {
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.footer--min :global(.footer-col-label) {
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.18em;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.footer--min :global(.footer-nav a),
|
||||
.footer--min :global(.footer-contact-link) {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.005em;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.footer--min :global(.footer-nav a) {
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.footer--min :global(.footer-nav a:hover),
|
||||
.footer--min :global(.footer-contact-link:hover) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.footer--min :global(.footer-bottom) {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.01em;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.footer--min :global(.footer-back-top) {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -183,6 +183,72 @@ export const dogWalkingContent: ServicePageContent = {
|
||||
]
|
||||
},
|
||||
testimonialsHeading: 'What our clients say',
|
||||
clarity: {
|
||||
definition: {
|
||||
eyebrow: 'In plain terms',
|
||||
heading: 'What a solo dog walk actually is',
|
||||
body:
|
||||
"A solo dog walk is a one-on-one outing where a single dog is walked by the same Goodwalk walker every time, at their own pace, across Auckland Central. There's no group to keep up with — just your dog, their route, and their tempo. It's built for dogs who do better without a pack: reactive or anxious dogs, seniors, those recovering from surgery, and rescues still finding their feet. You choose the length — 30 minutes for seniors or lower-energy dogs, 45 for most, or 60 for a longer outing — with free door-to-door pickup and drop-off and a photo update after every walk. Solo walks start from $45 per walk. Every dog begins with a free Meet & Greet, so we can agree the right pace and handling before the first walk."
|
||||
},
|
||||
diffEyebrow: 'What makes solo walks different',
|
||||
diffHeading: 'Built around your dog. Not a pack schedule.',
|
||||
traits: [
|
||||
{
|
||||
icon: 'fas fa-user',
|
||||
title: 'One dog, full attention',
|
||||
body: 'No group to manage. Just your dog and their walker, the whole time.'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-person-walking',
|
||||
title: 'Their pace, their route',
|
||||
body: 'Speed, distance, and tempo all shaped around your dog\'s confidence and energy.'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-user-check',
|
||||
title: 'The same Goodwalk walker, every time',
|
||||
body: 'One familiar handler your dog learns to trust. No rotating faces.'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-shield-heart',
|
||||
title: 'Room for sensitive dogs',
|
||||
body: 'Reactive, anxious, senior, or recovering. Solo gives the space and calm they need.'
|
||||
}
|
||||
],
|
||||
stepsEyebrow: 'How it works',
|
||||
stepsHeading: 'Three simple steps, no pressure.',
|
||||
steps: [
|
||||
{
|
||||
title: 'Free Meet & Greet',
|
||||
body: 'A Goodwalk walker comes to your home, meets your dog, and talks through exactly what they need.'
|
||||
},
|
||||
{
|
||||
title: 'A settle-in walk',
|
||||
body: 'A first one-on-one outing to learn your dog\'s pace, route, and the handling that suits them.'
|
||||
},
|
||||
{
|
||||
title: 'Your regular slot',
|
||||
body: 'A dependable weekly rhythm with the same walker, and a photo update after every walk.'
|
||||
}
|
||||
],
|
||||
imageBand: {
|
||||
imageUrl: '/images/goodwalk-large-dog-one-on-one-walk-auckland.webp',
|
||||
imageAlt:
|
||||
'A large dog enjoying a calm one-on-one Goodwalk solo walk in an Auckland park',
|
||||
caption: 'One dog, one Goodwalk walker, full attention. Door-to-door across Auckland Central.'
|
||||
},
|
||||
fitEyebrow: 'Honest fit check',
|
||||
pricingEyebrow: 'Simple per-walk pricing',
|
||||
priceNotes: [
|
||||
{ icon: 'fas fa-stopwatch', label: '30, 45, or 60 minutes.' },
|
||||
{ icon: 'fas fa-car', label: 'Free pickup & drop-off, every walk.' },
|
||||
{ icon: 'fas fa-camera', label: 'Photo update after every walk.' },
|
||||
{ icon: 'fas fa-shield-heart', label: 'Starts with a free Meet & Greet.' }
|
||||
],
|
||||
faqEyebrow: 'Common questions',
|
||||
areaTitle: 'Where we walk across Auckland Central',
|
||||
areaLead:
|
||||
'Goodwalk covers Auckland Central and the suburbs around it, with free door-to-door pickup and drop-off. The same walker, every time. Tap your suburb for its local parks and routes.'
|
||||
},
|
||||
booking: {
|
||||
title: 'See if solo walks are the right fit',
|
||||
subtitle: 'A few details. We arrange a free Meet & Greet to meet your dog.',
|
||||
|
||||
@@ -206,6 +206,95 @@ export const packWalksContent: ServicePageContent = {
|
||||
]
|
||||
},
|
||||
testimonialsHeading: 'What our clients say',
|
||||
clarity: {
|
||||
definition: {
|
||||
eyebrow: 'In plain terms',
|
||||
heading: 'What a Tiny Gang pack walk actually is',
|
||||
body:
|
||||
'A Tiny Gang pack walk is a small-group dog walk for sociable small and medium dogs across Auckland Central. Instead of one big anonymous pack, your dog joins a crew of 4 to 8 dogs, matched on temperament, energy, and play style, and walked by the same Goodwalk walker every week. Each outing runs 60 to 75 minutes on the ground at parks like Western Springs, Cornwall Park, and Grey Lynn Park, with free door-to-door pickup and drop-off included. Regular weekly walks start from $49.50 per walk; casual walks are $65. It suits dogs who enjoy company and thrive on routine — the ones who come home settled instead of pacing the house at 5pm. Every dog starts with a free Meet & Greet and two assessment walks, so the fit is right before anything becomes regular.'
|
||||
},
|
||||
diffEyebrow: 'What makes Tiny Gang different',
|
||||
diffHeading: 'Not a big anonymous pack. A small, matched crew.',
|
||||
traits: [
|
||||
{
|
||||
icon: 'fas fa-users',
|
||||
title: 'Carefully matched groups',
|
||||
body: '4 to 8 dogs, grouped by temperament, energy, and play style. Never a random pack.'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-shield-dog',
|
||||
title: 'Small & medium dogs only',
|
||||
body: 'Pace, group size, and play style are all built for little dogs, so yours is never the odd one out.'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-user-check',
|
||||
title: 'The same Goodwalk walker, every week',
|
||||
body: 'One familiar face at the door and on the walk. Your dog is known, not handed around.'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-eye',
|
||||
title: 'Eyes on every dog',
|
||||
body: 'Small groups mean we notice the little things early, from recall to a quiet day.'
|
||||
}
|
||||
],
|
||||
stepsEyebrow: 'How joining works',
|
||||
stepsHeading: 'Three simple steps, no pressure.',
|
||||
steps: [
|
||||
{
|
||||
title: 'Free Meet & Greet',
|
||||
body: 'A Goodwalk walker comes to your home, meets your dog, and talks honestly about whether the pack suits them.'
|
||||
},
|
||||
{
|
||||
title: 'Two assessment walks',
|
||||
body: 'Your dog settles in gently while we confirm the right group, pace, and handling.'
|
||||
},
|
||||
{
|
||||
title: 'Your weekly slot',
|
||||
body: 'A regular spot with the same little crew and the same walker, week in, week out.'
|
||||
}
|
||||
],
|
||||
imageBand: {
|
||||
imageUrl: '/images/goodwalk-tiny-gang-finishing-walk-suv-auckland.webp',
|
||||
imageAlt:
|
||||
'Eight small and medium Tiny Gang dogs sitting together in the back of the Goodwalk pickup vehicle after an Auckland pack walk',
|
||||
caption: 'Door-to-door pickup, every walk. The Goodwalk Tiny Gang, homeward bound after a park run.'
|
||||
},
|
||||
fitEyebrow: 'Honest fit check',
|
||||
pricingEyebrow: 'Simple weekly pricing',
|
||||
pricingTagline: "The best investment you'll ever make is in your dog's happiness.",
|
||||
priceNotes: [
|
||||
{ icon: 'fas fa-stopwatch', label: '60+ minute adventures.' },
|
||||
{ icon: 'fas fa-car', label: 'Free pickup & drop-off, every walk.' },
|
||||
{ icon: 'fas fa-shield-heart', label: 'Starts with a free Meet & Greet.' }
|
||||
],
|
||||
faqEyebrow: 'Common questions',
|
||||
planLines: {
|
||||
'1 Walk Per Week': [
|
||||
'A calm weekly outing',
|
||||
'Time to sniff, play, and unwind',
|
||||
'Great for dogs just starting out'
|
||||
],
|
||||
'2-3 Walks Per Week': [
|
||||
'Steady exercise and real social time',
|
||||
'A settled mid-week rhythm',
|
||||
'Our balanced routine'
|
||||
],
|
||||
'4-5 Walks Per Week': [
|
||||
'Best value per walk',
|
||||
'Maximum routine and structure',
|
||||
'For high-energy, social dogs'
|
||||
],
|
||||
'Casual Pack Walk': [
|
||||
'No weekly commitment',
|
||||
'Book only the weeks you need',
|
||||
'For dogs already in the pack'
|
||||
]
|
||||
},
|
||||
testimonialsHeading: 'Owners love the Tiny Gang',
|
||||
areaTitle: 'Where the Tiny Gang walks',
|
||||
areaLead:
|
||||
'Goodwalk covers Auckland Central and the suburbs around it, with free door-to-door pickup and drop-off. You drive nowhere. Tap your suburb for its local parks and routes.'
|
||||
},
|
||||
booking: {
|
||||
title: 'See if your dog fits our Tiny Gang',
|
||||
subtitle: '',
|
||||
|
||||
@@ -168,6 +168,77 @@ export const puppyVisitsContent: ServicePageContent = {
|
||||
]
|
||||
},
|
||||
testimonialsHeading: 'What our clients say',
|
||||
clarity: {
|
||||
definition: {
|
||||
eyebrow: 'In plain terms',
|
||||
heading: 'What a puppy visit actually is',
|
||||
body:
|
||||
"A puppy visit is an in-home check-in for young dogs who are too small or too young for full walks — usually puppies under 12 to 18 months. Instead of a walk, a Goodwalk walker comes to your home during the day to give your puppy a toilet break, feed them if needed, and spend calm one-on-one time playing, settling, and keeping them company. Visits run 20, 45, or 60 minutes, so you can match the length to your puppy's age and how much support they need that day. Pricing starts from $39 per visit, with photo updates included. It's low-impact support while they grow, and a gentle first step: many Tiny Gang pack dogs started with puppy visits before moving into full walks once they were ready."
|
||||
},
|
||||
diffEyebrow: 'What makes Puppy Visits different',
|
||||
diffHeading: 'Real support at home, while you\'re out.',
|
||||
traits: [
|
||||
{
|
||||
icon: 'fas fa-house',
|
||||
title: 'Care in your own home',
|
||||
body: 'Toilet break, fresh water, a feed if scheduled, and calm company. In the place your puppy already knows.'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-clock',
|
||||
title: 'Short and gentle, on purpose',
|
||||
body: 'Low-impact company while growth plates settle. The right stimulation, without the joint stress.'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-user-check',
|
||||
title: 'The same face, every visit',
|
||||
body: 'One trusted handler your puppy learns early. Consistency matters most at this age.'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-paw',
|
||||
title: 'A smoother step into Tiny Gang',
|
||||
body: 'Goodwalk knows your puppy from the start, so moving into walks later is calm, not sudden.'
|
||||
}
|
||||
],
|
||||
stepsEyebrow: 'How it works',
|
||||
stepsHeading: 'Three simple steps, no pressure.',
|
||||
steps: [
|
||||
{
|
||||
title: 'Free Meet & Greet',
|
||||
body: 'A Goodwalk handler comes to your home, meets your puppy, and sets up access and routine together.'
|
||||
},
|
||||
{
|
||||
title: 'Your first visit',
|
||||
body: 'A gentle settle-in visit so your puppy gets used to us in familiar surroundings.'
|
||||
},
|
||||
{
|
||||
title: 'Your regular routine',
|
||||
body: 'Dependable midday visits with the same handler, and a photo update every time.'
|
||||
}
|
||||
],
|
||||
imageBand: {
|
||||
imageUrl: '/images/goodwalk-puppy-visit-cavalier-king-charles-spaniel-auckland.webp',
|
||||
imageAlt:
|
||||
'A young Cavalier King Charles Spaniel puppy resting calmly at home during a Goodwalk puppy visit in Auckland',
|
||||
caption: 'Fed, played with, and settled by Goodwalk. A calmer day at home while you\'re out.'
|
||||
},
|
||||
fitEyebrow: 'Honest fit check',
|
||||
pricingEyebrow: 'Simple per-visit pricing',
|
||||
priceNotes: [
|
||||
{ icon: 'fas fa-stopwatch', label: '20, 45, or 60 minutes.' },
|
||||
{ icon: 'fas fa-house', label: 'In your own home, every visit.' },
|
||||
{ icon: 'fas fa-camera', label: 'Photo update after every visit.' },
|
||||
{ icon: 'fas fa-shield-heart', label: 'Starts with a free Meet & Greet.' }
|
||||
],
|
||||
faqEyebrow: 'Common questions',
|
||||
areaTitle: 'Where we visit',
|
||||
areaLead:
|
||||
'Goodwalk runs in-home puppy visits across Auckland Central and the suburbs around it. We come to you. Tap your suburb for its local details.',
|
||||
areaStats: [
|
||||
{ strong: '17', label: 'suburbs visited' },
|
||||
{ strong: 'In-home', label: 'one-on-one care' },
|
||||
{ strong: 'Same face', label: 'every visit' }
|
||||
]
|
||||
},
|
||||
booking: {
|
||||
title: 'See if Puppy Visits are the right start',
|
||||
subtitle: '',
|
||||
|
||||
@@ -15,6 +15,7 @@ const imageMetadata: Record<string, ImageMetadata> = {
|
||||
'/images/wallace-goodwalk-dog-walking-review-auckland.webp': { width: 1254, height: 1254 },
|
||||
'/images/goodwalk-small-dog-pack-walk-auckland.webp': { width: 640, height: 480 },
|
||||
'/images/goodwalk-tiny-gang-pack-walk-small-dogs-auckland.webp': { width: 1469, height: 1071 },
|
||||
'/images/goodwalk-tiny-gang-finishing-walk-suv-auckland.webp': { width: 1457, height: 1079 },
|
||||
'/images/goodwalk-small-medium-dogs-pack-walk-auckland.webp': { width: 1240, height: 1269 },
|
||||
'/images/goodwalk-happy-black-dog-one-on-one-walk-auckland.webp': { width: 1054, height: 1492 },
|
||||
'/images/goodwalk-large-breed-dog-one-on-one-walk-auckland.webp': { width: 1091, height: 1441 },
|
||||
|
||||
@@ -36,6 +36,34 @@ export function buildBreadcrumb(items: BreadcrumbItem[]) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a schema.org AggregateOffer from a set of priced plans. Used for the
|
||||
* "from $X" pricing on service pages and in the homepage OfferCatalog so both
|
||||
* advertise the same accurate low/high range in NZD. Returns null when no plan
|
||||
* carries a usable price.
|
||||
*/
|
||||
export function buildAggregateOffer(plans: { price: string }[]) {
|
||||
const numericPrices = plans
|
||||
.map((plan) => Number(plan.price.replace(/[^0-9.]/g, '')))
|
||||
.filter((value) => Number.isFinite(value) && value > 0);
|
||||
|
||||
if (numericPrices.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lowPrice = Math.min(...numericPrices);
|
||||
const highPrice = Math.max(...numericPrices);
|
||||
|
||||
return {
|
||||
'@type': 'AggregateOffer',
|
||||
priceCurrency: 'NZD',
|
||||
lowPrice: lowPrice.toFixed(2),
|
||||
highPrice: highPrice.toFixed(2),
|
||||
offerCount: numericPrices.length,
|
||||
availability: 'https://schema.org/InStock'
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAreaServed(locations = locationPages) {
|
||||
return locations.map((location) => ({
|
||||
'@type': 'Place',
|
||||
|
||||
@@ -157,6 +157,68 @@ export interface ServiceDecisionBlock {
|
||||
footnote?: string;
|
||||
}
|
||||
|
||||
export interface ServiceTrait {
|
||||
icon: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ServiceJoinStep {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ServiceImageBand {
|
||||
imageUrl: string;
|
||||
imageAlt: string;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
export interface ServicePriceNote {
|
||||
icon: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ServiceAreaStat {
|
||||
strong: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* v5 "clarity" content: the page-specific copy the shared ServiceClarityPage
|
||||
* needs so all three service pages run on one component. See docs/v5.md.
|
||||
*/
|
||||
/**
|
||||
* Optional "what it is" lede that opens a service page: a short, self-contained
|
||||
* definition of the service in Goodwalk's voice. Renders right after the hero.
|
||||
*/
|
||||
export interface ServiceDefinition {
|
||||
eyebrow?: string;
|
||||
heading: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ServiceClarityContent {
|
||||
definition?: ServiceDefinition;
|
||||
diffEyebrow: string;
|
||||
diffHeading: string;
|
||||
traits: ServiceTrait[];
|
||||
stepsEyebrow: string;
|
||||
stepsHeading: string;
|
||||
steps: ServiceJoinStep[];
|
||||
imageBand?: ServiceImageBand;
|
||||
fitEyebrow?: string;
|
||||
pricingEyebrow?: string;
|
||||
pricingTagline?: string;
|
||||
priceNotes?: ServicePriceNote[];
|
||||
faqEyebrow?: string;
|
||||
planLines?: Record<string, string[]>;
|
||||
testimonialsHeading?: string;
|
||||
areaTitle?: string;
|
||||
areaLead?: string;
|
||||
areaStats?: ServiceAreaStat[];
|
||||
}
|
||||
|
||||
export interface ServicePageContent {
|
||||
hero: {
|
||||
eyebrow: string;
|
||||
@@ -202,6 +264,7 @@ export interface ServicePageContent {
|
||||
};
|
||||
testimonialsHeading: string;
|
||||
booking: BookingContent;
|
||||
clarity?: ServiceClarityContent;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
|
||||
+26
-9
@@ -7,13 +7,24 @@
|
||||
// at /variants/homepage-original). Both are pure view components; all data
|
||||
// loading, SEO head, and structured data stay here.
|
||||
import HomeClarity from '$lib/components/pages/HomeClarity.svelte';
|
||||
import { buildAreaServed } from '$lib/seo';
|
||||
import { buildAggregateOffer, buildAreaServed } from '$lib/seo';
|
||||
import { packWalksContent } from '$lib/content/pack-walks';
|
||||
import { dogWalkingContent } from '$lib/content/dog-walking';
|
||||
import { puppyVisitsContent } from '$lib/content/puppy-visits';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
const siteUrl = 'https://www.goodwalk.co.nz';
|
||||
|
||||
// Pricing plans keyed by service URL, so the OfferCatalog can advertise the
|
||||
// same accurate "from–to" NZD range the individual service pages publish.
|
||||
const servicePlansByHref: Record<string, { price: string }[]> = {
|
||||
'/pack-walks': packWalksContent.pricing.plans,
|
||||
'/dog-walking': dogWalkingContent.pricing.plans,
|
||||
'/puppy-visits': puppyVisitsContent.pricing.plans
|
||||
};
|
||||
|
||||
function absoluteUrl(value: string) {
|
||||
if (value.startsWith('http://') || value.startsWith('https://')) {
|
||||
return value;
|
||||
@@ -63,14 +74,20 @@
|
||||
hasOfferCatalog: {
|
||||
'@type': 'OfferCatalog',
|
||||
name: 'Dog Walking Services',
|
||||
itemListElement: data.content.services.map((service) => ({
|
||||
'@type': 'Offer',
|
||||
itemOffered: {
|
||||
'@type': 'Service',
|
||||
name: service.title,
|
||||
url: `${siteUrl}${service.href}`
|
||||
}
|
||||
}))
|
||||
itemListElement: data.content.services.map((service) => {
|
||||
const offers = buildAggregateOffer(
|
||||
service.href ? servicePlansByHref[service.href] ?? [] : []
|
||||
);
|
||||
return {
|
||||
'@type': 'Offer',
|
||||
itemOffered: {
|
||||
'@type': 'Service',
|
||||
name: service.title,
|
||||
url: `${siteUrl}${service.href}`,
|
||||
...(offers ? { offers } : {})
|
||||
}
|
||||
};
|
||||
})
|
||||
},
|
||||
aggregateRating: {
|
||||
'@type': 'AggregateRating',
|
||||
|
||||
@@ -6,17 +6,22 @@
|
||||
import BookingPage from '$lib/components/pages/BookingPage.svelte';
|
||||
import LegalPage from '$lib/components/pages/LegalPage.svelte';
|
||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||
import PricingPage from '$lib/components/pages/PricingPage.svelte';
|
||||
import PricingClarityPage from '$lib/components/pages/PricingClarityPage.svelte';
|
||||
import TestimonialsPage from '$lib/components/pages/TestimonialsPage.svelte';
|
||||
import { aboutPageContent } from '$lib/content/about';
|
||||
import ServiceLandingPage from '$lib/components/pages/ServiceLandingPage.svelte';
|
||||
import ServiceClarityPage from '$lib/components/pages/ServiceClarityPage.svelte';
|
||||
import { dogWalkingContent } from '$lib/content/dog-walking';
|
||||
import { ourPricingContent } from '$lib/content/our-pricing';
|
||||
import { packWalksContent } from '$lib/content/pack-walks';
|
||||
import { privacyPolicyContent } from '$lib/content/privacy-policy';
|
||||
import { puppyVisitsContent } from '$lib/content/puppy-visits';
|
||||
import { termsAndConditionsContent } from '$lib/content/terms-and-conditions';
|
||||
import { buildAreaServed, buildBreadcrumb, absoluteUrl, goodwalkProviderNode } from '$lib/seo';
|
||||
import {
|
||||
buildAggregateOffer,
|
||||
buildAreaServed,
|
||||
buildBreadcrumb,
|
||||
absoluteUrl,
|
||||
goodwalkProviderNode
|
||||
} from '$lib/seo';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
@@ -25,35 +30,13 @@
|
||||
const defaultSeoImage = '/images/goodwalk-auckland-happy-dog-hero.webp';
|
||||
const defaultSeoImageAlt = 'Goodwalk Auckland dog walking services';
|
||||
|
||||
function aggregateOfferSchema(plans: { price: string }[]) {
|
||||
const numericPrices = plans
|
||||
.map((plan) => Number(plan.price.replace(/[^0-9.]/g, '')))
|
||||
.filter((value) => Number.isFinite(value) && value > 0);
|
||||
|
||||
if (numericPrices.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lowPrice = Math.min(...numericPrices);
|
||||
const highPrice = Math.max(...numericPrices);
|
||||
|
||||
return {
|
||||
'@type': 'AggregateOffer',
|
||||
priceCurrency: 'NZD',
|
||||
lowPrice: lowPrice.toFixed(2),
|
||||
highPrice: highPrice.toFixed(2),
|
||||
offerCount: numericPrices.length,
|
||||
availability: 'https://schema.org/InStock'
|
||||
};
|
||||
}
|
||||
|
||||
function buildServiceNode(base: {
|
||||
name: string;
|
||||
description: string;
|
||||
serviceType: string;
|
||||
image: string;
|
||||
url: string;
|
||||
offers: ReturnType<typeof aggregateOfferSchema>;
|
||||
offers: ReturnType<typeof buildAggregateOffer>;
|
||||
}) {
|
||||
const node: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
@@ -108,7 +91,7 @@
|
||||
serviceType: 'Pack walks for small and medium dogs',
|
||||
image: absoluteUrl(seoImage),
|
||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||
offers: aggregateOfferSchema(packWalksContent.pricing.plans)
|
||||
offers: buildAggregateOffer(packWalksContent.pricing.plans)
|
||||
}),
|
||||
buildBreadcrumb([
|
||||
{ name: 'Home', url: siteUrl },
|
||||
@@ -126,7 +109,7 @@
|
||||
serviceType: 'One-on-one dog walking',
|
||||
image: absoluteUrl(seoImage),
|
||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||
offers: aggregateOfferSchema(dogWalkingContent.pricing.plans)
|
||||
offers: buildAggregateOffer(dogWalkingContent.pricing.plans)
|
||||
}),
|
||||
buildBreadcrumb([
|
||||
{ name: 'Home', url: siteUrl },
|
||||
@@ -144,7 +127,7 @@
|
||||
serviceType: 'In-home puppy visits',
|
||||
image: absoluteUrl(seoImage),
|
||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||
offers: aggregateOfferSchema(puppyVisitsContent.pricing.plans)
|
||||
offers: buildAggregateOffer(puppyVisitsContent.pricing.plans)
|
||||
}),
|
||||
buildBreadcrumb([
|
||||
{ name: 'Home', url: siteUrl },
|
||||
@@ -228,13 +211,13 @@
|
||||
<Header navigation={data.content.navigation} />
|
||||
|
||||
{#if data.slug === 'pack-walks'}
|
||||
<ServiceLandingPage content={data.content} pageContent={packWalksContent} currentPath={data.page.canonicalPath} />
|
||||
<ServiceClarityPage content={data.content} pageContent={packWalksContent} currentPath={data.page.canonicalPath} />
|
||||
{:else if data.slug === 'dog-walking'}
|
||||
<ServiceLandingPage content={data.content} pageContent={dogWalkingContent} currentPath={data.page.canonicalPath} />
|
||||
<ServiceClarityPage content={data.content} pageContent={dogWalkingContent} currentPath={data.page.canonicalPath} />
|
||||
{:else if data.slug === 'puppy-visits'}
|
||||
<ServiceLandingPage content={data.content} pageContent={puppyVisitsContent} currentPath={data.page.canonicalPath} />
|
||||
<ServiceClarityPage content={data.content} pageContent={puppyVisitsContent} currentPath={data.page.canonicalPath} />
|
||||
{:else if data.slug === 'our-pricing'}
|
||||
<PricingPage content={data.content} pageContent={ourPricingContent} />
|
||||
<PricingClarityPage />
|
||||
{:else if data.slug === 'about'}
|
||||
<AboutPage pageContent={aboutPageContent} />
|
||||
{:else if data.slug === 'testimonials'}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* Preview of the redesigned Tiny Gang pack walks page.
|
||||
* Single source of truth is TinyGangPage.svelte. noindex so it does not
|
||||
* compete with the live /pack-walks page in search.
|
||||
* Single source of truth is ServiceClarityPage.svelte (shared across all
|
||||
* three service variants). noindex so it does not compete with the live
|
||||
* /pack-walks page in search.
|
||||
*
|
||||
* To go live: in src/routes/[slug]/+page.svelte, render TinyGangPage instead
|
||||
* of ServiceLandingPage in the `data.slug === 'pack-walks'` branch.
|
||||
* To go live: in src/routes/[slug]/+page.svelte, render ServiceClarityPage
|
||||
* instead of ServiceLandingPage in the `data.slug === 'pack-walks'` branch.
|
||||
*/
|
||||
import Header from '$lib/components/ui/Header.svelte';
|
||||
import Footer from '$lib/components/ui/Footer.svelte';
|
||||
import TinyGangPage from '$lib/components/pages/TinyGangPage.svelte';
|
||||
import ServiceClarityPage from '$lib/components/pages/ServiceClarityPage.svelte';
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
import { packWalksContent } from '$lib/content/pack-walks';
|
||||
</script>
|
||||
@@ -20,5 +21,5 @@
|
||||
</svelte:head>
|
||||
|
||||
<Header navigation={homepageContent.navigation} />
|
||||
<TinyGangPage content={homepageContent} pageContent={packWalksContent} currentPath="/variants/tiny-gang" />
|
||||
<Footer footer={homepageContent.footer} />
|
||||
<ServiceClarityPage content={homepageContent} pageContent={packWalksContent} currentPath="/variants/tiny-gang" />
|
||||
<Footer footer={homepageContent.footer} variant="minimal" />
|
||||
|
||||
Reference in New Issue
Block a user