Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=FOR_SIGNATURE_VERIFICATION
TWILIO_API_KEY=
TWILIO_API_SECRET=
TWILIO_PHONE_NUMBER=
TWILIO_MESSAGING_SERVICE_SID=
TWILIO_SYNC_SERVICE_SID=
TWILIO_VERIFY_SERVICE_SID=
Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ jobs:
TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }}
TWILIO_API_KEY: ${{ secrets.TWILIO_API_KEY }}
TWILIO_API_SECRET: ${{ secrets.TWILIO_API_SECRET }}
TWILIO_PHONE_NUMBER: ${{ vars.TWILIO_PHONE_NUMBER }}
TWILIO_MESSAGING_SERVICE_SID: ${{ vars.TWILIO_MESSAGING_SERVICE_SID }}
TWILIO_SYNC_SERVICE_SID: ${{ vars.TWILIO_SYNC_SERVICE_SID }}
TWILIO_VERIFY_SERVICE_SID: ${{ vars.TWILIO_VERIFY_SERVICE_SID }}
Expand Down Expand Up @@ -62,7 +61,6 @@ jobs:
TWILIO_AUTH_TOKEN=$TWILIO_AUTH_TOKEN
TWILIO_API_KEY=$TWILIO_API_KEY
TWILIO_API_SECRET=$TWILIO_API_SECRET
TWILIO_PHONE_NUMBER=$TWILIO_PHONE_NUMBER
TWILIO_MESSAGING_SERVICE_SID=$TWILIO_MESSAGING_SERVICE_SID
TWILIO_SYNC_SERVICE_SID=$TWILIO_SYNC_SERVICE_SID
TWILIO_VERIFY_SERVICE_SID=$TWILIO_VERIFY_SERVICE_SID
Expand Down
1 change: 0 additions & 1 deletion DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ TWILIO_API_SECRET="xxxxx"
TWILIO_VERIFY_SERVICE_SID="VAxxxxx"
TWILIO_SYNC_SERVICE_SID="ISxxxxx"
TWILIO_MESSAGING_SERVICE_SID="MGxxxxx"
TWILIO_PHONE_NUMBER="+15551234567"
```

The deploy script uses `.env.local` for both build-time values, such as `NEXT_PUBLIC_*`, and runtime environment variables.
Expand Down
99 changes: 62 additions & 37 deletions __tests__/e2e/browse-events.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { test, expect, type Page } from "@playwright/test";
import { Privilege } from "@/proxy";
import { createEvent, deleteIfExists } from "../global-setup";

test.describe("[no login]", () => {
test("should not be navigable", async ({ page }) => {
Expand Down Expand Up @@ -64,10 +65,6 @@ test.describe("[mixologist]", () => {
});

test.describe("[admin]", () => {
// These tests mutate the shared "test-event" fixture (item selection, mode);
// run them in order rather than in parallel to avoid clobbering each other.
test.describe.configure({ mode: "serial" });

test("should be navigable to an existing event", async ({
page,
context,
Expand Down Expand Up @@ -132,41 +129,69 @@ test.describe("[admin]", () => {
test("should not be able to select more than 9 menu items + navigate to smoothie", async ({
page,
context,
}) => {
await context.addCookies([
{
name: "privilege",
value: Privilege.ADMIN,
url: "http://localhost:3000",
},
]);
await context.setExtraHTTPHeaders({
Authorization: `Basic ${btoa(process.env.ADMIN_LOGIN || ":")}`,
});

await page.goto("http://localhost:3000/event/test-event");

await page.waitForTimeout(2000);

// TestEvent starts with 1 item selected (Espresso); select 9 more unselected
// items to reach the 10-item cap. Scoped to the literal aria-pressed="false"
// attribute (not the role=button pressed filter) — Chromium's accessibility
// tree reports pressed:false by default for any plain button, which would
// otherwise also match the header's "Log out" button and toast dismiss buttons.
const unselectedItem = page.locator('button[aria-pressed="false"]');
for (let i = 0; i < 9; i++) {
}, testInfo) => {
// This test mutates item selection and mode, unlike its siblings which only
// read "test-event" — give it a private event (keyed by parallelIndex, with
// its own display name) so it never clobbers the shared fixture other spec
// files depend on, and doesn't produce a duplicate "TestEvent" heading on
// the home page while other tests are concurrently asserting against it.
const slug = `test-event-menu-cap-${testInfo.parallelIndex}`;
// Event names are capped at 20 chars by the API (src/app/api/event/route.ts).
// Must not contain "TestEvent" as a substring — other tests query
// getByRole(..., { name: "TestEvent" }) without exact:true, which matches
// on substring, so any name merely starting with "TestEvent" still collides.
const name = `MenuCapEvent${testInfo.parallelIndex}`;
const baseURL = testInfo.project.use.baseURL || "http://localhost:3000";
await deleteIfExists(baseURL, slug);
const response = await createEvent(baseURL, slug, name);
expect(response.status).toBe(201);

try {
await context.addCookies([
{
name: "privilege",
value: Privilege.ADMIN,
url: "http://localhost:3000",
},
]);
await context.setExtraHTTPHeaders({
Authorization: `Basic ${btoa(process.env.ADMIN_LOGIN || ":")}`,
});

await page.goto(`http://localhost:3000/event/${slug}`);

