Skip to content
Draft
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
27 changes: 27 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,35 @@
const studyCitySlugs = [
'beijing',
'changsha',
'chengdu',
'dalian',
'guangzhou',
'hangzhou',
'harbin',
'hefei',
'nanjing',
'shanghai',
'tianjin',
'wuhan',
'xi-an',
'xiamen',
];

const cityRedirects = studyCitySlugs.map((city) => ({
source: `/study-in-${city}`,
destination: `/study-in/${city}`,
permanent: true,
}));

/** @type {import('next').NextConfig} */
const nextConfig = {
// Ensure consistent URLs by not adding trailing slashes
trailingSlash: false,

async redirects() {
return cityRedirects;
},

async rewrites() {
return [
{
Expand Down
21 changes: 21 additions & 0 deletions schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,19 @@ CREATE TABLE IF NOT EXISTS public.feedbacks (
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);

CREATE TABLE IF NOT EXISTS public.route_votes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
route_path TEXT NOT NULL CHECK (route_path ~ '^/'),
voter_key TEXT NOT NULL CHECK (voter_key ~ '^[a-f0-9]{64}$'),
user_id UUID REFERENCES public.users(id) ON DELETE SET NULL,
user_agent TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
UNIQUE(route_path, voter_key)
);

CREATE INDEX IF NOT EXISTS route_votes_route_path_idx ON public.route_votes(route_path);
CREATE INDEX IF NOT EXISTS route_votes_created_at_idx ON public.route_votes(created_at DESC);

-- 6. Dynamic Content (Cities, Universities & Programs)
CREATE TABLE IF NOT EXISTS public.cities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
Expand Down Expand Up @@ -198,6 +211,7 @@ ALTER TABLE public.purchases ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.user_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.leads ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.feedbacks ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.route_votes ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.cities ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.universities ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.programs ENABLE ROW LEVEL SECURITY;
Expand Down Expand Up @@ -286,6 +300,13 @@ CREATE POLICY "Admins can update feedbacks" ON public.feedbacks FOR UPDATE USING
DROP POLICY IF EXISTS "Admins can delete feedbacks" ON public.feedbacks;
CREATE POLICY "Admins can delete feedbacks" ON public.feedbacks FOR DELETE USING (EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND role = 'admin'));

-- route_votes: tracked through the server API, admins can inspect/delete
DROP POLICY IF EXISTS "Admins can view route votes" ON public.route_votes;
CREATE POLICY "Admins can view route votes" ON public.route_votes FOR SELECT USING (EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND role = 'admin'));

DROP POLICY IF EXISTS "Admins can delete route votes" ON public.route_votes;
CREATE POLICY "Admins can delete route votes" ON public.route_votes FOR DELETE USING (EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND role = 'admin'));

-- cities: anyone can read, admins modify
DROP POLICY IF EXISTS "Anyone can view cities" ON public.cities;
CREATE POLICY "Anyone can view cities" ON public.cities FOR SELECT USING (true);
Expand Down
89 changes: 89 additions & 0 deletions src/__tests__/RouteVoteWidget.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import RouteVoteWidget from '@/components/engagement/RouteVoteWidget';
import { getRouteVoteBaseCount } from '@/lib/routeVoting';

vi.mock('next/navigation', () => ({
usePathname: () => '/tools/roi',
}));

describe('RouteVoteWidget', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('shows the seeded route count before backend totals load', () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({
routePath: '/tools/roi',
baseCount: getRouteVoteBaseCount('/tools/roi'),
realCount: 4,
displayCount: getRouteVoteBaseCount('/tools/roi') + 4,
userHasVoted: false,
}),
});

render(<RouteVoteWidget />);

expect(screen.getByText(String(getRouteVoteBaseCount('/tools/roi')))).toBeInTheDocument();
});

it('uses a compact mobile layout so it does not cover page content', () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
json: async () => ({}),
});

render(<RouteVoteWidget />);

expect(screen.getByLabelText('Page popularity')).toHaveClass('right-4', 'sm:left-4');
expect(screen.getByRole('button', { name: /vote for this page/i })).toHaveClass('h-12', 'w-12', 'sm:w-auto');
expect(screen.getByText('Popular')).toHaveClass('hidden', 'sm:block');
});

it('loads real vote totals and posts a route vote', async () => {
const baseCount = getRouteVoteBaseCount('/tools/roi');
const fetchMock = vi.spyOn(global, 'fetch');

fetchMock
.mockResolvedValueOnce({
ok: true,
json: async () => ({
routePath: '/tools/roi',
baseCount,
realCount: 4,
displayCount: baseCount + 4,
userHasVoted: false,
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
routePath: '/tools/roi',
baseCount,
realCount: 5,
displayCount: baseCount + 5,
userHasVoted: true,
}),
});

render(<RouteVoteWidget />);

expect(await screen.findByText(String(baseCount + 4))).toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: /vote for this page/i }));

await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(
'/api/route-votes',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ path: '/tools/roi' }),
})
);
expect(screen.getByText(String(baseCount + 5))).toBeInTheDocument();
});
});
});
Loading
Loading