diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..e84ffe3 --- /dev/null +++ b/.env.sample @@ -0,0 +1,2 @@ +# Copy this file to .env and fill in the real values. .env is never committed. +SMTP_PASSWORD=your-mailbox-password diff --git a/.gitignore b/.gitignore index dc5d4b8..6b8995f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ *.log *.user *.suo + +# Secrets +/.env diff --git a/README.md b/README.md index e29cf51..2e20392 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A clean Classic ASP application scaffold based on the RouteKit MVC framework. 1. Install IIS Express with Classic ASP enabled. 2. Run `run_site.cmd`. -3. Browse to `http://localhost:8080/`. +3. Browse to `http://localhost:8082/`. The landing page is served by `HomeController.Index` through `app/views/Home/landing.asp`. The earlier logo-reference page remains at diff --git a/app/controllers/HomeController.asp b/app/controllers/HomeController.asp index ebf00d4..e36cfd2 100644 --- a/app/controllers/HomeController.asp +++ b/app/controllers/HomeController.asp @@ -3,9 +3,21 @@ Class HomeController_Class Public Title Public useLayout + ' Contact form state shared with the landing view + Private contactErrors + Private contactName + Private contactEmail + Private contactPhone + Private contactBusiness + Private contactMessage + Private contactSent + Private Sub Class_Initialize() Title = "PostcardMojo Logo Files" useLayout = False + contactErrors = Array() + ReDim contactErrors(-1) + contactSent = (Request.QueryString("contact") = "sent") End Sub Public Sub Index() @@ -13,6 +25,42 @@ Class HomeController_Class <% End Sub + + ' GetAntiCSRFToken returns an empty value the first time it creates a token, so read it again. + Private Function ContactCsrfToken() + ContactCsrfToken = HTMLSecurity().GetAntiCSRFToken("home.contact") + If Len(ContactCsrfToken) = 0 Then ContactCsrfToken = HTMLSecurity().GetAntiCSRFToken("home.contact") + End Function + + ' POST /contact - validate the inquiry and email the PostcardMojo team. + Public Sub Contact() + contactName = Trim(Request.Form("name")) + contactEmail = Trim(Request.Form("email")) + contactPhone = Trim(Request.Form("phone")) + contactBusiness = Trim(Request.Form("business")) + contactMessage = Trim(Request.Form("message")) + + ' Honeypot: real visitors never see or fill this field + If Len(Request.Form("zq_trap_field")) > 0 Then + Response.Redirect "/?contact=sent#contact" + End If + + If Not HTMLSecurity().IsValidAntiCSRFToken("home.contact", Request.Form("csrf_token")) Then + contactErrors = Array("Your session expired. Please try again.") + Else + contactErrors = ContactInquiryService().ValidationErrors(contactName, contactEmail, contactBusiness, contactMessage) + End If + + If UBound(contactErrors) < 0 Then + If ContactInquiryService().Notify(contactName, contactEmail, contactPhone, contactBusiness, contactMessage) Then + HTMLSecurity().ClearAntiCSRFToken "home.contact" + Response.Redirect "/?contact=sent#contact" + End If + contactErrors = Array("We could not send your message. Please email danielcovington@postcardmojo.com directly.") + End If + + Index + End Sub End Class Dim HomeController_Class__Singleton diff --git a/app/controllers/autoload_controllers.asp b/app/controllers/autoload_controllers.asp index ebc1d93..51bd037 100644 --- a/app/controllers/autoload_controllers.asp +++ b/app/controllers/autoload_controllers.asp @@ -1 +1,3 @@ + + diff --git a/app/services/ContactInquiryService.asp b/app/services/ContactInquiryService.asp new file mode 100644 index 0000000..b02967f --- /dev/null +++ b/app/services/ContactInquiryService.asp @@ -0,0 +1,81 @@ +<% +' Validates a visitor inquiry and notifies the PostcardMojo team by email. +Class ContactInquiryService_Class + Public Function ValidationErrors(name, email, business, message) + Dim errs : errs = Array() + ReDim errs(-1) + + If Len(name) = 0 Then AddMessage errs, "Please enter your name." + If Len(name) > 100 Then AddMessage errs, "Name must be 100 characters or fewer." + + If Len(email) = 0 Then + AddMessage errs, "Please enter your email address." + ElseIf Len(email) > 150 Or Not IsValidEmail(email) Then + AddMessage errs, "Please enter a valid email address." + End If + + If Len(business) > 150 Then AddMessage errs, "Business name must be 150 characters or fewer." + If Len(message) > 2000 Then AddMessage errs, "Message must be 2000 characters or fewer." + + ValidationErrors = errs + End Function + + Public Function Notify(name, email, phone, business, message) + Dim recipient, smtpServer, smtpPort, smtpUser, body + recipient = Setting("ContactRecipient", "covington.daniel@gmail.com") + smtpServer = Setting("SmtpServer", "localhost") + smtpPort = Setting("SmtpPort", "25") + smtpUser = Setting("SmtpUsername", "") + + body = "New PostcardMojo website inquiry" & vbCrLf & vbCrLf & _ + "Name: " & name & vbCrLf & _ + "Email: " & email & vbCrLf & _ + "Phone: " & phone & vbCrLf & _ + "Business: " & business & vbCrLf & vbCrLf & _ + "Message:" & vbCrLf & message & vbCrLf & vbCrLf & _ + "Received: " & Now() & vbCrLf & _ + "IP: " & Request.ServerVariables("REMOTE_ADDR") + + CDOEmail.From = Setting("SmtpFrom", Choice(Len(smtpUser) > 0, smtpUser, recipient)) + CDOEmail.Subject = "PostcardMojo inquiry from " & StripLineBreaks(name) + CDOEmail.Body = body + CDOEmail.IsBodyHTML = False + CDOEmail.SMTPServer = smtpServer + CDOEmail.SMTPPort = CLng(smtpPort) + CDOEmail.SMTPUsername = smtpUser + CDOEmail.SMTPPassword = EnvService().Value("SMTP_PASSWORD", "") + CDOEmail.SMTPUseSSL = (LCase(Setting("SmtpUseSsl", "false")) = "true") + CDOEmail.AddRecipient "TO", recipient + Notify = CDOEmail.Send() + End Function + + Public Function StripLineBreaks(value) + StripLineBreaks = Replace(Replace(value, vbCr, " "), vbLf, " ") + End Function + + Private Function Setting(key, fallback) + Dim value : value = GetAppSetting(key) + If value = "nothing" Or Len(value) = 0 Then value = fallback + Setting = value + End Function + + Private Function IsValidEmail(value) + Dim re : Set re = New RegExp + re.Pattern = "^[^\s@<>,;""]+@[^\s@<>,;""]+\.[A-Za-z]{2,}$" + IsValidEmail = re.Test(value) + End Function + + Private Sub AddMessage(ByRef errs, msg) + ReDim Preserve errs(UBound(errs) + 1) + errs(UBound(errs)) = msg + End Sub +End Class + +Dim ContactInquiryService_Class__Singleton +Function ContactInquiryService() + If IsEmpty(ContactInquiryService_Class__Singleton) Then + Set ContactInquiryService_Class__Singleton = New ContactInquiryService_Class + End If + Set ContactInquiryService = ContactInquiryService_Class__Singleton +End Function +%> diff --git a/app/services/EnvService.asp b/app/services/EnvService.asp new file mode 100644 index 0000000..3eb29a5 --- /dev/null +++ b/app/services/EnvService.asp @@ -0,0 +1,56 @@ +<% +' Reads KEY=VALUE secrets from the .env file in the project root (outside the +' public web root). Real environment variables take precedence over the file. +Class EnvService_Class + Private values + + Private Sub Class_Initialize() + Set values = Server.CreateObject("Scripting.Dictionary") + values.CompareMode = 1 + LoadFile + End Sub + + Public Function Value(key, fallback) + Dim shell : Set shell = Server.CreateObject("WScript.Shell") + Value = shell.Environment("Process")(key) + Set shell = Nothing + If Len(Value) > 0 Then Exit Function + If values.Exists(key) Then + Value = values(key) + Else + Value = fallback + End If + End Function + + Private Sub LoadFile() + Dim fso, path, stream, line, pos, key, val + Set fso = Server.CreateObject("Scripting.FileSystemObject") + path = Server.MapPath("..\.env") + If Not fso.FileExists(path) Then Exit Sub + Set stream = fso.OpenTextFile(path, 1, False) + Do Until stream.AtEndOfStream + line = Trim(stream.ReadLine()) + If Len(line) > 0 And Left(line, 1) <> "#" Then + pos = InStr(line, "=") + If pos > 1 Then + key = Trim(Left(line, pos - 1)) + val = Trim(Mid(line, pos + 1)) + If Len(val) > 1 Then + If (Left(val, 1) = """" And Right(val, 1) = """") Or (Left(val, 1) = "'" And Right(val, 1) = "'") Then val = Mid(val, 2, Len(val) - 2) + End If + values(key) = val + End If + End If + Loop + stream.Close + End Sub +End Class + +Dim EnvService_Class__Singleton +Function EnvService() + If IsEmpty(EnvService_Class__Singleton) Then + Set EnvService_Class__Singleton = New EnvService_Class + End If + Set EnvService = EnvService_Class__Singleton +End Function +%> diff --git a/app/views/Home/landing.asp b/app/views/Home/landing.asp index e85b868..83e48c5 100644 --- a/app/views/Home/landing.asp +++ b/app/views/Home/landing.asp @@ -30,7 +30,7 @@ Response.CodePage = 65001 Who it is for FAQ - Find your spot + Find your spot @@ -42,9 +42,9 @@ Response.CodePage = 65001