// Wait for the freshly-created event's menu to actually be rendered
// (Espresso pre-selected) rather than a fixed sleep, since a brand-new
// event's Sync data may take longer to propagate under concurrent load.
await expect(
page.getByRole("button", { name: "Espresso Strong black coffee" }),
).toHaveAttribute("aria-pressed", "true");

// TestEvent starts with 1 item selected (Espresso); select 9 more unselected
// items to reach the 10-item cap. Scoped to the literal aria-pressed="false"
// attribute (not the role=button pressed filter) — Chromium's accessibility
// tree reports pressed:false by default for any plain button, which would
// otherwise also match the header's "Log out" button and toast dismiss buttons.
const unselectedItem = page.locator('button[aria-pressed="false"]');
for (let i = 0; i < 9; i++) {
await unselectedItem.first().click();
// Wait for this click's selection save to land before firing the
// next — the save is a fire-and-forget PUT, and rapid unawaited
// requests can complete out of order under latency, regressing the
// count if a later click's save is overtaken by an earlier one.
await expect(page.getByText(`${i + 2} of 10 items selected`)).toBeVisible();
}

// selecting an 11th item should be blocked
await unselectedItem.first().click();
}
await expect(
page.getByText("Cannot select more items", { exact: true }),
).toBeVisible();

await expect(page.getByText("10 of 10 items selected")).toBeVisible();

// selecting an 11th item should be blocked
await unselectedItem.first().click();
await expect(
page.getByText("Cannot select more items", { exact: true }),
).toBeVisible();

await page.getByText("Smoothie").click();
await page.getByText("Smoothie").click();
} finally {
await deleteIfExists(baseURL, slug);
}
});

