@@ -240,7 +240,13 @@ export default {
},
computed: {
...mapGetters(["isPremiumUser"]),
- ...mapState(["authUser", "events", "groupsEnabled", "folders"]),
+ ...mapState([
+ "authUser",
+ "events",
+ "groupsEnabled",
+ "folders",
+ "enablePaywall",
+ ]),
orderedFolders() {
return this.folders.sort((a, b) => {
return a.name.localeCompare(b.name)
diff --git a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue
index 1e028082..d7377fa3 100644
--- a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue
+++ b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue
@@ -1048,6 +1048,7 @@ import {
isPremiumUser,
prefersStartOnMonday,
} from "@/utils"
+import { buildIcs, downloadIcs } from "@/utils/ics_utils"
import {
availabilityTypes,
calendarOptionsDefaults,
@@ -1250,11 +1251,7 @@ export default {
...mapState(["authUser", "overlayAvailabilitiesEnabled"]),
...mapGetters(["isPremiumUser"]),
showAds() {
- return (
- !this.ownerIsPremium &&
- !this.isPremiumUser &&
- this.state !== this.states.SET_SPECIFIC_TIMES
- )
+ return false
},
/** Returns the width of the right side of the calendar */
rightSideWidth() {
@@ -2076,7 +2073,7 @@ export default {
}
return `Tap and drag to add your "available" ${daysOrTimes} in green.`
case this.states.SCHEDULE_EVENT:
- return "Tap and drag on the calendar to schedule a Google Calendar event during those times."
+ return "Tap and drag on the calendar to schedule an event during those times."
default:
return ""
}
@@ -2092,7 +2089,7 @@ export default {
}
return `Click and drag to add your "available" ${daysOrTimes} in green.`
case this.states.SCHEDULE_EVENT:
- return "Click and drag on the calendar to schedule a Google Calendar event during those times."
+ return "Click and drag on the calendar to schedule an event during those times."
default:
return ""
}
@@ -3563,8 +3560,11 @@ export default {
this.state = this.defaultState
},
- /** Redirect user to Google Calendar to finish the creation of the event */
- confirmScheduleEvent(googleCalendar = true) {
+ /**
+ * Finish scheduling the dragged-out slot: open Google Calendar / Outlook
+ * prefilled, or download an .ics file. target: "google" | "outlook" | "ics"
+ */
+ confirmScheduleEvent(target = "google") {
if (!this.curScheduledEvent) return
// if (!isPremiumUser(this.authUser)) {
// this.showUpgradeDialog({
@@ -3615,9 +3615,27 @@ export default {
const emailsString = encodeURIComponent(emails.filter(Boolean).join(","))
const eventId = this.event.shortId ?? this.event._id
+ const eventUrl = `${window.location.origin}/e/${eventId}`
+ const description = `\n\nThis event was scheduled with Timeful: ${eventUrl}`
+
+ if (target === "ics") {
+ const ics = buildIcs({
+ uid: `${this.event._id}-${startDate.getTime()}@${window.location.host}`,
+ start: startDate,
+ end: endDate,
+ summary: this.event.name,
+ description: description.trim(),
+ location: this.event.location,
+ url: eventUrl,
+ })
+ const filename = `${this.event.name.replace(/[^\w-]+/g, "_") || "event"}.ics`
+ downloadIcs(filename, ics)
+ this.state = this.defaultState
+ return
+ }
let url = ""
- if (googleCalendar) {
+ if (target === "google") {
// Format start and end date to be in the format required by gcal (remove -, :, and .000)
const start = startDate.toISOString().replace(/([-:]|\.000)/g, "")
const end = endDate.toISOString().replace(/([-:]|\.000)/g, "")
@@ -3626,14 +3644,13 @@ export default {
url = `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(
this.event.name
)}&dates=${start}/${end}&details=${encodeURIComponent(
- "\n\nThis event was scheduled with Timeful: https://timeful.app/e/"
- )}${eventId}&ctz=${this.curTimezone.value}&add=${emailsString}`
+ description
+ )}&ctz=${this.curTimezone.value}&add=${emailsString}`
} else {
url = `https://outlook.live.com/calendar/0/deeplink/compose?subject=${encodeURIComponent(
this.event.name
)}&body=${encodeURIComponent(
- "\n\nThis event was scheduled with Timeful: https://timeful.app/e/" +
- eventId
+ description
)}&startdt=${startDate.toISOString()}&enddt=${endDate.toISOString()}&location=${encodeURIComponent(
this.event.location || ""
)}&path=/calendar/action/compose&timezone=${this.curTimezone.value}`
diff --git a/frontend/src/components/schedule_overlap/ToolRow.vue b/frontend/src/components/schedule_overlap/ToolRow.vue
index d8aaaddd..5822dd28 100644
--- a/frontend/src/components/schedule_overlap/ToolRow.vue
+++ b/frontend/src/components/schedule_overlap/ToolRow.vue
@@ -118,7 +118,9 @@
- $emit('confirmScheduleEvent', true)">
+ $emit('confirmScheduleEvent', 'google')"
+ >
Google Calendar
- $emit('confirmScheduleEvent', false)">
+ $emit('confirmScheduleEvent', 'outlook')"
+ >
Outlook
+ $emit('confirmScheduleEvent', 'ics')">
+ mdi-calendar-export
+
+ Download .ics
+
+
diff --git a/frontend/src/main.js b/frontend/src/main.js
index 2d370f7a..9dfa971a 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -5,19 +5,12 @@ import router from "./router"
import store from "./store"
import vuetify from "./plugins/vuetify"
import posthogPlugin from "./plugins/posthog"
-import VueGtm from "@gtm-support/vue2-gtm"
import VueMeta from "vue-meta"
import "./index.css"
// Posthog
Vue.use(posthogPlugin)
-// Google Analytics
-Vue.use(VueGtm, {
- id: "GTM-M677X6V",
- vueRouter: router,
-})
-
// Site Metadata
Vue.use(VueMeta)
diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js
index a0770841..b9460a8f 100644
--- a/frontend/src/store/index.js
+++ b/frontend/src/store/index.js
@@ -29,7 +29,7 @@ export default new Vuex.Store({
signUpFormEnabled: false,
daysOnlyEnabled: true,
overlayAvailabilitiesEnabled: true,
- enablePaywall: true,
+ enablePaywall: false,
// Experiments
pricingPageConversion: "control",
diff --git a/frontend/src/utils/ics_utils.js b/frontend/src/utils/ics_utils.js
new file mode 100644
index 00000000..c285bd1b
--- /dev/null
+++ b/frontend/src/utils/ics_utils.js
@@ -0,0 +1,69 @@
+/** Builds and downloads a single-event iCalendar (RFC 5545) file. */
+
+const utf8 = new TextEncoder()
+
+/** Escapes a TEXT property value (RFC 5545 §3.3.11) */
+const escapeText = (text) =>
+ text
+ .replace(/\\/g, "\\\\")
+ .replace(/;/g, "\\;")
+ .replace(/,/g, "\\,")
+ .replace(/\r?\n/g, "\\n")
+
+/** Formats a Date as a UTC DATE-TIME, e.g. 20261020T070000Z */
+const formatDateTime = (date) =>
+ date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "")
+
+/** Folds a content line so no physical line exceeds 75 octets (RFC 5545 §3.1) */
+const foldLine = (line) => {
+ const parts = []
+ let current = ""
+ let currentBytes = 0
+ for (const char of line) {
+ const charBytes = utf8.encode(char).length
+ // continuation lines start with a space, which counts toward the limit
+ const limit = parts.length === 0 ? 75 : 74
+ if (currentBytes + charBytes > limit) {
+ parts.push(current)
+ current = ""
+ currentBytes = 0
+ }
+ current += char
+ currentBytes += charBytes
+ }
+ parts.push(current)
+ return parts.join("\r\n ")
+}
+
+export const buildIcs = ({ uid, start, end, summary, description, location, url }) => {
+ const lines = [
+ "BEGIN:VCALENDAR",
+ "VERSION:2.0",
+ "PRODID:-//timeful//self-hosted//EN",
+ "CALSCALE:GREGORIAN",
+ "METHOD:PUBLISH",
+ "BEGIN:VEVENT",
+ `UID:${uid}`,
+ `DTSTAMP:${formatDateTime(new Date())}`,
+ `DTSTART:${formatDateTime(start)}`,
+ `DTEND:${formatDateTime(end)}`,
+ `SUMMARY:${escapeText(summary)}`,
+ ]
+ if (description) lines.push(`DESCRIPTION:${escapeText(description)}`)
+ if (location) lines.push(`LOCATION:${escapeText(location)}`)
+ if (url) lines.push(`URL:${url}`)
+ lines.push("END:VEVENT", "END:VCALENDAR")
+ return lines.map(foldLine).join("\r\n") + "\r\n"
+}
+
+export const downloadIcs = (filename, icsText) => {
+ const blob = new Blob([icsText], { type: "text/calendar;charset=utf-8" })
+ const href = URL.createObjectURL(blob)
+ const a = document.createElement("a")
+ a.href = href
+ a.download = filename
+ document.body.appendChild(a)
+ a.click()
+ a.remove()
+ URL.revokeObjectURL(href)
+}
diff --git a/frontend/src/views/Event.vue b/frontend/src/views/Event.vue
index 81887280..87dba8d1 100644
--- a/frontend/src/views/Event.vue
+++ b/frontend/src/views/Event.vue
@@ -463,13 +463,29 @@
Cancel
-
- Schedule
-
+
+
+
+ Schedule
+
+
+
+
+ Google Calendar
+
+
+ Outlook
+
+
+ Download .ics
+
+
+
We're open source!
- StarSource