Shared direct mail for local businesses

Make your business impossible to miss in the mailbox.

-

PostcardMojo puts a curated lineup of local businesses on one oversized 9×12 postcard. Your offer gets a bold, tangible place in nearby homes while participating businesses share the cost of printing and postage.

+

PostcardMojo puts a curated lineup of local businesses on one oversized 9×12 postcard and is delivered to 5,000 mailboxes. Your offer gets a bold, tangible place in nearby homes while participating businesses share the cost of printing and postage.

- Explore a campaign + Explore a campaign See how it works
One card. Multiple businesses. A whole neighborhood of opportunity.
+ + @@ -77,33 +115,27 @@ Response.CodePage = 65001

Simple by design

-

From your offer to their mailbox.

-

PostcardMojo turns a group of complementary local businesses into one well-organized, high-visibility mail piece.

+

Three steps to the mailbox.

+

Pick your size, give us your ad, and we handle the printing and mailing.

  1. 01 -

    Choose your campaign

    -

    Start with the local area and timing that fit your business goals.

    +

    Choose your ad size

    +

    Pick a 3×4 or a 6×8 space on the shared 9×12 postcard.

  2. 02 - -

    Claim your category

    -

    Reserve a space for your type of business, subject to campaign availability.

    -
  3. -
  4. - 03 -

    Shape the offer

    -

    Bring your logo, message, and promotion together in a clear, focused ad.

    +

    Send your layout or let us design it

    +

    Already have an ad? Send it over. Need one? We will design it for you.

  5. - 04 + 03 -

    Reach the mailbox

    -

    The finished postcard is printed and mailed as one coordinated campaign.

    +

    We mail it out

    +

    We print the postcard and deliver it to mailboxes in your area.