test("should show warning for inactive number", async ({ page, context }) => {
Expand Down
14 changes: 9 additions & 5 deletions __tests__/global-setup.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { expect, type FullConfig } from "@playwright/test";
import Axios from "axios";

async function deleteIfExists(baseURL: string) {
export async function deleteIfExists(baseURL: string, slug: string = "test-event") {
try {
await Axios.delete(`${baseURL}/api/event/test-event`, {
await Axios.delete(`${baseURL}/api/event/${slug}`, {
headers: {
"Content-Type": "application/json",
Authorization: `Basic ${btoa(process.env.ADMIN_LOGIN || ":")}`,
Expand All @@ -12,12 +12,16 @@ async function deleteIfExists(baseURL: string) {
} catch (e) {}
}

async function createEvent(baseURL: string) {
export async function createEvent(
baseURL: string,
slug: string = "test-event",
name: string = "TestEvent",
) {
return Axios.post(
`${baseURL}/api/event`,
{
name: "TestEvent",
slug: "test-event",
name,
slug,
state: "OPEN",
senders: ["+4915199999999", "whatsapp:+447700161860"],
selection: {
Expand Down
1 change: 0 additions & 1 deletion deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,6 @@ secret_keys=(
TWILIO_API_KEY
TWILIO_API_SECRET
TWILIO_AUTH_TOKEN
TWILIO_PHONE_NUMBER
TWILIO_MESSAGING_SERVICE_SID
TWILIO_SYNC_SERVICE_SID
TWILIO_VERIFY_SERVICE_SID
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"license": "MIT",
"scripts": {
"dev": "next dev",
"dev": "env NODE_OPTIONS=--require=dotenv/config DOTENV_CONFIG_PATH=./.env.local DOTENV_CONFIG_OVERRIDE=true next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
Expand Down
3 changes: 1 addition & 2 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
workers: process.env.CI ? 4 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
Expand Down
1 change: 0 additions & 1 deletion sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=FOR_SIGNATURE_VERIFICATION
TWILIO_API_KEY=
TWILIO_API_SECRET=
TWILIO_PHONE_NUMBER=
TWILIO_MESSAGING_SERVICE_SID=
TWILIO_SYNC_SERVICE_SID=
TWILIO_VERIFY_SERVICE_SID=
Expand Down
2 changes: 1 addition & 1 deletion src/app/(layout-free)/event/[slug]/kiosk/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<div className="min-h-screen flex flex-col">
<Header number="ABC" mode="barista" />
<Toaster />
<main className="px-24 pt-16">{children}</main>
<main className="px-4 pt-6 md:px-24 md:pt-16">{children}</main>
{/* Use these parameters to adapt to a different screen size */}
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion src/app/(layout-free)/event/[slug]/kiosk/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default async function KioskPage(props: {

return (
<div className="p-4 space-y-8 flex-1">
<p className="text-4xl">
<p className="text-xl md:text-4xl">
Order your beverage here and pick it up at the Twilio booth.
</p>
{hasPermissions && (
Expand Down
28 changes: 21 additions & 7 deletions src/app/(master-layout)/event/[slug]/orders/ordersList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useState } from "react";
import { Privilege } from "@/proxy";
import { getCookie } from "cookies-next";
import { sendMessage } from "@/lib/twilio";
import { sendMessage, getPinnedSender } from "@/lib/twilio";
import { Badge } from "@/components/ui/badge";

import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
Expand Down Expand Up @@ -127,6 +127,12 @@ export default function OrdersList({
return data.key;
}

// The sender the attendee last messaged in on — pinned so replies never
// come from a different Messaging Service-selected sender/channel.
function pinnedFrom(phone: string): Promise<string> {
return getPinnedSender(process.env.NEXT_PUBLIC_ATTENDEES_MAP || "", phone);
}

function listComponent(orders: any[]) {
return orders.map((order) => {
const { data, index, dateUpdated } = order;
Expand Down Expand Up @@ -184,10 +190,13 @@ export default function OrdersList({
onClick={async () => {
startProcessing(index, "remind");
try {
const message = await getOrderReadyReminderMessage(
data.item, index, event.pickupLocation, event.language,
);
sendMessage(toAddress(data), "", message.contentSid, message.contentVariables);
const [message, from] = await Promise.all([
getOrderReadyReminderMessage(
data.item, index, event.pickupLocation, event.language,
),
pinnedFrom(data.key),
]);
sendMessage(toAddress(data), "", message.contentSid, message.contentVariables, from);
updateOrder(index, { reminded: true });
toast({ title: "Customer Reminded", description: "Reminder sent." });
} finally {
Expand All @@ -208,8 +217,13 @@ export default function OrdersList({
try {
updateOrder(index, { status: "ready" });
if (!data?.manual) {
const message = await getOrderReadyMessage(data.item, index, event.pickupLocation);
sendMessage(toAddress(data), "", message.contentSid, message.contentVariables);
const [message, from] = await Promise.all([
getOrderReadyMessage(
data.item, index, event.pickupLocation, event.language,
),
pinnedFrom(data.key),
]);
sendMessage(toAddress(data), "", message.contentSid, message.contentVariables, from);
}
toast({
title: "Order Ready",
Expand Down
31 changes: 21 additions & 10 deletions src/app/(master-layout)/event/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -411,14 +411,22 @@ function EventPage({ params }: { params: Promise<{ slug: string }> }) {
<MenuSelect
menus={config.menus}
selection={internalEvent.selection}
onSelectionChange={async (newSelection) => {
onSelectionChange={(newSelection) => {
updateEvent({ ...internalEvent, selection: newSelection });
if (!isNewEvent) {
fetch(`/api/event/${internalEvent.slug}/selection`, {
method: "PUT",
body: JSON.stringify({ selection: newSelection }),
});
// Debounce the save (like updateMenuItemField/
// updateModifierField below) so rapid successive
// selections send a single PUT with the latest state,
// rather than one fire-and-forget PUT per click that can
// complete out of order and regress the saved selection.
clearTimeout(aiUpdateTimerRef.current);
aiUpdateTimerRef.current = setTimeout(() => {
fetch(`/api/event/${internalEvent.slug}/selection`, {
method: "PUT",
body: JSON.stringify({ selection: newSelection }),
});
}, 1500);
}
updateEvent({ ...internalEvent, selection: newSelection });
}}
/>
</CardContent>
Expand Down Expand Up @@ -504,8 +512,8 @@ function EventPage({ params }: { params: Promise<{ slug: string }> }) {
<Tooltip>
<TooltipTrigger asChild>
<Button
className="w-full bg-twilio-red hover:bg-red-600 text-white disabled:opacity-40"
disabled={isFormInvalid(internalEvent)}
className="w-full bg-twilio-red hover:bg-red-600 text-white aria-disabled:opacity-40 aria-disabled:cursor-not-allowed"
aria-disabled={isFormInvalid(internalEvent)}
onClick={(ev: React.MouseEvent<HTMLButtonElement, MouseEvent> & { target: HTMLButtonElement }) => {
if (isFormInvalid(internalEvent)) return;
ev.target.disabled = true;
Expand Down Expand Up @@ -539,13 +547,16 @@ function toKebabCase(string: string) {
// kebab case when a number is followed by a letter and vice versa
// no starting or tailing dashes
return string
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "") // strip diacritics, e.g. São -> Sao
.replace(/([a-z])([A-Z])/g, "$1-$2")
.replace(/\//g, "-")
.replace(/([0-9])([a-zA-Z])/g, "$1-$2")
.replace(/([a-zA-Z])([0-9])/g, "$1-$2")
.replace(/[\s_]+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();
.toLowerCase()
.replace(/[^a-z0-9-]/g, "") // drop any remaining non-ascii characters
.replace(/^-+|-+$/g, "");
}

function isFormInvalid(internalEvent: Event) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/(master-layout)/event/[slug]/stats/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ function StatsPage({ params }: { params: Promise<{ slug: string }> }) {
Orders by Channel
</CardTitle>
<CardDescription className="text-xs">
Breakdown of orders by messaging channel
Breakdown of orders by channel (SMS, WhatsApp, RCS, API)
</CardDescription>
</div>
<MessageCircleIcon className="w-4 h-4 text-gray-500" />
Expand Down
12 changes: 9 additions & 3 deletions src/app/api/[slug]/broadcast/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { headers } from "next/headers";
import { fetchSyncListItems, sendMessage } from "@/lib/twilio";
import { fetchSyncListItems, sendMessage, createSyncMapItemIfNotExists } from "@/lib/twilio";
import { Privilege, getAuthenticatedRole } from "@/proxy";

export async function POST(
Expand Down Expand Up @@ -46,11 +46,17 @@ export async function POST(
listItem.data?.status === "queued" || listItem.data?.status === "ready",
);

queuedOrders.forEach((order) => {
queuedOrders.forEach(async (order) => {
// @ts-ignore thinks is a object but actually it's a string
const { key, channel } = order.data;
const to = channel === "whatsapp" ? `whatsapp:${key}` : channel === "rcs" ? `rcs:${key}` : key;
sendMessage(to, message);
// Pin the sender the attendee last messaged in on, so the reply never
// comes from a different Messaging Service-selected sender/channel.
const { data: attendee } = await createSyncMapItemIfNotExists(
process.env.NEXT_PUBLIC_ATTENDEES_MAP || "",
key,
);
sendMessage(to, message, undefined, undefined, (attendee as any)?.from || "");
});

return new Response(null, { status: 201 });
Expand Down
Loading
Loading