SmartTap Sign In
Manage shopkeeper NFC business profiles, QR codes, and real-time tap telemetry.
Manage shopkeeper NFC business profiles, QR codes, and real-time tap telemetry.
| Shopkeeper / Business | Unique NFC URL | Profile Status | Visits | Button Clicks | Actions |
|---|
Every counter starts at 0 and increments strictly when a visitor opens /b/<slug> or taps Google Reviews, WhatsApp, or Instagram.
| Business Profile | Unique Visits | Total Visits | Google Clicks | WhatsApp Clicks | Instagram Clicks | Total Clicks (CTR) | Last Tap / Test |
|---|
One common CR80 (85.6mm × 54mm) matte-black PVC card design printed in bulk for all shopkeepers.
Hold your smartphone near the card to rate us on Google & connect instantly.
If a customer's phone has NFC turned off, scanning this QR code opens the exact same /b/<slug> landing page.
Every physical card is identical off the printing press. Before handing a card to a shopkeeper, write their unique URL to the card's NDEF URI record:
smarttap.in/b/abdullah-hotel).
Paste and run this complete script inside your Supabase Dashboard → SQL Editor. It creates admin_users, businesses, analytics_events, the business-logos Storage bucket, and strict Row Level Security policies.
-- ============================================================================
-- SmartTap NFC — Production Supabase Database Schema & Row Level Security (RLS)
-- Domain: smarttap.in
-- Run this entire script in the Supabase SQL Editor (https://supabase.com/dashboard)
-- ============================================================================
-- 1. EXTENSIONS
create extension if not exists "pgcrypto";
-- ============================================================================
-- 2. TABLES
-- ============================================================================
-- 2.1 ADMIN ACCESS TABLE
-- Maps Supabase Auth users (auth.users) who are authorized platform administrators.
create table if not exists public.admin_users (
user_id uuid primary key references auth.users(id) on delete cascade,
email text not null unique,
role text not null default 'admin' check (role in ('admin', 'super_admin')),
created_at timestamptz not null default now()
);
comment on table public.admin_users is 'Authorized SmartTap NFC platform administrators.';
-- Helper function to check if the currently authenticated user is an admin
create or replace function public.is_admin()
returns boolean
language sql
security definer
set search_path = public
stable
as $$
select exists (
select 1
from public.admin_users
where user_id = auth.uid()
);
$$;
-- 2.2 BUSINESSES TABLE
-- Stores every shopkeeper / business profile linked to a SmartTap NFC card (/b/<slug>)
create table if not exists public.businesses (
id uuid primary key default gen_random_uuid(),
name text not null check (char_length(trim(name)) >= 2),
slug text not null unique check (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'),
logo_url text,
description text default '',
google_review_url text not null,
whatsapp_number text not null,
instagram_url text not null,
address text default '',
contact_info text default '',
is_active boolean not null default true,
created_by uuid references auth.users(id) on delete set null default auth.uid(),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_businesses_slug on public.businesses (slug);
create index if not exists idx_businesses_is_active on public.businesses (is_active);
create index if not exists idx_businesses_created_at on public.businesses (created_at desc);
comment on table public.businesses is 'Shopkeeper business profiles served at smarttap.in/b/<slug>';
-- Automatic updated_at trigger
create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists trg_businesses_updated_at on public.businesses;
create trigger trg_businesses_updated_at
before update on public.businesses
for each row
execute function public.set_updated_at();
-- 2.3 ANALYTICS EVENTS TABLE
-- Tracks real page visits (unique + total) and button clicks per business.
create table if not exists public.analytics_events (
id uuid primary key default gen_random_uuid(),
business_id uuid not null references public.businesses(id) on delete cascade,
event_type text not null check (
event_type in ('page_visit', 'google_click', 'whatsapp_click', 'instagram_click')
),
visitor_hash text not null,
user_agent text default '',
referrer text default '',
created_at timestamptz not null default now()
);
create index if not exists idx_analytics_business_id on public.analytics_events (business_id);
create index if not exists idx_analytics_event_type on public.analytics_events (event_type);
create index if not exists idx_analytics_visitor_hash on public.analytics_events (business_id, visitor_hash);
create index if not exists idx_analytics_created_at on public.analytics_events (created_at desc);
comment on table public.analytics_events is 'Granular visit and button click telemetry for SmartTap NFC profiles.';
-- ============================================================================
-- 3. ANALYTICS AGGREGATION VIEW & RPC FUNCTIONS
-- ============================================================================
-- 3.1 Business-Wise Analytics View (Used by Admin Dashboard)
create or replace view public.business_analytics_summary
with (security_invoker = true)
as
select
b.id as business_id,
b.name,
b.slug,
b.is_active,
count(a.id) filter (where a.event_type = 'page_visit') as total_visits,
count(distinct a.visitor_hash) filter (where a.event_type = 'page_visit') as unique_visits,
count(a.id) filter (where a.event_type = 'google_click') as google_clicks,
count(a.id) filter (where a.event_type = 'whatsapp_click') as whatsapp_clicks,
count(a.id) filter (where a.event_type = 'instagram_click') as instagram_clicks,
count(a.id) filter (
where a.event_type in ('google_click', 'whatsapp_click', 'instagram_click')
) as total_button_clicks,
max(a.created_at) as last_activity_at
from public.businesses b
left join public.analytics_events a on a.business_id = b.id
group by b.id, b.name, b.slug, b.is_active;
-- 3.2 Secure Public RPC to Record a Visit or Button Click
-- Enforces that events can ONLY be recorded for active businesses.
create or replace function public.record_analytics_event(
p_business_id uuid,
p_event_type text,
p_visitor_hash text,
p_user_agent text default '',
p_referrer text default ''
)
returns boolean
language plpgsql
security definer
set search_path = public
as $$
declare
v_active boolean;
begin
-- Validate event type
if p_event_type not in ('page_visit', 'google_click', 'whatsapp_click', 'instagram_click') then
raise exception 'Invalid analytics event_type: %', p_event_type;
end if;
-- Confirm the business exists and is currently active
select is_active into v_active
from public.businesses
where id = p_business_id;
if v_active is not true then
return false;
end if;
insert into public.analytics_events (
business_id,
event_type,
visitor_hash,
user_agent,
referrer
) values (
p_business_id,
p_event_type,
coalesce(nullif(trim(p_visitor_hash), ''), 'anon'),
left(coalesce(p_user_agent, ''), 512),
left(coalesce(p_referrer, ''), 512)
);
return true;
end;
$$;
grant execute on function public.record_analytics_event(uuid, text, text, text, text) to anon, authenticated;
-- ============================================================================
-- 4. ROW LEVEL SECURITY (RLS) POLICIES
-- ============================================================================
alter table public.admin_users enable row level security;
alter table public.businesses enable row level security;
alter table public.analytics_events enable row level security;
-- 4.1 Policies for public.admin_users
drop policy if exists "Admins can read own admin record" on public.admin_users;
create policy "Admins can read own admin record"
on public.admin_users
for select
to authenticated
using (user_id = auth.uid() or public.is_admin());
-- 4.2 Policies for public.businesses
-- Public visitors (anon & authenticated) can ONLY read ACTIVE business profiles
drop policy if exists "Public can read active businesses" on public.businesses;
create policy "Public can read active businesses"
on public.businesses
for select
to anon, authenticated
using (is_active = true);
-- Authorized administrators can read ALL business profiles (including inactive)
drop policy if exists "Admins can read all businesses" on public.businesses;
create policy "Admins can read all businesses"
on public.businesses
for select
to authenticated
using (public.is_admin());
-- Authorized administrators can insert new business profiles
drop policy if exists "Admins can insert businesses" on public.businesses;
create policy "Admins can insert businesses"
on public.businesses
for insert
to authenticated
with check (public.is_admin());
-- Authorized administrators can update business profiles
drop policy if exists "Admins can update businesses" on public.businesses;
create policy "Admins can update businesses"
on public.businesses
for update
to authenticated
using (public.is_admin())
with check (public.is_admin());
-- Authorized administrators can delete business profiles
drop policy if exists "Admins can delete businesses" on public.businesses;
create policy "Admins can delete businesses"
on public.businesses
for delete
to authenticated
using (public.is_admin());
-- 4.3 Policies for public.analytics_events
-- Public visitors can insert analytics events ONLY for active businesses
drop policy if exists "Public can insert analytics for active businesses" on public.analytics_events;
create policy "Public can insert analytics for active businesses"
on public.analytics_events
for insert
to anon, authenticated
with check (
exists (
select 1 from public.businesses b
where b.id = analytics_events.business_id
and b.is_active = true
)
);
-- Only authorized administrators can read analytics events
drop policy if exists "Admins can read all analytics" on public.analytics_events;
create policy "Admins can read all analytics"
on public.analytics_events
for select
to authenticated
using (public.is_admin());
-- Only authorized administrators can delete/reset analytics events
drop policy if exists "Admins can delete analytics" on public.analytics_events;
create policy "Admins can delete analytics"
on public.analytics_events
for delete
to authenticated
using (public.is_admin());
-- ============================================================================
-- 5. SUPABASE STORAGE BUCKET FOR BUSINESS LOGOS
-- ============================================================================
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values (
'business-logos',
'business-logos',
true,
2097152, -- 2 MB limit per logo
array['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
)
on conflict (id) do update
set public = true,
file_size_limit = 2097152,
allowed_mime_types = array['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
-- Anyone can view business logos in the public bucket
drop policy if exists "Public can view business logos" on storage.objects;
create policy "Public can view business logos"
on storage.objects
for select
to public
using (bucket_id = 'business-logos');
-- Only authorized admins can upload logos
drop policy if exists "Admins can upload business logos" on storage.objects;
create policy "Admins can upload business logos"
on storage.objects
for insert
to authenticated
with check (bucket_id = 'business-logos' and public.is_admin());
-- Only authorized admins can update logos
drop policy if exists "Admins can update business logos" on storage.objects;
create policy "Admins can update business logos"
on storage.objects
for update
to authenticated
using (bucket_id = 'business-logos' and public.is_admin())
with check (bucket_id = 'business-logos' and public.is_admin());
-- Only authorized admins can delete logos
drop policy if exists "Admins can delete business logos" on storage.objects;
create policy "Admins can delete business logos"
on storage.objects
for delete
to authenticated
using (bucket_id = 'business-logos' and public.is_admin());
-- ============================================================================
-- 6. INITIAL ADMIN ENROLLMENT (RUN AFTER CREATING USER IN SUPABASE AUTH)
-- ============================================================================
-- Step 1: Go to Supabase Dashboard -> Authentication -> Users -> "Add User" -> "Create New User"
-- Enter your admin email (e.g., admin@smarttap.in) and strong password.
-- Step 2: Run the query below (replacing the email with your admin email) to grant admin access:
--
-- insert into public.admin_users (user_id, email, role)
-- select id, email, 'super_admin'
-- from auth.users
-- where email = 'admin@smarttap.in'
-- on conflict (user_id) do nothing;
Only your public Project URL and publishable `anon` key are used in the browser. Never use your `service_role` secret key in frontend code.
index.html, netlify.toml, and _redirects) to Netlify via GitHub or Netlify Drop.netlify.toml rewrites /b/* → /index.html (200) so URLs like smarttap.in/b/abdullah-hotel load directly.smarttap.in, point your registrar's A record (`@`) to 75.2.60.5 and CNAME (`www`) to your `.netlify.app` subdomain, then enable automatic Let's Encrypt HTTPS.Shopkeeper Dashboard
Your Business
Total Visits
0
Unique Visitors
0
Google Clicks
0
WhatsApp Clicks
0
Instagram Clicks
0
Total Button Clicks
0
Authentic Mughlai & Tandoori Family Restaurant. Tap below to share your Google Review or connect with us on WhatsApp and Instagram.
This NFC business profile is currently inactive or does not exist.
Generates a unique NFC URL and QR code immediately.
You are about to permanently delete Business (/b/slug) and all of its recorded visit and click analytics.
Connect your live Supabase PostgreSQL project or test locally.