@@ -235,7 +267,7 @@ Response.CodePage = 65001

Tell us what you do and where you want to be seen. We will help you explore the right PostcardMojo campaign.

- Request campaign details + Request campaign details Campaign availability varies by area.
@@ -251,7 +283,7 @@ Response.CodePage = 65001 © <%= Year(Date()) %> PostcardMojo. All rights reserved. diff --git a/applicationhost.config b/applicationhost.config index 8d6b56d..e2214ff 100644 --- a/applicationhost.config +++ b/applicationhost.config @@ -159,7 +159,7 @@ - + diff --git a/public/Default.asp b/public/Default.asp index e6463b7..509accc 100644 --- a/public/Default.asp +++ b/public/Default.asp @@ -5,6 +5,7 @@ router.AddRoute "GET", "/", "HomeController", "Index" router.AddRoute "GET", "", "HomeController", "Index" router.AddRoute "GET", "/home", "HomeController", "Index" + router.AddRoute "POST", "/contact", "HomeController", "Contact" ' Dispatch the request (resolves route and executes controller action) MVC.DispatchRequest Request.ServerVariables("REQUEST_METHOD"), _ diff --git a/public/css/home.css b/public/css/home.css index bfff558..35a95cb 100644 --- a/public/css/home.css +++ b/public/css/home.css @@ -570,7 +570,7 @@ summary { .steps { position: relative; display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(3, 1fr); gap: 18px; margin: 0; padding: 0; @@ -1123,7 +1123,6 @@ summary { gap: 30px; } - .steps, .fit-grid { grid-template-columns: repeat(2, 1fr); } @@ -1428,3 +1427,120 @@ summary { transition-duration: 0.01ms !important; } } + +/* Contact card */ +.contact-card { + position: relative; + z-index: 2; + padding: clamp(22px, 3vw, 34px); + border-radius: 18px; + background: var(--navy); + color: var(--white); + box-shadow: 0 24px 60px rgba(5, 47, 79, 0.28); +} + +.contact-card h2 { + margin: 0 0 18px; + font-family: var(--font-display); + font-size: clamp(1.5rem, 2.4vw, 2rem); + line-height: 1.15; +} + +.contact-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px 16px; +} + +.contact-form label { + display: grid; + gap: 6px; + font-size: 0.9rem; + font-weight: 600; +} + +.contact-form input, +.contact-form textarea { + width: 100%; + padding: 11px 12px; + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 8px; + background: var(--white); + color: var(--ink); + font: inherit; +} + +.contact-wide, +.contact-errors, +.contact-form .button { + grid-column: 1 / -1; +} + +.contact-form .button { + justify-self: start; +} + +.contact-errors { + padding: 10px 14px; + border-radius: 8px; + background: #fde8e8; + color: #8a1c1c; + font-size: 0.92rem; +} + +.contact-errors ul { + margin: 0; + padding-left: 18px; +} + +.contact-success { + margin: 0; + color: var(--gold); + font-weight: 700; +} + +.contact-trap { + position: absolute; + left: -9999px; +} + +.hero .hero-grid { + width: min(1640px, calc(100% - 32px)); + grid-template-columns: minmax(0, 0.75fr) minmax(0, 1.6fr) minmax(280px, 0.75fr); + grid-template-areas: "copy card contact"; + align-items: center; + gap: clamp(20px, 2.5vw, 40px); +} + +.hero-grid .hero-copy { grid-area: copy; } +.hero-grid .contact-card { grid-area: contact; } +.hero-grid .postcard-stage { grid-area: card; min-height: 0; } +.hero-grid .postcard { width: 100%; } + +.hero-grid h1 { font-size: clamp(2rem, 3vw, 3.1rem); } +.hero-grid .contact-form { grid-template-columns: 1fr; gap: 12px; } +.hero-grid .stage-note { position: static; margin-top: 16px; max-width: none; } + +@media (max-width: 1100px) { + .hero .hero-grid { + grid-template-columns: minmax(0, 1fr) minmax(300px, 0.9fr); + grid-template-areas: "copy contact" "card card"; + align-items: start; + } + + .hero-grid .postcard { width: min(100%, 610px); } + .hero-grid .postcard-stage { min-height: 520px; } +} + +@media (max-width: 700px) { + .hero .hero-grid { + grid-template-columns: 1fr; + grid-template-areas: "copy" "contact" "card"; + } +} + +@media (max-width: 700px) { + .contact-form { + grid-template-columns: 1fr; + } +} diff --git a/public/web.config b/public/web.config index a0165fc..a899764 100644 --- a/public/web.config +++ b/public/web.config @@ -38,6 +38,13 @@ + + + + + + +